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:
Claude 2026-08-26 14:08:56 +00:00
parent ae035f5a03
commit 4e771557a5
No known key found for this signature in database
12 changed files with 355 additions and 25 deletions

View file

@ -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")

View file

@ -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:

View file

@ -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")

View file

@ -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")

View file

@ -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:

View file

@ -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:

View file

@ -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")

View file

@ -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:

View file

@ -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")

View file

@ -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")

View file

@ -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 "

View file

@ -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: