diff --git a/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py b/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py index 120386eb..951cc426 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,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +300,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 +309,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..ea5c2c4f 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,22 @@ 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. + # + # But iqr == 0 only means the middle 50% is flat, which is a realistic shape for + # a real export: many similar posts plus a couple of viral ones. Collapsing + # everything to TYPICAL there hides genuine breakouts, trading one honesty + # problem for another. So when the centre is degenerate, rank against the median + # itself: equal to it is TYPICAL, above it is a real standout, below it is weak. + degenerate = iqr == 0 for p in clean: e = p["engagement_rate"] + if degenerate: + p["band"] = "TYPICAL" if e == med else ("BREAKOUT" if e > med else "WEAK") + continue if e >= hi_fence: p["band"] = "BREAKOUT" elif e >= q3: @@ -219,6 +242,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +278,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..4a509161 100644 --- a/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py +++ b/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py @@ -295,6 +295,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +331,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..fe4ec6c7 100644 --- a/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py +++ b/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py @@ -193,6 +193,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +233,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..635b12fc 100644 --- a/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py +++ b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py @@ -58,7 +58,23 @@ PITCH_RE = re.compile( r"schedule a (call|demo)|are you the right person|decision[- ]maker|" r"i'?d like to show you|free trial|pricing|proposal)\b", re.I) -ASK_RE = re.compile(r"\b(call|chat|meeting|demo|coffee|zoom|15 min|30 min|hop on|jump on)\b", re.I) +# There used to be a loose ASK_RE here (call|chat|meeting|demo|coffee|zoom|...). It +# was only ever safe against the --ask field, where every word is already an ask; +# against a whole note it matched "your post on on-call rotations". Both branches +# below scanned the whole note with it, in opposite directions: the connection branch +# raised a false premature-ask, and the else branch SUPPRESSED a real +# pitch-without-ask. This pattern requires meeting-request framing, so both can read +# the entire note - without which premature-ask was bypassable via --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", @@ -126,9 +142,12 @@ def validate(text: str, parts: dict, mtype: str, premium: bool) -> list: "rate drops.", "One specific line, one reason, one bounded ask. Everything else is for the reply.") + ask = (parts.get("ask") or "").strip() 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.", @@ -141,7 +160,10 @@ def validate(text: str, parts: dict, mtype: str, premium: bool) -> list: "Delete it. Nobody has ever bought from a connection request, and the request " "is the only impression you get.") else: - if PITCH_RE.search(low) and not ASK_RE.search(low): + # An ask is the --ask field, or meeting-request framing in the note. Matching + # loosely here silently lost the warning on any note that happened to contain + # "on-call", "chat feature" or "demo video". See MEETING_ASK_RE above. + if PITCH_RE.search(low) and not (ask or MEETING_ASK_RE.search(low)): add("warning", "pitch-without-ask", "Product language with no clear, bounded ask.", "Either make the ask explicit and small, or cut the product language.") @@ -208,6 +230,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +272,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..13609ba2 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,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +266,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..2e7ba912 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,36 @@ 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), +) + + +# "from " kept idiomatic replacements ("from scratch"/"from zero"/"from manual") +# when the bare prepositions were removed; "into " had none, so a headline built on +# an explicit transformation lost the signal unless it also used turn/convert. This +# restores it as the construction itself rather than a bare preposition, so ordinary +# prose ("putting budget into growth") still scores nothing. +OUTCOME_SHAPES = ( + re.compile(r"\bfrom\b[^|,.]{0,30}\b(to|into)\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 +142,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({ @@ -116,7 +152,7 @@ def score_headline(text: str) -> dict: }) # --- OUTCOME ------------------------------------------------------------ - out = _find(low, OUTCOME_MARKERS) + out = _find(low, OUTCOME_MARKERS) + _find_shapes(raw, OUTCOME_SHAPES) dims["outcome"] = 20 if len(out) >= 2 else (12 if out else 0) if not out: findings.append({ @@ -251,6 +287,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +326,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..a73c73cd 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) @@ -97,8 +98,33 @@ SAMPLE = { "days_since_last_post": 210, } +# Matched on word boundaries, never as substrings. As plain `w in low` tests, "led" +# credited "scheduled", "installed", "handled" and "recalled", and "cut" credited +# "executed" - so "Scheduled onboarding for new hires", a pure duty bullet, scored +# as outcome-carrying. That is the same substring defect that removing bare "to" / +# "from" / "x" from this tuple was meant to end; these two just survived it. OUTCOME_WORDS = ("cut", "grew", "reduced", "increased", "shipped", "launched", "saved", - "doubled", "migrated", "led", "%", "x", "from", "to") + "doubled", "migrated", "led") +OUTCOME_WORD_RE = re.compile(r"\b(" + "|".join(OUTCOME_WORDS) + r")\b", re.I) + +# 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 OUTCOME_WORD_RE.search(low): + return True + return any(r.search(low) for r in OUTCOME_SHAPES) def evaluate_check(key: str, p: dict) -> tuple: @@ -130,8 +156,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 +257,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +295,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..ccf8d315 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,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +146,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..0ed9c7ef 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,35 @@ 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.", + }, + { + # Same endorsed action reached through the other P1 pattern: + # "automate posting with LinkedIn's native scheduler" matches + # \bautomat(e|ed|ing|ion)\b and yields the bare snippet "automate". + # That token is far too generic to exempt on the signal alone - "use + # the native scheduler and automate my DMs" would sail through - so + # this one also requires posting language immediately after the + # matched word. "automate my DMs" keeps refusing. + "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"^automat(e|es|ed|ing|ion)$", + "context": r"\b(post|posts|posting|publish\w*|content|updates?)\b", + "why": "LinkedIn's own scheduling feature, applied to posting.", + }, + ], "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 +92,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 +121,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", @@ -104,7 +153,7 @@ REFUSE_RULES = [ }, { "id": "P5-BULK-MESSAGING", - "title": "Bulk or unsolicited mass messaging", + "title": "Bulk or unsolicited mass messaging or posting", "anchor": "LinkedIn User Agreement §8.2 (send or redirect messages by automated means; " "spam) + Professional Community Policies (no spam/unsolicited commercial content)", "patterns": [ @@ -113,10 +162,23 @@ REFUSE_RULES = [ r"\bsend the same (message|dm|note) to\b", r"\bcopy[- ]?paste\b.{0,25}\b(dm|message|outreach)\b.{0,25}\b(everyone|all|hundreds)\b", r"\bdrip (campaign|sequence)\b.{0,30}\blinkedin\b", + # Unsolicited bulk POSTING, not just messaging. Before the P1 exemption + # existed, "auto-post to 50 groups without permission" was refused only + # incidentally, because P1 refused every automation word. Now that native + # scheduling is correctly allowed, the spam itself needs its own rule - + # otherwise fixing the self-contradiction quietly permitted the spam. + r"\bpost\w*\b[^.]{0,40}\b(to|in|into|across)\b[^.]{0,20}\b\d{2,}\b[^.]{0,20}" + r"\b(groups?|communities|forums?|subreddits?)\b", + r"\b(spam|blast|dump)\w*\b[^.]{0,30}\b(groups?|communities|feeds?)\b", + r"\bwithout permission\b[^.]{0,40}\b(post|group|share|promot)\w*" + r"|\b(post|group|share|promot)\w*[^.]{0,40}\bwithout permission\b", ], "substitute": "Per-person messages with a specific reason, sent by hand, under a weekly " "cap. `outreach_message_builder.py` refuses a template with no " - "person-specific line for exactly this reason.", + "person-specific line for exactly this reason. For groups: post where " + "you are a participating member, on a cadence " + "`cadence_planner.py` says you can sustain, and only where the group's " + "own rules allow it.", }, { "id": "P6-FABRICATION", @@ -215,30 +277,137 @@ SAMPLE_TEXT = ("I want to grow to 20k followers in six months. Plan: use Dux-Sou "90 minutes, and blast the same DM to everyone who accepts.") -def _scan(text: str, rules: list, key: str) -> list: +def _scan(text: str, rules: list, key: str, exemptions: list) -> 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 = [], [] + # excused holds dicts, so a `snippet in excused` membership test compares a + # str against dicts and is always False - the same phrase appearing twice was + # re-evaluated and reported twice, making exemptions_applied overstate what + # was found. Track the snippet strings separately. + excused_snippets: set = set() 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: + continue + # Every OCCURRENCE is judged on its own position. De-duplicating by + # snippet text before this call silently dropped the second use of a + # generic word: "automate posting via the native scheduler, and also + # automate direct messaging to my whole network" excused the first + # "automate", then skipped the second as a duplicate, so the mass-DM + # half was never evaluated and the whole request returned ALLOW. + # De-duplication is for the display lists only, below. + reason = _exemption_for(snippet, low, rule.get("exemptions", ()), m.start()) + if reason: + if snippet not in excused_snippets: + excused_snippets.add(snippet) + excused.append({"id": rule["id"], "snippet": snippet, "why": reason}) + elif snippet not in matched: matched.append(snippet) + # Record exemptions whether or not the rule still fires. Attaching them only + # to a surviving hit hid them in exactly the case that matters most: when + # every match is exempted the rule produces no hit at all, so an ALLOW that + # recognized and excused a prohibited-looking phrase looked identical to one + # that never matched anything. + exemptions.extend(excused) 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"] = [e["snippet"] for e in excused][:5] + hits.append(hit) return hits +_SENTENCE_SPLIT = re.compile(r"[.!?;\n]") + + +def _sentence_around(text: str, at: int) -> str: + """Return the sentence containing offset `at`. + + The signal that makes a match legitimate has to be about THIS action. Searching + the whole input let an unrelated aside excuse a match elsewhere: "auto-post + daily to 50 groups without permission... LinkedIn's native scheduler is neat, + right?" was allowed, because the endorsement in the second sentence excused the + spam in the first. That turned a refusal into an ALLOW, which is the one + direction this gate must never move by accident. + """ + start = 0 + for m in _SENTENCE_SPLIT.finditer(text, 0, at): + start = m.end() + end_match = _SENTENCE_SPLIT.search(text, at) + end = end_match.start() if end_match else len(text) + return text[start:end] + + +_CLAUSE_BREAK = re.compile( + r"[.!?;,\n]|\b(and|but|then|also|plus|while|or|as well as)\b", re.IGNORECASE) + + +def _clause_after(text: str, at: int, span: int, cap: int = 40) -> str: + """Return the text just after a match, cut at the first clause boundary. + + `context` exists to require a word IMMEDIATELY after the matched token. A fixed + character window is not "immediately": it reaches into the next clause, so the + context belonging to a LATER occurrence could excuse an earlier, unrelated one. + "automate connects and automate posting via native scheduler" allowed the whole + request, because the second clause's "posting" fell inside the first + "automate"'s 40-char window - and adding a period in place of "and" refused it. + A gate whose verdict turns on incidental phrasing length is not a gate. + + Both bounds apply, whichever is tighter: the clause boundary keeps another + clause's words out, and the character cap keeps one long unpunctuated clause + from drifting ("automate connecting with recruiters for my posting campaign"). + """ + window = text[at + span:at + span + cap] + brk = _CLAUSE_BREAK.search(window) + return window[:brk.start()] if brk else window + + +def _exemption_for(snippet: str, text: str, exemptions, at: int = 0) -> str: + """Return the reason this snippet is exempt, or "" if it is not. + + Two gates always apply: the snippet must be one this exemption can excuse, and + the signal that makes it legitimate must appear IN THE SAME SENTENCE as the + match. An exemption may add a third, `context`, which must appear just after the + matched word - needed where the snippet itself is too generic to exempt safely. + + Sentence scoping costs a false refusal on a legitimate two-sentence phrasing + ("I use LinkedIn's native scheduler. I auto-post weekly."), which the user can + rephrase. The alternative cost is a false ALLOW on a prohibited tactic, which + they cannot detect at all. + """ + scope = _sentence_around(text, at) + for ex in exemptions: + if not re.search(ex["excuses"], snippet, re.IGNORECASE): + continue + if not re.search(ex["signal"], scope, re.IGNORECASE): + continue + ctx = ex.get("context") + if ctx and not re.search(ctx, _clause_after(text, at, len(snippet)), re.IGNORECASE): + continue + return ex["why"] + return "" + + def evaluate(text: str) -> dict: - refusals = _scan(text, REFUSE_RULES, "substitute") - constraints = _scan(text, CONSTRAIN_RULES, "constraint") + exemptions: list = [] + refusals = _scan(text, REFUSE_RULES, "substitute", exemptions) + constraints = _scan(text, CONSTRAIN_RULES, "constraint", exemptions) if refusals: verdict, code = "REFUSE", 4 elif constraints: @@ -250,6 +419,7 @@ def evaluate(text: str) -> dict: "exit_code": code, "refusals": refusals, "constraints": constraints, + "exemptions_applied": exemptions, "input_preview": text.strip()[:280], "standing_constraints": STANDING_CONSTRAINTS, "disclaimer": ("Deterministic pattern check against LinkedIn's published policies, not " @@ -273,6 +443,10 @@ def render_human(result: dict) -> str: out.append(f" [{c['id']}] {c['title']}") out.append(f" matched : {', '.join(c['matched'])}") out.append(f" honor : {c['constraint']}\n") + if result.get("exemptions_applied"): + out.append("\nRecognized but exempt — these look like rule matches and are not:\n") + for e in result["exemptions_applied"]: + out.append(f" [{e['id']}] '{e['snippet']}' — {e['why']}\n") if result["verdict"] == "ALLOW": out.append("\nNothing in this request trips a known rule. Proceed.\n") out.append("\nStanding constraints (always apply):") @@ -283,6 +457,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +493,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..1fa5d598 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 " @@ -220,7 +225,8 @@ def render_human(r: dict) -> str: def main() -> int: ap = argparse.ArgumentParser( - description="Size a sustainable LinkedIn week (fits=0 / below-floor=2 / over-budget=3).") + description="Size a sustainable LinkedIn week " + "(fits=0 / below-floor=2 / over-budget or no-posts-affordable=3).") ap.add_argument("--minutes", type=int, help="Minutes per week you will actually protect.") ap.add_argument("--stage", choices=sorted(STAGES), default="starting") ap.add_argument("--target-posts", type=int, default=0, diff --git a/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py b/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py index 7d1aff85..b9f5537c 100644 --- a/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py +++ b/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py @@ -246,6 +246,25 @@ 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, UnicodeDecodeError) as err: + # UnicodeDecodeError is a ValueError subclass, not an OSError, so a file that + # exists but is not valid UTF-8 escaped the OSError catch as a traceback - + # the same failure mode this helper exists to prevent for a bad path. + 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 +287,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: