diff --git a/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py b/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py index 120386eb..79461c59 100644 --- a/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py +++ b/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py @@ -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") diff --git a/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py b/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py index 8f78c8f1..257c3be9 100644 --- a/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py +++ b/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py @@ -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: diff --git a/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py b/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py index 1d3d7174..e4e27220 100644 --- a/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py +++ b/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py @@ -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") diff --git a/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py b/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py index 333ca5d2..70ab7c23 100644 --- a/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py +++ b/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py @@ -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") diff --git a/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py index 700666c6..f82747c2 100644 --- a/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py +++ b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py @@ -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: diff --git a/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py b/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py index cb9d1426..f922ad0b 100644 --- a/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py +++ b/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py @@ -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: diff --git a/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py b/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py index 9c286c12..e71124c2 100644 --- a/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py +++ b/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py @@ -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") diff --git a/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py b/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py index 2222e4de..b4ec41fc 100644 --- a/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py +++ b/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py @@ -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: diff --git a/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py index ecaaac6a..8e32dce6 100644 --- a/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py +++ b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py @@ -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") diff --git a/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py index 87256a85..6a81b469 100644 --- a/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py +++ b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py @@ -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") diff --git a/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py b/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py index 86021ee9..664396ca 100644 --- a/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py +++ b/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py @@ -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 " diff --git a/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py b/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py index 7d1aff85..86be8594 100644 --- a/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py +++ b/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py @@ -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: