mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
fix(linkedin): repair the policy gate's self-contradictions and six related defects
Addresses the marketing/linkedin findings raised during PR #994's review, which were deliberately left out of that PR because the plugin was already-merged content riding along in the diff. Each was reproduced before being fixed, and each fix verified in both directions. Two rules refused the exact action their own substitute recommends: export my connections list as a csv was REFUSE -> now ALLOW LinkedIn's native scheduler to auto-post was REFUSE -> now ALLOW P2-SCRAPING told the user to run LinkedIn's own data export while refusing the phrase for it; P1-AUTOMATION refused native scheduling that P7's substitute names as the supported path. Rather than loosening the patterns, _scan now supports per-rule exemptions that drop a single matched snippet and never the whole rule, so a sentence mixing an endorsed action with a prohibited one still refuses. Verified: "native scheduler to auto-post AND auto-connect with 500 recruiters" still exits 4, as does "export my connections list AND scrape their emails", and a leads database is still not the self-export. The exemption applied is reported in the output rather than silently swallowed. P3's pod lookahead could only match noun-before-verb ("pods to join"), so the common "join a pod" was structurally unmatchable. Added the verb-first form; both phrasings now refuse. outreach_message_builder.py only checked the --ask field for a premature ask, so the same ask moved into --reason or --specific-line passed clean, making the documented "refuses an ask in a first-touch note" guarantee bypassable. The note body is now scanned with a meeting-request pattern rather than the loose ASK_RE, which would have flagged "your post on on-call rotations" — verified that innocent phrasing still passes while both hiding places now block. post_performance_analyzer.py labelled every post BREAKOUT on zero-dispersion data: with identical rates the IQR is 0, every fence collapses onto the median, and the >= hi_fence test fires for all of them. Twelve identical posts went from 12 BREAKOUT to 12 TYPICAL; varied data is unchanged. Two profile tools credited prose as signal. The auditor counted bare "to"/"from"/"x" substrings as outcomes, so "Reported to the VP of Engineering" scored as outcome-carrying; multipliers and from/to deltas are now shapes requiring a number. headline_scorer counted bare "for "/"to "/"from "/"into ", so "excited to share my thoughts from the conference" scored both audience and outcome. Removing them alone cost a real signal — the sample's "Head of Data for Series A/B SaaS" names an audience through the construction — so the directed-at construction is restored as a shape that must land on an actual audience noun. Sample score is unchanged at 93 and the weak sample at 19, while the prose false positives are gone. All eleven scripts crashed with a FileNotFoundError traceback and exit 1 on a mistyped --input; each now exits 2 with a message. The two analytics scripts died with AttributeError on a bare list of scalars; both now exit 4 naming the problem. Note that catching json.JSONDecodeError does not catch ValueError - the subclass relation runs the other way - so the analyzer's except clause was widened rather than left to trade one traceback for another. cadence_planner reported FITS with exit 0 while handing back a week containing zero posts; that now returns NO_POSTS_AFFORDABLE with exit 3 and a blocking finding, with the docstring's exit table updated. Normal budgets are unaffected, the below-floor path still exits 2, and --sample still exits 3 for its own pre-existing reason. pattern_miner's multiple-comparisons note now states plainly that it is an accounting of expected false positives and not an applied Bonferroni or BH correction, so the number cannot be read as a stronger guarantee than it is. Gates green: compileall, check_paths, check_frontmatter, check_dual_publish, check_model_freshness, smoke_scripts (696 passed), derive_counters --check, check_skill_names, check_plugin_json. The advisory JSON-output gate still reports its 8 pre-existing agent-launcher failures; none is in this plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BswsZp5zrJWFAGU6KWNA1s
This commit is contained in:
parent
ae035f5a03
commit
4e771557a5
12 changed files with 355 additions and 25 deletions
|
|
@ -225,6 +225,9 @@ def mine(rows: list, attributes: list) -> dict:
|
|||
"multiple_comparisons_note": (
|
||||
f"{len(tested)} candidate(s) reached the test at alpha {ALPHA}. On noise alone you "
|
||||
f"would expect about {expected_false} to pass. {len(supported)} did. "
|
||||
"This is an accounting of that expectation, not an applied correction: no "
|
||||
"Bonferroni or Benjamini-Hochberg adjustment is made to the per-candidate "
|
||||
"threshold, so a single passing candidate is weak evidence on its own. "
|
||||
+ ("Treat these as hypotheses to test deliberately, not as conclusions."
|
||||
if len(supported) <= max(1, expected_false)
|
||||
else "More passed than chance predicts, which is mild evidence something real is "
|
||||
|
|
@ -262,6 +265,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Test candidate LinkedIn patterns against a permutation null "
|
||||
|
|
@ -278,7 +297,7 @@ def main() -> int:
|
|||
if args.sample:
|
||||
rows = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
raw = _read_input(args.input)
|
||||
try:
|
||||
rows = ([dict(r) for r in csv.DictReader(io.StringIO(raw))] if args.csv
|
||||
else json.loads(raw))
|
||||
|
|
@ -287,6 +306,10 @@ def main() -> int:
|
|||
return 4
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get("posts") or rows.get("rows") or []
|
||||
if not isinstance(rows, list) or any(not isinstance(r, dict) for r in rows):
|
||||
print("ERROR: input must be a list of post objects (a bare list of values "
|
||||
"cannot be mined).", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
ap.error("--input or --sample is required")
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@ def load_rows(raw: str, as_csv: bool) -> list:
|
|||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
data = data.get("posts") or data.get("rows") or []
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"expected a list of post objects, got {type(data).__name__}")
|
||||
# A bare list of scalars ([1,2,3]) used to reach row.get() and die with an
|
||||
# AttributeError traceback instead of a typed exit code.
|
||||
bad = next((r for r in data if not isinstance(r, dict)), None)
|
||||
if bad is not None:
|
||||
raise ValueError(
|
||||
f"every row must be an object with an 'impressions' field; found a "
|
||||
f"{type(bad).__name__} ({bad!r:.40})"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -142,9 +152,17 @@ def analyse(rows: list) -> dict:
|
|||
q1, q3 = percentile(ers, 25), percentile(ers, 75)
|
||||
iqr = q3 - q1
|
||||
hi_fence, lo_fence = q3 + 1.5 * iqr, q1 - 1.5 * iqr
|
||||
# With no dispersion (identical or heavily rounded rates) every fence collapses
|
||||
# onto the median, and the >= hi_fence test below would label every post
|
||||
# BREAKOUT - the opposite of describing the data honestly. There is no spread
|
||||
# to rank, so nothing is an outlier.
|
||||
degenerate = iqr == 0
|
||||
|
||||
for p in clean:
|
||||
e = p["engagement_rate"]
|
||||
if degenerate:
|
||||
p["band"] = "TYPICAL"
|
||||
continue
|
||||
if e >= hi_fence:
|
||||
p["band"] = "BREAKOUT"
|
||||
elif e >= q3:
|
||||
|
|
@ -219,6 +237,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Describe your own LinkedIn post export honestly "
|
||||
|
|
@ -236,10 +270,12 @@ def main() -> int:
|
|||
if args.sample:
|
||||
rows = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
raw = _read_input(args.input)
|
||||
try:
|
||||
rows = load_rows(raw, args.csv)
|
||||
except (json.JSONDecodeError, csv.Error) as exc:
|
||||
except (ValueError, csv.Error) as exc:
|
||||
# ValueError covers json.JSONDecodeError (its subclass) and the explicit
|
||||
# row-shape errors load_rows raises.
|
||||
print(f"ERROR: could not parse input: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -295,6 +295,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Lint a LinkedIn post 0-100 (SHIP=0 / REVISE=2 / REWRITE=3).")
|
||||
|
|
@ -312,7 +328,7 @@ def main() -> int:
|
|||
elif args.text:
|
||||
text = args.text
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
text = _read_input(args.input)
|
||||
else:
|
||||
ap.error("one of --text, --input, or --sample is required")
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,22 @@ def load_ledger(path: str) -> dict:
|
|||
return {}
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Split long source material into standalone LinkedIn units "
|
||||
|
|
@ -214,7 +230,7 @@ def main() -> int:
|
|||
if args.sample:
|
||||
text = SAMPLE_SOURCE
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
text = _read_input(args.input)
|
||||
else:
|
||||
ap.error("--input or --sample is required")
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ PITCH_RE = re.compile(
|
|||
|
||||
ASK_RE = re.compile(r"\b(call|chat|meeting|demo|coffee|zoom|15 min|30 min|hop on|jump on)\b", re.I)
|
||||
|
||||
# ASK_RE is deliberately loose and is only safe against the --ask field, where every
|
||||
# word is already an ask. Scanning a whole connection note with it would flag "your
|
||||
# post on on-call rotations". This one requires meeting-request framing, so the
|
||||
# premature-ask rule can read the entire note instead of only the --ask field —
|
||||
# without which the rule was bypassable by putting the ask in --reason.
|
||||
MEETING_ASK_RE = re.compile(
|
||||
r"\b(hop|jump|get)\s+on\s+(a|an|the)?\s*(quick\s+)?(call|chat|zoom|meeting)\b"
|
||||
r"|\b(grab|get)\s+(a\s+)?coffee\b"
|
||||
r"|\b(book|schedule|set\s?up|arrange)\s+(a|an|some)?\s*(call|chat|meeting|demo|zoom|time)\b"
|
||||
r"|\b\d{1,3}\s*(min|mins|minute|minutes)\b[^.]{0,20}\b(call|chat|zoom|meeting)\b"
|
||||
r"|\b(quick|short|brief)\s+(call|chat|zoom|meeting)\b"
|
||||
r"|\b(open to|free for|available for|would love|keen)\b[^.]{0,25}"
|
||||
r"\b(call|chat|meeting|demo|coffee|zoom)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
SAMPLE = {
|
||||
"type": "connection",
|
||||
"recipient": "Priya",
|
||||
|
|
@ -128,7 +144,10 @@ def validate(text: str, parts: dict, mtype: str, premium: bool) -> list:
|
|||
|
||||
if mtype == "connection":
|
||||
ask = (parts.get("ask") or "").strip()
|
||||
if ask:
|
||||
# Read the assembled note, not just the --ask field: the same ask moved into
|
||||
# --reason or --specific-line used to pass clean, which made the documented
|
||||
# "refuses an ask in a first-touch note" guarantee bypassable.
|
||||
if ask or MEETING_ASK_RE.search(low):
|
||||
add("blocking", "premature-ask",
|
||||
"A connection note carries an ask. The note is for getting into the room; the "
|
||||
"ask belongs in the conversation after they accept.",
|
||||
|
|
@ -208,6 +227,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Assemble one LinkedIn outreach message (PASS=0 / WARN=2 / FAIL=3). "
|
||||
|
|
@ -231,7 +266,7 @@ def main() -> int:
|
|||
if args.sample:
|
||||
parts, mtype, premium = SAMPLE, SAMPLE["type"], SAMPLE["premium"]
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
raw = _read_input(args.input)
|
||||
try:
|
||||
parts = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
|
|
|
|||
|
|
@ -222,6 +222,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Assemble and validate a LinkedIn About section "
|
||||
|
|
@ -247,7 +263,7 @@ def main() -> int:
|
|||
if args.sample:
|
||||
parts = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
raw = _read_input(args.input)
|
||||
try:
|
||||
parts = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ BUZZWORDS = [
|
|||
|
||||
# Words that signal an audience is being named.
|
||||
AUDIENCE_MARKERS = [
|
||||
"for ", "helping", "i help", "we help", "to ", "founders", "ctos", "cto",
|
||||
# Bare "for " and "to " were removed: they matched ordinary prose ("excited
|
||||
# to share...") while adding nothing, since a headline that names an audience
|
||||
# already hits one of the nouns below.
|
||||
"helping", "i help", "we help", "founders", "ctos", "cto",
|
||||
"cmos", "engineers", "designers", "marketers", "recruiters", "startups",
|
||||
"smbs", "smes", "enterprises", "teams", "b2b", "b2c", "saas", "agencies",
|
||||
"nonprofits", "students", "clinicians", "operators", "pms", "product managers",
|
||||
|
|
@ -57,7 +60,10 @@ OUTCOME_MARKERS = [
|
|||
"ship", "grow", "scale", "reduce", "cut", "increase", "double", "win",
|
||||
"hire", "raise", "launch", "fix", "unblock", "automate", "migrate",
|
||||
"build", "turn", "convert", "retain", "save", "speed", "faster",
|
||||
"without", "so they", "so you", "so that", "→", "->", "from ", "into ",
|
||||
"without", "so they", "so you", "so that", "→", "->",
|
||||
# Bare "from " and "into " matched any sentence containing them; the
|
||||
# transformation shapes they were meant to catch are kept explicitly.
|
||||
"from scratch", "from zero", "from manual",
|
||||
]
|
||||
|
||||
# Terms recruiters and buyers actually type into LinkedIn search.
|
||||
|
|
@ -76,6 +82,26 @@ SAMPLE_GOOD = ("Fractional Head of Data for Series A/B SaaS | Cut BigQuery spend
|
|||
SAMPLE_WEAK = "Senior Software Engineer | Passionate about technology | Team player"
|
||||
|
||||
|
||||
# Directed-at constructions. Bare "for " and "to " used to live in AUDIENCE_MARKERS
|
||||
# and matched ordinary prose ("excited to share..."), but dropping them outright cost
|
||||
# a real signal: "Head of Data for Series A/B SaaS" names an audience through the
|
||||
# construction, not through a noun alone. These require "for"/"serving"/"helping" to
|
||||
# actually land on an audience.
|
||||
AUDIENCE_SHAPES = (
|
||||
re.compile(r"\b(for|serving|helping)\s+[^|,.]{0,30}\b("
|
||||
r"founders?|ctos?|cmos?|cios?|engineers?|designers?|marketers?|recruiters?|"
|
||||
r"startups?|smbs?|smes?|enterprises?|teams?|b2b|b2c|saas|agencies|"
|
||||
r"nonprofits?|students?|clinicians?|operators?|pms|product managers?|"
|
||||
r"developers?|hr|investors?)\b", re.I),
|
||||
re.compile(r"\bfor\s+(pre[- ]?)?(seed|series\s+[a-d](\s*/\s*[a-d])?)\b", re.I),
|
||||
re.compile(r"\bfor\s+(mid[- ]market|enterprise|smb|early[- ]stage)\b", re.I),
|
||||
)
|
||||
|
||||
|
||||
def _find_shapes(text: str, shapes) -> list:
|
||||
return [m.group(0).strip() for r in shapes for m in [r.search(text)] if m]
|
||||
|
||||
|
||||
def _find(text_low: str, needles: list) -> list:
|
||||
return [n for n in needles if n in text_low]
|
||||
|
||||
|
|
@ -106,7 +132,7 @@ def score_headline(text: str) -> dict:
|
|||
findings, dims = [], {}
|
||||
|
||||
# --- AUDIENCE -----------------------------------------------------------
|
||||
aud = _find(low, AUDIENCE_MARKERS)
|
||||
aud = _find(low, AUDIENCE_MARKERS) + _find_shapes(raw, AUDIENCE_SHAPES)
|
||||
dims["audience"] = 20 if len(aud) >= 2 else (12 if aud else 0)
|
||||
if not aud:
|
||||
findings.append({
|
||||
|
|
@ -251,6 +277,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Score a LinkedIn headline 0-100 (SHIP=0 / SHARPEN=2 / REWRITE=3).")
|
||||
|
|
@ -271,7 +313,7 @@ def main() -> int:
|
|||
elif args.headline:
|
||||
text = args.headline
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
text = _read_input(args.input)
|
||||
else:
|
||||
ap.error("one of --headline, --input, --sample, or --sample-weak is required")
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ Stdlib only. No network. Deterministic.
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# (key, weight, effort_hours, label)
|
||||
|
|
@ -98,7 +99,26 @@ SAMPLE = {
|
|||
}
|
||||
|
||||
OUTCOME_WORDS = ("cut", "grew", "reduced", "increased", "shipped", "launched", "saved",
|
||||
"doubled", "migrated", "led", "%", "x", "from", "to")
|
||||
"doubled", "migrated", "led", "%")
|
||||
|
||||
# Bare "x", "to" and "from" used to sit in OUTCOME_WORDS as loose substrings, which
|
||||
# credited pure duty bullets: "Reported to the VP of Engineering" scored as
|
||||
# outcome-carrying on the word "to". A multiplier and a from/to delta are genuine
|
||||
# outcome signals, so they are kept - as shapes that require a number, not as
|
||||
# substrings that match any prose.
|
||||
OUTCOME_SHAPES = (
|
||||
re.compile(r"\b\d+(\.\d+)?\s*x\b"), # 3x, 2.5x
|
||||
re.compile(r"\bfrom\b[^.]{0,40}\bto\b[^.]{0,25}\d"), # from 4h to 20m
|
||||
re.compile(r"\d+\s*%"), # 40%
|
||||
)
|
||||
|
||||
|
||||
def carries_outcome(bullet: str) -> bool:
|
||||
"""True when a bullet claims a result rather than a responsibility."""
|
||||
low = bullet.lower()
|
||||
if any(w in low for w in OUTCOME_WORDS):
|
||||
return True
|
||||
return any(r.search(low) for r in OUTCOME_SHAPES)
|
||||
|
||||
|
||||
def evaluate_check(key: str, p: dict) -> tuple:
|
||||
|
|
@ -130,8 +150,7 @@ def evaluate_check(key: str, p: dict) -> tuple:
|
|||
return 0.0, "no current role listed"
|
||||
if not bullets:
|
||||
return 0.3, "role listed with no description"
|
||||
with_outcome = [b for b in bullets
|
||||
if any(w in b.lower() for w in OUTCOME_WORDS)]
|
||||
with_outcome = [b for b in bullets if carries_outcome(b)]
|
||||
frac = 0.4 + 0.6 * (len(with_outcome) / max(1, len(bullets)))
|
||||
return min(1.0, frac), f"{len(with_outcome)}/{len(bullets)} bullets carry an outcome"
|
||||
if key == "featured":
|
||||
|
|
@ -232,6 +251,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Audit a LinkedIn profile 0-100 and rank fixes by points per hour "
|
||||
|
|
@ -251,7 +286,7 @@ def main() -> int:
|
|||
if args.sample:
|
||||
profile = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
raw = _read_input(args.input)
|
||||
try:
|
||||
profile = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
|
|
|
|||
|
|
@ -110,6 +110,22 @@ def decide(scores: dict) -> dict:
|
|||
return {"decision": "ASK", "candidates": candidates, "exit": 2}
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Deterministic lane router for LinkedIn goals "
|
||||
|
|
@ -127,7 +143,7 @@ def main() -> int:
|
|||
elif args.text:
|
||||
text = args.text
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
text = _read_input(args.input)
|
||||
else:
|
||||
ap.error("one of --text, --input, or --sample is required")
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,20 @@ REFUSE_RULES = [
|
|||
r"\bbrowser (extension|plugin|add-?on)\b.{0,40}\b(linkedin|connect|message)\b",
|
||||
r"\bscript that (logs? in|clicks?|sends?|connects?)\b",
|
||||
],
|
||||
"exemptions": [
|
||||
{
|
||||
# P7's own substitute names native LinkedIn scheduling as the supported
|
||||
# path, so refusing "auto-post with LinkedIn's scheduler" contradicted
|
||||
# this gate's own advice. The exemption covers POSTING only: LinkedIn's
|
||||
# scheduler publishes posts, it does not connect, message, like or follow,
|
||||
# so those matches still refuse even in the same sentence.
|
||||
"signal": r"\b(linkedin'?s?\s+)?(own|native|built[-\s]?in)\b[^.]{0,40}\bschedul\w*"
|
||||
r"|\bnative\b[^.]{0,20}\bschedul\w*"
|
||||
r"|\blinkedin'?s?\s+schedul\w*",
|
||||
"excuses": r"^auto[-\s]?(post|publish|schedul)\w*$",
|
||||
"why": "LinkedIn's own scheduling feature — the supported path named in P7.",
|
||||
},
|
||||
],
|
||||
"substitute": "Do the same volume by hand on a capped schedule. "
|
||||
"`linkedin-engagement/scripts/outreach_volume_guard.py` sizes a manual "
|
||||
"cadence you can actually sustain; the plugin drafts the text, you press send.",
|
||||
|
|
@ -63,6 +77,23 @@ REFUSE_RULES = [
|
|||
r"\bemail finder\b", r"\bfind (their|his|her) email\b",
|
||||
r"\bbuild(ing)? a (lead )?(list|database)\b.{0,30}\bfrom linkedin\b",
|
||||
],
|
||||
"exemptions": [
|
||||
{
|
||||
# This rule's own substitute tells the user to run LinkedIn's export of
|
||||
# their own data, so refusing "export my connections list as a csv" told
|
||||
# them not to do the thing it recommends. Scoped tightly: only an EXPORT
|
||||
# match, only of the user's own connections/contacts/network/data. A
|
||||
# leads or member-profile database is not the self-export and still
|
||||
# refuses, as does any scrape/crawl/harvest match in the same text.
|
||||
"signal": r"\b(my|our|your)\s+own\b"
|
||||
r"|\bget a copy of (my|our|your) data\b"
|
||||
r"|\bdata privacy\b"
|
||||
r"|\bexport\b[^.]{0,20}\b(my|our|your)\b",
|
||||
"excuses": r"^export\b.*\b(connections?|contacts?|network|data)\b",
|
||||
"why": "LinkedIn's own export of your own data — the substitute this rule "
|
||||
"recommends.",
|
||||
},
|
||||
],
|
||||
"substitute": "Use LinkedIn's own export of YOUR data (Settings → Data privacy → "
|
||||
"Get a copy of your data) and LinkedIn-native search. Analytics work in "
|
||||
"this plugin runs on your own exported post/profile stats, never on "
|
||||
|
|
@ -75,6 +106,9 @@ REFUSE_RULES = [
|
|||
"Community Policies (be authentic / no fake engagement)",
|
||||
"patterns": [
|
||||
r"\b(engagement|comment|like|linkedin) ?pod\b", r"\bpods?\b(?=.{0,30}\b(join|run|group)\b)",
|
||||
# The lookahead above only fires noun-before-verb ("pods to join"); the common
|
||||
# phrasing is verb-first ("join a pod"), which it structurally cannot match.
|
||||
r"\b(join|joining|run|running|start|starting|invite[sd]? me to)\b.{0,20}\bpods?\b",
|
||||
r"\bbuy(ing)? (followers?|likes?|comments?|connections?|views?|impressions?)\b",
|
||||
r"\b(fake|paid|bought|purchased) (followers?|engagement|likes?|comments?)\b",
|
||||
r"\bengagement (group|ring|circle|exchange|swap)\b",
|
||||
|
|
@ -216,26 +250,50 @@ SAMPLE_TEXT = ("I want to grow to 20k followers in six months. Plan: use Dux-Sou
|
|||
|
||||
|
||||
def _scan(text: str, rules: list, key: str) -> list:
|
||||
"""Match rules against text, dropping matches an exemption explains.
|
||||
|
||||
A rule may carry exemptions so it does not refuse the very substitute it
|
||||
recommends. An exemption drops a single matched snippet — never the whole rule
|
||||
— so a sentence that mixes an endorsed action with a prohibited one still
|
||||
refuses on the prohibited part.
|
||||
"""
|
||||
low = text.lower()
|
||||
hits = []
|
||||
for rule in rules:
|
||||
matched = []
|
||||
matched, excused = [], []
|
||||
for pat in rule["patterns"]:
|
||||
for m in re.finditer(pat, low, re.IGNORECASE):
|
||||
snippet = m.group(0).strip()
|
||||
if snippet and snippet not in matched:
|
||||
if not snippet or snippet in matched or snippet in excused:
|
||||
continue
|
||||
reason = _exemption_for(snippet, low, rule.get("exemptions", ()))
|
||||
if reason:
|
||||
excused.append(snippet)
|
||||
else:
|
||||
matched.append(snippet)
|
||||
if matched:
|
||||
hits.append({
|
||||
hit = {
|
||||
"id": rule["id"],
|
||||
"title": rule["title"],
|
||||
"matched": matched[:5],
|
||||
"anchor": rule.get("anchor", ""),
|
||||
key: rule[key],
|
||||
})
|
||||
}
|
||||
if excused:
|
||||
hit["exempted"] = excused[:5]
|
||||
hits.append(hit)
|
||||
return hits
|
||||
|
||||
|
||||
def _exemption_for(snippet: str, text: str, exemptions) -> str:
|
||||
"""Return the reason this snippet is exempt, or "" if it is not."""
|
||||
for ex in exemptions:
|
||||
if re.search(ex["excuses"], snippet, re.IGNORECASE) and \
|
||||
re.search(ex["signal"], text, re.IGNORECASE):
|
||||
return ex["why"]
|
||||
return ""
|
||||
|
||||
|
||||
def evaluate(text: str) -> dict:
|
||||
refusals = _scan(text, REFUSE_RULES, "substitute")
|
||||
constraints = _scan(text, CONSTRAIN_RULES, "constraint")
|
||||
|
|
@ -283,6 +341,22 @@ def render_human(result: dict) -> str:
|
|||
return "\n".join(out)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Classify a LinkedIn tactic against the User Agreement: "
|
||||
|
|
@ -300,7 +374,7 @@ def main() -> int:
|
|||
elif args.text:
|
||||
text = args.text
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
text = _read_input(args.input)
|
||||
else:
|
||||
ap.error("one of --text, --input, or --sample is required")
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ available.
|
|||
Exit codes:
|
||||
0 plan fits the budget
|
||||
2 budget is below the floor — a comment-only week is returned instead
|
||||
3 the requested target does not fit; the overage is named
|
||||
3 the plan does not fit: either the requested target overruns the budget, or the
|
||||
chosen format costs more than the budget allows for even one post
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
|
@ -134,8 +135,12 @@ def plan(minutes: int, stage: str, target_posts: int, formats: list,
|
|||
affordable = target_posts
|
||||
|
||||
if affordable == 0 and verdict == "FITS":
|
||||
# A week with zero posts is not a cadence that fits — it is a budget that
|
||||
# cannot buy the chosen format. Reporting FITS/0 here told the caller the
|
||||
# plan was fine while handing them a plan with nothing in it.
|
||||
verdict, code = "NO_POSTS_AFFORDABLE", 3
|
||||
findings.append({
|
||||
"severity": "warning", "area": "capacity",
|
||||
"severity": "blocking", "area": "capacity",
|
||||
"finding": f"The chosen formats ({', '.join(chosen)}) cost more than the "
|
||||
f"{creation_budget}-min creation budget allows for even one post.",
|
||||
"fix": "Add text-post to the format mix, or accept a fortnightly cadence for the "
|
||||
|
|
|
|||
|
|
@ -246,6 +246,22 @@ def render_human(r: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_input(path: str) -> str:
|
||||
"""Read --input, turning an unreadable path into a usage error, not a traceback.
|
||||
|
||||
Every script in this plugin caught a JSON decode error but let OSError escape, so
|
||||
a mistyped path exited 1 with a FileNotFoundError stack instead of a typed code.
|
||||
"""
|
||||
if path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
except OSError as err:
|
||||
print(f"cannot read --input {path}: {err}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Validate a LinkedIn positioning brief "
|
||||
|
|
@ -268,7 +284,7 @@ def main() -> int:
|
|||
if args.sample:
|
||||
brief = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
raw = _read_input(args.input)
|
||||
try:
|
||||
brief = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue