mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-11-15 09:11:54 +00:00
Merge pull request #997 from alirezarezvani/claude/deep-learning-book-skill-xnbaz7
This commit is contained in:
commit
527a1b829e
12 changed files with 539 additions and 35 deletions
|
|
@ -225,6 +225,9 @@ def mine(rows: list, attributes: list) -> dict:
|
|||
"multiple_comparisons_note": (
|
||||
f"{len(tested)} candidate(s) reached the test at alpha {ALPHA}. On noise alone you "
|
||||
f"would expect about {expected_false} to pass. {len(supported)} did. "
|
||||
"This is an accounting of that expectation, not an applied correction: no "
|
||||
"Bonferroni or Benjamini-Hochberg adjustment is made to the per-candidate "
|
||||
"threshold, so a single passing candidate is weak evidence on its own. "
|
||||
+ ("Treat these as hypotheses to test deliberately, not as conclusions."
|
||||
if len(supported) <= max(1, expected_false)
|
||||
else "More passed than chance predicts, which is mild evidence something real is "
|
||||
|
|
@ -262,6 +265,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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue