mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
feat(marketing): add linkedin plugin — organic presence with platform rules in code
Answers discussion #934, which asked for a strategic assistant for growing a LinkedIn presence organically rather than a post generator. Six skills under marketing/linkedin/: an orchestrator (context: fork) plus profile, strategy, content, engagement, and analytics lanes. 17 stdlib-only tools, 15 references, 2 agents, 8 /cs:* commands. The design constraint is the differentiator: no LinkedIn credentials, no API calls, no scraping, nothing auto-sent. Automated posting, connecting, and commenting are prohibited by LinkedIn's User Agreement 8.2, and a restricted account ends a compounding asset. linkedin_policy_gate.py runs before any drafting and refuses seven request classes — automation, scraping, engagement pods, bulk messaging, fake identity, fabricated proof, named third-party automation platforms — each carrying the policy anchor and a compliant substitute, so the gate never just says no. Refusals are real rather than advisory. A cadence under 90 minutes a week returns a comment-only plan instead of a schedule that dies in week five. A newsletter whose six-month cost exceeds the budget is refused before the promise is made. An experiment needing more posts than a quarter allows is reported infeasible rather than quietly re-sized. The pattern miner refuses to test anything below 10 posts and reports NOTHING_SURVIVED as a finding. Evidence discipline: two widely repeated claims are corrected rather than propagated. The "personalised note triples acceptance" claim is not supported by the largest samples (acceptance is near-identical either way, ~26.4%); what a note moves is the post-accept reply rate (~5.4% to ~9.4%), which is why the message builder refuses an ask in a first-touch note. The ~19% in-body link reach reduction has never been confirmed by LinkedIn as a penalty and has a plausible dwell-time explanation, so it is a warning rather than a block. Every reference carries per-claim confidence levels. Accessibility is a blocking lint finding: Unicode pseudo-bold is announced by screen readers as mathematical symbols and is not indexed by search. All six SKILL.md files are 6/6 PASS on the write-a-skill checklist. Every tool supports --help, --sample, and --output json with typed exit codes. Counters: skills 380 -> 386; plugins 96 -> 97; tools 706 -> 723; refs 823 -> 838; agents 114 -> 116; commands 138 -> 146 (derive_counters.py --check). Also syncs three previously-merged skills (agent-memory, hivemind, skill-doctor) into the .hermes/ and .vibe/ mirror trees, which had drifted behind .codex/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSPxUHU6utqme7qC6EwHEh
This commit is contained in:
parent
437f321c8c
commit
645c523be1
100 changed files with 8866 additions and 23 deletions
|
|
@ -8,7 +8,7 @@
|
|||
"homepage": "https://github.com/alirezarezvani/claude-skills",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"metadata": {
|
||||
"description": "380 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 706 Python tools, 823 reference guides, 114 agents (cs-* + personas), 138 slash commands across 96 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
|
||||
"description": "386 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 723 Python tools, 838 reference guides, 116 agents (cs-* + personas), 146 slash commands across 97 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
|
||||
"version": "2.12.0"
|
||||
},
|
||||
"plugins": [
|
||||
|
|
@ -1285,6 +1285,28 @@
|
|||
],
|
||||
"category": "marketing"
|
||||
},
|
||||
{
|
||||
"name": "linkedin",
|
||||
"source": "./marketing/linkedin",
|
||||
"description": "Organic LinkedIn presence, end to end, with LinkedIn's own rules enforced in code. Orchestrator (context: fork) gates every request against User Agreement §8.2 — refusing automation, scraping, engagement pods, bulk DMs, fake identity and fabricated proof, each with a compliant substitute — then routes to profile / strategy / content / engagement / analytics. Headline and whole-profile scoring with fixes ranked by points per hour; positioning brief with a mandatory exclusion list; cadence priced against real hours with a 90-minute floor; newsletter eligibility + six-month sustainability gate; post linter blocking on engagement bait and screen-reader-hostile Unicode pseudo-bold; repurposing with a reuse ledger; capped commenting roster and template-refusing outreach; analytics that test patterns against a seeded permutation null and refuse to conclude below 10 posts. 17 stdlib tools, 15 references with per-claim confidence levels. No credentials, no API calls, nothing auto-sent. Answers discussion #934.",
|
||||
"version": "2.12.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani"
|
||||
},
|
||||
"keywords": [
|
||||
"linkedin",
|
||||
"personal-brand",
|
||||
"organic-growth",
|
||||
"content-strategy",
|
||||
"profile-optimization",
|
||||
"outreach",
|
||||
"newsletter",
|
||||
"social-media",
|
||||
"thought-leadership",
|
||||
"career-change"
|
||||
],
|
||||
"category": "marketing"
|
||||
},
|
||||
{
|
||||
"name": "pulse",
|
||||
"source": "./research/pulse",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"name": "claude-code-skills",
|
||||
"description": "Production-ready skill packages for AI agents - Marketing, Engineering, Product, C-Level, PM, and RA/QM",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"total_skills": 356,
|
||||
"total_skills": 362,
|
||||
"skills": [
|
||||
{
|
||||
"name": "agent-launcher-orchestrator",
|
||||
|
|
@ -1223,18 +1223,18 @@
|
|||
"category": "engineering-advanced",
|
||||
"description": "Use when the user asks to design a RAG pipeline, choose a chunking strategy or embedding model, pick a vector database, or evaluate retrieval quality (precision@k, recall@k, NDCG). Examples: 'design a RAG system for our docs', 'what chunk size should I use for this corpus', 'evaluate my retriever against ground truth'. NOT for general LLM cost tuning (use llm-cost-optimizer) or agent loops over retrieval (use agenthub)."
|
||||
},
|
||||
{
|
||||
"name": "run",
|
||||
"source": "../../engineering/agenthub/skills/run",
|
||||
"category": "engineering-advanced",
|
||||
"description": "One-shot lifecycle command that chains init \u2192 baseline \u2192 spawn \u2192 eval \u2192 merge in a single invocation. Use when the user runs /hub:run or asks to execute a full AgentHub competition end-to-end."
|
||||
},
|
||||
{
|
||||
"name": "run",
|
||||
"source": "../../engineering/autoresearch-agent/skills/run",
|
||||
"category": "engineering-advanced",
|
||||
"description": "Run a single experiment iteration. Edit the target file, evaluate, keep or discard. Use when the user runs /ar:run or asks for one manual autoresearch iteration."
|
||||
},
|
||||
{
|
||||
"name": "run",
|
||||
"source": "../../engineering/agenthub/skills/run",
|
||||
"category": "engineering-advanced",
|
||||
"description": "One-shot lifecycle command that chains init \u2192 baseline \u2192 spawn \u2192 eval \u2192 merge in a single invocation. Use when the user runs /hub:run or asks to execute a full AgentHub competition end-to-end."
|
||||
},
|
||||
{
|
||||
"name": "runbook-generator",
|
||||
"source": "../../engineering/skills/runbook-generator",
|
||||
|
|
@ -1541,6 +1541,42 @@
|
|||
"category": "marketing",
|
||||
"description": "When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' 'product update,' 'GTM plan,' 'launch checklist,' or 'launch momentum.' This skill covers phased launches, channel strategy, and ongoing launch momentum."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-analytics",
|
||||
"source": "../../marketing/linkedin/skills/linkedin-analytics",
|
||||
"category": "marketing",
|
||||
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-content",
|
||||
"source": "../../marketing/linkedin/skills/linkedin-content",
|
||||
"category": "marketing",
|
||||
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-engagement",
|
||||
"source": "../../marketing/linkedin/skills/linkedin-engagement",
|
||||
"category": "marketing",
|
||||
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-profile",
|
||||
"source": "../../marketing/linkedin/skills/linkedin-profile",
|
||||
"category": "marketing",
|
||||
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-skills",
|
||||
"source": "../../marketing/linkedin/skills/linkedin-skills",
|
||||
"category": "marketing",
|
||||
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-strategy",
|
||||
"source": "../../marketing/linkedin/skills/linkedin-strategy",
|
||||
"category": "marketing",
|
||||
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment."
|
||||
},
|
||||
{
|
||||
"name": "local-seo-manager",
|
||||
"source": "../../marketing-skill/skills/local-seo-manager",
|
||||
|
|
@ -2194,7 +2230,7 @@
|
|||
"description": "Financial analysis, valuation, and forecasting skills"
|
||||
},
|
||||
"marketing": {
|
||||
"count": 50,
|
||||
"count": 56,
|
||||
"source": "../../marketing-skill",
|
||||
"description": "Marketing, content, and demand generation skills"
|
||||
},
|
||||
|
|
|
|||
1
.codex/skills/linkedin-analytics
Symbolic link
1
.codex/skills/linkedin-analytics
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../marketing/linkedin/skills/linkedin-analytics
|
||||
1
.codex/skills/linkedin-content
Symbolic link
1
.codex/skills/linkedin-content
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../marketing/linkedin/skills/linkedin-content
|
||||
1
.codex/skills/linkedin-engagement
Symbolic link
1
.codex/skills/linkedin-engagement
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../marketing/linkedin/skills/linkedin-engagement
|
||||
1
.codex/skills/linkedin-profile
Symbolic link
1
.codex/skills/linkedin-profile
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../marketing/linkedin/skills/linkedin-profile
|
||||
1
.codex/skills/linkedin-skills
Symbolic link
1
.codex/skills/linkedin-skills
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../marketing/linkedin/skills/linkedin-skills
|
||||
1
.codex/skills/linkedin-strategy
Symbolic link
1
.codex/skills/linkedin-strategy
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../marketing/linkedin/skills/linkedin-strategy
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"name": "gemini-cli-skills",
|
||||
"total_skills": 430,
|
||||
"total_skills": 436,
|
||||
"skills": [
|
||||
{
|
||||
"name": "README",
|
||||
|
|
@ -1793,6 +1793,36 @@
|
|||
"category": "marketing-top-level",
|
||||
"description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-analytics",
|
||||
"category": "marketing-top-level",
|
||||
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-content",
|
||||
"category": "marketing-top-level",
|
||||
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-engagement",
|
||||
"category": "marketing-top-level",
|
||||
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-profile",
|
||||
"category": "marketing-top-level",
|
||||
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-skills",
|
||||
"category": "marketing-top-level",
|
||||
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts."
|
||||
},
|
||||
{
|
||||
"name": "linkedin-strategy",
|
||||
"category": "marketing-top-level",
|
||||
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment."
|
||||
},
|
||||
{
|
||||
"name": "agile-product-owner",
|
||||
"category": "product",
|
||||
|
|
@ -2208,7 +2238,7 @@
|
|||
"description": "Marketing resources"
|
||||
},
|
||||
"marketing-top-level": {
|
||||
"count": 1,
|
||||
"count": 7,
|
||||
"description": "Marketing-top-level resources"
|
||||
},
|
||||
"product": {
|
||||
|
|
|
|||
1
.gemini/skills/linkedin-analytics/SKILL.md
Symbolic link
1
.gemini/skills/linkedin-analytics/SKILL.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../marketing/linkedin/skills/linkedin-analytics/SKILL.md
|
||||
1
.gemini/skills/linkedin-content/SKILL.md
Symbolic link
1
.gemini/skills/linkedin-content/SKILL.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../marketing/linkedin/skills/linkedin-content/SKILL.md
|
||||
1
.gemini/skills/linkedin-engagement/SKILL.md
Symbolic link
1
.gemini/skills/linkedin-engagement/SKILL.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../marketing/linkedin/skills/linkedin-engagement/SKILL.md
|
||||
1
.gemini/skills/linkedin-profile/SKILL.md
Symbolic link
1
.gemini/skills/linkedin-profile/SKILL.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../marketing/linkedin/skills/linkedin-profile/SKILL.md
|
||||
1
.gemini/skills/linkedin-skills/SKILL.md
Symbolic link
1
.gemini/skills/linkedin-skills/SKILL.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../marketing/linkedin/skills/linkedin-skills/SKILL.md
|
||||
1
.gemini/skills/linkedin-strategy/SKILL.md
Symbolic link
1
.gemini/skills/linkedin-strategy/SKILL.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../marketing/linkedin/skills/linkedin-strategy/SKILL.md
|
||||
1
.hermes/skills/claude-skills/engineering/agent-memory
Symbolic link
1
.hermes/skills/claude-skills/engineering/agent-memory
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../engineering/agent-memory/skills/agent-memory
|
||||
1
.hermes/skills/claude-skills/engineering/hivemind
Symbolic link
1
.hermes/skills/claude-skills/engineering/hivemind
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../engineering/hivemind
|
||||
1
.hermes/skills/claude-skills/engineering/skill-doctor
Symbolic link
1
.hermes/skills/claude-skills/engineering/skill-doctor
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../engineering/skill-doctor/skills/skill-doctor
|
||||
1
.hermes/skills/claude-skills/marketing/linkedin-analytics
Symbolic link
1
.hermes/skills/claude-skills/marketing/linkedin-analytics
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-analytics
|
||||
1
.hermes/skills/claude-skills/marketing/linkedin-content
Symbolic link
1
.hermes/skills/claude-skills/marketing/linkedin-content
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-content
|
||||
1
.hermes/skills/claude-skills/marketing/linkedin-engagement
Symbolic link
1
.hermes/skills/claude-skills/marketing/linkedin-engagement
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-engagement
|
||||
1
.hermes/skills/claude-skills/marketing/linkedin-profile
Symbolic link
1
.hermes/skills/claude-skills/marketing/linkedin-profile
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-profile
|
||||
1
.hermes/skills/claude-skills/marketing/linkedin-skills
Symbolic link
1
.hermes/skills/claude-skills/marketing/linkedin-skills
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-skills
|
||||
1
.hermes/skills/claude-skills/marketing/linkedin-strategy
Symbolic link
1
.hermes/skills/claude-skills/marketing/linkedin-strategy
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-strategy
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"source": "claude-code-skills",
|
||||
"total_skills": 353,
|
||||
"total_skills": 362,
|
||||
"domains": {
|
||||
"engineering": [
|
||||
{
|
||||
|
|
@ -198,6 +198,11 @@
|
|||
"description": "Turn any domain folder of skills into a bounded agentic loop: compile a goal into a verifiable task plan, execute tasks with the domain's own tools, verify every task with machine-run checks, retry with caps, escalate to a human when budgets exhaust, and refuse to close until everything is verified or explicitly waived. Use when you want an agent or subagent to pick up a goal and drive it to a verified close across one of this repo's 18 domains ('run this goal through the engineering harness', 'set up an agentic loop for marketing work', 'make the finance domain self-verifying'). NOT for authoring Claude Code Workflow-tool .js scripts (workflow-builder), N-agent tournaments on one task (agenthub), single-file metric optimization (autoresearch-agent), or discovering published loop recipes (loop-library).",
|
||||
"path": "engineering/agent-harness"
|
||||
},
|
||||
{
|
||||
"name": "agent-memory",
|
||||
"description": "Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead \u2014 or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.",
|
||||
"path": "engineering/agent-memory"
|
||||
},
|
||||
{
|
||||
"name": "agenthub",
|
||||
"description": "Multi-agent collaboration plugin that spawns N parallel subagents competing on the same task via git worktree isolation. Agents work independently, results are evaluated by metric or LLM judge, and the best branch is merged. Use when: user wants multiple approaches tried in parallel \u2014 code optimization, content variation, research exploration, or any task that benefits from parallel competition. Requires: a git repo.",
|
||||
|
|
@ -348,6 +353,11 @@
|
|||
"description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw \u2014 chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test.",
|
||||
"path": "engineering/helm-chart-builder"
|
||||
},
|
||||
{
|
||||
"name": "hivemind",
|
||||
"description": "Orchestrate free opencode workers from Claude Code to cut token costs. Use when delegating grunt work to a single worker or a parallel swarm (scout/coder/tester) with worktree isolation, benchmarking against opencode, or when the user says \"spawn a worker\", \"swarm\", \"delegate to opencode\", or \"/oc\".",
|
||||
"path": "engineering/hivemind"
|
||||
},
|
||||
{
|
||||
"name": "human-gate",
|
||||
"description": "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer).",
|
||||
|
|
@ -393,6 +403,11 @@
|
|||
"description": "PreToolUse security-anti-pattern hook for Claude Code. Catches 12 common security risks (command injection, XSS, SQL injection, unsafe deserialization, GitHub Actions workflow injection, eval/new Function code injection) BEFORE the Edit/Write/MultiEdit operation completes. Session-state caching prevents duplicate warnings on the same file+rule combo. Stdlib only \u2014 no dependencies. Use when you want a safety net during Claude Code sessions that touch security-sensitive code (auth, payments, user input handling, IaC). Disable with ENABLE_SECURITY_REMINDER=0 if you need to perform a verified-safe operation that would otherwise trip a pattern. Triggers \u2014 \"add security hook\", \"block unsafe code\", \"detect command injection before write\", \"prevent SQL injection patterns\", \"security warning hook\".",
|
||||
"path": "engineering/security-guidance"
|
||||
},
|
||||
{
|
||||
"name": "skill-doctor",
|
||||
"description": "Use when the user wants their agent setup graded from real conversation history, asks which installed skills are actually working, or wants evidence-backed skill edits \u2014 scores recent local Claude Code / Codex sessions against efficiency and code-quality rubrics, then drafts skill changes gated by a deterministic aggregator and renders one local shareable report.",
|
||||
"path": "engineering/skill-doctor"
|
||||
},
|
||||
{
|
||||
"name": "skillopt-sleep",
|
||||
"description": "Use when the user wants their Claude agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, memory/skill consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule offline self-optimization. Drives the skillopt_sleep engine: harvest past sessions -> mine recurring tasks -> replay offline -> consolidate validated CLAUDE.md and SKILL.md behind a held-out gate.",
|
||||
|
|
@ -1537,6 +1552,36 @@
|
|||
"name": "landing",
|
||||
"description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides.",
|
||||
"path": "marketing/landing"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-analytics",
|
||||
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts.",
|
||||
"path": "marketing/linkedin-analytics"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-content",
|
||||
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.",
|
||||
"path": "marketing/linkedin-content"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-engagement",
|
||||
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.",
|
||||
"path": "marketing/linkedin-engagement"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-profile",
|
||||
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold.",
|
||||
"path": "marketing/linkedin-profile"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-skills",
|
||||
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.",
|
||||
"path": "marketing/linkedin-skills"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-strategy",
|
||||
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.",
|
||||
"path": "marketing/linkedin-strategy"
|
||||
}
|
||||
],
|
||||
"research": [
|
||||
|
|
|
|||
1
.vibe/skills/claude-skills/engineering/agent-memory
Symbolic link
1
.vibe/skills/claude-skills/engineering/agent-memory
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../engineering/agent-memory/skills/agent-memory
|
||||
1
.vibe/skills/claude-skills/engineering/hivemind
Symbolic link
1
.vibe/skills/claude-skills/engineering/hivemind
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../engineering/hivemind
|
||||
1
.vibe/skills/claude-skills/engineering/skill-doctor
Symbolic link
1
.vibe/skills/claude-skills/engineering/skill-doctor
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../engineering/skill-doctor/skills/skill-doctor
|
||||
1
.vibe/skills/claude-skills/marketing/linkedin-analytics
Symbolic link
1
.vibe/skills/claude-skills/marketing/linkedin-analytics
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-analytics
|
||||
1
.vibe/skills/claude-skills/marketing/linkedin-content
Symbolic link
1
.vibe/skills/claude-skills/marketing/linkedin-content
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-content
|
||||
1
.vibe/skills/claude-skills/marketing/linkedin-engagement
Symbolic link
1
.vibe/skills/claude-skills/marketing/linkedin-engagement
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-engagement
|
||||
1
.vibe/skills/claude-skills/marketing/linkedin-profile
Symbolic link
1
.vibe/skills/claude-skills/marketing/linkedin-profile
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-profile
|
||||
1
.vibe/skills/claude-skills/marketing/linkedin-skills
Symbolic link
1
.vibe/skills/claude-skills/marketing/linkedin-skills
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-skills
|
||||
1
.vibe/skills/claude-skills/marketing/linkedin-strategy
Symbolic link
1
.vibe/skills/claude-skills/marketing/linkedin-strategy
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../marketing/linkedin/skills/linkedin-strategy
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"source": "claude-code-skills",
|
||||
"layout": "nested",
|
||||
"total_skills": 353,
|
||||
"total_skills": 362,
|
||||
"domains": {
|
||||
"engineering": [
|
||||
{
|
||||
|
|
@ -199,6 +199,11 @@
|
|||
"description": "Turn any domain folder of skills into a bounded agentic loop: compile a goal into a verifiable task plan, execute tasks with the domain's own tools, verify every task with machine-run checks, retry with caps, escalate to a human when budgets exhaust, and refuse to close until everything is verified or explicitly waived. Use when you want an agent or subagent to pick up a goal and drive it to a verified close across one of this repo's 18 domains ('run this goal through the engineering harness', 'set up an agentic loop for marketing work', 'make the finance domain self-verifying'). NOT for authoring Claude Code Workflow-tool .js scripts (workflow-builder), N-agent tournaments on one task (agenthub), single-file metric optimization (autoresearch-agent), or discovering published loop recipes (loop-library).",
|
||||
"path": "engineering/agent-harness"
|
||||
},
|
||||
{
|
||||
"name": "agent-memory",
|
||||
"description": "Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead \u2014 or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.",
|
||||
"path": "engineering/agent-memory"
|
||||
},
|
||||
{
|
||||
"name": "agenthub",
|
||||
"description": "Multi-agent collaboration plugin that spawns N parallel subagents competing on the same task via git worktree isolation. Agents work independently, results are evaluated by metric or LLM judge, and the best branch is merged. Use when: user wants multiple approaches tried in parallel \u2014 code optimization, content variation, research exploration, or any task that benefits from parallel competition. Requires: a git repo.",
|
||||
|
|
@ -349,6 +354,11 @@
|
|||
"description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw \u2014 chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test.",
|
||||
"path": "engineering/helm-chart-builder"
|
||||
},
|
||||
{
|
||||
"name": "hivemind",
|
||||
"description": "Orchestrate free opencode workers from Claude Code to cut token costs. Use when delegating grunt work to a single worker or a parallel swarm (scout/coder/tester) with worktree isolation, benchmarking against opencode, or when the user says \"spawn a worker\", \"swarm\", \"delegate to opencode\", or \"/oc\".",
|
||||
"path": "engineering/hivemind"
|
||||
},
|
||||
{
|
||||
"name": "human-gate",
|
||||
"description": "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer).",
|
||||
|
|
@ -394,6 +404,11 @@
|
|||
"description": "PreToolUse security-anti-pattern hook for Claude Code. Catches 12 common security risks (command injection, XSS, SQL injection, unsafe deserialization, GitHub Actions workflow injection, eval/new Function code injection) BEFORE the Edit/Write/MultiEdit operation completes. Session-state caching prevents duplicate warnings on the same file+rule combo. Stdlib only \u2014 no dependencies. Use when you want a safety net during Claude Code sessions that touch security-sensitive code (auth, payments, user input handling, IaC). Disable with ENABLE_SECURITY_REMINDER=0 if you need to perform a verified-safe operation that would otherwise trip a pattern. Triggers \u2014 \"add security hook\", \"block unsafe code\", \"detect command injection before write\", \"prevent SQL injection patterns\", \"security warning hook\".",
|
||||
"path": "engineering/security-guidance"
|
||||
},
|
||||
{
|
||||
"name": "skill-doctor",
|
||||
"description": "Use when the user wants their agent setup graded from real conversation history, asks which installed skills are actually working, or wants evidence-backed skill edits \u2014 scores recent local Claude Code / Codex sessions against efficiency and code-quality rubrics, then drafts skill changes gated by a deterministic aggregator and renders one local shareable report.",
|
||||
"path": "engineering/skill-doctor"
|
||||
},
|
||||
{
|
||||
"name": "skillopt-sleep",
|
||||
"description": "Use when the user wants their Claude agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, memory/skill consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule offline self-optimization. Drives the skillopt_sleep engine: harvest past sessions -> mine recurring tasks -> replay offline -> consolidate validated CLAUDE.md and SKILL.md behind a held-out gate.",
|
||||
|
|
@ -1538,6 +1553,36 @@
|
|||
"name": "landing",
|
||||
"description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides.",
|
||||
"path": "marketing/landing"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-analytics",
|
||||
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts.",
|
||||
"path": "marketing/linkedin-analytics"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-content",
|
||||
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.",
|
||||
"path": "marketing/linkedin-content"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-engagement",
|
||||
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.",
|
||||
"path": "marketing/linkedin-engagement"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-profile",
|
||||
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold.",
|
||||
"path": "marketing/linkedin-profile"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-skills",
|
||||
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.",
|
||||
"path": "marketing/linkedin-skills"
|
||||
},
|
||||
{
|
||||
"name": "linkedin-strategy",
|
||||
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.",
|
||||
"path": "marketing/linkedin-strategy"
|
||||
}
|
||||
],
|
||||
"research": [
|
||||
|
|
|
|||
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -5,6 +5,65 @@ All notable changes to the Claude Skills Library will be documented in this file
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — marketing/linkedin: organic LinkedIn presence with the platform rules in code
|
||||
|
||||
New `marketing/linkedin/` plugin, answering
|
||||
[discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934), which
|
||||
asked for a strategic assistant for growing a LinkedIn presence organically rather than a
|
||||
post generator. Six skills, 17 stdlib-only tools, 15 references, 2 agents, 8 commands.
|
||||
|
||||
- **The design constraint is the differentiator.** The plugin holds no LinkedIn credentials,
|
||||
makes no API calls, scrapes nothing, and sends nothing — automated posting, connecting,
|
||||
commenting, and liking are prohibited by LinkedIn's User Agreement §8.2, and a restricted
|
||||
account ends a compounding asset. `linkedin_policy_gate.py` runs before any drafting and
|
||||
refuses seven classes of request (automation, scraping, engagement pods, bulk messaging,
|
||||
fake identity, fabricated proof, named third-party automation platforms), each with the
|
||||
policy anchor and a **compliant substitute** — the gate never just says no.
|
||||
- **`linkedin-skills`** (orchestrator, `context: fork`) — policy gate + deterministic
|
||||
five-lane router (route 0 / ask 2 / no-signal 3) with cross-lane prerequisites.
|
||||
- **`linkedin-profile`** — headline scored on audience/outcome/proof/searchability/clarity
|
||||
against the 220-char cap and the ~60-char front-load window; whole-profile audit across 14
|
||||
weighted checks with fixes ranked by **points per hour** and a first-hour plan; About
|
||||
builder that refuses a fold cutting mid-sentence or carrying no audience and no proof.
|
||||
- **`linkedin-strategy`** — positioning brief validator (six real objectives, an audience
|
||||
specific enough to exclude someone, 2-4 proof-backed pillars, a **mandatory exclusion
|
||||
list**); cadence planner that prices the week in minutes and returns a comment-only plan
|
||||
below a 90-minute floor; newsletter gate on LinkedIn's published 150-follower evaluation
|
||||
threshold plus six-month cadence cost, with a stop rule written before issue one.
|
||||
- **`linkedin-content`** — post linter across mechanics / hook / integrity / accessibility,
|
||||
blocking on the 3,000-char cap, engagement bait, and **Unicode pseudo-bold** (screen
|
||||
readers announce it as mathematical symbols; search does not index it as words); format
|
||||
picker over nine native formats; repurpose splitter with a **content-hash reuse ledger**.
|
||||
- **`linkedin-engagement`** — comment roster capped at two appearances per account per week;
|
||||
message builder that refuses a template without a person-specific line and refuses an ask
|
||||
in a first-touch connection note; volume guard that refuses above 40 invitations a day as
|
||||
an automation plan regardless of intent.
|
||||
- **`linkedin-analytics`** — median/MAD describer with Tukey bands (a mean describes a
|
||||
distribution none of your posts belong to); four-gate permutation pattern miner with
|
||||
**multiple-comparisons accounting** and mirrored-candidate de-duplication; experiment
|
||||
planner that reports infeasibility rather than quietly shrinking the effect. Refuses to
|
||||
conclude anything below 10 posts.
|
||||
- **Evidence discipline — two widely repeated claims corrected rather than propagated.**
|
||||
(1) "A personalised connection note triples acceptance (~45% vs ~15%)" is not supported by
|
||||
the largest samples, which show acceptance close to identical either way (~26.4%); what a
|
||||
note moves is the **post-accept reply rate** (~5.4% → ~9.4%), which is why the builder
|
||||
refuses an ask in a first-touch note. (2) The ~19% in-body link reach reduction has never
|
||||
been confirmed by LinkedIn as a penalty and has a plausible dwell-time explanation, so it
|
||||
is a warning rather than a blocking finding. Every reference carries per-claim confidence
|
||||
levels (🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore).
|
||||
- All six SKILL.md files are a full **6/6 PASS** on the write-a-skill checklist. Every tool
|
||||
supports `--help`, `--sample`, and `--output json` with typed exit codes.
|
||||
- **Counters:** skills 380 → 386; plugins 96 → 97; tools 706 → 723; references 823 → 838;
|
||||
agents 114 → 116; commands 138 → 146 (verified via `scripts/derive_counters.py --check`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Synced three previously-merged skills (`engineering/agent-memory`, `engineering/hivemind`,
|
||||
`engineering/skill-doctor`) into the `.hermes/` and `.vibe/` mirror trees, which had
|
||||
drifted behind `.codex/` and `.gemini/`.
|
||||
|
||||
## [2.12.0] - 2026-08-24 — consolidated release: 20 domains, 380 skills, full issue-triage sweep
|
||||
|
||||
**First tagged release since v2.9.0.** Versions 2.10.0–2.11.2 were documented in
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
File diff suppressed because one or more lines are too long
16
README.md
16
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Claude Code Skills & Plugins — Agent Skills for Every Coding Tool
|
||||
|
||||
**380 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
|
||||
**386 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
|
||||
|
||||
The most comprehensive open-source library of Claude Code skills and agent plugins — also works with OpenAI Codex, Gemini CLI, Cursor, and 9 more coding agents. Reusable expertise packages covering engineering, DevOps, marketing (incl. AEO — Answer Engine Optimization for LLM citation), security (PreToolUse hooks), compliance, C-level advisory (incl. founder-mode CFO/CMO/CRO/CPO/COO/CHRO/CISO/GC/CDO/CAIO/CCO/VPE personas + 21 /cs:* slash commands), productivity (capture/email/reflect/weekly-review/deep-work/meetings), an academic research stack (litreview/grants/dossier/patent/syllabus/pulse/notebooklm/deep-research + hybrid router), and enterprise Research Operations (clinical-research/research-finance/market-research/product-research, v2.9.0).
|
||||
|
||||
|
|
@ -10,10 +10,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
|
|||
[^vibe]: Mistral Vibe is also **BYO-sync tier**: the repo ships a pre-generated `.vibe/skills/claude-skills/` tree, run `./scripts/vibe-install.sh` once locally to install into `~/.vibe/skills/`. Same agentskills.io SKILL.md standard — no format conversion. Docs: <https://docs.mistral.ai/mistral-vibe/agents-skills>.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](#skills-overview)
|
||||
[](#agents)
|
||||
[](#skills-overview)
|
||||
[](#agents)
|
||||
[](#personas)
|
||||
[](#commands)
|
||||
[](#commands)
|
||||
[](https://github.com/alirezarezvani/claude-skills/stargazers)
|
||||
[](https://getskillcheck.com)
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ Claude Code skills (also called agent skills or coding agent plugins) are modula
|
|||
- **Python tools** — 706 CLI scripts (all stdlib-only, zero pip installs)
|
||||
- **Reference docs** — 823 templates, checklists, and domain-specific knowledge files
|
||||
|
||||
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 706 Python tools run anywhere Python runs.
|
||||
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 723 Python tools run anywhere Python runs.
|
||||
|
||||
### Skills vs Agents vs Personas
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally.
|
|||
|
||||
## Skills Overview
|
||||
|
||||
**380 production-ready skills across 20 domains:**
|
||||
**386 production-ready skills across 20 domains:**
|
||||
|
||||
| Domain | Skills | Highlights | Details |
|
||||
|--------|--------|------------|---------|
|
||||
|
|
@ -161,7 +161,7 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally.
|
|||
| **🎯 Product** | 17 | Product manager, agile PO, strategist, UX researcher, UI design, landing pages, SaaS scaffolder, analytics, experiment designer, discovery, roadmap communicator, code-to-prd, apple-hig-expert | [product-team/](product-team/) |
|
||||
| **📣 Marketing** | 49 | 8 pods: Content, SEO + AEO (`aeo` — E-E-A-T audit, citation tracking across 5 LLMs) + local (`local-seo-manager` — GBP/NAP/Map-Pack), CRO, Channels, Growth, Intelligence, Sales + `business-name-fit` (cross-cultural naming) + context foundation + orchestration router | [marketing-skill/](marketing-skill/) |
|
||||
| **🚀 Productivity** | 12 | `capture` (brain-dump-to-action), `email` pair (inbox-setup + inbox-triage), `reflect` (journal), `handoff` (Matt Pocock-inspired), `andreessen` (market-first decision mode), `roast` (5-angle idea panel → GO/RESHAPE/KILL), `fable-goal` (ramble → autonomous /goal prompt), `weekly-review` (GTD loop with refusal gate), `deep-work` (time-blocking + shallow-work budget), `meetings` (cost gate + agenda + action items), `swedish-mentor` (CEFR-leveled Swedish learning paths) | [productivity/](productivity/) |
|
||||
| **🎨 Marketing (top-level)** | 1 | `landing` — single-file HTML landing-page generator (4 design styles, GSAP patterns, brand palette validator) | [marketing/](marketing/) |
|
||||
| **🎨 Marketing (top-level)** | 7 | `landing` — single-file HTML landing-page generator (4 design styles, GSAP patterns, brand palette validator); `linkedin` — organic LinkedIn presence with User Agreement §8.2 refusals in code (orchestrator + profile, strategy, content, engagement, analytics) | [marketing/](marketing/) |
|
||||
| **🔬 Research (academic)** | 10 | `research` orchestrator (hybrid router + fallback) + 8 specialists: `pulse`, `litreview`, `grants` (NIH), `dossier`, `patent`, `syllabus`, `notebooklm`, `deep-research` (rigor-first meta-research), `deepread` (evidence-first reading of supplied documents) | [research/](research/) |
|
||||
| **🧪 Research Operations** ✨v2.9.0 | 5 | Enterprise/cross-functional research: orchestrator + `clinical-research` (study design), `research-finance` (R&D program finance), `market-research` (sizing/survey/segmentation), `product-research` (user research) — each with onboarding + customization + opt-in autoresearch bridge | [research-ops/](research-ops/) |
|
||||
| **📋 Project Management** | 9 | Senior PM, scrum master, Jira, Confluence, Atlassian admin, templates + bundled Atlassian Remote MCP | [project-management/](project-management/) |
|
||||
|
|
@ -359,7 +359,7 @@ Yes. Skills work natively with 13 tools: Claude Code, OpenAI Codex, Gemini CLI,
|
|||
No. We follow semantic versioning and maintain backward compatibility within patch releases. Existing script arguments, plugin source paths, and SKILL.md structures are never changed in patch versions. See the [CHANGELOG](CHANGELOG.md) for details on each release.
|
||||
|
||||
**Are the Python tools dependency-free?**
|
||||
Yes. All 706 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
|
||||
Yes. All 723 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
|
||||
|
||||
**How do I create my own Claude Code skill?**
|
||||
Each skill is a folder with a `SKILL.md` (frontmatter + instructions), optional `scripts/`, `references/`, and `assets/`. See the [Skills & Agents Factory](https://github.com/alirezarezvani/claude-code-skills-agents-factory) for a step-by-step guide.
|
||||
|
|
|
|||
11
marketing/linkedin/.claude-plugin/authoring-notes.json
Normal file
11
marketing/linkedin/.claude-plugin/authoring-notes.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"source": {
|
||||
"spec": "https://github.com/alirezarezvani/claude-skills/discussions/934",
|
||||
"build_pattern": "Domain plugin — context: fork orchestrator with a policy gate in front of a deterministic five-lane router; every sub-skill ships 3 stdlib-only tools with typed exit codes, references citing 7 sources each with per-claim confidence levels (LinkedIn-official / third-party study / folklore), and fillable assets. Answers discussion #934's ten requested capabilities: content strategy, post ideation, multi-format drafting, posting cadence, newsletter planning, profile optimization, outreach messages, reach tactics via comments and communities, repurposing from articles/video/audio, and analysis of existing posts.",
|
||||
"distinct_from": "marketing-skill/x-twitter-growth (X/Twitter mechanics — different platform, format economics, and rules); marketing-skill/social-content and social-media-manager (multi-platform brand calendars — this is one person's own presence on one platform, in depth); marketing-skill/social-media-analyzer (cross-platform campaign reporting — this reads your own LinkedIn export and refuses to conclude below 10 posts); marketing-skill/cold-email (different channel, different law, different caps); productivity/andreessen (venture judgement, not audience building)"
|
||||
},
|
||||
"attribution": {
|
||||
"policy_basis": "Refusal rules are derived from LinkedIn's published User Agreement §8.2, the Prohibited Software and Extensions help article, and the Professional Community Policies. No LinkedIn code, data, or proprietary material is included, and the plugin makes no LinkedIn API calls.",
|
||||
"evidence_discipline": "Two widely repeated third-party claims are corrected rather than propagated. (1) The claim that a personalised connection note roughly triples acceptance (~45% vs ~15%) is not supported by the largest available samples, which show acceptance close to identical either way (~26.4%); what the note moves is the post-accept reply rate (~5.4% to ~9.4%). (2) The in-body external-link reach reduction (~19% median in a ~1.3M-post third-party study) has never been confirmed by LinkedIn as a penalty and has a plausible non-punitive explanation via dwell time, so it is flagged as a warning rather than a blocking finding. Per-claim confidence levels are carried in every reference."
|
||||
}
|
||||
}
|
||||
20
marketing/linkedin/.claude-plugin/plugin.json
Normal file
20
marketing/linkedin/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "linkedin",
|
||||
"description": "Organic LinkedIn presence, end to end, with the platform rules enforced in code. An orchestrator (context: fork) gates every request against LinkedIn's User Agreement §8.2 — refusing automation, scraping, engagement pods, bulk DMs, fake identity, fabricated proof, and named third-party automation tools, each with a compliant substitute — then routes to one of five sub-skills. linkedin-profile scores a headline on five dimensions against the 220-character cap, audits the whole profile 0-100 with fixes ranked by points per hour, and assembles an About section that survives the '…see more' fold. linkedin-strategy validates a positioning brief (six real objectives, an audience specific enough to exclude someone, 2-4 proof-backed pillars, a mandatory exclusion list), sizes a week against the hours that actually exist and refuses below a 90-minute floor, and gates a newsletter on eligibility plus a six-month cadence commitment. linkedin-content lints posts 0-100 across mechanics, hook, integrity, and accessibility — blocking on the 3,000-character cap, engagement bait, and Unicode pseudo-bold that screen readers cannot read — picks the format the material actually supports, and splits long sources into standalone units against a reuse ledger. linkedin-engagement builds a capped commenting roster, refuses template outreach without a person-specific line, and refuses volumes no human could type. linkedin-analytics reads your own export, reports medians rather than misleading means, and tests candidate patterns against a seeded permutation null with multiple-comparisons accounting — refusing to conclude anything below 10 posts. 17 stdlib-only Python tools, 15 references citing 7 sources each with per-claim confidence levels. No LinkedIn credentials, no API calls, nothing auto-sent.",
|
||||
"version": "2.12.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani",
|
||||
"url": "https://alirezarezvani.com"
|
||||
},
|
||||
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/marketing/linkedin",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"license": "MIT",
|
||||
"skills": [
|
||||
"./skills/linkedin-skills",
|
||||
"./skills/linkedin-profile",
|
||||
"./skills/linkedin-strategy",
|
||||
"./skills/linkedin-content",
|
||||
"./skills/linkedin-engagement",
|
||||
"./skills/linkedin-analytics"
|
||||
]
|
||||
}
|
||||
108
marketing/linkedin/README.md
Normal file
108
marketing/linkedin/README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# linkedin — organic LinkedIn presence, with the rules enforced in code
|
||||
|
||||
A strategic assistant for building an authentic LinkedIn presence over months, not a post
|
||||
generator. Built in response to
|
||||
[discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934).
|
||||
|
||||
**The design constraint that shapes everything else:** this plugin holds no LinkedIn
|
||||
credentials, makes no API calls, scrapes nothing, and sends nothing. Every output is text the
|
||||
account holder posts themselves. Automated posting, connecting, commenting, and liking are
|
||||
prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding
|
||||
asset that took months to build. So the refusals are in code, at the front of every lane,
|
||||
and each one names a compliant substitute.
|
||||
|
||||
```
|
||||
/cs:linkedin # gate + route
|
||||
/cs:grill-linkedin # five forcing questions before the work starts
|
||||
/cs:linkedin-profile # headline, About, whole-profile audit
|
||||
/cs:linkedin-plan # brief, pillars, cadence, newsletter
|
||||
/cs:linkedin-post # format, draft, lint
|
||||
/cs:linkedin-repurpose # article/talk -> posts, with a reuse ledger
|
||||
/cs:linkedin-outreach # comment roster + one message at a time
|
||||
/cs:linkedin-analyze # your own export, analysed honestly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What is here
|
||||
|
||||
| Skill | Tools | Does |
|
||||
|---|---|---|
|
||||
| **linkedin-skills** (orchestrator, `context: fork`) | 2 | Policy gate (ALLOW / CONSTRAIN / REFUSE) then a deterministic five-lane router with cross-lane prerequisites |
|
||||
| **linkedin-profile** | 3 | Headline scored on five dimensions vs the 220-char cap; whole profile 0-100 with fixes ranked by points per hour; About section assembled to survive the fold |
|
||||
| **linkedin-strategy** | 3 | Positioning brief validator; cadence priced in minutes with a 90-minute floor; newsletter eligibility + six-month sustainability gate |
|
||||
| **linkedin-content** | 3 | Post linter (mechanics / hook / integrity / accessibility); format picker; repurpose splitter with a reuse ledger |
|
||||
| **linkedin-engagement** | 3 | Capped commenting roster; message builder that refuses templates; volume guard that refuses automation-shaped plans |
|
||||
| **linkedin-analytics** | 3 | Median/MAD describer; four-gate permutation pattern miner; experiment planner |
|
||||
|
||||
Plus 2 agents, 8 commands, 15 references (7 sources each), 11 assets.
|
||||
|
||||
## The parts that are different
|
||||
|
||||
**The policy gate runs before anything is drafted.** Seven refusal rules covering automation,
|
||||
scraping, engagement pods, bulk messaging, fake identity, fabricated proof, and named
|
||||
third-party automation tools — each with the User Agreement clause and a substitute that
|
||||
achieves the same goal legitimately. A REFUSE outranks any route.
|
||||
|
||||
**Evidence is graded, and two popular claims are corrected.** Every quantitative claim carries
|
||||
🟢 (LinkedIn-official), 🟡 (third-party study), or 🔴 (folklore, named as folklore).
|
||||
|
||||
- The "a personalised note triples acceptance" claim is not supported by the largest samples,
|
||||
which show acceptance close to identical either way (~26.4%). What a note actually moves is
|
||||
the **post-accept reply rate** (~5.4% → ~9.4%). That changes what the note should say: it
|
||||
earns the conversation, not the meeting — which is why `outreach_message_builder.py`
|
||||
refuses an ask in a first-touch connection note.
|
||||
- The ~19% in-body link reach reduction has **never been confirmed by LinkedIn as a penalty**
|
||||
and has a plausible non-punitive explanation via dwell time. It is a warning, not a block.
|
||||
|
||||
**The analytics refuse to over-conclude.** `pattern_miner.py` puts every candidate through four
|
||||
gates: a group-size floor, a 15% minimum relative effect, a seeded permutation test, and a
|
||||
multiple-comparisons accounting that reports how many candidates would pass on noise alone.
|
||||
Below 10 posts it refuses to test anything. `NOTHING_SURVIVED` is the most common honest
|
||||
answer and is reported as a finding, not a failure.
|
||||
|
||||
**Accessibility is a blocking finding, not a footnote.** Unicode pseudo-bold — the output of
|
||||
"bold text generators" — is a blocking lint failure, because screen readers announce those
|
||||
characters as mathematical symbols and LinkedIn search does not index them as words.
|
||||
|
||||
**Refusals are refusals.** A cadence under 90 minutes a week returns a comment-only plan
|
||||
rather than a schedule that will be abandoned in week five. A newsletter whose six-month cost
|
||||
exceeds the budget is refused before the promise is made. An experiment that needs more posts
|
||||
than a quarter allows is reported as infeasible rather than quietly re-sized.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# See every tool run with zero configuration
|
||||
python3 skills/linkedin-skills/scripts/linkedin_policy_gate.py --sample --output human
|
||||
python3 skills/linkedin-profile/scripts/headline_scorer.py --sample-weak --output human
|
||||
python3 skills/linkedin-strategy/scripts/cadence_planner.py --sample --output human
|
||||
python3 skills/linkedin-content/scripts/post_linter.py --sample --output human
|
||||
python3 skills/linkedin-engagement/scripts/outreach_volume_guard.py --sample --output human
|
||||
python3 skills/linkedin-analytics/scripts/pattern_miner.py --sample --output human
|
||||
```
|
||||
|
||||
Every tool supports `--help`, `--sample`, and `--output json`, uses the standard library
|
||||
only, and returns typed exit codes so an agent can branch on the verdict rather than parse
|
||||
prose.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`marketing-skill/x-twitter-growth`** — X/Twitter mechanics. Different platform, different
|
||||
format economics, different rules.
|
||||
- **`marketing-skill/social-content`, `social-media-manager`** — multi-platform brand
|
||||
calendars. This is one person's own presence on one platform, in depth.
|
||||
- **`marketing-skill/social-media-analyzer`** — cross-platform campaign reporting. This reads
|
||||
your own LinkedIn export and refuses to conclude below 10 posts.
|
||||
- **`marketing-skill/cold-email`** — different channel, different law, different caps.
|
||||
|
||||
## Provenance
|
||||
|
||||
Requested in [discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934).
|
||||
Refusal rules derive from LinkedIn's published User Agreement §8.2, the Prohibited Software
|
||||
and Extensions help article, and the Professional Community Policies. No LinkedIn code, data,
|
||||
or proprietary material is included. See `.claude-plugin/authoring-notes.json`.
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0 · MIT · 6 skills · 17 stdlib tools · 15 references · nothing auto-sent
|
||||
89
marketing/linkedin/agents/cs-linkedin-editor.md
Normal file
89
marketing/linkedin/agents/cs-linkedin-editor.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
name: cs-linkedin-editor
|
||||
description: Drafts, edits, and lints LinkedIn posts to a publishable standard — hook that survives the ~140-character mobile fold, one idea, real numbers, no engagement bait, no Unicode pseudo-bold, alt text and captions written. Runs post_linter.py to a clean exit rather than declaring a draft done, picks the format the material actually supports, and splits long sources into standalone units against a reuse ledger so nothing goes out twice. Refuses to invent a metric, client, result, or quote. Use when someone wants a LinkedIn post written, reviewed, rewritten, or repurposed from an article, talk, or transcript.
|
||||
skills: marketing/linkedin/skills/linkedin-content
|
||||
domain: marketing
|
||||
model: opus
|
||||
tools: [Read, Bash, Write, Edit]
|
||||
---
|
||||
|
||||
# LinkedIn Editor Agent
|
||||
|
||||
## Purpose
|
||||
|
||||
`cs-linkedin-editor` owns the draft. Its job is to get the specific thing out of the author's
|
||||
head and onto the page, then remove everything that would stop a stranger reading it.
|
||||
|
||||
1. **Interview for specifics first.** Never draft from a topic. Get the number, the mistake,
|
||||
the sentence someone actually said, the thing that surprised them. A post with no
|
||||
specifics cannot be fixed by editing.
|
||||
2. **Pick the format from the material** (`format_picker.py`), not from what is working for
|
||||
other people this month.
|
||||
3. **Draft to the fold.** A sentence completes inside the first ~140 characters. That is
|
||||
where the reader decides.
|
||||
4. **Lint to a clean exit** (`post_linter.py`). Two passes is normal. Do not declare a draft
|
||||
finished on a REVISE unless the user explicitly accepts a named warning.
|
||||
5. **Hand back with the accessibility work done** — alt text written, captions flagged as
|
||||
required, no pseudo-bold anywhere.
|
||||
|
||||
## Voice
|
||||
|
||||
- Cuts first, adds second. The first paragraph is usually throat-clearing; check whether the
|
||||
post starts better at paragraph two, because it usually does.
|
||||
- Reads drafts out loud. Every sentence you stumble on is one a reader stumbles on.
|
||||
- Allergic to the interchangeable sentence. If a line would fit any post about anything, it
|
||||
is not carrying meaning.
|
||||
- Never flatters a draft. "This is close, and here are the three things stopping it" is
|
||||
more useful than encouragement.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Never fabricate a number, client, result, credential, or quote.** If the proof does not
|
||||
exist, the post is about the process — which is a legitimate post and it ages better.
|
||||
2. **No engagement bait.** "Comment X below", "like if you agree", "tag someone", "Agree?".
|
||||
Blocking in the linter, named as demoted content in LinkedIn's Professional Community
|
||||
Policies, and recognisable to every reader.
|
||||
3. **No Unicode pseudo-bold.** Screen readers announce it as mathematical symbols and search
|
||||
does not index it as words. This is an accessibility failure, not a style preference.
|
||||
4. **Alt text on every image, captions on every video.** LinkedIn does not add them for you,
|
||||
and auto-captions mangle exactly the domain terms the post is about.
|
||||
5. **Links in the first comment**, with "link in the comments" in the post — unless the click
|
||||
is the goal and the user accepts the reach cost, which they should say out loud.
|
||||
6. **One idea per post.** If it needs two, it is two posts.
|
||||
7. **The author reads every line before it ships.** Say so on handover.
|
||||
|
||||
## Skill Integration
|
||||
|
||||
**Skill location:** `../skills/linkedin-content/`
|
||||
|
||||
### Tools
|
||||
|
||||
1. `skills/linkedin-content/scripts/post_linter.py` — 0-100 across mechanics, hook,
|
||||
integrity, and accessibility; blocking findings for the 3,000-char cap, engagement bait,
|
||||
and pseudo-bold.
|
||||
2. `skills/linkedin-content/scripts/format_picker.py` — nine native formats scored against
|
||||
goal, material, and minutes; refuses camera-less video and decision-less polls.
|
||||
3. `skills/linkedin-content/scripts/repurpose_splitter.py` — standalone units with a
|
||||
content-hash reuse ledger so the same idea does not go out twice across months.
|
||||
|
||||
### Knowledge bases
|
||||
|
||||
- `skills/linkedin-content/references/hook_and_fold_mechanics.md` (7 sources)
|
||||
- `skills/linkedin-content/references/post_formats_canon.md` (7 sources)
|
||||
- `skills/linkedin-content/references/repurposing_discipline.md` (7 sources)
|
||||
- `skills/linkedin-content/references/accessibility_and_inclusion.md` (7 sources)
|
||||
|
||||
## Differentiates from siblings
|
||||
|
||||
- **vs `cs-copywriting` / `content-humanizer`** — general copy craft and de-AI passes. Reach
|
||||
for those on tone; this one owns LinkedIn's mechanics, its fold, and its accessibility floor.
|
||||
- **vs `cs-linkedin-orchestrator`** — that one routes and gates. This one writes.
|
||||
|
||||
## Related agents
|
||||
|
||||
- [cs-linkedin-orchestrator](cs-linkedin-orchestrator.md) — routing, policy gate, and the
|
||||
other four lanes
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
100
marketing/linkedin/agents/cs-linkedin-orchestrator.md
Normal file
100
marketing/linkedin/agents/cs-linkedin-orchestrator.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
name: cs-linkedin-orchestrator
|
||||
description: Routes any LinkedIn organic-growth request to the right lane and gates it against LinkedIn's User Agreement before a word is drafted. Runs the policy gate (ALLOW / CONSTRAIN / REFUSE against §8.2 — automation, scraping, engagement pods, bulk DMs, fake identity, fabricated proof, prohibited third-party tools), then the deterministic five-lane router (profile / strategy / content / engagement / analytics), then walks the forcing questions until the chosen lane can run honestly. Holds no LinkedIn credentials and sends nothing. Use when someone wants to grow an organic LinkedIn presence, fix a profile, plan content, write posts, do outreach, or understand their numbers.
|
||||
skills: marketing/linkedin/skills/linkedin-skills
|
||||
domain: marketing
|
||||
model: opus
|
||||
tools: [Read, Bash, Write, Edit]
|
||||
---
|
||||
|
||||
# LinkedIn Orchestrator Agent
|
||||
|
||||
## Purpose
|
||||
|
||||
`cs-linkedin-orchestrator` runs the `linkedin` domain end to end for one person's own
|
||||
presence. It is a router with a gate in front of it, and the gate comes first.
|
||||
|
||||
1. **Gate.** `linkedin_policy_gate.py` on the user's own words, before any drafting. Exit 4
|
||||
REFUSE means a named rule is broken — do not draft it, name the rule, and offer the
|
||||
substitute the gate prints. Exit 3 CONSTRAIN means proceed and say the constraint out
|
||||
loud. Exit 0 ALLOW means proceed.
|
||||
2. **Route.** `linkedin_goal_router.py` scores five lanes. Route at exit 0, ask exactly one
|
||||
clarifying question at exit 2 (naming both candidates with a recommendation), and at
|
||||
exit 3 ask what they want to walk away with rather than guessing.
|
||||
3. **Grill.** Walk the five forcing questions one at a time, each with a recommended answer.
|
||||
Stop as soon as the lane can run honestly — do not run the full set for its own sake.
|
||||
4. **Run the lane.** Invoke the sub-skill, use its tools, iterate against their exit codes.
|
||||
5. **Deliver.** The artifact, the confidence level on any platform claim, and the one thing
|
||||
the user has to do next that no tool can do for them.
|
||||
|
||||
## Voice
|
||||
|
||||
- Blunt about the trade. The shortcuts work faster and risk the account. Say the arithmetic
|
||||
once, then respect the answer.
|
||||
- Evidence-graded by default. 🟢 LinkedIn-official, 🟡 third-party study, 🔴 folklore — and
|
||||
folklore gets named as folklore rather than repeated.
|
||||
- Refuses without moralising. One sentence, the rule, the substitute, then move on.
|
||||
- Never impressed by follower counts, including the user's.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Gate before route, route before draft.** A REFUSE outranks any route.
|
||||
2. **Nothing is sent and nothing is fetched.** No credentials, no API calls, no scraping.
|
||||
Every output is text the user posts themselves.
|
||||
3. **Never fabricate a number, client, result, credential, or quote** — not even as a
|
||||
placeholder that "they'll fill in later". Placeholders ship.
|
||||
4. **The account holder is the author of record.** Say it when handing over a draft.
|
||||
5. **Never silently chain lanes.** Offer the next lane as a question with a recommendation.
|
||||
6. **Refuse pods, automation tools, scraping, and bulk messaging** every time, including
|
||||
when the user pushes back. If they reaffirm after the explanation, say the risk is theirs
|
||||
and decline to build it — do not build a partial version.
|
||||
7. **Under 10 posts, describe; do not conclude.** Applies to every claim about their data.
|
||||
|
||||
## Skill Integration
|
||||
|
||||
**Skill location:** `../skills/linkedin-skills/`
|
||||
|
||||
### Orchestrator tools
|
||||
|
||||
1. `skills/linkedin-skills/scripts/linkedin_policy_gate.py` — 7 refusal rules + 3 constraint
|
||||
rules against User Agreement §8.2 and the Professional Community Policies, each with a
|
||||
named compliant substitute.
|
||||
2. `skills/linkedin-skills/scripts/linkedin_goal_router.py` — deterministic five-lane
|
||||
classifier with cross-lane prerequisites (content needs a brief; analytics needs volume;
|
||||
engagement wastes a weak profile).
|
||||
|
||||
### Lanes
|
||||
|
||||
- `linkedin-profile` — headline scorer, whole-profile auditor ranked by points per hour,
|
||||
About builder that survives the fold.
|
||||
- `linkedin-strategy` — positioning brief validator, cadence planner with a 90-minute floor,
|
||||
newsletter eligibility and six-month sustainability gate.
|
||||
- `linkedin-content` — post linter (mechanics / hook / integrity / accessibility), format
|
||||
picker, repurpose splitter with a reuse ledger.
|
||||
- `linkedin-engagement` — comment roster with per-account caps, message builder that refuses
|
||||
templates, volume guard that refuses automation-shaped plans.
|
||||
- `linkedin-analytics` — median/MAD describer, four-gate permutation pattern miner,
|
||||
experiment planner.
|
||||
|
||||
### Knowledge bases
|
||||
|
||||
- `skills/linkedin-skills/references/linkedin_platform_canon.md` — LiRank, dwell time, the
|
||||
link-penalty evidence, and what LinkedIn does not publish (7 sources)
|
||||
- `skills/linkedin-skills/references/policy_and_account_safety.md` — §8.2, prohibited
|
||||
software, restriction triggers, regional obligations (7 sources)
|
||||
|
||||
## Differentiates from siblings
|
||||
|
||||
- **vs `cs-social-media-manager` / `social-content`** — those plan multi-platform brand
|
||||
social. This is one person's own LinkedIn presence, in depth, with platform rules attached.
|
||||
- **vs `x-twitter-growth`** — different platform, different format economics, different rules.
|
||||
- **vs `cs-cold-email`** — email outreach is a different channel with different law and
|
||||
different caps.
|
||||
|
||||
## Related agents
|
||||
|
||||
- [cs-linkedin-editor](cs-linkedin-editor.md) — the drafting and linting counterpart
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
73
marketing/linkedin/commands/cs-grill-linkedin.md
Normal file
73
marketing/linkedin/commands/cs-grill-linkedin.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
name: "cs-grill-linkedin"
|
||||
description: "/cs:grill-linkedin — Interrogate a LinkedIn plan one forcing question at a time, each with a recommended answer anchored in the plugin's canon. Refuses to start the work until the objective, the audience, the hours, the proof, and the exclusion list survive the questions."
|
||||
argument-hint: "[the LinkedIn plan or ambition you want pressure-tested]"
|
||||
---
|
||||
|
||||
# /cs:grill-linkedin — One question at a time, with a recommendation
|
||||
|
||||
**Command:** `/cs:grill-linkedin [your plan]`
|
||||
|
||||
Most LinkedIn plans fail on inputs, not execution. This walks the five questions that decide
|
||||
whether any of the tools can run honestly. One question per turn, each with a recommended
|
||||
answer and the canon it comes from. Never bundle.
|
||||
|
||||
## When to run
|
||||
|
||||
- The ambition is real but the plan is vague ("I want to build a presence")
|
||||
- Before committing a quarter to a cadence
|
||||
- When a previous attempt stalled and nobody has said why
|
||||
|
||||
## The questions
|
||||
|
||||
**Q1 — What has to be true in 90 days for this to have been worth it?**
|
||||
*Recommended: one observable outcome another person could verify — an inbound conversation,
|
||||
an offer, a hire. Not a follower count.*
|
||||
Canon: [`objective_to_pillars.md`](../skills/linkedin-strategy/references/objective_to_pillars.md). Follower count moves for
|
||||
reasons unrelated to the objective; optimising the number you can see instead of the outcome
|
||||
you want is the most common way a LinkedIn strategy fails while appearing to work.
|
||||
|
||||
**Q2 — Who is this for, specifically enough that someone is excluded?**
|
||||
*Recommended: role + company stage + the problem they have this quarter.*
|
||||
Canon: same document, and `positioning_brief.py` refuses "business leaders" at exit 3. An
|
||||
audience that excludes nobody cannot guide a single editorial decision.
|
||||
|
||||
**Q3 — How many minutes a week will you protect, measured from a bad week?**
|
||||
*Recommended: the honest number, not the aspirational one. Below 90, the answer is a
|
||||
comment-only week.*
|
||||
Canon: [`cadence_and_consistency.md`](../skills/linkedin-strategy/references/cadence_and_consistency.md). A cadence abandoned in
|
||||
week five is worse than one never started, because the abandonment is visible.
|
||||
|
||||
**Q4 — What proof already exists?**
|
||||
*Recommended: name shipped work, a measurement, a repo, a hire, a talk. If none exists, the
|
||||
first pillar is process, not results.*
|
||||
Canon: [`policy_and_account_safety.md`](../skills/linkedin-skills/references/policy_and_account_safety.md) — the fabrication refusal.
|
||||
A pillar with no proof is a claim you would have to invent evidence for.
|
||||
|
||||
**Q5 — What will you not post about?**
|
||||
*Recommended: two topics, including the trending one you have no edge on.*
|
||||
Canon: `objective_to_pillars.md`. A positioning that excludes nothing is availability, and
|
||||
the exclusion list is what settles the "should I comment on this news cycle" question in
|
||||
advance.
|
||||
|
||||
## Discipline
|
||||
|
||||
- **One question per turn.** Wait for the answer. Never bundle.
|
||||
- **Always recommend.** A question with no recommended answer is homework, not a grill.
|
||||
- **Cite the canon** for each challenge — the reference document, not a feeling.
|
||||
- **Stop early** when the lane can run honestly. The full set is not a ritual.
|
||||
- **Push back once on a weak answer, then accept it.** Their presence, their call. Record the
|
||||
weak answer in the brief so it is visible later rather than arguing it now.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- All five answered well enough that `positioning_brief.py` would exit 0 → hand off to
|
||||
[`/cs:linkedin-plan`](cs-linkedin-plan.md).
|
||||
- The user declines to answer Q1 or Q2 → say plainly that the work cannot be aimed without
|
||||
them, and offer the profile lane instead, which needs neither.
|
||||
|
||||
## Related
|
||||
|
||||
- Agent: [`cs-linkedin-orchestrator`](../agents/cs-linkedin-orchestrator.md)
|
||||
- Command: [`/cs:linkedin`](cs-linkedin.md)
|
||||
- Agreement: [`linkedin_operating_agreement.md`](../skills/linkedin-skills/assets/linkedin_operating_agreement.md)
|
||||
72
marketing/linkedin/commands/cs-linkedin-analyze.md
Normal file
72
marketing/linkedin/commands/cs-linkedin-analyze.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
name: "cs-linkedin-analyze"
|
||||
description: "/cs:linkedin-analyze — Read your own exported LinkedIn post data, report medians and outlier bands rather than misleading means, test candidate patterns against a seeded permutation null with multiple-comparisons accounting, and size a real experiment. Refuses to conclude anything below 10 posts."
|
||||
argument-hint: "[path to your LinkedIn post export, or the pattern you think you see]"
|
||||
---
|
||||
|
||||
# /cs:linkedin-analyze — Describe honestly, refuse to over-conclude
|
||||
|
||||
**Command:** `/cs:linkedin-analyze [export path or the claim to test]`
|
||||
|
||||
Your own data only. Export from LinkedIn Analytics → Post impressions → Export, or Settings
|
||||
→ Data privacy → Get a copy of your data. Nothing is fetched; scraping post data is
|
||||
prohibited by User Agreement §8.2 and none of this needs it.
|
||||
|
||||
## When to run
|
||||
|
||||
- "Why did my reach drop?"
|
||||
- "Do carousels actually do better for me?"
|
||||
- "What's working?"
|
||||
- Before changing strategy on the basis of one post that did well
|
||||
|
||||
## What you get
|
||||
|
||||
1. **A description** — median and MAD, percentile bands, a 1.5×IQR breakout threshold, and a
|
||||
per-post band from BREAKOUT to DUD.
|
||||
2. **A verdict on the pattern** — SUPPORTED, NOT_SUPPORTED, TOO_SMALL, or NOT_TESTED, with
|
||||
the reason for each, plus how many candidates would pass on noise alone.
|
||||
3. **A sized experiment** if something survived — or an honest "this needs more posts than a
|
||||
quarter allows".
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
python3 ../skills/linkedin-analytics/scripts/post_performance_analyzer.py \
|
||||
--input export.csv --csv --output human
|
||||
# exit 2 = under 10 posts. Descriptive only. Say so and stop.
|
||||
|
||||
python3 ../skills/linkedin-analytics/scripts/pattern_miner.py \
|
||||
--input export.csv --csv --output human
|
||||
# exit 2 = nothing survived. This is a real finding, not a failure.
|
||||
|
||||
# CV for the planner = 1.4826 * MAD / median, from step one
|
||||
python3 ../skills/linkedin-analytics/scripts/experiment_planner.py \
|
||||
--hypothesis "..." --variable "..." --cv 0.45 --effect 0.30 \
|
||||
--posts-per-week 2 --max-weeks 12 --output human
|
||||
```
|
||||
|
||||
## Discipline
|
||||
|
||||
- **Under 10 posts, describe; do not conclude.** State it plainly rather than hedging into
|
||||
something that reads like a conclusion.
|
||||
- **"Nothing survived" is the most common honest answer.** Report it as a finding.
|
||||
- **A pattern in past posts is a hypothesis.** Retrospective data is confounded — you made
|
||||
carousels when you had structured material, on topics you knew best, in weeks you had time.
|
||||
- **Never benchmark against someone else's numbers.** Different denominator, different
|
||||
audience, usually a vendor's sample.
|
||||
- **Follower count is not a success metric.** Point them at the Tier 1 log instead.
|
||||
- **One good post is not evidence.** It is the least informative event available.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Description delivered and the user knows which three outcome metrics to log by hand → done.
|
||||
- Miner returns nothing supported → say so, recommend re-running in six weeks, and stop.
|
||||
Do not keep slicing the data until something passes.
|
||||
- Experiment planner says TOO_LONG → present the minimum detectable effect in their window
|
||||
and let them decide. Do not quietly shrink the effect to make it fit.
|
||||
|
||||
## Related
|
||||
|
||||
- Skill: [`linkedin-analytics`](../skills/linkedin-analytics/SKILL.md)
|
||||
- Log: [`measurement_log_template.md`](../skills/linkedin-analytics/assets/measurement_log_template.md)
|
||||
- Reference: [`evidence_thresholds.md`](../skills/linkedin-analytics/references/evidence_thresholds.md)
|
||||
69
marketing/linkedin/commands/cs-linkedin-outreach.md
Normal file
69
marketing/linkedin/commands/cs-linkedin-outreach.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
name: "cs-linkedin-outreach"
|
||||
description: "/cs:linkedin-outreach — Build a weekly commenting roster inside a real time budget, and write one outreach message at a time that refuses to be a template. Caps volume against LinkedIn's observed invitation limits and refuses automation-shaped plans. Nothing is ever sent."
|
||||
argument-hint: "[who you want to reach, and how much time you have]"
|
||||
---
|
||||
|
||||
# /cs:linkedin-outreach — Comments first, messages second
|
||||
|
||||
**Command:** `/cs:linkedin-outreach [who, and how much time]`
|
||||
|
||||
**Nothing here is sent.** No credentials, no API calls. Automated connecting, messaging,
|
||||
commenting, liking, and sharing are prohibited by LinkedIn's User Agreement §8.2.
|
||||
|
||||
## When to run
|
||||
|
||||
- "Who should I be commenting on?"
|
||||
- "Write a connection request to X"
|
||||
- "How many invites can I safely send this week?"
|
||||
- Reach is flat and you are only publishing
|
||||
|
||||
## What you get
|
||||
|
||||
1. **A five-day comment roster** from accounts you name, capped at two appearances per
|
||||
account per week and balanced across tiers.
|
||||
2. **A volume verdict** — safe, tight, over a cap, or refused as an automation plan.
|
||||
3. **One assembled message** with the person-specific line enforced and the 200/300-character
|
||||
cap checked.
|
||||
4. **The follow-up rule**, stated once.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Roster (tiers: huge | larger | peer | smaller)
|
||||
python3 ../skills/linkedin-engagement/scripts/comment_target_planner.py \
|
||||
--account "Priya Raman:5:4:larger" --account "Tomas Lind:5:3:peer" \
|
||||
--minutes-per-day 18 --output human
|
||||
|
||||
# 2. Volume check BEFORE writing anything
|
||||
python3 ../skills/linkedin-engagement/scripts/outreach_volume_guard.py \
|
||||
--invites 20 --pending 5 --minutes 120 --acceptance 0.42 --output human
|
||||
# exit 4 = refused as an automation plan. Do not build a smaller version of it silently.
|
||||
|
||||
# 3. One message, for one person
|
||||
python3 ../skills/linkedin-engagement/scripts/outreach_message_builder.py \
|
||||
--type connection --recipient "Priya" --specific-line "..." --reason "..." --output human
|
||||
```
|
||||
|
||||
## Discipline
|
||||
|
||||
- **No engagement pods.** Coordinated reciprocal commenting is inauthentic engagement under
|
||||
§8.2 regardless of who pressed the key.
|
||||
- **The tool builds the roster, never the comments.** A generated comment is exactly what the
|
||||
rule names, and it is recognisable anyway.
|
||||
- **Every message carries a line that could only have been written for that person.**
|
||||
- **No ask in a first-touch connection note.** The note earns the conversation, not the meeting.
|
||||
- **Acceptance below 20% is a stop signal**, not a reason to send more.
|
||||
- **One follow-up, a week later, only with something new to say.**
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Roster built and the first day's comments written by the user → done.
|
||||
- Volume guard at exit 4 → refuse, explain once, offer the manual cadence. If the user
|
||||
reaffirms, say the risk is theirs and decline to build it.
|
||||
- Message at exit 0 → hand it over with "send this yourself, to this one person".
|
||||
|
||||
## Related
|
||||
|
||||
- Skill: [`linkedin-engagement`](../skills/linkedin-engagement/SKILL.md)
|
||||
- Worksheet: [`outreach_worksheet.md`](../skills/linkedin-engagement/assets/outreach_worksheet.md)
|
||||
63
marketing/linkedin/commands/cs-linkedin-plan.md
Normal file
63
marketing/linkedin/commands/cs-linkedin-plan.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
name: "cs-linkedin-plan"
|
||||
description: "/cs:linkedin-plan — Build the positioning brief (objective, audience, 2-4 proof-backed pillars, exclusion list), size a weekly cadence against the hours that actually exist, and gate a newsletter against eligibility and a six-month commitment."
|
||||
argument-hint: "[what you want LinkedIn to do for you in 90 days]"
|
||||
---
|
||||
|
||||
# /cs:linkedin-plan — Brief, cadence, newsletter
|
||||
|
||||
**Command:** `/cs:linkedin-plan [your 90-day goal]`
|
||||
|
||||
Three decisions in order. Out of order is why most LinkedIn efforts produce a feed of
|
||||
unrelated observations and stop in week five.
|
||||
|
||||
## When to run
|
||||
|
||||
- "What should I post about?" / "How often should I post?"
|
||||
- "Should I start a LinkedIn newsletter?"
|
||||
- Before drafting anything — posts without pillars are noise
|
||||
|
||||
## What you get
|
||||
|
||||
1. **A validated positioning brief** — one of six real objectives, an audience specific
|
||||
enough to exclude someone, two to four pillars with shares summing to 100, and at least
|
||||
two exclusions.
|
||||
2. **Observable 90-day criteria.** Follower count is deliberately not among them.
|
||||
3. **A weekly plan priced in minutes**, allocated by stage, plus the minimum viable week that
|
||||
survives a bad week.
|
||||
4. **A newsletter verdict** — green, thin, or refused, with a 12-issue arc and a stop rule.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
python3 ../skills/linkedin-strategy/scripts/positioning_brief.py --input brief.json --output human
|
||||
# exit 3 = objective or audience too vague to proceed. Fix that before anything else.
|
||||
|
||||
python3 ../skills/linkedin-strategy/scripts/cadence_planner.py \
|
||||
--minutes 240 --stage starting --target-posts 3 --output human
|
||||
# exit 2 = below the 90-minute floor -> comment-only week returned
|
||||
# exit 3 = over budget, with the overage named
|
||||
|
||||
python3 ../skills/linkedin-strategy/scripts/newsletter_planner.py \
|
||||
--followers 1800 --cadence biweekly --minutes-per-month 420 --output human
|
||||
```
|
||||
|
||||
## Discipline
|
||||
|
||||
- **One objective.** Two serve neither; sequence instead of blending.
|
||||
- **The exclusion list is the positioning.** Do not skip it because it feels negative.
|
||||
- **Price the week from a bad week.** The plan has to survive week five, not week one.
|
||||
- **From a standing start, most of the budget belongs in other people's comments.**
|
||||
- **Review quarterly, not weekly.** A brief revised monthly is a mood.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Brief at exit 0 and the cadence plan fits → done; hand over the minimum viable week.
|
||||
- Cadence over budget twice on the same target → the target is the problem, not the plan.
|
||||
Say so and cut it.
|
||||
- Newsletter refused → say why once, recommend posts on a fixed day instead, and stop.
|
||||
|
||||
## Related
|
||||
|
||||
- Skill: [`linkedin-strategy`](../skills/linkedin-strategy/SKILL.md)
|
||||
- Next: [`/cs:linkedin-post`](cs-linkedin-post.md) once the brief exists
|
||||
69
marketing/linkedin/commands/cs-linkedin-post.md
Normal file
69
marketing/linkedin/commands/cs-linkedin-post.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
name: "cs-linkedin-post"
|
||||
description: "/cs:linkedin-post — Pick the format the material actually supports, draft to the ~140-character mobile fold, and lint 0-100 across mechanics, hook, integrity, and accessibility. Blocking findings for the 3,000-character cap, engagement bait, and Unicode pseudo-bold."
|
||||
argument-hint: "[the post idea, or paste a draft to be reviewed]"
|
||||
---
|
||||
|
||||
# /cs:linkedin-post — Format, draft, lint
|
||||
|
||||
**Command:** `/cs:linkedin-post [idea or draft]`
|
||||
|
||||
## When to run
|
||||
|
||||
- "Write a LinkedIn post about X" / "review my draft"
|
||||
- "Is this hook any good?"
|
||||
- "Should this be a carousel or a text post?"
|
||||
|
||||
## When NOT to run
|
||||
|
||||
- No positioning brief yet → [`/cs:linkedin-plan`](cs-linkedin-plan.md) first
|
||||
- A comment or a DM → [`/cs:linkedin-outreach`](cs-linkedin-outreach.md); different craft
|
||||
- Repurposing a long source → [`/cs:linkedin-repurpose`](cs-linkedin-repurpose.md)
|
||||
|
||||
## What you get
|
||||
|
||||
1. **A format recommendation** with the constraint it carries — or one question when the top
|
||||
two score within a point.
|
||||
2. **A draft** built from your specifics, with a sentence completing inside the first ~140
|
||||
characters.
|
||||
3. **A lint score 0-100** with every finding carrying a fix.
|
||||
4. **Accessibility done** — alt text written, captions flagged, no pseudo-bold.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Format from the material, not from fashion
|
||||
python3 ../skills/linkedin-content/scripts/format_picker.py \
|
||||
--goal authority --material data --material tutorial --minutes 120 --output human
|
||||
|
||||
# 2. Draft (the interview comes first: the number, the mistake, the sentence someone said)
|
||||
|
||||
# 3. Lint to a clean exit
|
||||
python3 ../skills/linkedin-content/scripts/post_linter.py \
|
||||
--input draft.md --has-image --output human
|
||||
# exit 0 SHIP · exit 2 REVISE (or any blocking finding) · exit 3 REWRITE
|
||||
```
|
||||
|
||||
## Discipline
|
||||
|
||||
- **Interview before drafting.** A post with no specifics cannot be fixed by editing.
|
||||
- **Never fabricate a number, client, result, or quote** — not even as a placeholder.
|
||||
- **Write to the mobile fold.** A sentence completes before character 140.
|
||||
- **Links in the first comment**, and say so in the post.
|
||||
- **No engagement bait.** Ask the question the post actually earned.
|
||||
- **One idea per post.** If it needs two, it is two posts.
|
||||
- **Cut the first paragraph** and check whether the post starts better at paragraph two.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Linter at exit 0 → done. Hand over with "you are the author of record; read every line".
|
||||
- Linter at exit 2 with only warnings the user has knowingly accepted → done, with the
|
||||
accepted warnings restated.
|
||||
- Three REWRITE passes on the same draft → the problem is the idea, not the wording. Go back
|
||||
to the specifics.
|
||||
|
||||
## Related
|
||||
|
||||
- Agent: [`cs-linkedin-editor`](../agents/cs-linkedin-editor.md)
|
||||
- Skill: [`linkedin-content`](../skills/linkedin-content/SKILL.md)
|
||||
- Assets: [`post_templates.md`](../skills/linkedin-content/assets/post_templates.md)
|
||||
64
marketing/linkedin/commands/cs-linkedin-profile.md
Normal file
64
marketing/linkedin/commands/cs-linkedin-profile.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
name: "cs-linkedin-profile"
|
||||
description: "/cs:linkedin-profile — Audit a LinkedIn profile 0-100, rank every fix by points per hour, score the headline on five dimensions against the 220-character cap, and assemble an About section that survives the '…see more' fold."
|
||||
argument-hint: "[your headline, or a description of your profile section by section]"
|
||||
---
|
||||
|
||||
# /cs:linkedin-profile — Audit first, rewrite what pays
|
||||
|
||||
**Command:** `/cs:linkedin-profile [headline or profile description]`
|
||||
|
||||
Nothing is fetched. You describe your own profile, or fill in
|
||||
`skills/linkedin-profile/assets/profile_worksheet.md`.
|
||||
|
||||
## When to run
|
||||
|
||||
- "Fix my headline" / "rewrite my About section"
|
||||
- "My profile gets views but nothing happens"
|
||||
- Before any outreach push — comments and DMs drive profile visits, and a weak headline
|
||||
wastes every one of them
|
||||
|
||||
## What you get
|
||||
|
||||
1. **A completeness score 0-100** across 14 weighted checks, with every gap ranked by points
|
||||
per hour and a first-hour plan.
|
||||
2. **A headline score** on audience / outcome / proof / searchability / clarity, plus the
|
||||
220-character cap and the front-load check on the first ~60 characters.
|
||||
3. **An assembled About section** that ends a sentence before the fold and carries audience
|
||||
or proof above it.
|
||||
4. **Rewritten experience bullets** — outcomes, not duties.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Whole profile, fixes ranked by leverage
|
||||
python3 ../skills/linkedin-profile/scripts/profile_completeness_auditor.py \
|
||||
--input profile.json --output human # or --sample to see the shape
|
||||
|
||||
# 2. Headline — iterate to exit 0
|
||||
python3 ../skills/linkedin-profile/scripts/headline_scorer.py \
|
||||
--headline "..." --output human
|
||||
|
||||
# 3. About — refuses a broken fold, a missing CTA, or an over-length section
|
||||
python3 ../skills/linkedin-profile/scripts/about_section_builder.py \
|
||||
--input about.json --output human
|
||||
```
|
||||
|
||||
## Discipline
|
||||
|
||||
- **Never invent a credential, metric, or role.** Everything on a profile is checkable.
|
||||
- **First person.** Third person on a personal profile reads as a press release.
|
||||
- **The fold is the section** — whatever sits above "…see more" is what most readers get.
|
||||
- **Front-load the headline**; the first 60 characters do most of the work.
|
||||
- Start with the first-hour plan, not with the About section you want to agonise over.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Headline at exit 0 (SHIP), or the user would say it out loud to a peer → done.
|
||||
- About at exit 0, or the user knowingly accepts a named warning → done.
|
||||
- Auditor at STRONG, or the first-hour plan completed and the rest scheduled → done.
|
||||
|
||||
## Related
|
||||
|
||||
- Skill: [`linkedin-profile`](../skills/linkedin-profile/SKILL.md)
|
||||
- Run [`/cs:linkedin-plan`](cs-linkedin-plan.md) first if the audience answer is still fuzzy
|
||||
63
marketing/linkedin/commands/cs-linkedin-repurpose.md
Normal file
63
marketing/linkedin/commands/cs-linkedin-repurpose.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
name: "cs-linkedin-repurpose"
|
||||
description: "/cs:linkedin-repurpose — Split an article, talk transcript, README, or write-up into standalone LinkedIn units, score each on whether it survives without its context, and skip anything already in the reuse ledger so the same idea never goes out twice."
|
||||
argument-hint: "[path to the source: article, transcript, README, notes]"
|
||||
---
|
||||
|
||||
# /cs:linkedin-repurpose — One source, many posts, no duplicates
|
||||
|
||||
**Command:** `/cs:linkedin-repurpose [path to source]`
|
||||
|
||||
## When to run
|
||||
|
||||
- "Turn my talk into LinkedIn posts"
|
||||
- "I wrote this article — what can I post from it?"
|
||||
- You have a body of work and an empty content calendar
|
||||
|
||||
## What you get
|
||||
|
||||
1. **Scored standalone units** — length, dangling references, evidence, substance. A unit
|
||||
opening with "This meant that…" is disqualified regardless of its score, because it refers
|
||||
to something the reader never saw.
|
||||
2. **A suggested format** per unit.
|
||||
3. **Ledger-aware output** — anything already posted is skipped, with its date available on
|
||||
request.
|
||||
4. **The named gap you have to fill** — the first-person sentence only you can write.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# Split and see what is available
|
||||
python3 ../skills/linkedin-content/scripts/repurpose_splitter.py \
|
||||
--input talk.md --ledger .linkedin-ledger.json --output human
|
||||
|
||||
# After publishing unit 2, record it
|
||||
python3 ../skills/linkedin-content/scripts/repurpose_splitter.py \
|
||||
--input talk.md --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25
|
||||
|
||||
# Then lint the drafted post
|
||||
python3 ../skills/linkedin-content/scripts/post_linter.py --input draft.md --output human
|
||||
```
|
||||
|
||||
Commit the ledger alongside the source. It is project state, not a cache.
|
||||
|
||||
## Discipline
|
||||
|
||||
- **Every unit is source material, not a post.** Add what it cost, what you assumed, or what
|
||||
you would do differently. That sentence is the only genuinely new thing in a repurposed post.
|
||||
- **Consent and confidentiality first** for post-mortems, customer notes, and anything with a
|
||||
named third party. The pattern is publishable; the customer is not.
|
||||
- **Never publish identical text on two platforms the same day.**
|
||||
- **One source should not carry a quarter.** When units start needing more setup than
|
||||
payload, the ledger is telling you to go do something new.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Usable units identified and the first one drafted and linted clean → done.
|
||||
- `NOT_SPLITTABLE` (exit 3) → this is one post, not a series. Say so and write the one post.
|
||||
- All units already in the ledger → the source is mined out. Do not re-cut it.
|
||||
|
||||
## Related
|
||||
|
||||
- Skill: [`linkedin-content`](../skills/linkedin-content/SKILL.md)
|
||||
- Reference: [`repurposing_discipline.md`](../skills/linkedin-content/references/repurposing_discipline.md)
|
||||
78
marketing/linkedin/commands/cs-linkedin.md
Normal file
78
marketing/linkedin/commands/cs-linkedin.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
name: "cs-linkedin"
|
||||
description: "/cs:linkedin — Route any organic LinkedIn goal to the right lane (profile / strategy / content / engagement / analytics) after gating it against LinkedIn's User Agreement. Refuses automation, scraping, engagement pods, bulk DMs, fake identity, and fabricated proof before a word is drafted. Nothing is ever sent."
|
||||
argument-hint: "[what you want from LinkedIn — a goal, a profile problem, a post, an outreach plan, or a question about your numbers]"
|
||||
---
|
||||
|
||||
# /cs:linkedin — Gate, then route
|
||||
|
||||
**Command:** `/cs:linkedin [your goal]`
|
||||
|
||||
This plugin holds no LinkedIn credentials, makes no API calls, and sends nothing. Every
|
||||
output is text you paste and post yourself.
|
||||
|
||||
## When to run
|
||||
|
||||
- "Help me grow on LinkedIn" / "I want to build an audience"
|
||||
- You do not know which part of the problem to work on first
|
||||
- Any LinkedIn request where you want the platform rules checked before the work starts
|
||||
|
||||
## When NOT to run
|
||||
|
||||
- You already know the lane → go straight to `/cs:linkedin-profile`, `/cs:linkedin-plan`,
|
||||
`/cs:linkedin-post`, `/cs:linkedin-outreach`, or `/cs:linkedin-analyze`
|
||||
- X/Twitter → `marketing-skill/x-twitter-growth`
|
||||
- Multi-platform brand social calendars → `marketing-skill/social-content`
|
||||
- Cold email → `marketing-skill/cold-email`
|
||||
|
||||
## What you get
|
||||
|
||||
1. **A policy verdict** — ALLOW, CONSTRAIN (with the constraint stated), or REFUSE (with the
|
||||
rule named and a compliant substitute offered).
|
||||
2. **A route** — one of five lanes, with the matched signals, or one clarifying question when
|
||||
two lanes are genuinely close.
|
||||
3. **Forcing questions**, one at a time with a recommended answer, until the lane can run
|
||||
honestly.
|
||||
4. **The lane's artifact** — audit, brief, draft, roster, or analysis.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Gate first, always
|
||||
python3 ../skills/linkedin-skills/scripts/linkedin_policy_gate.py \
|
||||
--text "<the user's own words>" --output human
|
||||
# exit 4 REFUSE -> name the rule, offer the substitute, route there instead
|
||||
# exit 3 CONSTRAIN -> proceed and state the constraint out loud
|
||||
# exit 0 ALLOW -> proceed
|
||||
|
||||
# 2. Route
|
||||
python3 ../skills/linkedin-skills/scripts/linkedin_goal_router.py \
|
||||
--text "<the goal>" --output human
|
||||
# exit 0 route · exit 2 ask ONE question · exit 3 ask what they want to walk away with
|
||||
```
|
||||
|
||||
## Trigger phrases (auto-invoke without /cs:)
|
||||
|
||||
"grow my LinkedIn" · "LinkedIn strategy" · "build an audience on LinkedIn" ·
|
||||
"my LinkedIn isn't working" · "what should I post on LinkedIn"
|
||||
|
||||
## Discipline
|
||||
|
||||
- **Gate before route, route before draft.** A REFUSE outranks any route.
|
||||
- **Never silently chain lanes.** Offer the next one as a question with a recommendation.
|
||||
- **Evidence-graded claims.** 🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore, and
|
||||
folklore gets named rather than repeated.
|
||||
- **Nothing is fetched or sent.**
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- Lane delivered its artifact and the user knows the one thing only they can do next → done.
|
||||
- REFUSE delivered with a substitute offered, and the user declines the substitute → done.
|
||||
Do not build a partial version of the refused thing.
|
||||
- Router returns NO_SIGNAL twice on the same input → hand the question back plainly.
|
||||
|
||||
## Related
|
||||
|
||||
- Agent: [`cs-linkedin-orchestrator`](../agents/cs-linkedin-orchestrator.md)
|
||||
- Skill: [`linkedin-skills`](../skills/linkedin-skills/SKILL.md)
|
||||
- Siblings: [`/cs:grill-linkedin`](cs-grill-linkedin.md) and the six lane commands
|
||||
100
marketing/linkedin/skills/linkedin-analytics/SKILL.md
Normal file
100
marketing/linkedin/skills/linkedin-analytics/SKILL.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
name: linkedin-analytics
|
||||
description: Use when someone wants to understand their own LinkedIn numbers — which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on "why did my reach drop", "what's working on my LinkedIn", "analyze my posts", "do carousels do better for me", "should I test this", "LinkedIn analytics". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment — refusing to conclude anything below 10 posts.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: marketing
|
||||
updated: 2026-08-25
|
||||
---
|
||||
|
||||
# LinkedIn Analytics — describe honestly, then refuse to over-conclude
|
||||
|
||||
The characteristic sentence of LinkedIn analytics is "carousels do 3x better for me", built
|
||||
on four posts. With engagement as heavy-tailed as it is, four posts will show a 3x difference
|
||||
between almost any two groups you care to define. These three scripts stop that sentence
|
||||
becoming a strategy.
|
||||
|
||||
**Your own data only.** Nothing is fetched; scraping post or profile data is prohibited by
|
||||
User Agreement §8.2 and none of this analysis needs it.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. Get the export.** LinkedIn Analytics → Post impressions → Export, or Settings → Data
|
||||
privacy → Get a copy of your data. CSV and JSON both work.
|
||||
|
||||
**2. Describe it.** Exit 0 analysed / 2 below the 10-post floor, descriptive only / 3
|
||||
unusable. Reports median and MAD rather than mean and standard deviation — one breakout post
|
||||
makes a mean describe a distribution none of your posts belong to — plus Tukey percentile
|
||||
bands and a 1.5×IQR breakout threshold, so "this did well" has a number behind it.
|
||||
|
||||
```bash
|
||||
python3 scripts/post_performance_analyzer.py --input posts.csv --csv --output human
|
||||
```
|
||||
|
||||
**3. Test the pattern they think they see.**
|
||||
|
||||
```bash
|
||||
python3 scripts/pattern_miner.py --input posts.json --output human
|
||||
```
|
||||
|
||||
Exit 0 something survived / 2 nothing survived / 3 under 10 posts. Four gates: 5 posts in and
|
||||
5 out; a 15% relative difference in medians; beating 90% of 2,000 seeded label shuffles; and
|
||||
a multiple-comparisons accounting of how many candidates would pass on noise alone.
|
||||
|
||||
**"Nothing survived" is the most common honest answer and it is a real finding.** Report it
|
||||
as one. Do not soften it into a hedge that reads like a conclusion.
|
||||
|
||||
**4. Turn a survivor into a test.**
|
||||
|
||||
```bash
|
||||
python3 scripts/experiment_planner.py --hypothesis "..." --variable "..." \
|
||||
--cv 0.45 --effect 0.30 --posts-per-week 2 --max-weeks 12 --output human
|
||||
```
|
||||
|
||||
CV comes from step 2: `1.4826 * MAD / median`. Exit 0 feasible / 2 too long, with the minimum
|
||||
detectable effect in their window / 3 refused. It will frequently say the test needs more
|
||||
posts than a quarter allows — **that is the honest answer**, and more useful than a confident
|
||||
conclusion from retrospective data.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Under 10 posts, describe; do not conclude.** Say so plainly.
|
||||
- **A pattern in past posts is a hypothesis.** Retrospective data is confounded — you made
|
||||
carousels when you had structured material, on topics you knew best, in weeks you had time.
|
||||
No statistics on the same data removes that.
|
||||
- **Never benchmark against someone else's numbers.** Different denominator, different
|
||||
audience, usually a vendor's sample.
|
||||
- **Follower count is not a success metric.** Track inbound conversations, specific
|
||||
references, invitations — the Tier 1 metrics you count by hand.
|
||||
- **Report the confidence level.** LinkedIn-official 🟢, third-party study 🟡, folklore 🔴.
|
||||
- **One good post is not evidence.** It is the most common cause of a strategy change and the
|
||||
least informative event available.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Role |
|
||||
|---|---|
|
||||
| [`scripts/post_performance_analyzer.py`](scripts/post_performance_analyzer.py) | Median/MAD, percentile bands, IQR outlier fence, per-post BREAKOUT→DUD classification; refuses conclusions below 10 posts. |
|
||||
| [`scripts/pattern_miner.py`](scripts/pattern_miner.py) | Four-gate permutation test with multiple-comparisons accounting; reports why every rejected candidate failed. |
|
||||
| [`scripts/experiment_planner.py`](scripts/experiment_planner.py) | Sizes a two-arm posting experiment, names the confounds to hold constant, and writes the falsification condition before the first post. |
|
||||
|
||||
## References and assets
|
||||
|
||||
- [`references/linkedin_metrics_canon.md`](references/linkedin_metrics_canon.md) — what each number is, what it is not, and which three tiers to track (7 sources)
|
||||
- [`references/evidence_thresholds.md`](references/evidence_thresholds.md) — the four gates, forking paths, and the uncomfortable arithmetic of LinkedIn A/B tests (7 sources)
|
||||
|
||||
- [`assets/example_post_export.csv`](assets/example_post_export.csv) — a 12-post export in the expected shape
|
||||
- [`assets/measurement_log_template.md`](assets/measurement_log_template.md) — the Tier 1 outcome log you keep by hand
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`marketing-skill/social-media-analyzer`** — cross-platform brand campaign reporting. This
|
||||
is one person's own LinkedIn export, with refusals attached.
|
||||
- **`linkedin-strategy`** — decides what to do next. This says what happened.
|
||||
- **`product-team/experiment-designer`** — product A/B tests with real traffic; here n is
|
||||
posts, and usually too small.
|
||||
|
||||
---
|
||||
**Version:** 1.0.0
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
date,title,impressions,reactions,comments,reposts,format,pillar,chars
|
||||
2026-05-04,Onboarding 6 weeks to 4 days,8120,214,63,11,text-post,trust-debt,1450
|
||||
2026-05-11,dbt exposures rollout,3050,61,9,2,text-post,modelling,620
|
||||
2026-05-18,Hiring the first analytics engineer,2110,39,6,1,text-post,hiring,540
|
||||
2026-05-25,Cost teardown carousel,5400,132,28,9,document-carousel,trust-debt,900
|
||||
2026-06-01,Why we deleted the kickoff call,4300,97,24,5,text-post,trust-debt,1610
|
||||
2026-06-08,Modelling tradeoffs,1780,28,3,0,text-post,modelling,480
|
||||
2026-06-15,Analytics engineer job spec,2450,44,11,2,text-post,hiring,700
|
||||
2026-06-22,Queue time carousel,6900,168,41,14,document-carousel,trust-debt,1100
|
||||
2026-06-29,Three dbt anti-patterns,3600,74,15,3,document-carousel,modelling,950
|
||||
2026-07-06,What I got wrong about capacity,5100,121,33,6,text-post,trust-debt,1720
|
||||
2026-07-13,Interview loop for analytics engineers,1950,31,4,1,text-post,hiring,510
|
||||
2026-07-20,Freshness SLAs,2800,52,12,2,text-post,modelling,820
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
# Measurement Log
|
||||
|
||||
The Tier 1 metrics — the ones tied to your objective — are not in LinkedIn's analytics. They
|
||||
have to be written down as they happen, which is why almost nobody has them and why the
|
||||
people who do can actually tell whether the work is working.
|
||||
|
||||
Five minutes a week. Keep it in the repo or wherever the positioning brief lives.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — outcomes (count these by hand)
|
||||
|
||||
| Date | What happened | Traceable to | Objective advanced? |
|
||||
|---|---|---|---|
|
||||
| | Inbound conversation started by them | post / comment / profile / referral | |
|
||||
| | Someone referenced a specific post in their first message | | |
|
||||
| | Invitation (podcast, panel, guest post, talk) | | |
|
||||
| | Referral or intro offered without asking | | |
|
||||
| | Qualified enquiry (they named budget, timeline, or a scoped problem) | | |
|
||||
|
||||
Attribution is never clean. Record what they said, not what you infer.
|
||||
|
||||
## Tier 2 — behavioural proxies (weekly)
|
||||
|
||||
| Week | Posts | Median comments/post | Comment share (comments ÷ total interactions) | Notes |
|
||||
|---|---|---|---|---|
|
||||
| | | | | |
|
||||
|
||||
Comment share is the cleanest available proxy for whether the work lands with people who
|
||||
care: a comment costs a reader thirty seconds and a small reputational exposure; a reaction
|
||||
costs a tap.
|
||||
|
||||
## Tier 3 — reach (monthly, to notice large changes only)
|
||||
|
||||
| Month | Posts | Median impressions | Median engagement rate | Followers | What changed |
|
||||
|---|---|---|---|---|---|
|
||||
| | | | | | |
|
||||
|
||||
Do not make weekly decisions on these. They are noisy, redefined without notice, and easy to
|
||||
move in ways that do not serve the objective.
|
||||
|
||||
## Quarterly review
|
||||
|
||||
Against the 90-day criteria in the positioning brief:
|
||||
|
||||
- [ ] Criterion 1: ______ — met / not met
|
||||
- [ ] Criterion 2: ______ — met / not met
|
||||
- [ ] Criterion 3: ______ — met / not met
|
||||
|
||||
Then three questions:
|
||||
|
||||
1. Which pillar produced the **outcomes**, as opposed to the engagement?
|
||||
2. Did the experimental pillar earn promotion, or should it be replaced?
|
||||
3. Has the audience description got vaguer? (It always drifts broader. Drag it back.)
|
||||
|
||||
## Re-run the tools
|
||||
|
||||
```bash
|
||||
python3 ../scripts/post_performance_analyzer.py --input export.csv --csv --output human
|
||||
python3 ../scripts/pattern_miner.py --input export.csv --csv --output human
|
||||
```
|
||||
|
||||
Expect `NOTHING_SURVIVED` most times. That is what honest analysis of a small sample looks
|
||||
like, and it is a finding — not a reason to keep slicing the data until something passes.
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
# Evidence Thresholds — why most LinkedIn "insights" are noise
|
||||
|
||||
The characteristic sentence of LinkedIn analytics is: "carousels do 3x better for
|
||||
me." It is usually built on four posts. With engagement as heavy-tailed as it is,
|
||||
four posts will show a 3x difference between almost any two groups you care to
|
||||
define — including groups defined by the first letter of the first word.
|
||||
|
||||
`pattern_miner.py` exists to kill that sentence before it becomes a strategy.
|
||||
|
||||
---
|
||||
|
||||
## The four gates
|
||||
|
||||
A candidate pattern has to pass all four. Most fail at the first.
|
||||
|
||||
### 1. Group size floor — at least 5 in, 5 out
|
||||
Below five, the median is one or two posts and any difference is a coin flip.
|
||||
Reported as `NOT_TESTED`, with the counts, because "you do not have enough data
|
||||
yet" is a real and useful finding.
|
||||
|
||||
### 2. Effect floor — 15% relative difference in medians
|
||||
A statistically detectable 3% difference is not a decision. If you would not
|
||||
change what you write over it, testing it is a waste of the posts. The floor is
|
||||
set at the level where a rational person would actually act.
|
||||
|
||||
### 3. Permutation test — beat 90% of 2,000 label shuffles
|
||||
The labels ("carousel" / "not carousel") are shuffled 2,000 times against a fixed
|
||||
seed, the difference of medians recomputed each time, and the observed difference
|
||||
must be larger than at least 90% of them.
|
||||
|
||||
A permutation test is the right instrument here because it makes **no
|
||||
distributional assumption**. A t-test assumes something approximately normal;
|
||||
engagement rate is not. The permutation null asks the only question that matters:
|
||||
*given these exact numbers, how often would random labelling produce a gap this
|
||||
big?*
|
||||
|
||||
The seed is fixed, so the same data always produces the same verdict. An analysis
|
||||
tool that returns a different answer on re-run is not an analysis tool.
|
||||
|
||||
### 4. Multiple comparisons — count every test, report the expected false positives
|
||||
This is the gate nobody else implements, and it is the one that catches the most
|
||||
self-deception.
|
||||
|
||||
If you test twenty candidate patterns at α = 0.10, **two will pass on noise
|
||||
alone**. That is not a flaw in the method; it is what the threshold means. The
|
||||
miner reports how many candidates reached the test, how many you would expect to
|
||||
pass by chance, and how many actually did. When those numbers are close, it says
|
||||
so.
|
||||
|
||||
It also skips mirrored candidates: for a two-value attribute, "carousel vs rest"
|
||||
and "text vs rest" are the same comparison with the sign flipped, and counting
|
||||
both would double-count it in the accounting.
|
||||
|
||||
Gelman and Loken's "garden of forking paths" is the sharper version of the
|
||||
problem: even without formally testing twenty hypotheses, an analyst who *would
|
||||
have* tested a different cut had the data looked different is effectively
|
||||
multiple-testing. The defence is to declare the cuts in advance — which is why
|
||||
the miner takes a fixed attribute list rather than searching for whatever splits
|
||||
best.
|
||||
|
||||
## Why a found pattern is a hypothesis, not a finding
|
||||
|
||||
Everything the miner reports is retrospective. It found a difference in posts you
|
||||
already wrote, chosen for reasons that correlate with everything else about them:
|
||||
you probably made carousels when you had structured material, on topics you knew
|
||||
best, in weeks when you had time.
|
||||
|
||||
That is confounding, and no amount of statistics on the same dataset removes it.
|
||||
|
||||
The only way to get a finding is a deliberate test: decide the variable in
|
||||
advance, alternate the arms, hold the confounds constant, and run the window.
|
||||
`experiment_planner.py` sizes it — and will frequently tell you the test needs
|
||||
more posts than you can produce in a quarter. **That is an honest answer**, and
|
||||
it is more useful than a confident conclusion from retrospective data.
|
||||
|
||||
## The uncomfortable arithmetic
|
||||
|
||||
At a realistic coefficient of variation (0.35-0.6 for most accounts) and a
|
||||
30% target effect, a two-arm test needs roughly 20-60 posts. At two posts a week,
|
||||
that is 5-30 weeks.
|
||||
|
||||
Which means: **most of the LinkedIn A/B tests people describe are not runnable at
|
||||
their actual posting volume.** The honest responses are to test only variables
|
||||
where you expect a large effect, to accept a large minimum detectable effect and
|
||||
say so, or to stop testing and write the thing you would rather write.
|
||||
|
||||
## Things that are not evidence
|
||||
|
||||
- **One post that did well.** The single most common cause of a strategy change,
|
||||
and the least informative event available. A breakout post tells you a specific
|
||||
post worked, in a specific week, with a specific audience state.
|
||||
- **Comparing this month to last month.** Confounded by season, news cycle,
|
||||
audience growth, and LinkedIn product changes, all at once.
|
||||
- **Someone else's benchmark.** Different denominator, different audience,
|
||||
usually a vendor's sample.
|
||||
- **A pattern that appeared after you went looking for one.** See forking paths
|
||||
above.
|
||||
|
||||
## What to do instead of measuring more
|
||||
|
||||
Post consistently for a quarter against a brief. Track the Tier 1 outcome metrics
|
||||
by hand — conversations, references, invitations. Re-run the miner every six
|
||||
weeks and expect it to say "nothing survived" most times, because that is what
|
||||
honest analysis of a small sample looks like.
|
||||
|
||||
The compounding comes from consistency, not from optimisation, and the
|
||||
optimisation is unavailable at this sample size anyway.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Good, P. **Permutation, Parametric, and Bootstrap Tests of Hypotheses**
|
||||
(3rd ed.) — the permutation framework and its distribution-free guarantee.
|
||||
2. Gelman, A. & Loken, E. **"The Garden of Forking Paths"** (2013) — why
|
||||
researcher degrees of freedom produce false positives without any explicit
|
||||
p-hacking.
|
||||
3. Cohen, J. **Statistical Power Analysis for the Behavioral Sciences** (2nd ed.)
|
||||
— the two-sample sizing formula used by `experiment_planner.py`, and the case
|
||||
for declaring a minimum effect of interest.
|
||||
4. Tukey, J. **Exploratory Data Analysis** (1977) — robust summaries, and the
|
||||
distinction between exploratory and confirmatory analysis that this whole
|
||||
document rests on.
|
||||
5. Ioannidis, J. **"Why Most Published Research Findings Are False."** *PLoS
|
||||
Medicine*, 2005 — the relationship between small samples, many tests, and
|
||||
false discovery.
|
||||
6. Benjamini, Y. & Hochberg, Y. **"Controlling the False Discovery Rate."**
|
||||
*JRSS-B*, 1995 — the formal treatment of the multiple-comparisons accounting
|
||||
the miner reports informally.
|
||||
7. Taleb, N.N. **Statistical Consequences of Fat Tails** (2020) — why sample
|
||||
means and standard deviations mislead for heavy-tailed processes, and why
|
||||
medians are used throughout.
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
# LinkedIn Metrics Canon — what each number is, and what it is not
|
||||
|
||||
LinkedIn gives creators a small set of numbers and almost no definitions. The
|
||||
result is a advice ecosystem built on metrics whose meaning has changed at least
|
||||
twice, compared across periods where they meant different things.
|
||||
|
||||
This is what each one actually is, and which ones are worth tracking.
|
||||
|
||||
---
|
||||
|
||||
## The numbers LinkedIn shows you
|
||||
|
||||
### Impressions
|
||||
The count of times a post was rendered in a feed. **Not unique people.** One
|
||||
person scrolling past your post twice can produce two impressions. LinkedIn has
|
||||
revised the counting rules (notably around what counts as "viewed") more than
|
||||
once, without a changelog a creator can read.
|
||||
|
||||
Consequence: **compare posts from the same period, not across a year.** A 2024
|
||||
impression and a 2026 impression are not obviously the same object.
|
||||
|
||||
### Members reached
|
||||
Where available, this is closer to unique people and is the better denominator.
|
||||
It is not exposed everywhere, which is why engagement rate is usually computed on
|
||||
impressions.
|
||||
|
||||
### Reactions, comments, reposts
|
||||
Counts of the three interaction types. Reposts split into "repost" and "repost
|
||||
with your thoughts", which behave differently and are usually reported together.
|
||||
|
||||
### Engagement rate
|
||||
LinkedIn does not publish a single definition, and neither does anyone else
|
||||
consistently. This plugin uses:
|
||||
|
||||
```
|
||||
engagement_rate = (reactions + comments + reposts) / impressions
|
||||
```
|
||||
|
||||
Stated explicitly because the alternative definitions (including clicks,
|
||||
including follows, dividing by followers) produce numbers that differ by 3-5x.
|
||||
**Any benchmark you read elsewhere is on an unknown denominator.** Compare
|
||||
against your own history, not against a published benchmark.
|
||||
|
||||
### Profile views
|
||||
Weakly attributable and heavily lagged. Useful as a trend across weeks, useless
|
||||
per post.
|
||||
|
||||
### Followers gained
|
||||
The metric people optimise and should not. It moves for reasons unrelated to
|
||||
whether the work is working: one post reaching an adjacent audience adds
|
||||
followers who will never engage again. A quarter of excellent, well-targeted work
|
||||
can add very few.
|
||||
|
||||
---
|
||||
|
||||
## What to actually track
|
||||
|
||||
Three tiers, in descending reliability.
|
||||
|
||||
**Tier 1 — outcome metrics.** Inbound conversations, specific references
|
||||
("I saw your post on X"), invitations, referrals, qualified enquiries. Counted by
|
||||
hand, in a note. These are the only numbers tied to the objective, and they are
|
||||
the ones nobody tracks because they require writing things down.
|
||||
|
||||
**Tier 2 — behavioural proxies.** Comment count and comment *share*
|
||||
(comments / total interactions). A comment costs a reader thirty seconds and a
|
||||
small reputational exposure; a reaction costs a tap. Comment share is the
|
||||
cleanest available proxy for whether the work is landing with people who care.
|
||||
|
||||
**Tier 3 — reach metrics.** Impressions, engagement rate, followers. Noisy,
|
||||
redefined without notice, and easy to move in ways that do not serve the
|
||||
objective. Track them to notice large changes, not to make weekly decisions.
|
||||
|
||||
## Why median, not mean
|
||||
|
||||
`post_performance_analyzer.py` reports median and MAD (median absolute
|
||||
deviation), not mean and standard deviation. LinkedIn post performance is
|
||||
heavy-tailed: a small number of posts reach far outside the normal range, and one
|
||||
of them drags a mean to a value that describes none of your posts.
|
||||
|
||||
The bands the analyzer reports are Tukey's: Q1, Q3, and a 1.5×IQR fence above Q3
|
||||
for "breakout". This is the standard robust definition of an outlier and it means
|
||||
"breakout" has a threshold rather than a feeling.
|
||||
|
||||
## The floor
|
||||
|
||||
The analyzer refuses to characterise a body of work under **10 posts**, and the
|
||||
pattern miner refuses to test anything under 10.
|
||||
|
||||
This is not conservatism. The between-post variance on LinkedIn is routinely
|
||||
larger than any group difference eight posts could show — which means with eight
|
||||
posts you can "discover" almost any pattern you go looking for. See
|
||||
`evidence_thresholds.md`.
|
||||
|
||||
## Exporting your own data
|
||||
|
||||
LinkedIn provides two routes, both to *your own* data:
|
||||
|
||||
- **Analytics → Post impressions → Export** for per-post performance.
|
||||
- **Settings → Data privacy → Get a copy of your data** for a fuller archive.
|
||||
|
||||
Both tools in this skill read these exports. **Nothing is fetched from LinkedIn
|
||||
and no other member's data is involved** — scraping profiles or post data is
|
||||
prohibited by User Agreement §8.2, and there is no version of this analysis that
|
||||
needs it.
|
||||
|
||||
## Benchmarks against other people
|
||||
|
||||
Don't. Three reasons, any one of which is sufficient:
|
||||
|
||||
1. The denominators differ and are usually unstated.
|
||||
2. Audience composition dominates. A 12% engagement rate on 400 followers who
|
||||
all know you personally is not comparable to 2% on 20,000.
|
||||
3. The published benchmarks come from samples of accounts that opted into being
|
||||
measured, usually by a vendor selling to them.
|
||||
|
||||
Your own history is the only honest baseline, which is another argument for
|
||||
posting consistently enough to have one.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. LinkedIn Help. **Analytics and post performance** documentation — the
|
||||
definitions LinkedIn does publish, and the export path.
|
||||
2. LinkedIn Help. **"Get a copy of your data"** — the supported self-export.
|
||||
3. LinkedIn. **User Agreement §8.2** — why analysis here is limited to your own
|
||||
exports.
|
||||
4. Tukey, J. **Exploratory Data Analysis** (1977) — median, IQR, and the 1.5×IQR
|
||||
outlier fence used for the band thresholds.
|
||||
5. Huber, P. & Ronchetti, E. **Robust Statistics** (2nd ed.) — MAD as a robust
|
||||
scale estimate, and the 1.4826 consistency constant used to derive CV.
|
||||
6. Taleb, N.N. **The Black Swan** / **Statistical Consequences of Fat Tails** —
|
||||
why sample means are unreliable descriptors for heavy-tailed processes.
|
||||
7. Nielsen, J. **"The 90-9-1 Rule for Participation Inequality"** (NN/g) — the
|
||||
structural reason interaction counts are small and skewed relative to reach.
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env python3
|
||||
"""experiment_planner.py — turn a LinkedIn hypothesis into a test you could actually lose.
|
||||
|
||||
A pattern found in past posts is a hypothesis. This sizes the deliberate test:
|
||||
how many posts per arm, how many weeks that takes at your cadence, what to hold
|
||||
constant, and — the part that gets skipped — what result would make you abandon
|
||||
the idea.
|
||||
|
||||
Sizing uses the standard two-sample formula with the coefficient of variation of
|
||||
your own engagement rate:
|
||||
|
||||
n per arm = 2 * (z_alpha/2 + z_beta)^2 * CV^2 / effect^2
|
||||
|
||||
Get CV from post_performance_analyzer.py: a robust estimate is
|
||||
1.4826 * MAD / median. It is typically 0.3-0.6 for an established account and
|
||||
higher for a new one, which is why honest LinkedIn experiments need more posts
|
||||
than people expect.
|
||||
|
||||
Treat the number as a planning estimate, not inference. Engagement rate is
|
||||
heavy-tailed and posts are not independent draws — the same week's news moves all
|
||||
of them. Analyse the result with pattern_miner.py's permutation test rather than
|
||||
a t-test.
|
||||
|
||||
Exit codes:
|
||||
0 FEASIBLE — the test fits the window
|
||||
2 TOO_LONG — does not fit; the minimum detectable effect in the window is returned
|
||||
3 REFUSED — the hypothesis or the effect size is not worth testing
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
|
||||
# Two-sided z for alpha, one-sided z for power. Small table, no scipy.
|
||||
Z_ALPHA = {0.20: 1.282, 0.10: 1.645, 0.05: 1.960}
|
||||
Z_POWER = {0.70: 0.524, 0.80: 0.842, 0.90: 1.282}
|
||||
|
||||
MIN_ACTIONABLE_EFFECT = 0.10
|
||||
|
||||
CONFOUNDS = [
|
||||
"Post at the same time of day in both arms. Time of day and day of week move "
|
||||
"engagement more than most of the variables people test.",
|
||||
"Alternate arms post by post. Running arm A for a month and arm B for the next month "
|
||||
"tests the month, not the variable.",
|
||||
"Hold the pillar mix steady. If arm A is all your strongest topic, you measured the topic.",
|
||||
"Do not change the profile, headline, or posting cadence mid-test.",
|
||||
"Log the arm before you publish, not after. Deciding which arm a post belonged to once "
|
||||
"you have seen the numbers is how every informal test goes wrong.",
|
||||
]
|
||||
|
||||
# Chosen to be feasible so the sample shows the full happy path. Note how narrow
|
||||
# the feasible region is: at CV 0.45 and a 30% target effect this same test needs
|
||||
# 28 weeks. Most LinkedIn "A/B tests" people describe are not runnable at their
|
||||
# actual posting volume, and the tool says so rather than pretending otherwise.
|
||||
SAMPLE = {
|
||||
"hypothesis": "Document carousels earn a higher engagement rate than text posts for my audience",
|
||||
"variable": "format (document-carousel vs text-post)",
|
||||
"baseline_median_er": 0.025,
|
||||
"cv": 0.35,
|
||||
"effect": 0.40,
|
||||
"posts_per_week": 3,
|
||||
"max_weeks": 16,
|
||||
}
|
||||
|
||||
|
||||
def plan(hypothesis: str, variable: str, cv: float, effect: float,
|
||||
posts_per_week: float, max_weeks: int, alpha: float, power: float,
|
||||
baseline: float) -> dict:
|
||||
if not hypothesis.strip() or not variable.strip():
|
||||
return {"verdict": "REFUSED", "exit_code": 3,
|
||||
"finding": "No hypothesis or no named variable.",
|
||||
"fix": "State it as a sentence you could be wrong about: 'X earns a higher "
|
||||
"engagement rate than Y for my audience.' If you cannot say what would "
|
||||
"disprove it, it is a preference, not a hypothesis."}
|
||||
if effect < MIN_ACTIONABLE_EFFECT:
|
||||
return {"verdict": "REFUSED", "exit_code": 3,
|
||||
"finding": f"A {effect:.0%} relative effect is below the "
|
||||
f"{MIN_ACTIONABLE_EFFECT:.0%} floor.",
|
||||
"fix": "Test something you would actually change your approach over. Detecting a "
|
||||
"5% difference needs hundreds of posts and would not change a single "
|
||||
"decision when you found it."}
|
||||
if cv <= 0:
|
||||
return {"verdict": "REFUSED", "exit_code": 3,
|
||||
"finding": "Coefficient of variation must be positive.",
|
||||
"fix": "Run post_performance_analyzer.py and compute 1.4826 * MAD / median. "
|
||||
"With fewer than 10 posts you do not have a usable CV yet."}
|
||||
|
||||
z_a = Z_ALPHA[alpha]
|
||||
z_b = Z_POWER[power]
|
||||
n_per_arm = math.ceil(2 * (z_a + z_b) ** 2 * cv ** 2 / effect ** 2)
|
||||
total = n_per_arm * 2
|
||||
weeks = math.ceil(total / posts_per_week) if posts_per_week > 0 else 10 ** 6
|
||||
|
||||
common = {
|
||||
"hypothesis": hypothesis.strip(),
|
||||
"variable": variable.strip(),
|
||||
"design": {
|
||||
"arms": 2, "alpha": alpha, "power": power, "cv": cv,
|
||||
"target_relative_effect": effect,
|
||||
"baseline_median_engagement_rate": baseline,
|
||||
"posts_per_arm": n_per_arm, "total_posts": total,
|
||||
"weeks_at_cadence": weeks, "posts_per_week": posts_per_week,
|
||||
},
|
||||
"hold_constant": CONFOUNDS,
|
||||
"analysis_rule": "Analyse with pattern_miner.py (difference of medians, permutation "
|
||||
"test). Do not use a t-test: engagement rate is heavy-tailed and one "
|
||||
"breakout post will carry a mean on its own.",
|
||||
"caveat": "Posts are not independent draws — a busy news week moves every arm at once. "
|
||||
"This sizing is a planning aid, not a guarantee of power.",
|
||||
}
|
||||
|
||||
if weeks > max_weeks:
|
||||
affordable_per_arm = max(1, int(max_weeks * posts_per_week / 2))
|
||||
mde = math.sqrt(2 * (z_a + z_b) ** 2 * cv ** 2 / affordable_per_arm)
|
||||
return {**common, "verdict": "TOO_LONG", "exit_code": 2,
|
||||
"finding": f"{total} posts at {posts_per_week}/week is {weeks} weeks, past the "
|
||||
f"{max_weeks}-week window.",
|
||||
"options": [
|
||||
f"Accept a bigger minimum detectable effect: in {max_weeks} weeks you can "
|
||||
f"detect about {mde:.0%} relative, not {effect:.0%}. If a {mde:.0%} "
|
||||
"difference would still change your decision, run it.",
|
||||
"Raise the cadence — but only if the cadence is sustainable for the whole "
|
||||
"window; an abandoned test is worse than none.",
|
||||
"Accept lower power (0.70) and treat the result as directional.",
|
||||
"Do not run it, and pick the option you would rather write anyway. Not every "
|
||||
"question is worth a quarter of your output.",
|
||||
],
|
||||
"minimum_detectable_effect_in_window": round(mde, 3),
|
||||
"stop_rule": "If you change the plan mid-test, the test is over. Start again or "
|
||||
"accept the result as anecdote."}
|
||||
|
||||
return {**common, "verdict": "FEASIBLE", "exit_code": 0,
|
||||
"schedule": f"{n_per_arm} posts per arm, alternating, {posts_per_week}/week — "
|
||||
f"{weeks} weeks.",
|
||||
"falsification": f"If the median engagement rate of the {variable.strip()} arm is not "
|
||||
f"at least {effect:.0%} above the other arm at the end of the window, "
|
||||
"the hypothesis failed. Write that down now, before the first post.",
|
||||
"stop_rule": [
|
||||
"Run the full window. Stopping early because the numbers look good is how a "
|
||||
"coin flip becomes a strategy.",
|
||||
"One exception: stop if something outside the test changes — a job change, a "
|
||||
"viral post, a LinkedIn product change. Then restart rather than salvage.",
|
||||
]}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
if r["verdict"] == "REFUSED":
|
||||
return (f"Experiment: REFUSED\n{'=' * 44}\n{r['finding']}\nfix → {r['fix']}")
|
||||
d = r["design"]
|
||||
lines = [f"Experiment: {r['verdict']}", "=" * 60,
|
||||
f"Hypothesis : {r['hypothesis']}",
|
||||
f"Variable : {r['variable']}",
|
||||
f"Design : 2 arms, alpha {d['alpha']}, power {d['power']}, CV {d['cv']}, "
|
||||
f"target effect {d['target_relative_effect']:.0%}",
|
||||
f"Sample : {d['posts_per_arm']} posts/arm ({d['total_posts']} total) = "
|
||||
f"{d['weeks_at_cadence']} weeks at {d['posts_per_week']}/week", ""]
|
||||
if r["verdict"] == "TOO_LONG":
|
||||
lines += [r["finding"], "", "Options:"]
|
||||
for o in r["options"]:
|
||||
lines.append(f" - {o}")
|
||||
lines += ["", f"Stop rule: {r['stop_rule']}"]
|
||||
else:
|
||||
lines += [f"Schedule : {r['schedule']}", "",
|
||||
f"Falsification: {r['falsification']}", "", "Stop rule:"]
|
||||
for s in r["stop_rule"]:
|
||||
lines.append(f" - {s}")
|
||||
lines.append("\nHold constant:")
|
||||
for c in r["hold_constant"]:
|
||||
lines.append(f" - {c}")
|
||||
lines += ["", r["analysis_rule"], "", r["caveat"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Size a LinkedIn posting experiment "
|
||||
"(feasible=0 / too-long=2 / refused=3).")
|
||||
ap.add_argument("--hypothesis", default="", help="The claim, stated so it can fail.")
|
||||
ap.add_argument("--variable", default="", help="The one thing that differs between arms.")
|
||||
ap.add_argument("--cv", type=float, default=0.0,
|
||||
help="Coefficient of variation of your engagement rate "
|
||||
"(1.4826 * MAD / median from post_performance_analyzer.py).")
|
||||
ap.add_argument("--effect", type=float, default=0.30,
|
||||
help="Relative effect you would act on (default 0.30 = 30%%).")
|
||||
ap.add_argument("--posts-per-week", type=float, default=2.0)
|
||||
ap.add_argument("--max-weeks", type=int, default=12)
|
||||
ap.add_argument("--alpha", type=float, choices=sorted(Z_ALPHA), default=0.10)
|
||||
ap.add_argument("--power", type=float, choices=sorted(Z_POWER), default=0.80)
|
||||
ap.add_argument("--baseline-er", type=float, default=0.0,
|
||||
help="Your median engagement rate, for reference in the output.")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Size a built-in example experiment.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
s = SAMPLE
|
||||
result = plan(s["hypothesis"], s["variable"], s["cv"], s["effect"],
|
||||
s["posts_per_week"], s["max_weeks"], 0.10, 0.80,
|
||||
s["baseline_median_er"])
|
||||
else:
|
||||
if not args.hypothesis:
|
||||
ap.error("--hypothesis is required (or use --sample)")
|
||||
result = plan(args.hypothesis, args.variable, args.cv, args.effect,
|
||||
args.posts_per_week, args.max_weeks, args.alpha, args.power,
|
||||
args.baseline_er)
|
||||
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
#!/usr/bin/env python3
|
||||
"""pattern_miner.py — test whether an apparent LinkedIn pattern survives a null test.
|
||||
|
||||
The standard failure of LinkedIn analytics is a confident sentence built on four
|
||||
posts: "carousels do 3x better for you". With post engagement as heavy-tailed as
|
||||
it is, four posts will show a 3x difference between almost any two groups you
|
||||
care to define.
|
||||
|
||||
So this tool tries to kill every candidate pattern before reporting it:
|
||||
|
||||
1. Group size floor — at least 5 posts in the group and 5 outside it.
|
||||
2. Effect floor — the median engagement rate must differ by at least 15%
|
||||
relative. A statistically detectable 3% difference is not a decision.
|
||||
3. Permutation test — labels are shuffled a fixed number of times against a
|
||||
fixed seed, and the observed difference must beat at least 90% of the
|
||||
shuffles. Same data in, same verdict out.
|
||||
4. Multiple-comparisons note — every candidate tested is counted, and the
|
||||
expected number of false positives at the chosen threshold is reported
|
||||
alongside the number that passed. If you test twenty things at p<0.10, two
|
||||
will "pass" on noise alone, and the report says so.
|
||||
|
||||
Every rejected candidate is reported with the reason it failed, because "not
|
||||
enough data yet" is the finding most of the time and it is a useful one.
|
||||
|
||||
Input: the same export shape as post_performance_analyzer.py.
|
||||
|
||||
Exit codes:
|
||||
0 at least one candidate survived
|
||||
2 nothing survived — reasons listed per candidate
|
||||
3 not enough posts to test anything (under 10)
|
||||
|
||||
Stdlib only. No network. Deterministic (fixed seed).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
|
||||
MIN_GROUP = 5
|
||||
MIN_RELATIVE_EFFECT = 0.15
|
||||
ALPHA = 0.10
|
||||
SHUFFLES = 2000
|
||||
SEED = 20260825
|
||||
MIN_POSTS = 10
|
||||
|
||||
WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
|
||||
SAMPLE = [
|
||||
{"date": "2026-05-04", "impressions": 8120, "reactions": 214, "comments": 63, "reposts": 11,
|
||||
"format": "text-post", "pillar": "trust-debt", "chars": 1450},
|
||||
{"date": "2026-05-06", "impressions": 3050, "reactions": 61, "comments": 9, "reposts": 2,
|
||||
"format": "text-post", "pillar": "modelling", "chars": 620},
|
||||
{"date": "2026-05-11", "impressions": 2110, "reactions": 39, "comments": 6, "reposts": 1,
|
||||
"format": "text-post", "pillar": "hiring", "chars": 540},
|
||||
{"date": "2026-05-13", "impressions": 5400, "reactions": 132, "comments": 28, "reposts": 9,
|
||||
"format": "document-carousel", "pillar": "trust-debt", "chars": 900},
|
||||
{"date": "2026-05-18", "impressions": 4300, "reactions": 97, "comments": 24, "reposts": 5,
|
||||
"format": "text-post", "pillar": "trust-debt", "chars": 1610},
|
||||
{"date": "2026-05-20", "impressions": 1780, "reactions": 28, "comments": 3, "reposts": 0,
|
||||
"format": "text-post", "pillar": "modelling", "chars": 480},
|
||||
{"date": "2026-05-25", "impressions": 2450, "reactions": 44, "comments": 11, "reposts": 2,
|
||||
"format": "text-post", "pillar": "hiring", "chars": 700},
|
||||
{"date": "2026-05-27", "impressions": 6900, "reactions": 168, "comments": 41, "reposts": 14,
|
||||
"format": "document-carousel", "pillar": "trust-debt", "chars": 1100},
|
||||
{"date": "2026-06-01", "impressions": 3600, "reactions": 74, "comments": 15, "reposts": 3,
|
||||
"format": "document-carousel", "pillar": "modelling", "chars": 950},
|
||||
{"date": "2026-06-03", "impressions": 5100, "reactions": 121, "comments": 33, "reposts": 6,
|
||||
"format": "text-post", "pillar": "trust-debt", "chars": 1720},
|
||||
{"date": "2026-06-08", "impressions": 1950, "reactions": 31, "comments": 4, "reposts": 1,
|
||||
"format": "text-post", "pillar": "hiring", "chars": 510},
|
||||
{"date": "2026-06-10", "impressions": 2800, "reactions": 52, "comments": 12, "reposts": 2,
|
||||
"format": "text-post", "pillar": "modelling", "chars": 820},
|
||||
{"date": "2026-06-15", "impressions": 7400, "reactions": 181, "comments": 47, "reposts": 12,
|
||||
"format": "document-carousel", "pillar": "trust-debt", "chars": 1050},
|
||||
{"date": "2026-06-17", "impressions": 2300, "reactions": 40, "comments": 5, "reposts": 1,
|
||||
"format": "text-post", "pillar": "hiring", "chars": 560},
|
||||
{"date": "2026-06-22", "impressions": 6100, "reactions": 149, "comments": 38, "reposts": 10,
|
||||
"format": "document-carousel", "pillar": "trust-debt", "chars": 1180},
|
||||
{"date": "2026-06-24", "impressions": 2650, "reactions": 47, "comments": 9, "reposts": 2,
|
||||
"format": "text-post", "pillar": "modelling", "chars": 760},
|
||||
]
|
||||
|
||||
|
||||
def median(xs):
|
||||
s = sorted(xs)
|
||||
n = len(s)
|
||||
if not n:
|
||||
return 0.0
|
||||
mid = n // 2
|
||||
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
||||
|
||||
|
||||
def _num(row, key, default=0):
|
||||
val = row.get(key, default)
|
||||
if val in (None, ""):
|
||||
return default
|
||||
try:
|
||||
return float(str(val).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def prepare(rows: list) -> list:
|
||||
out = []
|
||||
for r in rows:
|
||||
imp = _num(r, "impressions")
|
||||
if imp <= 0:
|
||||
continue
|
||||
inter = sum(_num(r, f) for f in ("reactions", "comments", "reposts"))
|
||||
rec = {"er": inter / imp,
|
||||
"format": str(r.get("format") or "unspecified"),
|
||||
"pillar": str(r.get("pillar") or "unspecified")}
|
||||
date = str(r.get("date") or "")
|
||||
try:
|
||||
rec["weekday"] = WEEKDAYS[datetime.date.fromisoformat(date[:10]).weekday()]
|
||||
except (ValueError, IndexError):
|
||||
rec["weekday"] = "unspecified"
|
||||
chars = _num(r, "chars", 0)
|
||||
if chars:
|
||||
rec["length"] = ("short (<800)" if chars < 800 else
|
||||
"medium (800-1500)" if chars <= 1500 else "long (>1500)")
|
||||
else:
|
||||
rec["length"] = "unspecified"
|
||||
links = _num(r, "links_in_body", -1)
|
||||
rec["link_in_body"] = "unspecified" if links < 0 else ("yes" if links else "no")
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def permutation_p(group_ers, other_ers, observed, rng) -> float:
|
||||
pool = group_ers + other_ers
|
||||
k = len(group_ers)
|
||||
extreme = 0
|
||||
for _ in range(SHUFFLES):
|
||||
rng.shuffle(pool)
|
||||
diff = median(pool[:k]) - median(pool[k:])
|
||||
if abs(diff) >= abs(observed):
|
||||
extreme += 1
|
||||
return (extreme + 1) / (SHUFFLES + 1)
|
||||
|
||||
|
||||
def mine(rows: list, attributes: list) -> dict:
|
||||
data = prepare(rows)
|
||||
if len(data) < MIN_POSTS:
|
||||
return {"verdict": "INSUFFICIENT_DATA", "exit_code": 3, "posts": len(data),
|
||||
"floor": MIN_POSTS,
|
||||
"finding": f"{len(data)} usable posts. Under {MIN_POSTS} there is nothing to test: "
|
||||
"the between-post variance on LinkedIn swamps any group difference this "
|
||||
"small a sample could show.",
|
||||
"instead": "Keep posting on the plan you have and re-run this in six weeks. "
|
||||
"Changing strategy on eight posts is how people end up rewriting their "
|
||||
"approach every month and compounding nothing."}
|
||||
|
||||
rng = random.Random(SEED)
|
||||
candidates = []
|
||||
mirrored = []
|
||||
for attr in attributes:
|
||||
values = sorted({d[attr] for d in data if d[attr] != "unspecified"})
|
||||
# A two-value attribute is one test, not two: "carousel vs rest" and
|
||||
# "text vs rest" are the same comparison with the sign flipped. Testing
|
||||
# both would double-count it in the multiple-comparisons accounting.
|
||||
if len(values) == 2:
|
||||
mirrored.append(f"{attr}: only '{values[0]}' tested — '{values[1]}' is the same "
|
||||
"comparison mirrored")
|
||||
values = values[:1]
|
||||
for val in values:
|
||||
group = [d["er"] for d in data if d[attr] == val]
|
||||
other = [d["er"] for d in data if d[attr] != val and d[attr] != "unspecified"]
|
||||
entry = {"attribute": attr, "value": val,
|
||||
"n_group": len(group), "n_other": len(other)}
|
||||
if len(group) < MIN_GROUP or len(other) < MIN_GROUP:
|
||||
entry.update({"verdict": "NOT_TESTED",
|
||||
"reason": f"needs {MIN_GROUP} in and {MIN_GROUP} out; "
|
||||
f"has {len(group)} and {len(other)}"})
|
||||
candidates.append(entry)
|
||||
continue
|
||||
m_g, m_o = median(group), median(other)
|
||||
observed = m_g - m_o
|
||||
rel = (m_g - m_o) / m_o if m_o else 0.0
|
||||
entry.update({"median_group": round(m_g, 5), "median_other": round(m_o, 5),
|
||||
"relative_effect": round(rel, 3)})
|
||||
if abs(rel) < MIN_RELATIVE_EFFECT:
|
||||
entry.update({"verdict": "TOO_SMALL",
|
||||
"reason": f"{rel:+.1%} relative difference is under the "
|
||||
f"{MIN_RELATIVE_EFFECT:.0%} floor — real or not, it is "
|
||||
"not a reason to change anything"})
|
||||
candidates.append(entry)
|
||||
continue
|
||||
p = permutation_p(list(group), list(other), observed, rng)
|
||||
entry["p_value"] = round(p, 4)
|
||||
if p < ALPHA:
|
||||
entry.update({"verdict": "SUPPORTED",
|
||||
"reason": f"{rel:+.1%} median difference, beat {1 - p:.0%} of "
|
||||
f"{SHUFFLES} label shuffles"})
|
||||
else:
|
||||
entry.update({"verdict": "NOT_SUPPORTED",
|
||||
"reason": f"{rel:+.1%} difference, but {p:.0%} of random shuffles "
|
||||
"produced one as large — this is noise"})
|
||||
candidates.append(entry)
|
||||
|
||||
tested = [c for c in candidates if "p_value" in c]
|
||||
supported = [c for c in candidates if c["verdict"] == "SUPPORTED"]
|
||||
expected_false = round(len(tested) * ALPHA, 1)
|
||||
|
||||
return {
|
||||
"verdict": "PATTERNS_FOUND" if supported else "NOTHING_SURVIVED",
|
||||
"exit_code": 0 if supported else 2,
|
||||
"posts": len(data),
|
||||
"method": {"min_group": MIN_GROUP, "min_relative_effect": MIN_RELATIVE_EFFECT,
|
||||
"alpha": ALPHA, "shuffles": SHUFFLES, "seed": SEED,
|
||||
"statistic": "difference of medians, permutation test"},
|
||||
"candidates_generated": len(candidates),
|
||||
"candidates_tested": len(tested),
|
||||
"supported": supported,
|
||||
"all_candidates": candidates,
|
||||
"mirrored_candidates_skipped": mirrored,
|
||||
"independence_note": (
|
||||
"Candidates within one attribute are not independent of each other — each group is "
|
||||
"tested against the rest of the same posts. Read them as one question about that "
|
||||
"attribute, not as several separate findings."),
|
||||
"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. "
|
||||
+ ("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 "
|
||||
"here — still worth confirming with a deliberate experiment.")),
|
||||
"next_step": "Feed a supported candidate to experiment_planner.py. A pattern found in "
|
||||
"past data is a hypothesis; a pattern that survives a planned test is a "
|
||||
"finding.",
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
if r["verdict"] == "INSUFFICIENT_DATA":
|
||||
return (f"Pattern mining: INSUFFICIENT_DATA\n{'=' * 46}\n{r['finding']}\n\n"
|
||||
f"Instead: {r['instead']}")
|
||||
lines = [f"Pattern mining: {r['verdict']} ({r['posts']} posts)", "=" * 62,
|
||||
f"Method: {r['method']['statistic']}, {r['method']['shuffles']} shuffles, "
|
||||
f"seed {r['method']['seed']}, alpha {r['method']['alpha']}, "
|
||||
f"min effect {r['method']['min_relative_effect']:.0%}, "
|
||||
f"min group {r['method']['min_group']}", ""]
|
||||
if r["supported"]:
|
||||
lines.append("SUPPORTED:")
|
||||
for c in r["supported"]:
|
||||
lines.append(f" {c['attribute']}={c['value']} n={c['n_group']} vs {c['n_other']} "
|
||||
f"{c['relative_effect']:+.1%} p={c['p_value']}")
|
||||
lines.append(f" {c['reason']}")
|
||||
lines.append("")
|
||||
lines.append("All candidates:")
|
||||
for c in r["all_candidates"]:
|
||||
lines.append(f" [{c['verdict']:<14}] {c['attribute']}={c['value']:<20} {c['reason']}")
|
||||
if r.get("mirrored_candidates_skipped"):
|
||||
lines.append("\nSkipped as mirrored:")
|
||||
for m in r["mirrored_candidates_skipped"]:
|
||||
lines.append(f" - {m}")
|
||||
lines += ["", r["independence_note"], "", r["multiple_comparisons_note"], "", r["next_step"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Test candidate LinkedIn patterns against a permutation null "
|
||||
"(found=0 / nothing-survived=2 / insufficient-data=3).")
|
||||
ap.add_argument("--input", help="Post export file ('-' for stdin).")
|
||||
ap.add_argument("--csv", action="store_true", help="Input is CSV rather than JSON.")
|
||||
ap.add_argument("--attribute", action="append", default=[],
|
||||
choices=["format", "pillar", "weekday", "length", "link_in_body"],
|
||||
help="Attribute to test. Repeatable. Default: all five.")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Mine a built-in 16-post sample.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
rows = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
try:
|
||||
rows = ([dict(r) for r in csv.DictReader(io.StringIO(raw))] if args.csv
|
||||
else json.loads(raw))
|
||||
except (json.JSONDecodeError, csv.Error) as exc:
|
||||
print(f"ERROR: could not parse input: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get("posts") or rows.get("rows") or []
|
||||
else:
|
||||
ap.error("--input or --sample is required")
|
||||
|
||||
attrs = args.attribute or ["format", "pillar", "weekday", "length", "link_in_body"]
|
||||
result = mine(rows, attrs)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""post_performance_analyzer.py — read your own exported post stats and describe them honestly.
|
||||
|
||||
Input is the export LinkedIn gives you for your own content (Analytics → Post
|
||||
impressions → Export, or the JSON shape below). Nothing is fetched and no other
|
||||
member's data is involved.
|
||||
|
||||
What it does that a spreadsheet does not:
|
||||
|
||||
- engagement rate per post on a consistent denominator, so posts are comparable
|
||||
- median and MAD rather than mean and standard deviation, because LinkedIn post
|
||||
performance is heavy-tailed and one breakout post drags a mean somewhere
|
||||
useless
|
||||
- percentile bands and an IQR outlier test, so "this did well" has a threshold
|
||||
- an explicit refusal to characterise a body of work under 10 posts
|
||||
|
||||
The last one is the point. Ten posts is not enough to conclude anything, and the
|
||||
tool says so rather than producing a confident-looking table.
|
||||
|
||||
Input JSON: [{"date": "2026-05-04", "title": "...", "impressions": 4210,
|
||||
"reactions": 88, "comments": 21, "reposts": 4,
|
||||
"format": "text-post", "pillar": "trust-debt"}, ...]
|
||||
CSV with the same column names also works.
|
||||
|
||||
Exit codes:
|
||||
0 analysed
|
||||
2 analysed, but below the 10-post floor — descriptive only, no conclusions
|
||||
3 unusable input (no rows, or no impressions column)
|
||||
|
||||
Stdlib only. No network.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
|
||||
MIN_POSTS_FOR_CONCLUSIONS = 10
|
||||
REQUIRED = ("impressions",)
|
||||
INTERACTION_FIELDS = ("reactions", "comments", "reposts")
|
||||
|
||||
SAMPLE = [
|
||||
{"date": "2026-05-04", "title": "Onboarding 6 weeks to 4 days", "impressions": 8120,
|
||||
"reactions": 214, "comments": 63, "reposts": 11, "format": "text-post", "pillar": "trust-debt"},
|
||||
{"date": "2026-05-11", "title": "dbt exposures rollout", "impressions": 3050,
|
||||
"reactions": 61, "comments": 9, "reposts": 2, "format": "text-post", "pillar": "modelling"},
|
||||
{"date": "2026-05-18", "title": "Hiring the first AE", "impressions": 2110,
|
||||
"reactions": 39, "comments": 6, "reposts": 1, "format": "text-post", "pillar": "hiring"},
|
||||
{"date": "2026-05-25", "title": "Cost teardown carousel", "impressions": 5400,
|
||||
"reactions": 132, "comments": 28, "reposts": 9, "format": "document-carousel",
|
||||
"pillar": "trust-debt"},
|
||||
{"date": "2026-06-01", "title": "Why we deleted the kickoff call", "impressions": 4300,
|
||||
"reactions": 97, "comments": 24, "reposts": 5, "format": "text-post", "pillar": "trust-debt"},
|
||||
{"date": "2026-06-08", "title": "Modelling tradeoffs", "impressions": 1780,
|
||||
"reactions": 28, "comments": 3, "reposts": 0, "format": "text-post", "pillar": "modelling"},
|
||||
{"date": "2026-06-15", "title": "Analytics eng job spec", "impressions": 2450,
|
||||
"reactions": 44, "comments": 11, "reposts": 2, "format": "text-post", "pillar": "hiring"},
|
||||
{"date": "2026-06-22", "title": "Queue time carousel", "impressions": 6900,
|
||||
"reactions": 168, "comments": 41, "reposts": 14, "format": "document-carousel",
|
||||
"pillar": "trust-debt"},
|
||||
{"date": "2026-06-29", "title": "Three dbt anti-patterns", "impressions": 3600,
|
||||
"reactions": 74, "comments": 15, "reposts": 3, "format": "document-carousel",
|
||||
"pillar": "modelling"},
|
||||
{"date": "2026-07-06", "title": "What I got wrong about capacity", "impressions": 5100,
|
||||
"reactions": 121, "comments": 33, "reposts": 6, "format": "text-post", "pillar": "trust-debt"},
|
||||
{"date": "2026-07-13", "title": "Interview loop for AEs", "impressions": 1950,
|
||||
"reactions": 31, "comments": 4, "reposts": 1, "format": "text-post", "pillar": "hiring"},
|
||||
{"date": "2026-07-20", "title": "Freshness SLAs", "impressions": 2800,
|
||||
"reactions": 52, "comments": 12, "reposts": 2, "format": "text-post", "pillar": "modelling"},
|
||||
]
|
||||
|
||||
|
||||
def median(xs):
|
||||
s = sorted(xs)
|
||||
n = len(s)
|
||||
if not n:
|
||||
return 0.0
|
||||
mid = n // 2
|
||||
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
||||
|
||||
|
||||
def percentile(xs, p):
|
||||
s = sorted(xs)
|
||||
if not s:
|
||||
return 0.0
|
||||
k = (len(s) - 1) * (p / 100.0)
|
||||
lo, hi = int(k), min(int(k) + 1, len(s) - 1)
|
||||
return float(s[lo] + (s[hi] - s[lo]) * (k - lo))
|
||||
|
||||
|
||||
def load_rows(raw: str, as_csv: bool) -> list:
|
||||
if as_csv:
|
||||
return [dict(r) for r in csv.DictReader(io.StringIO(raw))]
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
data = data.get("posts") or data.get("rows") or []
|
||||
return data
|
||||
|
||||
|
||||
def _num(row, key):
|
||||
val = row.get(key, 0)
|
||||
if val in (None, ""):
|
||||
return 0
|
||||
try:
|
||||
return float(str(val).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def analyse(rows: list) -> dict:
|
||||
clean = []
|
||||
for r in rows:
|
||||
imp = _num(r, "impressions")
|
||||
if imp <= 0:
|
||||
continue
|
||||
inter = sum(_num(r, f) for f in INTERACTION_FIELDS)
|
||||
clean.append({
|
||||
"date": str(r.get("date", "")),
|
||||
"title": str(r.get("title", ""))[:80],
|
||||
"impressions": int(imp),
|
||||
"reactions": int(_num(r, "reactions")),
|
||||
"comments": int(_num(r, "comments")),
|
||||
"reposts": int(_num(r, "reposts")),
|
||||
"interactions": int(inter),
|
||||
"engagement_rate": round(inter / imp, 5),
|
||||
"comment_share": round(_num(r, "comments") / inter, 3) if inter else 0.0,
|
||||
"format": str(r.get("format", "") or "unspecified"),
|
||||
"pillar": str(r.get("pillar", "") or "unspecified"),
|
||||
})
|
||||
|
||||
if not clean:
|
||||
return {"verdict": "UNUSABLE", "exit_code": 3,
|
||||
"finding": "No rows with a positive impressions value.",
|
||||
"fix": "Export from LinkedIn Analytics → Post impressions → Export, or supply "
|
||||
"JSON with an 'impressions' field per post."}
|
||||
|
||||
ers = [p["engagement_rate"] for p in clean]
|
||||
imps = [p["impressions"] for p in clean]
|
||||
med = median(ers)
|
||||
mad = median([abs(e - med) for e in ers])
|
||||
q1, q3 = percentile(ers, 25), percentile(ers, 75)
|
||||
iqr = q3 - q1
|
||||
hi_fence, lo_fence = q3 + 1.5 * iqr, q1 - 1.5 * iqr
|
||||
|
||||
for p in clean:
|
||||
e = p["engagement_rate"]
|
||||
if e >= hi_fence:
|
||||
p["band"] = "BREAKOUT"
|
||||
elif e >= q3:
|
||||
p["band"] = "STRONG"
|
||||
elif e >= q1:
|
||||
p["band"] = "TYPICAL"
|
||||
elif e > lo_fence:
|
||||
p["band"] = "WEAK"
|
||||
else:
|
||||
p["band"] = "DUD"
|
||||
|
||||
n = len(clean)
|
||||
below_floor = n < MIN_POSTS_FOR_CONCLUSIONS
|
||||
result = {
|
||||
"verdict": "DESCRIPTIVE_ONLY" if below_floor else "ANALYSED",
|
||||
"exit_code": 2 if below_floor else 0,
|
||||
"posts_analysed": n,
|
||||
"floor": MIN_POSTS_FOR_CONCLUSIONS,
|
||||
"engagement_rate": {
|
||||
"median": round(med, 5),
|
||||
"mad": round(mad, 5),
|
||||
"p10": round(percentile(ers, 10), 5),
|
||||
"p25": round(q1, 5), "p75": round(q3, 5),
|
||||
"p90": round(percentile(ers, 90), 5),
|
||||
"breakout_threshold": round(hi_fence, 5),
|
||||
},
|
||||
"impressions": {"median": round(median(imps)), "p90": round(percentile(imps, 90)),
|
||||
"total": int(sum(imps))},
|
||||
"bands": {b: sum(1 for p in clean if p["band"] == b)
|
||||
for b in ("BREAKOUT", "STRONG", "TYPICAL", "WEAK", "DUD")},
|
||||
"posts": sorted(clean, key=lambda p: -p["engagement_rate"]),
|
||||
"reading_notes": [
|
||||
"Median and MAD, not mean and standard deviation: one breakout post makes a mean "
|
||||
"describe a distribution nobody's posts belong to.",
|
||||
"Impressions are not unique people, and LinkedIn's definition has changed over time. "
|
||||
"Compare posts from the same period, not across a year.",
|
||||
"Comment share is worth watching separately: comments are the costlier signal for a "
|
||||
"reader to give, and the one most closely tied to distribution.",
|
||||
],
|
||||
}
|
||||
if below_floor:
|
||||
result["warning"] = (
|
||||
f"{n} posts is below the {MIN_POSTS_FOR_CONCLUSIONS}-post floor. Everything above is "
|
||||
"description, not evidence. Do not change strategy on it — the variance between two "
|
||||
"posts on the same topic is routinely larger than the difference this would 'show'.")
|
||||
return result
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
if r["verdict"] == "UNUSABLE":
|
||||
return f"Post analysis: UNUSABLE\n{'=' * 40}\n{r['finding']}\nfix → {r['fix']}"
|
||||
e = r["engagement_rate"]
|
||||
lines = [f"Post analysis: {r['verdict']} ({r['posts_analysed']} posts)", "=" * 60]
|
||||
if "warning" in r:
|
||||
lines += [f"! {r['warning']}", ""]
|
||||
lines += [
|
||||
f"Engagement rate median {e['median']:.2%} (MAD {e['mad']:.2%})",
|
||||
f" p10 {e['p10']:.2%} · p25 {e['p25']:.2%} · p75 {e['p75']:.2%} · "
|
||||
f"p90 {e['p90']:.2%}",
|
||||
f"Breakout above {e['breakout_threshold']:.2%}",
|
||||
f"Impressions median {r['impressions']['median']:,} · "
|
||||
f"p90 {r['impressions']['p90']:,} · total {r['impressions']['total']:,}",
|
||||
f"Bands " + " · ".join(f"{k} {v}" for k, v in r["bands"].items()),
|
||||
"", "Posts by engagement rate:"]
|
||||
for p in r["posts"]:
|
||||
lines.append(f" {p['band']:<9} {p['engagement_rate']:>7.2%} "
|
||||
f"{p['impressions']:>7,} imp {p['comments']:>3}c "
|
||||
f"{p['date']:<11} {p['title']}")
|
||||
lines.append("\nReading notes:")
|
||||
for note in r["reading_notes"]:
|
||||
lines.append(f" - {note}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Describe your own LinkedIn post export honestly "
|
||||
"(analysed=0 / below-floor=2 / unusable=3).")
|
||||
ap.add_argument("--input", help="Post export file ('-' for stdin).")
|
||||
ap.add_argument("--csv", action="store_true", help="Input is CSV rather than JSON.")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Analyse a built-in 12-post sample.")
|
||||
ap.add_argument("--print-schema", action="store_true", help="Print the input shape and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_schema:
|
||||
print(json.dumps(SAMPLE[:2], indent=2))
|
||||
return 0
|
||||
if args.sample:
|
||||
rows = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
try:
|
||||
rows = load_rows(raw, args.csv)
|
||||
except (json.JSONDecodeError, csv.Error) as exc:
|
||||
print(f"ERROR: could not parse input: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
ap.error("--input or --sample is required (see --print-schema)")
|
||||
|
||||
result = analyse(rows)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
99
marketing/linkedin/skills/linkedin-content/SKILL.md
Normal file
99
marketing/linkedin/skills/linkedin-content/SKILL.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
name: linkedin-content
|
||||
description: Use when someone wants to write, edit, or lint a LinkedIn post — a story, how-to, opinion piece, carousel script, video script, or poll — or wants an article, talk, or transcript repurposed into posts. Triggers on "write a LinkedIn post", "is this hook any good", "review my post", "turn this into LinkedIn posts", "carousel", "what format should this be". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: marketing
|
||||
updated: 2026-08-25
|
||||
---
|
||||
|
||||
# LinkedIn Content — format, draft, lint
|
||||
|
||||
The post is not the deliverable; the specific thing only this person can say is. The scripts
|
||||
handle format choice, mechanical faults, and de-duplication. The interesting part — what
|
||||
actually happened and what it cost — comes from the user, and cannot be generated.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. Check the brief exists.** If there is no positioning brief, offer `linkedin-strategy`
|
||||
first as a question. Posts without pillars are noise. Never chain silently.
|
||||
|
||||
**2. Pick the format from the material, not from fashion.**
|
||||
|
||||
```bash
|
||||
python3 scripts/format_picker.py --goal authority --material data --material tutorial \
|
||||
--minutes 120 --output human
|
||||
```
|
||||
|
||||
Exit 0 recommends / 2 asks when the top two are within a point (tie-break on which one they
|
||||
would enjoy making — the one they repeat beats the one that scores higher once) / 3 no fit,
|
||||
go get material. It refuses video with no camera and no footage, and a poll with no real
|
||||
decision behind it.
|
||||
|
||||
**3. Draft.** Interview for specifics before writing a line: the number, the mistake, the
|
||||
sentence someone said. Then write to the fold — **the first ~140 characters are the whole
|
||||
post for most readers**, and a sentence must complete inside them.
|
||||
|
||||
**4. Lint before it ships.**
|
||||
|
||||
```bash
|
||||
python3 scripts/post_linter.py --input draft.md --has-image --output human
|
||||
```
|
||||
|
||||
Exit 0 SHIP / 2 REVISE (or any blocking finding) / 3 REWRITE. Blocking findings have named
|
||||
consequences: over the 3,000-character cap; engagement bait, named as demoted content in the
|
||||
Professional Community Policies; Unicode pseudo-bold, which screen readers announce as
|
||||
mathematical symbols and search does not index as words. Two passes is normal; the stop
|
||||
condition is exit 0 or a stated decision to accept a warning.
|
||||
|
||||
**5. Repurposing.** Split a source and keep the ledger — it prevents the specific recurring
|
||||
failure of the same idea going out three times over eight months. Commit it alongside the
|
||||
source; it is project state, not a cache. Every unit is source material, not a post: add the
|
||||
sentence only the author can write — what it cost, or what they would do differently.
|
||||
|
||||
```bash
|
||||
python3 scripts/repurpose_splitter.py --input talk.md --ledger .linkedin-ledger.json --output human
|
||||
python3 scripts/repurpose_splitter.py --input talk.md --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never fabricate a number, a client, a result, or a quote.** Not one, not as a placeholder.
|
||||
- **Write to the mobile fold.** A sentence completes before character 140.
|
||||
- **Links in the first comment**, and say so in the post.
|
||||
- **No Unicode pseudo-bold, ever.** It is an accessibility failure, not a style choice.
|
||||
- **Alt text on every image; captions on every video.** LinkedIn does not add them for you.
|
||||
- **No engagement bait.** Ask the question the post actually earned.
|
||||
- **The author reads every line before it ships.** They are the author of record.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Role |
|
||||
|---|---|
|
||||
| [`scripts/post_linter.py`](scripts/post_linter.py) | 0-100 across mechanics / hook / integrity / accessibility; blocking findings for the cap, bait, and pseudo-bold. |
|
||||
| [`scripts/format_picker.py`](scripts/format_picker.py) | Ranks nine native formats against goal, material, and minutes; refuses camera-less video and decision-less polls. |
|
||||
| [`scripts/repurpose_splitter.py`](scripts/repurpose_splitter.py) | Splits a source into standalone units, scores them, and skips anything already in the reuse ledger. |
|
||||
|
||||
## References and assets
|
||||
|
||||
- [`references/hook_and_fold_mechanics.md`](references/hook_and_fold_mechanics.md) — the first 140 characters, and the openers to delete on sight (7 sources)
|
||||
- [`references/post_formats_canon.md`](references/post_formats_canon.md) — what each native format is good at, and the carousel trap (7 sources)
|
||||
- [`references/repurposing_discipline.md`](references/repurposing_discipline.md) — standalone units, the reuse ledger, source types and their risks (7 sources)
|
||||
- [`references/accessibility_and_inclusion.md`](references/accessibility_and_inclusion.md) — pseudo-bold, alt text, captions, and why they are blocking (7 sources)
|
||||
|
||||
- [`assets/post_templates.md`](assets/post_templates.md) — five worked post shapes with the hook already doing its job
|
||||
- [`assets/example_post.md`](assets/example_post.md) — a post that passes the linter, annotated
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`linkedin-strategy`** — decides what the posts are about. This writes them.
|
||||
- **`linkedin-engagement`** — comments and DMs. A comment is a different craft with a
|
||||
different budget; it lives there.
|
||||
- **`marketing-skill/copywriting` / `content-humanizer`** — general copy and de-AI passes.
|
||||
Reach for those for tone; this one owns LinkedIn's mechanics and its accessibility floor.
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Worked Example — a post that passes the linter
|
||||
|
||||
The post below scores 98/100 in `post_linter.py` with one INFO finding (it sits below the
|
||||
1,300-2,500 character band, which is not a defect). Annotations follow.
|
||||
|
||||
---
|
||||
|
||||
Our onboarding took 6 weeks. We got it to 4 days without hiring anyone.
|
||||
|
||||
The bottleneck was not the product. It was that three different teams each owned
|
||||
one step and none of them owned the handoff.
|
||||
|
||||
What we changed:
|
||||
|
||||
1. One named owner for the whole path, not per step. Every stall now has someone
|
||||
whose week it ruins.
|
||||
2. We deleted the "kickoff call" and replaced it with a 4-question form. 80% of
|
||||
accounts never needed the call.
|
||||
3. We stopped treating the CRM stage as the source of truth and started measuring
|
||||
the customer's first real use.
|
||||
|
||||
The part I got wrong: I assumed the delay was engineering capacity. It was
|
||||
handoffs. We spent five weeks building automation for the wrong step before
|
||||
anyone measured where the time actually went.
|
||||
|
||||
If you are staring at a slow onboarding number, measure the wait between steps
|
||||
before you optimise any single step. That is where ours was hiding.
|
||||
|
||||
What did the handoff cost you the last time you measured it?
|
||||
|
||||
---
|
||||
|
||||
## Why it passes
|
||||
|
||||
**Hook (first 140 characters).** Two complete sentences land inside the mobile fold, both
|
||||
carrying numbers, and the second names a constraint people assume is binding — "without
|
||||
hiring anyone". A reader decides on those two sentences alone.
|
||||
|
||||
**Specificity throughout.** 6 weeks, 4 days, 80%, five wasted weeks. Every number is one the
|
||||
author could be challenged on, which is exactly why they carry weight.
|
||||
|
||||
**The admission.** "The part I got wrong" is the load-bearing paragraph. It is expensive to
|
||||
write, which is why it reads as true, and it is the paragraph practitioners reply to.
|
||||
|
||||
**Structure.** Seven blocks, none longer than four lines. Numbered list where the content is
|
||||
a sequence. No Unicode pseudo-bold, no emoji, no hashtags — none of which are required.
|
||||
|
||||
**The close.** A real question with a specific answer available only to someone who has
|
||||
measured the same thing. Not "thoughts?".
|
||||
|
||||
**No link in the body.** If there were a write-up, it goes in the first comment with "link
|
||||
in the comments" in the post.
|
||||
|
||||
## Run it yourself
|
||||
|
||||
```bash
|
||||
python3 ../scripts/post_linter.py --sample --output human
|
||||
```
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# Post Shapes
|
||||
|
||||
Five structures, each with the hook already doing its job. They are scaffolding, not
|
||||
templates to fill and ship — the specific detail is the post, and it has to be yours.
|
||||
|
||||
Every one of these assumes: a sentence completes inside the first ~140 characters, links go
|
||||
in the first comment, no Unicode pseudo-bold, and a real question at the end.
|
||||
|
||||
---
|
||||
|
||||
## 1. The measurement that changed our mind
|
||||
|
||||
Best for: `trust`, `authority`. Format: text post or carousel.
|
||||
|
||||
```
|
||||
[The number nobody believed.]
|
||||
[The instinct everyone had, named fairly.]
|
||||
|
||||
What we actually measured:
|
||||
[The two figures that reframed it.]
|
||||
|
||||
[What we changed - three items, each with a consequence.]
|
||||
|
||||
The part I got wrong:
|
||||
[The thing you spent time on before measuring.]
|
||||
|
||||
[A question aimed at someone facing the same measurement.]
|
||||
```
|
||||
|
||||
Why it works: the hook is a number and a reversal. The "part I got wrong" is a costly
|
||||
signal — admitting error is expensive, which is why it reads as true.
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision, with the cost attached
|
||||
|
||||
Best for: `thought-leadership`. Format: text post.
|
||||
|
||||
```
|
||||
[We stopped doing X.] [The consequence, quantified.]
|
||||
|
||||
The argument for keeping it was [the strongest version of the other side].
|
||||
It was a real argument. Here is why it lost:
|
||||
|
||||
[Two or three sentences of actual reasoning.]
|
||||
|
||||
What it cost us: [the real downside, named].
|
||||
|
||||
[The question: where does this break for you?]
|
||||
```
|
||||
|
||||
Why it works: steelmanning the other side before disagreeing is the rarest move in a
|
||||
professional feed, and the "what it cost" line inoculates against the obvious objection.
|
||||
|
||||
---
|
||||
|
||||
## 3. The teardown
|
||||
|
||||
Best for: `authority`, `consulting`. Format: carousel or long text post.
|
||||
|
||||
```
|
||||
[One named artifact - with permission, or anonymised.]
|
||||
[What it gets right, in one sentence. Say this first and mean it.]
|
||||
|
||||
Three things I would change:
|
||||
|
||||
1. [Change] - because [mechanism, not preference]
|
||||
2. [Change] - because [mechanism]
|
||||
3. [Change] - because [mechanism]
|
||||
|
||||
The one I am least sure about: [the honest uncertainty].
|
||||
|
||||
[Question to the person who has done this differently.]
|
||||
```
|
||||
|
||||
Why it works: specificity plus a stated uncertainty. The uncertainty is what invites the
|
||||
comment worth having.
|
||||
|
||||
---
|
||||
|
||||
## 4. The field note
|
||||
|
||||
Best for: any pillar, and the format that keeps a cadence alive in a bad week. Format: text
|
||||
post, ~500-900 characters.
|
||||
|
||||
```
|
||||
[What you did this week, concretely.]
|
||||
|
||||
[The thing that surprised you.]
|
||||
|
||||
[What you will do differently next week because of it.]
|
||||
|
||||
[Optional: a question only someone doing the same work could answer.]
|
||||
```
|
||||
|
||||
Why it works: it costs 15 minutes, it is impossible to fake, and it is the format that
|
||||
compounds because you can always write one.
|
||||
|
||||
---
|
||||
|
||||
## 5. The transition post
|
||||
|
||||
Best for: `career-change`. Format: text post.
|
||||
|
||||
```
|
||||
[The specific thing you did in the new field this month.]
|
||||
[Not "I am excited to announce" - the thing itself.]
|
||||
|
||||
What I brought from [old field] that turned out to matter:
|
||||
[One transferable capability, evidenced.]
|
||||
|
||||
What I had to learn from zero:
|
||||
[One honest gap, named without apology.]
|
||||
|
||||
[Where you are heading, stated as a direction rather than a request.]
|
||||
|
||||
[A question for people already doing the thing.]
|
||||
```
|
||||
|
||||
Why it works: it demonstrates the transition rather than announcing it. Announcements ask
|
||||
for permission; evidence does not. See `linkedin-strategy/references/objective_to_pillars.md`
|
||||
on why career-change posts should be aimed at the target field, not at current colleagues.
|
||||
|
||||
---
|
||||
|
||||
## Openers to delete on sight
|
||||
|
||||
"I'm excited to announce" · "I'm thrilled" · "I'm humbled" · "In today's fast-paced world" ·
|
||||
"Quick thought:" · any first eight words that would fit any post about anything.
|
||||
|
||||
## Closers that are engagement bait, not questions
|
||||
|
||||
"Thoughts?" · "Agree?" · "Comment X below and I'll send you the guide" · "Like if you agree" ·
|
||||
"Tag someone who needs this". `post_linter.py` treats these as blocking.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Accessibility and Inclusion — the part of LinkedIn craft nobody audits
|
||||
|
||||
LinkedIn is a professional network, which means a meaningful fraction of your
|
||||
audience uses assistive technology at work — screen readers, captions,
|
||||
magnification, high-contrast modes. It also means the accessibility failures in
|
||||
LinkedIn posts are unusually visible, because they are almost all self-inflicted
|
||||
by formatting tricks people adopt for reach.
|
||||
|
||||
`post_linter.py` treats two of these as blocking findings. This document is why.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unicode pseudo-bold is the big one — blocking
|
||||
|
||||
LinkedIn does not support rich text in posts, so people paste text through "bold
|
||||
text generators". Those tools do not bold anything. They substitute the Latin
|
||||
letters for characters from the **Mathematical Alphanumeric Symbols** block
|
||||
(U+1D400-U+1D7FF), which are visually similar and semantically unrelated.
|
||||
|
||||
What actually happens:
|
||||
|
||||
- **Screen readers** announce them individually as mathematical symbols —
|
||||
"mathematical sans-serif bold small a, mathematical sans-serif bold small b" —
|
||||
or skip them entirely, depending on the reader and its verbosity settings.
|
||||
A "bolded" heading becomes noise or silence.
|
||||
- **Search** does not index them as the words they resemble. Your post about
|
||||
𝗸𝘂𝗯𝗲𝗿𝗻𝗲𝘁𝗲𝘀 does not match a search for kubernetes.
|
||||
- **Copy-paste** into any system with real text handling produces mojibake.
|
||||
- **Translation** fails.
|
||||
|
||||
There is no version of this that is worth it. Emphasis on LinkedIn comes from
|
||||
line breaks, word order, and putting the important thing first — the same tools
|
||||
every writer had before bold existed.
|
||||
|
||||
The same applies to fullwidth characters (A-z) and enclosed alphanumerics.
|
||||
|
||||
## 2. Alt text on images — LinkedIn supports it and will not write it
|
||||
|
||||
LinkedIn offers an alt-text field on image uploads. It is not filled in for you
|
||||
and it is easy to skip.
|
||||
|
||||
Writing it well takes one sentence and one rule: **describe what the image
|
||||
communicates, not what it is.** "Chart" is useless. "Line chart: median
|
||||
onboarding time falling from 41 days in March to 4 days in July" is the whole
|
||||
content of the image, delivered to someone who cannot see it — and, incidentally,
|
||||
to anyone whose image failed to load.
|
||||
|
||||
For a document carousel, upload a real PDF with selectable text rather than
|
||||
exported images. The text layer is what makes the slides readable to assistive
|
||||
technology at all.
|
||||
|
||||
## 3. Video captions — the floor, and also just correct
|
||||
|
||||
Most feed video is watched sound-off, so captions serve everyone. LinkedIn
|
||||
provides auto-captions with an editing step. **The editing step is not optional**:
|
||||
auto-captioning mangles exactly the domain vocabulary your post is about, and an
|
||||
uncorrected caption track is worse than none because it looks like a caption
|
||||
track.
|
||||
|
||||
WCAG 2.2 SC 1.2.2 (Captions, Prerecorded) is the standard, and for a professional
|
||||
audience it is also the polite minimum.
|
||||
|
||||
## 4. Emoji load — warning, not blocking
|
||||
|
||||
Emoji are read aloud by name. A bullet list built from 🔥 emoji becomes "fire,
|
||||
fire, fire". A handful is fine and can even help structure. Fifteen makes a post
|
||||
tiring to hear.
|
||||
|
||||
Where emoji do useful work: as list markers (one per line, consistently), or as a
|
||||
segment separator in a headline. Where they do not: decoration, emphasis, or
|
||||
replacing words.
|
||||
|
||||
## 5. ALL-CAPS lines — warning
|
||||
|
||||
Some screen readers spell out all-caps words letter by letter, treating them as
|
||||
initialisms. A full line of capitals becomes an alphabet recital. Sentence case,
|
||||
with the emphasis carried by the words.
|
||||
|
||||
## 6. Colour and contrast in carousels and images
|
||||
|
||||
If you make slides:
|
||||
|
||||
- Do not encode meaning in colour alone (WCAG 1.4.1). If the red bar is the bad
|
||||
one, label it.
|
||||
- Body text on a slide needs 4.5:1 contrast against its background (WCAG 1.4.3);
|
||||
large text needs 3:1.
|
||||
- Slide text should be large enough to read on a phone at feed size. If you have
|
||||
to zoom to read your own slide on your own phone, it is too small.
|
||||
|
||||
## 7. Plain language is an accessibility feature
|
||||
|
||||
Not only for non-native readers, though that is a large part of a global
|
||||
professional audience. Dense sentences with three subordinate clauses are harder
|
||||
for everyone, and much harder for anyone reading through a screen reader with no
|
||||
ability to skim back.
|
||||
|
||||
Practical version: short sentences, one idea per paragraph, expand an acronym the
|
||||
first time. This is the same advice as good writing, which is convenient.
|
||||
|
||||
## 8. What this costs
|
||||
|
||||
About four minutes per post: write the alt text, check the captions, do not paste
|
||||
through a bold generator. It is the cheapest quality signal available and almost
|
||||
nobody does it, which means it is also differentiating.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. W3C. **Web Content Accessibility Guidelines (WCAG) 2.2** — SC 1.1.1 (Non-text
|
||||
Content), 1.2.2 (Captions, Prerecorded), 1.4.1 (Use of Color), 1.4.3
|
||||
(Contrast Minimum).
|
||||
2. Unicode Consortium. **Unicode Standard, Chapter 22 / Mathematical Alphanumeric
|
||||
Symbols (U+1D400-U+1D7FF)** — what these characters are actually for, and the
|
||||
explicit note that they are not styled Latin letters.
|
||||
3. WebAIM. **Screen Reader User Survey** (recurring) — how screen-reader users
|
||||
actually navigate, and the cost of non-semantic text substitutes.
|
||||
4. LinkedIn Help. **"Add alternative text to images"** and LinkedIn's video
|
||||
captioning documentation — what the platform supports.
|
||||
5. LinkedIn. **Accessibility statement / LinkedIn Accessibility** — the
|
||||
platform's own commitments and the assistive-technology surfaces it supports.
|
||||
6. Nielsen Norman Group. **"Plain Language Is for Everyone, Even Experts"** —
|
||||
comprehension gains from plain language across expertise levels.
|
||||
7. Deque / axe accessibility documentation on **text alternatives and meaningful
|
||||
sequence** — practical guidance for writing alt text that carries the content
|
||||
rather than naming the object.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Hook and Fold Mechanics — the first 140 characters decide everything else
|
||||
|
||||
LinkedIn truncates a post in the feed. On mobile that happens around 140
|
||||
characters, on desktop around 210, and the reader's decision to press "…see more"
|
||||
is made entirely on what is above that line. Everything you wrote below it is
|
||||
conditional on those two sentences.
|
||||
|
||||
This is not a copywriting flourish. It is the single structural constraint the
|
||||
platform imposes on text, and most posts ignore it.
|
||||
|
||||
---
|
||||
|
||||
## The numbers
|
||||
|
||||
| Limit | Value | Confidence |
|
||||
|---|---|---|
|
||||
| Post hard cap | 3,000 characters | 🟡 third-party documented, stable |
|
||||
| Mobile fold ("…see more") | ~140 characters | 🟡 varies with viewport and locale |
|
||||
| Desktop fold | ~210 characters | 🟡 |
|
||||
| Highest median engagement band | ~1,300-2,500 characters | 🟡 third-party studies |
|
||||
| Comment character cap | 1,250 | 🟡 |
|
||||
|
||||
Mobile is the binding constraint. Write to 140 and desktop takes care of itself.
|
||||
|
||||
**On the engagement band:** it is a correlation in third-party data, not a rule.
|
||||
Long posts are longer because people had more to say, and a 400-character post
|
||||
with something specific in it beats a padded 1,800-character one every time.
|
||||
`post_linter.py` reports the band as INFO, never as a defect.
|
||||
|
||||
## What a hook has to do
|
||||
|
||||
Not "be catchy". Three concrete jobs:
|
||||
|
||||
1. **Complete a thought inside the fold.** A truncated fragment gives the reader
|
||||
nothing to decide on. `post_linter.py` flags a post where no sentence ends
|
||||
before character 140.
|
||||
2. **Create a specific gap.** Not a vague tease — a gap the reader can feel the
|
||||
shape of. "Our onboarding took six weeks. We got it to four days without
|
||||
hiring anyone." The gap is *how*, and it is specific enough to be worth the
|
||||
click.
|
||||
3. **Signal who it is for.** A reader outside your audience should be able to
|
||||
skip cleanly. Trying to hook everyone is how a post hooks nobody.
|
||||
|
||||
## Hook shapes that work, and why
|
||||
|
||||
| Shape | Example opening | Why it works |
|
||||
|---|---|---|
|
||||
| **Number + reversal** | "Our onboarding took 6 weeks. We got it to 4 days without hiring anyone." | Concrete, and the reversal names the constraint people assume is binding |
|
||||
| **The mistake** | "I spent five weeks automating the wrong step." | Costly-signal: admitting error is expensive, so it reads as true |
|
||||
| **The measurement** | "Work-in-progress time was 6 days. Wait time between owners was 35." | The number does the work; no adjective required |
|
||||
| **The sentence someone said** | "'Nobody believes the dashboard.' That was the actual problem." | Quoted speech is concrete and carries a voice |
|
||||
| **The refusal** | "We deleted the kickoff call. 80% of accounts never needed it." | A decision with a consequence attached |
|
||||
|
||||
## Openers to delete on sight
|
||||
|
||||
`post_linter.py` flags these because they are the most-scrolled-past
|
||||
constructions on the platform:
|
||||
|
||||
- "I'm excited to announce…" / "I'm thrilled…" / "I'm humbled…"
|
||||
- "In today's fast-paced world…"
|
||||
- "Quick thought:"
|
||||
- Any opener whose first eight words would fit any post about anything.
|
||||
|
||||
The excitement openers have a specific problem: they put *your* feeling first and
|
||||
the reader's interest second, in the two sentences where you can least afford it.
|
||||
Announce the thing, then say why it mattered.
|
||||
|
||||
## Formatting inside the fold and below it
|
||||
|
||||
- **Line breaks are structure, not decoration.** Three to four lines per block.
|
||||
- **Do not write in one-line paragraphs throughout.** The "broetry" cadence —
|
||||
every sentence its own paragraph — reads as formatted for an algorithm rather
|
||||
than for a person, and the linter flags six or more consecutive one-liners.
|
||||
- **No Unicode pseudo-bold.** The "bold text generators" produce Mathematical
|
||||
Alphanumeric Symbols. Screen readers announce them character by character as
|
||||
mathematical symbols, and LinkedIn's search does not index them as words. This
|
||||
is a blocking finding in the linter, not a style preference — see
|
||||
`accessibility_and_inclusion.md`.
|
||||
- **Links go in the first comment.** Say "link in the comments" in the post.
|
||||
|
||||
## The close
|
||||
|
||||
The end of a post is where a comment either happens or does not. Two failure
|
||||
modes:
|
||||
|
||||
- **No invitation at all.** The post is complete, correct, and closed; the only
|
||||
available response is agreement.
|
||||
- **A fake invitation.** "Thoughts?" and "Agree?" are engagement bait with a
|
||||
question mark. `post_linter.py` treats bait as blocking, and LinkedIn's
|
||||
Professional Community Policies name it as demoted content.
|
||||
|
||||
What works is a real question you would want answered, narrow enough that a
|
||||
specific person has a specific answer: "What did the handoff cost you the last
|
||||
time you measured it?"
|
||||
|
||||
## Editing pass
|
||||
|
||||
Three passes, in this order:
|
||||
|
||||
1. **Cut the first paragraph.** It is usually the throat-clearing. Check whether
|
||||
the post starts better at paragraph two — it does more often than not.
|
||||
2. **Read the first 140 characters alone.** Would you press "see more"?
|
||||
3. **Read it aloud.** Every sentence you stumble on is a sentence a reader
|
||||
stumbles on. This catches more than any linter.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Nielsen Norman Group. **F-Shaped Pattern for Reading Web Content** (2006) and
|
||||
**"How People Read Online"** — scanning behaviour and the weight of the first
|
||||
visible lines.
|
||||
2. Nielsen Norman Group. **"Microcontent: How to Write Headlines, Page Titles,
|
||||
and Subject Lines"** — front-loading for any truncated string.
|
||||
3. Loewenstein, G. **"The Psychology of Curiosity: A Review and Reinterpretation."**
|
||||
*Psychological Bulletin*, 1994 — the information-gap theory that explains why a
|
||||
*specific* gap works and a vague tease does not.
|
||||
4. Heath, C. & Heath, D. **Made to Stick** — concreteness, unexpectedness, and
|
||||
the curse of knowledge.
|
||||
5. Zinsser, W. **On Writing Well** — cutting every word that does no work; the
|
||||
"cut the first paragraph" pass comes straight from here.
|
||||
6. LinkedIn Engineering. **"Understanding feed dwell time to improve LinkedIn
|
||||
feed ranking."** — why holding attention past the fold is a measured
|
||||
objective, and why padding to game it is not.
|
||||
7. LinkedIn. **Professional Community Policies** — engagement bait as named,
|
||||
demoted content.
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# Post Formats Canon — what each native format is good at
|
||||
|
||||
Format is usually chosen by fashion. "Carousels are working right now" is the
|
||||
single most common reason a person makes a carousel, and it is why most carousels
|
||||
are a text post cut into slides.
|
||||
|
||||
The useful question is not which format performs best in general. It is which
|
||||
format the material you actually have can support, in the time you actually have.
|
||||
|
||||
---
|
||||
|
||||
## The nine native formats
|
||||
|
||||
### Plain text post
|
||||
**Good at:** stories, opinions, single ideas, anything where the words are the
|
||||
point. The default, and it should be.
|
||||
**Cost:** ~25 minutes including revision.
|
||||
**Constraint:** one idea. If it needs two, it is two posts.
|
||||
|
||||
### Document post (PDF carousel)
|
||||
**Good at:** step sequences, comparisons, data with structure, anything
|
||||
inherently visual.
|
||||
**Cost:** ~90 minutes.
|
||||
**Constraints:** every slide must survive alone — most readers swipe two and
|
||||
leave, so the payload cannot live on slide nine. Upload a real PDF with
|
||||
selectable text rather than exported images: text is accessible, indexed, and
|
||||
readable when a slide is zoomed. Slide one is the hook, and it is subject to the
|
||||
same test as a text hook.
|
||||
|
||||
### Native video
|
||||
**Good at:** demonstration, personality, anything where seeing it beats reading it.
|
||||
**Cost:** ~120 minutes for anything watchable.
|
||||
**Constraints:** captions are mandatory — most feed viewing is sound-off, and
|
||||
captions are also the accessibility floor. Say the point in the first five
|
||||
seconds; the fold applies to video too, it is just measured in seconds.
|
||||
|
||||
### Single image + text
|
||||
**Good at:** one chart, one photo, one artifact. Announcements.
|
||||
**Cost:** ~30 minutes.
|
||||
**Constraint:** write alt text. LinkedIn supports it and does not generate it for
|
||||
you. A chart with no alt text excludes readers and says nothing to anyone whose
|
||||
image fails to load.
|
||||
|
||||
### Poll
|
||||
**Good at:** settling a real question you will report back on.
|
||||
**Cost:** ~10 minutes.
|
||||
**Constraint:** `format_picker.py` refuses a poll without a declared decision.
|
||||
A poll you do not follow up on is a reach trick with a two-week half-life, and
|
||||
readers have learned to recognise it. The follow-up post — "here is what 400 of
|
||||
you said, and here is what we changed" — is the actual content.
|
||||
|
||||
### Long-form article
|
||||
**Good at:** the durable artifact. A link you will still send someone in two
|
||||
years.
|
||||
**Cost:** ~180 minutes.
|
||||
**Constraint:** articles reach far fewer people than posts. Write one when the
|
||||
artifact matters more than this week's impressions, and then mine it for posts.
|
||||
|
||||
### Newsletter issue
|
||||
**Good at:** a returning readership.
|
||||
**Cost:** ~150 minutes, plus a standing promise. See `newsletter_playbook.md`.
|
||||
|
||||
### Substantive comment on someone else's post
|
||||
**Good at:** visibility from a standing start. The most under-rated format on the
|
||||
platform and the cheapest by an order of magnitude.
|
||||
**Cost:** ~6 minutes.
|
||||
**Constraint:** it has to add something the original missed. Agreement is not a
|
||||
comment.
|
||||
|
||||
### Repost with your own take
|
||||
**Good at:** entering a conversation someone else started, with a position.
|
||||
**Cost:** ~15 minutes.
|
||||
**Constraint:** your take must be longer than "this". A bare repost spends your
|
||||
credibility on someone else's idea and returns nothing.
|
||||
|
||||
---
|
||||
|
||||
## Choosing
|
||||
|
||||
`format_picker.py` scores goal fit against material fit and refuses what the time
|
||||
budget cannot pay for. Two refusals are hard:
|
||||
|
||||
- **Video with no camera and no footage.** Not a judgement about your face; a
|
||||
refusal to plan work that will not happen.
|
||||
- **A poll with no real decision behind it.**
|
||||
|
||||
When two formats score within a point, the tool asks rather than picking. The
|
||||
tie-breaker is which one you would actually enjoy making, because the one you
|
||||
repeat beats the one that scores higher once.
|
||||
|
||||
## The carousel trap
|
||||
|
||||
Carousels reliably out-perform on engagement rate in third-party data, which
|
||||
produces a predictable failure: people convert text posts into carousels for the
|
||||
reach and end up with ten slides carrying two slides of content.
|
||||
|
||||
The test: **could a reader get the value from slide one plus the caption?** If
|
||||
yes, it is a text post and the extra nine slides are cost. If no — if the
|
||||
sequence itself is the content — it is a carousel.
|
||||
|
||||
## Video captions are not optional
|
||||
|
||||
Beyond accessibility: the majority of feed video is watched sound-off. An
|
||||
uncaptioned video is a silent film with no intertitles. LinkedIn offers
|
||||
auto-captions with an edit step; the edit step is not optional either, because
|
||||
auto-captions mangle exactly the domain terms your post is about.
|
||||
|
||||
## Cross-format sequencing
|
||||
|
||||
A single body of work supports a sequence, and the sequence out-performs any one
|
||||
piece:
|
||||
|
||||
1. **Text post** with the specific finding (cheap, tests appetite)
|
||||
2. **Carousel** with the full method, two weeks later, if the post landed
|
||||
3. **Article or newsletter issue** as the durable artifact
|
||||
4. **Comments** on other people's related posts throughout, linking nothing
|
||||
|
||||
Run the source through `repurpose_splitter.py` with a ledger so the same unit
|
||||
does not go out twice under two different formats.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Tufte, E. **The Visual Display of Quantitative Information** — data-ink ratio,
|
||||
the direct argument against a carousel slide that carries one sentence.
|
||||
2. Tufte, E. **The Cognitive Style of PowerPoint** — the specific failure of
|
||||
slide sequences that fragment an argument into bullet residue.
|
||||
3. Nielsen Norman Group. **"How People Read Online"** and mobile reading
|
||||
research — scanning and the cost of sequential reveal.
|
||||
4. W3C. **Web Content Accessibility Guidelines (WCAG) 2.2**, SC 1.1.1 (Non-text
|
||||
Content) and 1.2.2 (Captions, Prerecorded) — the floor for images and video.
|
||||
5. LinkedIn Help. **"Add alternative text to images"** and LinkedIn's video
|
||||
caption documentation — what the platform supports and what it does not do
|
||||
for you.
|
||||
6. van der Blom, R. **Algorithm Insights** (annual) — 🟡 comparative format
|
||||
performance in public-post samples; directionally useful, not a specification.
|
||||
7. Kleon, A. **Show Your Work!** — process content as the material most formats
|
||||
are actually best at carrying.
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
# Repurposing Discipline — one idea, many surfaces, no duplicates
|
||||
|
||||
Repurposing is the highest-leverage move available to anyone who already produces
|
||||
work: a talk, an article, a README, an internal write-up, a customer call you
|
||||
took notes on. It is also the fastest route to a feed that feels like a rerun.
|
||||
|
||||
The discipline has two halves. One is mechanical — splitting a source into units
|
||||
that can stand alone. The other is editorial, and it is the half that gets
|
||||
skipped: **the first-person sentence only you can write.**
|
||||
|
||||
---
|
||||
|
||||
## What makes a unit standalone
|
||||
|
||||
`repurpose_splitter.py` scores four things, each 25 points:
|
||||
|
||||
1. **Length in range** (240-2,400 characters). Under 240 there is not room for a
|
||||
claim and its evidence; over 2,400 it wants splitting again.
|
||||
2. **No dangling reference.** A unit that opens with "This meant that…" or "As we
|
||||
saw above" refers to something the reader never saw. This is the single most
|
||||
common defect in repurposed material and it is invisible to the author, who
|
||||
has read the source.
|
||||
3. **Evidence present.** A number, a duration, a measurable detail. A unit
|
||||
carrying no evidence can still work as an opinion post, but it is a different
|
||||
kind of post and should be routed as one.
|
||||
4. **Three or more substantive sentences.** Below that it is a note.
|
||||
|
||||
A unit failing 2 or 4 is disqualified regardless of its total score. A dangling
|
||||
opener fails in the feed no matter how good the evidence beneath it is.
|
||||
|
||||
## The reuse ledger
|
||||
|
||||
This is the part that matters and the part no other repurposing workflow has.
|
||||
|
||||
Repurposing fails in one specific way: the same idea goes out three times over
|
||||
eight months, and the audience notices before the author does. It happens because
|
||||
the source is long, the good units are memorable, and eight months is longer than
|
||||
anyone's memory of what they posted.
|
||||
|
||||
`--ledger` stores a normalised content hash of every unit marked as posted, with
|
||||
the date. Used units are skipped by default and shown with their date on request.
|
||||
It is a small file and it prevents a specific, embarrassing, recurring failure.
|
||||
|
||||
```bash
|
||||
# Split, see what is available
|
||||
python3 scripts/repurpose_splitter.py --input talk-transcript.md --ledger .linkedin-ledger.json
|
||||
|
||||
# After publishing unit 2, record it
|
||||
python3 scripts/repurpose_splitter.py --input talk-transcript.md \
|
||||
--ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25
|
||||
```
|
||||
|
||||
Commit the ledger alongside the source if the source lives in a repo. It is
|
||||
project state, not a cache.
|
||||
|
||||
## What you always have to add
|
||||
|
||||
Every unit the splitter produces is **source material, not a post**. The tool
|
||||
will not write the missing part and should not: it is the only genuinely new
|
||||
thing in a repurposed post.
|
||||
|
||||
The missing part is one of three sentences:
|
||||
|
||||
- **What it cost.** "We spent five weeks automating the wrong step."
|
||||
- **What you assumed.** "I thought the delay was engineering capacity."
|
||||
- **What you would do differently.** "I would instrument the handoffs first now."
|
||||
|
||||
A repurposed post without one of these reads as a summary of something else,
|
||||
because that is what it is.
|
||||
|
||||
## Source types and what they yield
|
||||
|
||||
| Source | Typical yield | The specific risk |
|
||||
|---|---|---|
|
||||
| Conference talk transcript | 4-8 units | Spoken asides do not survive as text; the connective tissue is all dangling references |
|
||||
| Long article / essay | 3-6 units | Sections written to build on each other rarely stand alone |
|
||||
| Technical README or docs | 2-4 units | Instructional voice; needs the "why we needed this" frame added |
|
||||
| Internal post-mortem | 2-5 units | **Consent and confidentiality first.** Anonymise, or get sign-off, or do not |
|
||||
| Customer conversation notes | 1-3 units | Never quotable without permission. The pattern is publishable; the customer is not |
|
||||
| Podcast appearance | 3-6 units | You do not own the recording; check before quoting at length |
|
||||
|
||||
## Cross-platform, not just within LinkedIn
|
||||
|
||||
The same source usually supports a LinkedIn post, a longer piece somewhere you
|
||||
own, and a talk proposal. Sequence matters:
|
||||
|
||||
**LinkedIn first when** you want to test whether anyone cares before investing the
|
||||
long-form time. The post is cheap and the response is informative.
|
||||
|
||||
**Owned platform first when** the artifact is the point and LinkedIn is
|
||||
distribution. Publish there, then post the strongest unit on LinkedIn with the
|
||||
link in the first comment.
|
||||
|
||||
What does not work is publishing the identical text in both places on the same
|
||||
day. LinkedIn readers who follow you elsewhere see a duplicate, and the
|
||||
LinkedIn version carries the link penalty for no gain.
|
||||
|
||||
## Frequency
|
||||
|
||||
A single strong source can carry a month of posting. It should not carry a
|
||||
quarter. The signal that you have over-mined a source is that the units start
|
||||
needing more setup than payload — that is the ledger telling you to go do
|
||||
something new and write about that instead.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Kleon, A. **Show Your Work!** — process as publishable material, and the case
|
||||
for surfacing the same work repeatedly in different forms.
|
||||
2. Handley, A. **Everybody Writes** — repurposing as an editorial discipline
|
||||
rather than a content-volume tactic.
|
||||
3. Vaynerchuk, G. **Jab, Jab, Jab, Right Hook** — platform-native adaptation;
|
||||
the same idea has to be re-formed, not re-pasted.
|
||||
4. Nielsen Norman Group. **"How People Read Online"** — why a unit that assumes
|
||||
prior context fails for a scanning reader who has none.
|
||||
5. Google Search Central. **Duplicate content guidance** — the mechanics of
|
||||
cross-posting identical text, and why canonical placement matters for anything
|
||||
you also own.
|
||||
6. Ebbinghaus, H. **Über das Gedächtnis** (1885), forgetting curve — the
|
||||
empirical case for deliberate repetition, and the reason it must be spaced
|
||||
and varied rather than repeated verbatim.
|
||||
7. LinkedIn. **Professional Community Policies** — repetitive posting of the same
|
||||
content is named as spam behaviour; the ledger is how you stay clear of it.
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env python3
|
||||
"""format_picker.py — pick the LinkedIn post format the material actually supports.
|
||||
|
||||
Format is usually chosen by fashion ("carousels are working right now") rather than
|
||||
by what the person has to say. This scores the nine native formats against three
|
||||
inputs you actually control — the goal, the raw material in hand, and the minutes
|
||||
available — and returns a ranked shortlist with the constraint each format carries.
|
||||
|
||||
It refuses two common mistakes outright:
|
||||
- a poll with no real decision behind it (a reach trick with a two-week half-life
|
||||
and nothing to say afterwards)
|
||||
- video when you have said you will not appear on camera and have no footage
|
||||
|
||||
Exit codes:
|
||||
0 a format is recommended
|
||||
2 the top two are within one point — ask which, do not guess
|
||||
3 no format fits the declared material; go get material first
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
GOALS = ["reach", "authority", "leads", "recruiting", "community", "career-change"]
|
||||
MATERIALS = ["story", "data", "opinion", "tutorial", "announcement",
|
||||
"transcript", "visual", "question", "curation"]
|
||||
|
||||
# fit[format][goal] and fit[format][material]: 0-3
|
||||
FORMATS = {
|
||||
"text-post": {
|
||||
"label": "Plain text post",
|
||||
"goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 2,
|
||||
"community": 3, "career-change": 3},
|
||||
"material": {"story": 3, "data": 2, "opinion": 3, "tutorial": 2,
|
||||
"announcement": 2, "transcript": 1, "visual": 0,
|
||||
"question": 3, "curation": 1},
|
||||
"minutes": 25,
|
||||
"constraint": "One idea. If it needs two, it is two posts.",
|
||||
},
|
||||
"document-carousel": {
|
||||
"label": "Document post (PDF carousel)",
|
||||
"goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 1,
|
||||
"community": 2, "career-change": 2},
|
||||
"material": {"story": 1, "data": 3, "opinion": 1, "tutorial": 3,
|
||||
"announcement": 0, "transcript": 1, "visual": 3,
|
||||
"question": 0, "curation": 3},
|
||||
"minutes": 90,
|
||||
"constraint": "Every slide must survive alone — most readers swipe two and leave. "
|
||||
"Upload a real PDF with selectable text, not exported images.",
|
||||
},
|
||||
"native-video": {
|
||||
"label": "Native video",
|
||||
"goal": {"reach": 3, "authority": 2, "leads": 2, "recruiting": 3,
|
||||
"community": 2, "career-change": 2},
|
||||
"material": {"story": 3, "data": 1, "opinion": 2, "tutorial": 3,
|
||||
"announcement": 2, "transcript": 3, "visual": 3,
|
||||
"question": 1, "curation": 0},
|
||||
"minutes": 120,
|
||||
"constraint": "Captions are mandatory — most viewing is sound-off, and captions are "
|
||||
"also the accessibility floor. Say the point in the first five seconds.",
|
||||
},
|
||||
"image-post": {
|
||||
"label": "Single image + text",
|
||||
"goal": {"reach": 2, "authority": 2, "leads": 1, "recruiting": 2,
|
||||
"community": 2, "career-change": 2},
|
||||
"material": {"story": 2, "data": 3, "opinion": 1, "tutorial": 1,
|
||||
"announcement": 3, "transcript": 0, "visual": 3,
|
||||
"question": 1, "curation": 1},
|
||||
"minutes": 30,
|
||||
"constraint": "Write alt text. A chart with no alt text excludes readers and says nothing "
|
||||
"to anyone who cannot load it.",
|
||||
},
|
||||
"poll": {
|
||||
"label": "Poll",
|
||||
"goal": {"reach": 2, "authority": 1, "leads": 1, "recruiting": 1,
|
||||
"community": 3, "career-change": 1},
|
||||
"material": {"story": 0, "data": 1, "opinion": 1, "tutorial": 0,
|
||||
"announcement": 0, "transcript": 0, "visual": 0,
|
||||
"question": 3, "curation": 0},
|
||||
"minutes": 10,
|
||||
"constraint": "Only if you will publish what the answers changed. A poll you do not follow "
|
||||
"up on is a reach trick, and readers have learned to spot it.",
|
||||
},
|
||||
"article": {
|
||||
"label": "Long-form article",
|
||||
"goal": {"reach": 1, "authority": 3, "leads": 2, "recruiting": 1,
|
||||
"community": 1, "career-change": 2},
|
||||
"material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 3,
|
||||
"announcement": 0, "transcript": 2, "visual": 1,
|
||||
"question": 0, "curation": 3},
|
||||
"minutes": 180,
|
||||
"constraint": "Articles reach far fewer people than posts. Write one when the artifact "
|
||||
"matters more than this week's impressions — it is a durable link.",
|
||||
},
|
||||
"newsletter-issue": {
|
||||
"label": "Newsletter issue",
|
||||
"goal": {"reach": 2, "authority": 3, "leads": 3, "recruiting": 1,
|
||||
"community": 3, "career-change": 1},
|
||||
"material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 3,
|
||||
"announcement": 1, "transcript": 2, "visual": 1,
|
||||
"question": 0, "curation": 3},
|
||||
"minutes": 150,
|
||||
"constraint": "Subscribers are notified every issue. That is a standing promise about "
|
||||
"cadence and topic — do not start one you cannot hold for six months.",
|
||||
},
|
||||
"comment-as-content": {
|
||||
"label": "Substantive comment on someone else's post",
|
||||
"goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 2,
|
||||
"community": 3, "career-change": 3},
|
||||
"material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 1,
|
||||
"announcement": 0, "transcript": 0, "visual": 0,
|
||||
"question": 2, "curation": 1},
|
||||
"minutes": 10,
|
||||
"constraint": "It has to add something the original missed. Agreement is not a comment. "
|
||||
"This is the fastest route to visibility from a standing start.",
|
||||
},
|
||||
"repost-with-take": {
|
||||
"label": "Repost with your own take",
|
||||
"goal": {"reach": 1, "authority": 2, "leads": 1, "recruiting": 1,
|
||||
"community": 2, "career-change": 1},
|
||||
"material": {"story": 0, "data": 2, "opinion": 3, "tutorial": 0,
|
||||
"announcement": 1, "transcript": 0, "visual": 1,
|
||||
"question": 1, "curation": 3},
|
||||
"minutes": 15,
|
||||
"constraint": "Your take must be longer than 'this'. A bare repost spends your credibility "
|
||||
"on someone else's idea and returns nothing.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def pick(goal: str, materials: list, minutes: int, on_camera: bool,
|
||||
has_real_decision: bool) -> dict:
|
||||
if goal not in GOALS:
|
||||
raise ValueError(f"goal must be one of {GOALS}")
|
||||
bad = [m for m in materials if m not in MATERIALS]
|
||||
if bad:
|
||||
raise ValueError(f"unknown material(s): {bad}; choose from {MATERIALS}")
|
||||
|
||||
ranked, excluded = [], []
|
||||
for key, spec in FORMATS.items():
|
||||
if key == "native-video" and not on_camera and "transcript" not in materials \
|
||||
and "visual" not in materials:
|
||||
excluded.append({"format": key, "reason":
|
||||
"You said no camera and supplied no footage or transcript."})
|
||||
continue
|
||||
if key == "poll" and not has_real_decision:
|
||||
excluded.append({"format": key, "reason":
|
||||
"No real decision behind it. A poll without a follow-up post is a "
|
||||
"reach trick; declare --has-decision if you will publish what "
|
||||
"the answers changed."})
|
||||
continue
|
||||
if spec["minutes"] > minutes:
|
||||
excluded.append({"format": key, "reason":
|
||||
f"Needs about {spec['minutes']} min; you have {minutes}."})
|
||||
continue
|
||||
mat_scores = [spec["material"][m] for m in materials] or [0]
|
||||
score = spec["goal"][goal] * 2 + max(mat_scores) + (sum(mat_scores) / len(mat_scores))
|
||||
ranked.append({
|
||||
"format": key, "label": spec["label"], "score": round(score, 2),
|
||||
"goal_fit": spec["goal"][goal],
|
||||
"best_material_fit": max(mat_scores),
|
||||
"effort_minutes": spec["minutes"],
|
||||
"constraint": spec["constraint"],
|
||||
})
|
||||
|
||||
ranked.sort(key=lambda r: (-r["score"], r["effort_minutes"]))
|
||||
if not ranked:
|
||||
return {"decision": "NO_FIT", "exit_code": 3, "ranked": [], "excluded": excluded,
|
||||
"instruction": "Nothing fits. Either the time budget is too small or the material "
|
||||
"does not exist yet. Go get the material — the format is the easy part."}
|
||||
if len(ranked) > 1 and ranked[0]["score"] - ranked[1]["score"] < 1.0:
|
||||
return {"decision": "ASK", "exit_code": 2, "ranked": ranked[:3], "excluded": excluded,
|
||||
"instruction": f"'{ranked[0]['label']}' and '{ranked[1]['label']}' score within a "
|
||||
"point. Ask which the person would actually enjoy making — the one "
|
||||
"they will repeat beats the one that scores higher once."}
|
||||
return {"decision": "RECOMMEND", "exit_code": 0, "recommended": ranked[0],
|
||||
"runners_up": ranked[1:3], "ranked": ranked, "excluded": excluded}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
lines = [f"Format decision: {r['decision']}", "=" * 52]
|
||||
if r["decision"] == "RECOMMEND":
|
||||
rec = r["recommended"]
|
||||
lines += [f"→ {rec['label']} (score {rec['score']}, ~{rec['effort_minutes']} min)",
|
||||
f" constraint: {rec['constraint']}", ""]
|
||||
if r["runners_up"]:
|
||||
lines.append("Runners-up:")
|
||||
for u in r["runners_up"]:
|
||||
lines.append(f" - {u['label']} ({u['score']}) — {u['constraint']}")
|
||||
else:
|
||||
lines.append(r["instruction"])
|
||||
for u in r.get("ranked", []):
|
||||
lines.append(f" - {u['label']} ({u['score']}) — {u['constraint']}")
|
||||
if r.get("excluded"):
|
||||
lines.append("\nRuled out:")
|
||||
for e in r["excluded"]:
|
||||
lines.append(f" - {e['format']}: {e['reason']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Rank LinkedIn post formats against goal, material, and time "
|
||||
"(recommend=0 / ask=2 / no-fit=3).")
|
||||
ap.add_argument("--goal", choices=GOALS, help="What this post is for.")
|
||||
ap.add_argument("--material", action="append", default=[], choices=MATERIALS,
|
||||
help="What you actually have. Repeatable.")
|
||||
ap.add_argument("--minutes", type=int, default=45,
|
||||
help="Minutes you will realistically spend (default 45).")
|
||||
ap.add_argument("--on-camera", action="store_true",
|
||||
help="You are willing to appear on camera.")
|
||||
ap.add_argument("--has-decision", action="store_true",
|
||||
help="A poll would settle a real decision you will report back on.")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Run a built-in example.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
goal, materials, minutes = "authority", ["data", "tutorial"], 120
|
||||
on_camera, decision = False, False
|
||||
else:
|
||||
if not args.goal or not args.material:
|
||||
ap.error("--goal and at least one --material are required (or use --sample)")
|
||||
goal, materials, minutes = args.goal, args.material, args.minutes
|
||||
on_camera, decision = args.on_camera, args.has_decision
|
||||
|
||||
try:
|
||||
result = pick(goal, materials, minutes, on_camera, decision)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
result["inputs"] = {"goal": goal, "materials": materials, "minutes": minutes,
|
||||
"on_camera": on_camera, "has_real_decision": decision}
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
#!/usr/bin/env python3
|
||||
"""post_linter.py — lint a LinkedIn post before it goes out. Score 0-100.
|
||||
|
||||
Checks the things that measurably cost reach or credibility, in four families:
|
||||
|
||||
MECHANICS length caps, the mobile fold, hashtag count, link placement
|
||||
HOOK does the visible first line survive truncation and earn the click
|
||||
INTEGRITY engagement bait, unverifiable superlatives, machine-written tells
|
||||
ACCESSIBILITY Unicode pseudo-bold, emoji load, ALL-CAPS, wall-of-text density
|
||||
|
||||
Blocking findings are the ones with named consequences: a post over the 3,000
|
||||
character cap will not publish; Unicode pseudo-bold is read aloud by screen
|
||||
readers as "mathematical bold small a"; engagement bait is named in LinkedIn's
|
||||
Professional Community Policies as demoted content.
|
||||
|
||||
Evidence note: the 3,000-character cap and ~140-character mobile fold are
|
||||
third-party-documented and stable. The reach cost of an in-body external link
|
||||
(~19% lower median reach in a 1.3M-post third-party study) is a third-party
|
||||
finding, not a LinkedIn statement — it is flagged as a warning, not a block,
|
||||
and the reference doc carries the confidence level.
|
||||
|
||||
Exit codes:
|
||||
0 SHIP (>= 75 and no blocking findings)
|
||||
2 REVISE (50-74, or any blocking finding)
|
||||
3 REWRITE (< 50)
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
HARD_LIMIT = 3000
|
||||
FOLD_MOBILE = 140 # characters before "…see more" on mobile
|
||||
FOLD_DESKTOP = 210
|
||||
BAND_LOW, BAND_HIGH = 1300, 2500 # highest observed median engagement band
|
||||
|
||||
BAIT_PATTERNS = [
|
||||
(r"\bcomment ['\"][\w ]{1,24}['\"]\s*(below|and i'?ll|to get|for the|if you)\b",
|
||||
"comment-for-lead-magnet"),
|
||||
(r"\bcomment ['\"]?\w{1,14}['\"]? below\b", "comment-keyword"),
|
||||
(r"\band i'?ll (dm|send) (you )?(the|it|a|my)\b", "dm-for-engagement"),
|
||||
(r"\b(like|repost|share) (this )?if you\b", "like-if"),
|
||||
(r"\btag (someone|a friend|3 people|three people)\b", "tag-someone"),
|
||||
(r"\bwho else (agrees|thinks|feels)\b", "agreement-farming"),
|
||||
(r"\bagree\?\s*$", "agree-closer"),
|
||||
(r"\brepost (this )?(to|so|if)\b", "repost-plea"),
|
||||
]
|
||||
|
||||
# Phrases that read as machine-written to anyone who reads LinkedIn daily.
|
||||
SLOP_PATTERNS = [
|
||||
(r"\bin today'?s (fast[- ]paced|ever[- ]changing|digital|competitive) (world|landscape|era)\b",
|
||||
"in-todays-world opener"),
|
||||
(r"\bdelve into\b", "delve"),
|
||||
(r"\bit'?s not (just )?about \w+[.,] it'?s about\b", "not-x-its-y"),
|
||||
(r"\blet that sink in\b", "let-that-sink-in"),
|
||||
(r"\bgame[- ]chang(er|ing)\b", "game-changer"),
|
||||
(r"\bunlock (the|your) (power|potential|secret)\b", "unlock-the-potential"),
|
||||
(r"\bin the ever[- ]evolving\b", "ever-evolving"),
|
||||
(r"\bhere'?s the (thing|kicker|secret)\b(?=[\s\S]{0,400}\bhere'?s the)", "repeated here's-the"),
|
||||
(r"\bthe results? (speak for themsel|were nothing short of)\w*", "results-speak"),
|
||||
(r"\b(revolutioniz|supercharg|turbocharg)\w+", "hype-verb"),
|
||||
(r"\bas an? (ai|language model)\b", "assistant-artifact"),
|
||||
(r"\bcertainly[!,]", "assistant-artifact"),
|
||||
]
|
||||
|
||||
SUPERLATIVES = [
|
||||
"the best", "the only", "guaranteed", "never fails", "always works",
|
||||
"100% of", "everyone knows", "no one is talking about", "nobody talks about",
|
||||
"the #1", "world-class", "unprecedented", "revolutionary",
|
||||
]
|
||||
|
||||
GENERIC_OPENERS = [
|
||||
"i'm excited to announce", "i am excited to announce", "i'm thrilled",
|
||||
"i am thrilled", "i'm humbled", "i am humbled", "excited to share",
|
||||
"happy to share", "proud to announce", "quick thought", "just a thought",
|
||||
]
|
||||
|
||||
URL_RE = re.compile(r"https?://[^\s)]+|\bwww\.[^\s)]+")
|
||||
HASHTAG_RE = re.compile(r"(?<!\w)#[A-Za-z][A-Za-z0-9_]{1,49}")
|
||||
EMOJI_RE = re.compile("[\U0001F300-\U0001FAFF☀-➿️⬀-⯿]")
|
||||
# Mathematical Alphanumeric Symbols + enclosed alphanumerics: the "bold text
|
||||
# generators" people paste into LinkedIn. Screen readers do not render these.
|
||||
PSEUDO_BOLD_RE = re.compile("[\U0001D400-\U0001D7FF\U0001F130-\U0001F189A-z]")
|
||||
|
||||
SAMPLE_POST = """Our onboarding took 6 weeks. We got it to 4 days without hiring anyone.
|
||||
|
||||
The bottleneck was not the product. It was that three different teams each owned
|
||||
one step and none of them owned the handoff.
|
||||
|
||||
What we changed:
|
||||
|
||||
1. One named owner for the whole path, not per step. Every stall now has someone
|
||||
whose week it ruins.
|
||||
2. We deleted the "kickoff call" and replaced it with a 4-question form. 80% of
|
||||
accounts never needed the call.
|
||||
3. We stopped treating the CRM stage as the source of truth and started measuring
|
||||
the customer's first real use.
|
||||
|
||||
The part I got wrong: I assumed the delay was engineering capacity. It was
|
||||
handoffs. We spent five weeks building automation for the wrong step before
|
||||
anyone measured where the time actually went.
|
||||
|
||||
If you are staring at a slow onboarding number, measure the wait between steps
|
||||
before you optimise any single step. That is where ours was hiding.
|
||||
|
||||
What did the handoff cost you the last time you measured it?
|
||||
"""
|
||||
|
||||
|
||||
def _visible(text: str, n: int) -> str:
|
||||
return text[:n]
|
||||
|
||||
|
||||
def lint(text: str, has_image: bool = False) -> dict:
|
||||
raw = text.rstrip("\n")
|
||||
low = raw.lower()
|
||||
findings = []
|
||||
n = len(raw)
|
||||
|
||||
def add(sev, family, msg, fix):
|
||||
findings.append({"severity": sev, "family": family, "finding": msg, "fix": fix})
|
||||
|
||||
# ---------------- MECHANICS ------------------------------------------
|
||||
if n > HARD_LIMIT:
|
||||
add("blocking", "mechanics",
|
||||
f"{n} characters — {n - HARD_LIMIT} over the {HARD_LIMIT} cap. LinkedIn will not publish it.",
|
||||
"Cut to one idea. The section you are most attached to is usually the one to lose.")
|
||||
elif n < 400:
|
||||
add("warning", "mechanics",
|
||||
f"{n} characters. Short posts can work, but under ~400 there is rarely room for a claim "
|
||||
"and the evidence for it.",
|
||||
"Either add the specific example, or accept it as a comment rather than a post.")
|
||||
elif not (BAND_LOW <= n <= BAND_HIGH):
|
||||
add("info", "mechanics",
|
||||
f"{n} characters — outside the {BAND_LOW}-{BAND_HIGH} band where third-party studies "
|
||||
"report the highest median engagement.",
|
||||
"Not a defect. Worth knowing if reach is the goal for this specific post.")
|
||||
|
||||
urls = URL_RE.findall(raw)
|
||||
if urls:
|
||||
add("warning", "mechanics",
|
||||
f"{len(urls)} external link(s) in the post body. A 1.3M-post third-party study reports "
|
||||
"~19% lower median reach for a body link (LinkedIn has never confirmed a penalty).",
|
||||
"Put the link in the first comment and say so in the post: 'link in the comments'. "
|
||||
"Keep it in the body only when the click IS the goal and you accept the reach cost.")
|
||||
|
||||
tags = HASHTAG_RE.findall(raw)
|
||||
if len(tags) > 3:
|
||||
add("warning", "mechanics",
|
||||
f"{len(tags)} hashtags. Past three they stop being topic signals and start reading as "
|
||||
"reach-chasing.",
|
||||
"Keep the two or three that describe what the post is actually about.")
|
||||
|
||||
# ---------------- HOOK ------------------------------------------------
|
||||
hook = _visible(raw, FOLD_MOBILE).strip()
|
||||
first_line = raw.split("\n", 1)[0].strip()
|
||||
if len(raw) > FOLD_MOBILE:
|
||||
boundary = max(hook.rfind("."), hook.rfind("?"), hook.rfind("!"), hook.rfind("\n"))
|
||||
if boundary < FOLD_MOBILE - 90:
|
||||
add("major", "hook",
|
||||
f"Nothing completes inside the first {FOLD_MOBILE} characters — the mobile fold. "
|
||||
"The reader's decision to expand is made on a fragment.",
|
||||
f"End a sentence before character {FOLD_MOBILE}. Desktop folds around "
|
||||
f"{FOLD_DESKTOP}, so mobile is the binding constraint.")
|
||||
gen = [g for g in GENERIC_OPENERS if low.startswith(g) or low[:60].find(g) >= 0]
|
||||
if gen:
|
||||
add("major", "hook",
|
||||
f"Opens with a stock phrase: '{gen[0]}'. It is the most-scrolled-past construction on "
|
||||
"the platform.",
|
||||
"Open on the specific thing: the number, the mistake, or the sentence someone said to you.")
|
||||
if not re.search(r"\d", hook) and "?" not in hook and len(first_line.split()) > 3:
|
||||
add("info", "hook",
|
||||
"The visible hook has no number and asks nothing. It can still work, but it is doing "
|
||||
"it on voice alone.",
|
||||
"A concrete number or a real question in the first line is the cheapest specificity available.")
|
||||
|
||||
# ---------------- INTEGRITY -------------------------------------------
|
||||
bait = sorted({label for pat, label in BAIT_PATTERNS if re.search(pat, low, re.I)})
|
||||
if bait:
|
||||
add("blocking", "integrity",
|
||||
f"Engagement bait: {', '.join(bait)}. LinkedIn's Professional Community Policies name "
|
||||
"bait as content it demotes, and readers recognise it.",
|
||||
"Ask the question the post actually earned, or offer the resource with no toll gate.")
|
||||
slop = [label for pat, label in SLOP_PATTERNS if re.search(pat, low, re.I)]
|
||||
if slop:
|
||||
add("major", "integrity",
|
||||
f"Machine-written tells: {', '.join(sorted(set(slop)))}.",
|
||||
"Rewrite those lines the way you would say them out loud. If a phrase could sit in "
|
||||
"anyone's post about anything, it is not carrying meaning.")
|
||||
sup = [s for s in SUPERLATIVES if s in low]
|
||||
if sup:
|
||||
add("warning", "integrity",
|
||||
f"Unverifiable superlatives: {', '.join(sup)}.",
|
||||
"Replace with the bounded version: what you measured, over what period, in what context.")
|
||||
# Rule-of-three cadence: three consecutive short sentence fragments in a row.
|
||||
if len(re.findall(r"(?m)^[^\n]{1,45}\.\s*$", raw)) >= 6:
|
||||
add("info", "integrity",
|
||||
"Many short standalone one-line sentences ('broetry' cadence). It reads as formatted "
|
||||
"for the algorithm rather than for a reader.",
|
||||
"Keep the line breaks that separate ideas; join the ones that only separate clauses.")
|
||||
|
||||
# ---------------- ACCESSIBILITY ---------------------------------------
|
||||
pseudo = PSEUDO_BOLD_RE.findall(raw)
|
||||
if pseudo:
|
||||
add("blocking", "accessibility",
|
||||
f"{len(pseudo)} Unicode pseudo-bold/italic characters. Screen readers announce these "
|
||||
"character by character as mathematical symbols, and LinkedIn search does not index "
|
||||
"them as words.",
|
||||
"Use plain text. Emphasis comes from line breaks and word order, not from a font hack.")
|
||||
emoji = EMOJI_RE.findall(raw)
|
||||
if len(emoji) > 8:
|
||||
add("warning", "accessibility",
|
||||
f"{len(emoji)} emoji. Each one is read aloud by name; past a handful the post becomes "
|
||||
"tiring to hear.",
|
||||
"Keep the ones doing structural work (list markers), cut decorative ones.")
|
||||
caps_lines = [ln for ln in raw.split("\n") if len(ln) > 25 and ln.isupper()]
|
||||
if caps_lines:
|
||||
add("warning", "accessibility",
|
||||
f"{len(caps_lines)} ALL-CAPS line(s). Some screen readers spell these out letter by letter.",
|
||||
"Sentence case. Put the emphasis in the words.")
|
||||
paragraphs = [p for p in re.split(r"\n\s*\n", raw) if p.strip()]
|
||||
longest = max((len(p) for p in paragraphs), default=0)
|
||||
if longest > 600:
|
||||
add("warning", "accessibility",
|
||||
f"Longest paragraph is {longest} characters. On a phone that is a wall.",
|
||||
"Break at the idea boundaries. Three to four lines per block is readable.")
|
||||
if has_image:
|
||||
add("info", "accessibility",
|
||||
"Image attached: LinkedIn supports alt text on images and does not add it for you.",
|
||||
"Write alt text describing what the image shows, not 'chart'. One sentence is enough.")
|
||||
|
||||
# ---------------- CTA --------------------------------------------------
|
||||
tail = raw[-300:].lower()
|
||||
if "?" not in tail and not re.search(r"\b(tell me|curious|what would you|how do you|"
|
||||
r"what did|would love to hear|message me|reply)\b", tail):
|
||||
add("info", "cta",
|
||||
"The post ends without a question or a next step. Comments are the engagement signal "
|
||||
"that matters most; nothing here invites one.",
|
||||
"One genuine question you would actually want answered. Not 'thoughts?'.")
|
||||
|
||||
# ---------------- score -------------------------------------------------
|
||||
weights = {"blocking": 22, "major": 11, "warning": 6, "info": 2}
|
||||
score = max(0, 100 - sum(weights[f["severity"]] for f in findings))
|
||||
blocking = any(f["severity"] == "blocking" for f in findings)
|
||||
if blocking:
|
||||
verdict, code = "REVISE", 2
|
||||
score = min(score, 60)
|
||||
elif score >= 75:
|
||||
verdict, code = "SHIP", 0
|
||||
elif score >= 50:
|
||||
verdict, code = "REVISE", 2
|
||||
else:
|
||||
verdict, code = "REWRITE", 3
|
||||
|
||||
order = {"blocking": 0, "major": 1, "warning": 2, "info": 3}
|
||||
return {
|
||||
"score": score,
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"stats": {
|
||||
"chars": n, "limit": HARD_LIMIT,
|
||||
"optimal_band": [BAND_LOW, BAND_HIGH],
|
||||
"hashtags": len(tags), "links_in_body": len(urls),
|
||||
"emoji": len(emoji), "paragraphs": len(paragraphs),
|
||||
"visible_before_see_more_mobile": hook,
|
||||
},
|
||||
"findings": sorted(findings, key=lambda f: order[f["severity"]]),
|
||||
"counts": {sev: sum(1 for f in findings if f["severity"] == sev)
|
||||
for sev in ("blocking", "major", "warning", "info")},
|
||||
"note": ("Character caps and fold positions are third-party-documented and stable. Reach "
|
||||
"effects (link penalty, length band) come from third-party studies of public "
|
||||
"posts, not from LinkedIn — see references/hook_and_fold_mechanics.md for the "
|
||||
"per-claim confidence levels."),
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
s = r["stats"]
|
||||
lines = [f"Post lint: {r['score']}/100 — {r['verdict']}", "=" * 56,
|
||||
f"{s['chars']}/{s['limit']} chars · {s['hashtags']} hashtags · "
|
||||
f"{s['links_in_body']} body links · {s['emoji']} emoji · {s['paragraphs']} blocks",
|
||||
"", "VISIBLE BEFORE \"…see more\" (mobile):",
|
||||
f" {s['visible_before_see_more_mobile']}", ""]
|
||||
if r["findings"]:
|
||||
lines.append("Findings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['family']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
else:
|
||||
lines.append("No findings. Ship it.")
|
||||
lines += ["", r["note"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Lint a LinkedIn post 0-100 (SHIP=0 / REVISE=2 / REWRITE=3).")
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument("--text", help="The post body.")
|
||||
src.add_argument("--input", help="Read the post from a file ('-' for stdin).")
|
||||
ap.add_argument("--has-image", action="store_true",
|
||||
help="The post carries an image or document (adds the alt-text check).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Lint a built-in sample post.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
text = SAMPLE_POST
|
||||
elif args.text:
|
||||
text = args.text
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
else:
|
||||
ap.error("one of --text, --input, or --sample is required")
|
||||
|
||||
result = lint(text, has_image=args.has_image)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
#!/usr/bin/env python3
|
||||
"""repurpose_splitter.py — split long source material into standalone LinkedIn units.
|
||||
|
||||
Feed it an article, a talk transcript, a README, or your own notes. It cuts the
|
||||
source into candidate post units, scores each on whether it can stand alone in a
|
||||
feed, suggests a format, and refuses the ones that only make sense in context.
|
||||
|
||||
The part that matters is the reuse ledger. Repurposing fails in one specific way:
|
||||
the same idea goes out three times over eight months and the audience notices
|
||||
before the author does. `--ledger` keeps a content hash of every unit already
|
||||
posted, so a unit that has run is skipped by default and shown with its date when
|
||||
you ask for it.
|
||||
|
||||
The tool never writes the post. It hands you a unit, the reason it can stand
|
||||
alone, and the gap you have to fill — which is always the same gap: the sentence
|
||||
only you can write about what this cost you or taught you.
|
||||
|
||||
Exit codes:
|
||||
0 usable units found
|
||||
2 units found but all are weak (fix the flagged gaps before drafting)
|
||||
3 nothing in the source can stand alone — this is one post, not a series
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
MIN_UNIT, MAX_UNIT = 240, 2400
|
||||
DANGLING_START = re.compile(
|
||||
r"^(this|these|those|that|it|they|he|she|such|as (mentioned|noted|we saw|discussed)|"
|
||||
r"the former|the latter|therefore|thus|however|but|and|so|which|meanwhile|"
|
||||
r"in conclusion|finally|next,)\b", re.I)
|
||||
|
||||
EVIDENCE_RE = re.compile(
|
||||
r"\d+\s*(%|x\b|×|ms\b|s\b|min\b|hours?|days?|weeks?|months?|years?)|"
|
||||
r"[$€£]\s?\d|\b\d{1,3}(,\d{3})+\b|\bversion \d|\b\d+\s*(users|customers|teams|"
|
||||
r"engineers|requests|rows|queries|tests)\b", re.I)
|
||||
|
||||
STORY_RE = re.compile(r"\b(i |we |my |our |the day|last (year|month|week)|when i|when we|"
|
||||
r"turned out|i thought|we assumed|the mistake)\b", re.I)
|
||||
STEP_RE = re.compile(r"(?m)^\s*(\d+[.)]|[-*•]|step \d)", re.I)
|
||||
OPINION_RE = re.compile(r"\b(should|shouldn'?t|wrong|overrated|underrated|myth|"
|
||||
r"stop |disagree|the real reason|nobody|most people)\b", re.I)
|
||||
|
||||
SAMPLE_SOURCE = """## Why our onboarding took six weeks
|
||||
|
||||
We measured it for the first time in March. Median time from contract signed to
|
||||
first real use was 41 days. Nobody in the company believed the number, which is
|
||||
usually the sign that it is right.
|
||||
|
||||
The instinct was to blame engineering capacity. We had a backlog of integration
|
||||
work and it was easy to point at.
|
||||
|
||||
## The measurement that changed our mind
|
||||
|
||||
We instrumented each handoff instead of each step. Work-in-progress time was 6
|
||||
days. Wait time between owners was 35 days. The work was not slow. The queue was.
|
||||
|
||||
That ratio — 6 to 35 — is the only number from this whole project I still quote.
|
||||
|
||||
## What we changed
|
||||
|
||||
1. One named owner for the whole path rather than one per stage.
|
||||
2. Deleted the kickoff call, replaced it with a four-question form. 80% of
|
||||
accounts never needed the call at all.
|
||||
3. Stopped treating the CRM stage as truth and started measuring first real use.
|
||||
|
||||
Median went to 4 days over the next quarter.
|
||||
|
||||
## What I got wrong
|
||||
|
||||
I spent five weeks building automation for the intake step before anyone measured
|
||||
where the time went. The automation worked. It saved about four hours across the
|
||||
whole quarter, against 35 days of queue time sitting untouched two steps later.
|
||||
"""
|
||||
|
||||
|
||||
def split_units(text: str) -> list:
|
||||
"""Heading-aware split, falling back to paragraph accumulation."""
|
||||
heading_blocks = re.split(r"(?m)^#{1,6}\s+", text)
|
||||
if len(heading_blocks) > 2:
|
||||
units = []
|
||||
for block in heading_blocks[1:]:
|
||||
line, _, rest = block.partition("\n")
|
||||
body = rest.strip()
|
||||
if body:
|
||||
units.append({"title": line.strip(), "text": body})
|
||||
if units:
|
||||
return units
|
||||
|
||||
units, buf = [], []
|
||||
for para in [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]:
|
||||
buf.append(para)
|
||||
joined = "\n\n".join(buf)
|
||||
if len(joined) >= MIN_UNIT:
|
||||
units.append({"title": "", "text": joined})
|
||||
buf = []
|
||||
if buf:
|
||||
joined = "\n\n".join(buf)
|
||||
if units and len(joined) < MIN_UNIT:
|
||||
units[-1]["text"] += "\n\n" + joined
|
||||
else:
|
||||
units.append({"title": "", "text": joined})
|
||||
return units
|
||||
|
||||
|
||||
def suggest_format(unit_text: str) -> str:
|
||||
if STEP_RE.search(unit_text) and len(EVIDENCE_RE.findall(unit_text)) >= 2:
|
||||
return "document-carousel"
|
||||
if STORY_RE.search(unit_text):
|
||||
return "text-post"
|
||||
if OPINION_RE.search(unit_text):
|
||||
return "text-post"
|
||||
if EVIDENCE_RE.search(unit_text):
|
||||
return "image-post"
|
||||
return "text-post"
|
||||
|
||||
|
||||
def score_unit(unit: dict) -> dict:
|
||||
text = unit["text"].strip()
|
||||
first = text.split("\n", 1)[0].strip()
|
||||
gaps, score = [], 0
|
||||
|
||||
if MIN_UNIT <= len(text) <= MAX_UNIT:
|
||||
score += 25
|
||||
elif len(text) < MIN_UNIT:
|
||||
gaps.append({"gap": "too-thin",
|
||||
"detail": f"{len(text)} chars — not enough to make a claim and back it.",
|
||||
"fix": "Merge with the neighbouring section or drop it."})
|
||||
else:
|
||||
score += 10
|
||||
gaps.append({"gap": "too-long",
|
||||
"detail": f"{len(text)} chars — over the {MAX_UNIT}-char working ceiling.",
|
||||
"fix": "Split again at the strongest internal boundary; two posts beat one crammed one."})
|
||||
|
||||
if DANGLING_START.match(first):
|
||||
gaps.append({"gap": "dangling-reference",
|
||||
"detail": f"Opens with '{first.split()[0]}' — it refers to something the "
|
||||
"reader never saw.",
|
||||
"fix": "Rewrite the first sentence to name the thing outright."})
|
||||
else:
|
||||
score += 25
|
||||
|
||||
ev = EVIDENCE_RE.findall(text)
|
||||
if ev:
|
||||
score += 25
|
||||
else:
|
||||
gaps.append({"gap": "no-evidence",
|
||||
"detail": "No number, duration, or measurable detail.",
|
||||
"fix": "Either add the real figure from the source, or route this to an "
|
||||
"opinion post where the argument does the work."})
|
||||
|
||||
sentences = [s for s in re.split(r"[.!?](?:\s|$)", text) if len(s.strip()) > 15]
|
||||
if len(sentences) >= 3:
|
||||
score += 25
|
||||
else:
|
||||
gaps.append({"gap": "fragment",
|
||||
"detail": f"{len(sentences)} substantive sentence(s) — reads as a note, "
|
||||
"not a post.",
|
||||
"fix": "Expand or merge."})
|
||||
|
||||
return {
|
||||
"title": unit["title"],
|
||||
"chars": len(text),
|
||||
"standalone_score": score,
|
||||
"suggested_format": suggest_format(text),
|
||||
"hook_candidate": first[:140],
|
||||
"gaps": gaps,
|
||||
"text": text,
|
||||
"author_gap": ("Every unit here is source material, not a post. Add the first-person "
|
||||
"sentence only you can write: what it cost, what you assumed, or what "
|
||||
"you would do differently."),
|
||||
}
|
||||
|
||||
|
||||
def unit_hash(text: str) -> str:
|
||||
normalized = re.sub(r"\s+", " ", text.strip().lower())
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def load_ledger(path: str) -> dict:
|
||||
if path and os.path.isfile(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Split long source material into standalone LinkedIn units "
|
||||
"(usable=0 / all-weak=2 / not-splittable=3).")
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument("--input", help="Source file to split ('-' for stdin).")
|
||||
ap.add_argument("--ledger", help="JSON file tracking units already posted "
|
||||
"(created on --record if absent).")
|
||||
ap.add_argument("--record", action="append", default=[],
|
||||
help="Mark a unit index as posted in the ledger. Repeatable.")
|
||||
ap.add_argument("--posted-on", default="",
|
||||
help="Date string stored with --record entries (e.g. 2026-08-25).")
|
||||
ap.add_argument("--show-used", action="store_true",
|
||||
help="Include units already in the ledger instead of skipping them.")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Split a built-in sample article.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
text = SAMPLE_SOURCE
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
else:
|
||||
ap.error("--input or --sample is required")
|
||||
|
||||
ledger = load_ledger(args.ledger) if args.ledger else {}
|
||||
raw_units = split_units(text)
|
||||
scored = []
|
||||
for idx, u in enumerate(raw_units):
|
||||
s = score_unit(u)
|
||||
s["index"] = idx
|
||||
s["hash"] = unit_hash(s["text"])
|
||||
prev = ledger.get(s["hash"])
|
||||
s["already_posted"] = bool(prev)
|
||||
s["posted_on"] = prev.get("posted_on") if isinstance(prev, dict) else prev
|
||||
scored.append(s)
|
||||
|
||||
if args.record:
|
||||
if not args.ledger:
|
||||
print("ERROR: --record requires --ledger", file=sys.stderr)
|
||||
return 4
|
||||
try:
|
||||
wanted = {int(i) for i in args.record}
|
||||
except ValueError:
|
||||
print("ERROR: --record takes unit indexes (integers)", file=sys.stderr)
|
||||
return 4
|
||||
for s in scored:
|
||||
if s["index"] in wanted:
|
||||
ledger[s["hash"]] = {"title": s["title"] or s["hook_candidate"][:60],
|
||||
"posted_on": args.posted_on or "unspecified"}
|
||||
with open(args.ledger, "w", encoding="utf-8") as fh:
|
||||
json.dump(ledger, fh, indent=2)
|
||||
|
||||
visible = scored if args.show_used else [s for s in scored if not s["already_posted"]]
|
||||
# A unit with a structural gap is not usable however well it scores elsewhere:
|
||||
# a dangling opener or a fragment fails in the feed regardless of its evidence.
|
||||
disqualifying = {"too-thin", "dangling-reference", "fragment"}
|
||||
usable = [s for s in visible
|
||||
if s["standalone_score"] >= 75
|
||||
and not any(g["gap"] in disqualifying for g in s["gaps"])]
|
||||
skipped = len(scored) - len(visible)
|
||||
|
||||
if not scored or (len(scored) == 1 and scored[0]["standalone_score"] < 75):
|
||||
decision, code = "NOT_SPLITTABLE", 3
|
||||
elif usable:
|
||||
decision, code = "USABLE", 0
|
||||
else:
|
||||
decision, code = "ALL_WEAK", 2
|
||||
|
||||
result = {
|
||||
"decision": decision,
|
||||
"exit_code": code,
|
||||
"units_found": len(scored),
|
||||
"units_skipped_as_already_posted": skipped,
|
||||
"usable_units": len(usable),
|
||||
"units": sorted(visible, key=lambda s: -s["standalone_score"]),
|
||||
"ledger_path": args.ledger,
|
||||
"rule": ("A unit is source material, not a post. The tool never fabricates the "
|
||||
"first-person line — that is the author's job and it is the only part "
|
||||
"of a repurposed post that is actually new."),
|
||||
}
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(f"Repurpose: {decision} — {len(scored)} unit(s), {len(usable)} usable"
|
||||
+ (f", {skipped} skipped as already posted" if skipped else ""))
|
||||
print("=" * 64)
|
||||
for s in result["units"]:
|
||||
flag = " [ALREADY POSTED %s]" % s["posted_on"] if s["already_posted"] else ""
|
||||
print(f"\n#{s['index']} score {s['standalone_score']}/100 "
|
||||
f"{s['chars']} chars → {s['suggested_format']}{flag}")
|
||||
if s["title"]:
|
||||
print(f" title: {s['title']}")
|
||||
print(f" hook : {s['hook_candidate']}")
|
||||
for g in s["gaps"]:
|
||||
print(f" gap : [{g['gap']}] {g['detail']}")
|
||||
print(f" fix → {g['fix']}")
|
||||
print(f"\n{result['rule']}")
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
100
marketing/linkedin/skills/linkedin-engagement/SKILL.md
Normal file
100
marketing/linkedin/skills/linkedin-engagement/SKILL.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
name: linkedin-engagement
|
||||
description: Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn — a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on "who should I engage with", "write a connection request", "cold DM", "LinkedIn outreach", "networking strategy", "how many invites can I send". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: marketing
|
||||
updated: 2026-08-25
|
||||
---
|
||||
|
||||
# LinkedIn Engagement — comments first, outreach second
|
||||
|
||||
From a standing start your posts reach almost nobody, and publishing harder does not fix it.
|
||||
A substantive comment on a post that already has an audience puts your name, headline, and a
|
||||
paragraph of thinking in front of people already reading about your subject, for six minutes
|
||||
of work — the cheapest distribution on the platform, and the most badly used.
|
||||
|
||||
**Nothing here sends anything** — no credentials, no API calls. Automated connecting,
|
||||
messaging, commenting, liking, and sharing are prohibited by User Agreement §8.2.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. Build the comment roster.** Name 8-12 accounts they would read anyway, with audience
|
||||
overlap and rough size tier, then:
|
||||
|
||||
```bash
|
||||
python3 scripts/comment_target_planner.py --account "Priya Raman:5:4:larger" \
|
||||
--account "Tomas Lind:5:3:peer" --minutes-per-day 18 --output human
|
||||
```
|
||||
|
||||
Tiers are `huge` (10x+, crowded), `larger` (2-10x, the best ratio), `peer` (~1x, where
|
||||
reciprocity compounds), `smaller` (goodwill). The roster caps any account at twice a week —
|
||||
commenting daily on one person reads as following them around — and keeps the huge tier under
|
||||
half of any day. It builds the roster, never the comments: a generated comment is exactly
|
||||
what §8.2 names, and it is recognisable anyway.
|
||||
|
||||
**2. Outreach — check the volume before writing anything.**
|
||||
|
||||
```bash
|
||||
python3 scripts/outreach_volume_guard.py --invites 20 --pending 5 --minutes 120 \
|
||||
--acceptance 0.42 --output human
|
||||
```
|
||||
|
||||
Exit 0 safe / 2 tight / 3 over a cap or the time budget / **4 refused** above 40 invitations
|
||||
a day, because nobody reads that many profiles and writes that many specific lines. Pending
|
||||
invitations count against the weekly limit (observed around 100), and acceptance below 20% is
|
||||
a stop signal — it is the pattern LinkedIn reviews, and the targeting is wrong.
|
||||
|
||||
**3. Write one message, for one person.**
|
||||
|
||||
```bash
|
||||
python3 scripts/outreach_message_builder.py --type connection \
|
||||
--recipient "Priya" --specific-line "..." --reason "..." --output human
|
||||
```
|
||||
|
||||
It refuses without a person-specific line, refuses an ask in a first-touch connection note,
|
||||
and enforces the 200-character cap (300 with `--premium`). In large third-party samples a
|
||||
note barely moves acceptance (~26.4% either way) but roughly doubles the post-accept reply
|
||||
rate: **the note earns the conversation, not the meeting.**
|
||||
|
||||
**4. The order that works.** Comment on their work for two weeks. Then invite, referencing
|
||||
something specific from that reading. Then, after acceptance and a pause, ask once, small.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Nothing is auto-sent.** Ever. The user pastes and sends, one person at a time.
|
||||
- **No engagement pods.** Coordinated reciprocal commenting is inauthentic engagement under
|
||||
§8.2 regardless of who pressed the key. Build a real reciprocity list instead.
|
||||
- **Every message carries a line that could only have been written for that person.**
|
||||
- **Never paste the same comment twice.** Identical comments at volume are the definition of
|
||||
the thing §8.2 prohibits.
|
||||
- **One follow-up, a week later, only with something new to say.**
|
||||
- **Agreement is not a comment.** Add the counter-example, the number, or the case where it
|
||||
breaks — or skip the slot.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Role |
|
||||
|---|---|
|
||||
| [`scripts/comment_target_planner.py`](scripts/comment_target_planner.py) | Weekly roster from scored accounts, inside a time budget, with per-account and per-tier caps. |
|
||||
| [`scripts/outreach_message_builder.py`](scripts/outreach_message_builder.py) | Assembles one message; refuses templates, premature asks, and 14 dead phrases. |
|
||||
| [`scripts/outreach_volume_guard.py`](scripts/outreach_volume_guard.py) | Caps invitations against the observed weekly limit, pending backlog, acceptance floor, and the hours available. |
|
||||
|
||||
## References and assets
|
||||
|
||||
- [`references/comment_strategy.md`](references/comment_strategy.md) — tiers, what a comment competes on, pods, replying to your own posts (7 sources)
|
||||
- [`references/outreach_ethics_and_benchmarks.md`](references/outreach_ethics_and_benchmarks.md) — the benchmark numbers with their provenance, and the personalisation claim corrected (7 sources)
|
||||
|
||||
- [`assets/outreach_worksheet.md`](assets/outreach_worksheet.md) — fillable per-person prep sheet
|
||||
- [`assets/example_outreach.json`](assets/example_outreach.json) — input shape for the builder
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`linkedin-content`** — writes posts. A comment is a different craft on a different budget.
|
||||
- **`marketing-skill/cold-email`** — email. Different channel, law, and caps.
|
||||
- **`business-growth/`, `commercial/`** — sales process and deal economics, not networking.
|
||||
|
||||
---
|
||||
**Version:** 1.0.0
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"_comment": "Input shape for outreach_message_builder.py. This example passes at exit 0: the specific line names an artifact and the part that mattered, the reason is a clause, and there is no ask - because this is a first-touch connection note and the note's job is the conversation, not the meeting.",
|
||||
"type": "connection",
|
||||
"recipient": "Priya",
|
||||
"specific_line": "Your teardown of the dbt exposures rollout matched what broke for us at step three.",
|
||||
"reason": "I am working the same problem from the platform side and would like to follow what you publish.",
|
||||
"ask": "",
|
||||
"premium": false
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
# Outreach Prep Sheet — one per person
|
||||
|
||||
If you cannot fill section 2 without opening a template, do not send the message. The
|
||||
person-specific line is the entire difference between outreach and spam, and
|
||||
`outreach_message_builder.py` refuses without it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Who
|
||||
|
||||
- Name (spelled the way they spell it): ______
|
||||
- What they actually do (not their title): ______
|
||||
- How you found them: ______
|
||||
- Have you commented on their work in the last two weeks? ______
|
||||
|
||||
If the last answer is no, consider doing that first. Comment for two weeks, then invite.
|
||||
That order converts better than any wording optimisation.
|
||||
|
||||
## 2. The specific line — the one that could not be sent to anyone else
|
||||
|
||||
Name the artifact and the part of it that mattered:
|
||||
|
||||
> ______________________________________________________________
|
||||
|
||||
Checks:
|
||||
- Does it name something they made, said, or decided? ______
|
||||
- Could this sentence be sent to anyone else on your list? If yes, rewrite it. ______
|
||||
- Is it a disagreement or a use, rather than a compliment? (Better if so.) ______
|
||||
|
||||
## 3. The reason
|
||||
|
||||
One clause: what you want to follow, learn, or compare notes on.
|
||||
|
||||
> ______________________________________________________________
|
||||
|
||||
## 4. The ask — **not in a first-touch connection note**
|
||||
|
||||
Leave blank for a connection request. The note's job is the conversation, not the meeting:
|
||||
in large third-party samples a note barely moves acceptance (~26.4% either way) but roughly
|
||||
doubles the reply rate after acceptance.
|
||||
|
||||
For a later message, keep it bounded and small:
|
||||
|
||||
> ______________________________________________________________
|
||||
|
||||
## 5. Volume check before sending
|
||||
|
||||
- Invitations already pending: ______
|
||||
- New invitations planned this week: ______
|
||||
- Recent acceptance rate: ______ (below 20% → stop and fix targeting)
|
||||
|
||||
```bash
|
||||
python3 ../scripts/outreach_volume_guard.py --invites <n> --pending <n> \
|
||||
--minutes <n> --acceptance <0.xx> --output human
|
||||
```
|
||||
|
||||
## 6. Assemble and check
|
||||
|
||||
```bash
|
||||
python3 ../scripts/outreach_message_builder.py --type connection \
|
||||
--recipient "..." --specific-line "..." --reason "..." --output human
|
||||
```
|
||||
|
||||
Cap: 200 characters free, 300 with `--premium`.
|
||||
|
||||
## 7. Follow-up rule
|
||||
|
||||
One follow-up, at least a week later, **only with something new to say**. "Just bumping this
|
||||
up" is not new information. A second follow-up with nothing new is the point at which you
|
||||
become the thing you were avoiding.
|
||||
|
||||
- Date sent: ______
|
||||
- Accepted? ______
|
||||
- Follow-up sent (date, and what was new): ______
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
# Comment Strategy — the cheapest distribution on LinkedIn
|
||||
|
||||
From a standing start, your posts reach almost nobody. That is not a failure of
|
||||
the writing; it is what an empty follower graph and a relevance-based retrieval
|
||||
system produce together. Publishing harder does not fix it.
|
||||
|
||||
Commenting does. A substantive comment on a post that already has an audience
|
||||
puts your name, your headline, and one paragraph of your thinking in front of
|
||||
people who are already reading about your subject. It costs six minutes.
|
||||
|
||||
Most people do it badly, in an identifiable way.
|
||||
|
||||
---
|
||||
|
||||
## What a comment is competing on
|
||||
|
||||
A reader scanning a comment thread is making the same decision they make in the
|
||||
feed, with less patience. Your comment competes against forty others, most of
|
||||
which say some version of "great post". The bar for standing out is therefore
|
||||
much lower than it looks, and it is entirely about **adding something the post
|
||||
did not say.**
|
||||
|
||||
Five things that qualify:
|
||||
|
||||
1. **A counter-example.** "This holds until the team crosses about fifteen
|
||||
people, and then the handoff cost dominates."
|
||||
2. **A number.** "We measured this: 6 days of work, 35 days of queue."
|
||||
3. **The case where it breaks.** The most valuable comment type, and the rarest.
|
||||
4. **A specific mechanism.** Why the thing the author observed happens.
|
||||
5. **A question only someone who read it properly could ask.**
|
||||
|
||||
Two that do not: agreement, and a summary of the post the author just wrote.
|
||||
|
||||
## Tiering, and why the biggest accounts are the wrong target
|
||||
|
||||
`comment_target_planner.py` scores accounts on audience overlap, posting
|
||||
frequency, and tier.
|
||||
|
||||
| Tier | Relative size | Factor | Reality |
|
||||
|---|---|---|---|
|
||||
| **huge** | 10x+ | 1.0 | Crowded. 400 comments, most never read. Worth it only if you are early *and* excellent |
|
||||
| **larger** | 2-10x | 1.5 | The best ratio of reachable audience to competition |
|
||||
| **peer** | ~1x | 1.3 | Reciprocity compounds. These relationships still exist in a year |
|
||||
| **smaller** | <0.5x | 0.9 | Low reach today, high goodwill, and some will not be smaller for long |
|
||||
|
||||
The instinct is to comment on the biggest accounts in the field. It is the worst
|
||||
use of the time: your comment is one of hundreds, the author will not read it,
|
||||
and their audience is scanning past the whole thread.
|
||||
|
||||
**The under-rated tier is peer.** Reciprocity is real and it is mutual — the
|
||||
people at your size who show up in your comments now are the ones whose audience
|
||||
overlaps yours most, and the relationship runs both directions.
|
||||
|
||||
## The rules the planner enforces
|
||||
|
||||
- **No account more than twice a week.** Commenting daily on one person reads as
|
||||
following them around, and it exhausts the goodwill it earns.
|
||||
- **At least one peer slot per day**, for the reason above.
|
||||
- **No more than half a day's slots in the huge tier.**
|
||||
|
||||
## What the planner will not do
|
||||
|
||||
It does not write comments. This is the whole point of the design: a comment
|
||||
written by a tool is exactly the thing LinkedIn's User Agreement §8.2 names when
|
||||
it prohibits automated methods to "create, comment on, like, share, or re-share
|
||||
posts, or otherwise drive inauthentic engagement."
|
||||
|
||||
More practically: a generated comment is recognisable, and being recognised as
|
||||
someone who generates comments is worse than not commenting.
|
||||
|
||||
## Engagement pods
|
||||
|
||||
A pod is a group that agrees to reciprocally comment on each other's posts,
|
||||
usually within the first hour. The pitch is that it is just humans helping
|
||||
humans.
|
||||
|
||||
It is prohibited. The User Agreement language is "otherwise drive inauthentic
|
||||
engagement", and coordinated reciprocal commenting on schedule is the central
|
||||
example. `linkedin_policy_gate.py` refuses pod requests at exit 4.
|
||||
|
||||
The legitimate version of the same instinct is the reciprocity list: a set of
|
||||
people whose work you genuinely read, whom you comment on because you have
|
||||
something to say. It is slower, it produces the same relationships, and it
|
||||
survives an audit.
|
||||
|
||||
## Replying to your own comments
|
||||
|
||||
Half of the value of a post is in the thread underneath it. Replies to your own
|
||||
post are not a bonus round:
|
||||
|
||||
- Reply to every substantive comment within 24 hours, and to the early ones
|
||||
within the hour if you can.
|
||||
- Reply with something, not "thanks!". The reply is visible to everyone who opens
|
||||
the thread, and it is a second chance to say the thing.
|
||||
- **Reply to replies on your comments elsewhere.** That sub-thread is where
|
||||
people actually meet you.
|
||||
|
||||
`cadence_planner.py` budgets 20 minutes per published post for this, and treats
|
||||
it as part of the post rather than an extra.
|
||||
|
||||
## Groups and communities
|
||||
|
||||
LinkedIn Groups are mostly dormant. The exceptions are narrow, actively moderated
|
||||
professional groups, where the signal is high because the population is small.
|
||||
The test is whether posts in the group get real replies from named humans; if the
|
||||
last three posts have no comments, it is a graveyard and posting there is a
|
||||
donation to nobody.
|
||||
|
||||
The higher-yield version of the same instinct is off-LinkedIn communities where
|
||||
your audience already talks — a Slack, a Discord, a mailing list, a conference —
|
||||
and bringing what you learn there back to LinkedIn as posts.
|
||||
|
||||
## Measuring it
|
||||
|
||||
Commenting does not produce a metric LinkedIn will show you cleanly. The
|
||||
observable signals, in order of reliability:
|
||||
|
||||
1. Profile views trending up in weeks you commented consistently.
|
||||
2. Connection requests *from* people whose posts you commented on.
|
||||
3. Named references: someone says "I saw your comment on X's post".
|
||||
|
||||
None of these are clean attribution, and there is no honest way to make them so.
|
||||
Treat commenting as a fixed practice rather than a tracked channel.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. LinkedIn. **User Agreement §8.2** — the explicit prohibition on automated or
|
||||
inauthentic commenting, liking, and sharing.
|
||||
2. Granovetter, M. **"The Strength of Weak Ties."** *American Journal of
|
||||
Sociology*, 1973 — why the peer and larger tiers, not the huge tier, are where
|
||||
opportunity actually flows.
|
||||
3. Burt, R. **Structural Holes: The Social Structure of Competition** (1992) —
|
||||
brokerage between clusters; commenting across adjacent fields is exactly this.
|
||||
4. Cialdini, R. **Influence: The Psychology of Persuasion** — reciprocity and
|
||||
liking, and why they are destroyed by being made transactional.
|
||||
5. Grant, A. **Give and Take** — givers who succeed do so through specific,
|
||||
bounded generosity, not indiscriminate availability.
|
||||
6. Nielsen, J. **"The 90-9-1 Rule for Participation Inequality"** (NN/g) — the
|
||||
small population of people who comment at all, and why being one of them is
|
||||
cheap differentiation.
|
||||
7. LinkedIn Engineering. **"Understanding feed dwell time to improve LinkedIn
|
||||
feed ranking."** — the mechanism by which threads that hold attention are
|
||||
measured.
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
# Outreach Ethics and Benchmarks — what the numbers say, and what they cost
|
||||
|
||||
Cold outreach on LinkedIn works and is widely hated, and both facts have the same
|
||||
cause: it is cheap to send and expensive to receive. The volume that makes it
|
||||
economically attractive is the volume that makes it spam, and the platform
|
||||
enforces against the pattern rather than the intent.
|
||||
|
||||
This document holds the numbers, with their provenance, and the rules that keep
|
||||
the practice on the right side of the line.
|
||||
|
||||
---
|
||||
|
||||
## The benchmarks, with a correction
|
||||
|
||||
Third-party outreach studies are large but self-selected: the data comes from
|
||||
users of outreach tooling, measured by the vendors of that tooling. Treat all of
|
||||
it 🟡.
|
||||
|
||||
| Metric | Reported | Source class |
|
||||
|---|---|---|
|
||||
| Connection acceptance, platform-wide average | ~26-29% | 🟡 multi-million-touch vendor datasets |
|
||||
| Acceptance with a personalised note | ~26.4% | 🟡 same |
|
||||
| Acceptance with **no** note | ~26.4% | 🟡 same |
|
||||
| Post-accept reply rate, **without** a note | ~5.4% | 🟡 |
|
||||
| Post-accept reply rate, **with** a note | ~9.4% | 🟡 |
|
||||
| Cold InMail response | 10-25% typical | 🟡 |
|
||||
| Individually sent vs bulk InMail | ~15% higher response individually | 🟢 LinkedIn-reported |
|
||||
|
||||
**The correction that matters.** The widely repeated claim is that a personalised
|
||||
note roughly triples acceptance (≈45% vs ≈15%). The largest available datasets do
|
||||
not support it: acceptance is close to identical either way (26.42% vs 26.37% in
|
||||
one ~13M-touch sample). What the note does move — and moves a lot — is the
|
||||
**reply rate after acceptance**, roughly 5.4% → 9.4%.
|
||||
|
||||
The practical conclusion is the same as the folklore's, but the reasoning is
|
||||
different and it changes what you write. **The note is not there to get the
|
||||
connection. It is there to earn the conversation.** Which means it should be
|
||||
about them and about why this conversation, not about you and what you sell.
|
||||
|
||||
`outreach_message_builder.py` encodes exactly this: it refuses an ask in a
|
||||
first-touch connection note, because the note's job is the conversation, not the
|
||||
meeting.
|
||||
|
||||
## The character caps
|
||||
|
||||
| Message type | Free | Premium | Confidence |
|
||||
|---|---|---|---|
|
||||
| Connection request note | 200 | 300 | 🟡 |
|
||||
| Direct message | ~1,800 | ~1,800 | 🟡 |
|
||||
| InMail body | ~1,900 | ~1,900 | 🟡 |
|
||||
|
||||
Two hundred characters is roughly two sentences. That is the entire budget, and
|
||||
it is why the builder puts the person-specific line first and treats everything
|
||||
else as optional.
|
||||
|
||||
## The person-specific line
|
||||
|
||||
The builder refuses to assemble a message without one. It is the single
|
||||
discriminator between outreach and spam, and it is checkable: **could this
|
||||
sentence have been sent to anyone else on your list?** If yes, it is not
|
||||
specific.
|
||||
|
||||
What qualifies:
|
||||
- Something they published, named, with the part that mattered to you.
|
||||
- A decision they made that you are facing.
|
||||
- A disagreement. "Your point about X is the opposite of what we found" is a
|
||||
better opener than any compliment.
|
||||
|
||||
What does not:
|
||||
- "I came across your profile."
|
||||
- Their job title, company, or industry.
|
||||
- "As a fellow [category]."
|
||||
- Praise with no specifics, which reads as a mail merge because it usually is.
|
||||
|
||||
## Phrases to delete
|
||||
|
||||
The builder flags these because they mark a message as bulk before anyone reads
|
||||
the content: *I came across your profile · I'd love to pick your brain · hope
|
||||
this finds you well · quick question · just following up · touch base · synergy ·
|
||||
I'll keep it short · as a fellow · I see we're both in · let's connect.*
|
||||
|
||||
"Pick your brain" deserves a specific note: it asks for unbounded unpaid time
|
||||
with no bounded question attached. The version that works is the opposite —
|
||||
one specific question, answerable in two sentences, with an explicit "no reply
|
||||
needed if you're busy".
|
||||
|
||||
## Volume, and what actually triggers restrictions
|
||||
|
||||
Covered in full in `linkedin-skills/references/policy_and_account_safety.md`. The
|
||||
short version:
|
||||
|
||||
- Weekly invitation limit observed around **100**, adjusted per account, with
|
||||
**pending invitations counting against it**. 🟡
|
||||
- Withdrawn invitations cannot be re-sent to the same person for about three
|
||||
weeks. 🟡
|
||||
- **Low acceptance rate at volume is the discriminating signal**, not volume
|
||||
alone. Below ~20%, stop and fix the targeting rather than pushing through.
|
||||
- Machine-regular pacing looks automated whether or not it is.
|
||||
|
||||
`outreach_volume_guard.py` refuses above 40 invitations per day outright: nobody
|
||||
reads that many profiles and writes that many specific lines, so the plan is an
|
||||
automation plan whatever the intent.
|
||||
|
||||
## Follow-up
|
||||
|
||||
One follow-up, at least a week later, **only if you have something new to say**.
|
||||
A second follow-up with no new information is the point at which you become the
|
||||
thing you were avoiding.
|
||||
|
||||
"Just bumping this up" is not new information. A relevant thing that happened
|
||||
since, or a genuinely useful link with nothing attached, is.
|
||||
|
||||
## The order that actually works
|
||||
|
||||
1. **Comment on their work for two weeks.** Substantively, in public.
|
||||
2. **Then send the invitation**, referencing something specific from that
|
||||
reading — which by now you have actually done.
|
||||
3. **After acceptance, do nothing for a while.** Then ask, once, small.
|
||||
|
||||
This converts far better than any note optimisation, and it is not a growth hack
|
||||
— it is what happens when you talk to people whose work you read.
|
||||
|
||||
## When outreach is the wrong channel
|
||||
|
||||
If the target number is the point rather than the people, LinkedIn outreach is
|
||||
the wrong instrument. Advertising exists, it is designed for volume, it is
|
||||
honest about what it is, and it does not risk the account you spent a year
|
||||
building.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. LinkedIn. **User Agreement §8.2** — automated messaging, bulk sending, and
|
||||
inauthentic engagement.
|
||||
2. LinkedIn Help. **"Account restrictions"** and **"Automated activity on
|
||||
LinkedIn"** — what LinkedIn says it acts on.
|
||||
3. Expandi. **LinkedIn Outreach Benchmarks** (2026 edition, ~13.2M data points) —
|
||||
🟡 acceptance with and without a note, and the post-accept reply-rate gap.
|
||||
4. Cleverly. **LinkedIn Benchmarks** (~20M outreach touches) — 🟡 connection and
|
||||
reply rates by industry.
|
||||
5. LinkedIn Sales Solutions. **InMail best-practice guidance** — 🟢 the
|
||||
individually-sent vs bulk response-rate difference.
|
||||
6. Cialdini, R. **Influence** — reciprocity and commitment, and why an ask
|
||||
arriving before any relationship inverts both.
|
||||
7. Granovetter, M. **"The Strength of Weak Ties"** (1973) — the structural reason
|
||||
cold outreach works at all, and why it works better after public visibility.
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
#!/usr/bin/env python3
|
||||
"""comment_target_planner.py — build a weekly commenting roster inside a real time budget.
|
||||
|
||||
Commenting is the highest-return activity available to an account with no
|
||||
distribution, and it is the one people do worst: they comment on the same three
|
||||
huge accounts, in threads with 400 other comments, saying "great post".
|
||||
|
||||
This builds a five-day roster from the accounts you name, scored on audience
|
||||
overlap, how often they actually post, and how crowded their threads are. It
|
||||
enforces three rules that keep the roster from becoming a pattern:
|
||||
|
||||
- no account appears more than twice a week (commenting daily on one person
|
||||
reads as following them around, and it exhausts the goodwill it earns)
|
||||
- at least one peer-tier account per day, because reciprocity is the part that
|
||||
compounds
|
||||
- never more than half a day's slots in the "huge" tier, where being early
|
||||
matters more than being right and most comments are never read
|
||||
|
||||
The output is a roster, not comment text. Comment text that a tool wrote is the
|
||||
thing this whole approach is trying to avoid.
|
||||
|
||||
Exit codes:
|
||||
0 roster built
|
||||
2 budget below one comment a day — a reduced roster is returned
|
||||
3 no usable accounts supplied
|
||||
|
||||
Stdlib only. No network. Nothing is posted.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
MINUTES_PER_COMMENT = 6
|
||||
DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"]
|
||||
MAX_PER_ACCOUNT_PER_WEEK = 2
|
||||
|
||||
TIERS = {
|
||||
"huge": {"factor": 1.0,
|
||||
"note": "10x+ your size. Crowded threads — only worth it if you are early and add "
|
||||
"something the post missed."},
|
||||
"larger": {"factor": 1.5,
|
||||
"note": "2-10x your size. The best ratio of reachable audience to competition."},
|
||||
"peer": {"factor": 1.3,
|
||||
"note": "Roughly your size. Reciprocity compounds here; these are the relationships "
|
||||
"that still exist in a year."},
|
||||
"smaller": {"factor": 0.9,
|
||||
"note": "Smaller than you. Low reach today, high goodwill, and some of them will "
|
||||
"not be smaller for long."},
|
||||
}
|
||||
|
||||
SAMPLE_ACCOUNTS = [
|
||||
"Priya Raman:5:4:larger",
|
||||
"Data Council:4:6:huge",
|
||||
"Tomas Lind:5:3:peer",
|
||||
"Anna Beck:4:2:peer",
|
||||
"Jules Okafor:3:5:larger",
|
||||
"Sam Idris:5:1:smaller",
|
||||
]
|
||||
|
||||
|
||||
def parse_account(spec: str) -> dict:
|
||||
parts = spec.split(":")
|
||||
if len(parts) != 4:
|
||||
raise ValueError(f"expected name:overlap:posts_per_week:tier — got '{spec}'")
|
||||
name, overlap, freq, tier = parts
|
||||
tier = tier.strip().lower()
|
||||
if tier not in TIERS:
|
||||
raise ValueError(f"unknown tier '{tier}' in '{spec}'; choose from {sorted(TIERS)}")
|
||||
try:
|
||||
overlap_i, freq_i = int(overlap), int(freq)
|
||||
except ValueError:
|
||||
raise ValueError(f"overlap and posts_per_week must be integers in '{spec}'")
|
||||
if not 1 <= overlap_i <= 5:
|
||||
raise ValueError(f"overlap must be 1-5 in '{spec}'")
|
||||
if freq_i < 0:
|
||||
raise ValueError(f"posts_per_week cannot be negative in '{spec}'")
|
||||
return {"name": name.strip(), "overlap": overlap_i, "posts_per_week": freq_i, "tier": tier}
|
||||
|
||||
|
||||
def score(acct: dict) -> float:
|
||||
# Posting frequency has diminishing returns: an account posting daily does not
|
||||
# give you five times the opportunity of one posting twice a week, because you
|
||||
# can only comment on so many of them well.
|
||||
freq_factor = min(acct["posts_per_week"], 5) ** 0.5
|
||||
return round(acct["overlap"] * freq_factor * TIERS[acct["tier"]]["factor"], 2)
|
||||
|
||||
|
||||
def build(accounts: list, minutes_per_day: int) -> dict:
|
||||
if not accounts:
|
||||
return {"verdict": "NO_ACCOUNTS", "exit_code": 3,
|
||||
"instruction": "Name the accounts first. Ten is plenty: the people whose audience "
|
||||
"you want, whose posts you would read anyway, and who post often "
|
||||
"enough to give you an opening each week."}
|
||||
|
||||
per_day = minutes_per_day // MINUTES_PER_COMMENT
|
||||
findings = []
|
||||
verdict, code = "ROSTER", 0
|
||||
if per_day < 1:
|
||||
per_day = 1
|
||||
verdict, code = "BELOW_BUDGET", 2
|
||||
findings.append({
|
||||
"severity": "warning", "area": "budget",
|
||||
"finding": f"{minutes_per_day} min/day is under the {MINUTES_PER_COMMENT} minutes one "
|
||||
"substantive comment takes.",
|
||||
"fix": "Comment on alternate days rather than writing a worse comment daily. "
|
||||
"The roster below assumes one comment per listed day.",
|
||||
})
|
||||
|
||||
scored = sorted(({**a, "score": score(a)} for a in accounts),
|
||||
key=lambda a: (-a["score"], a["name"]))
|
||||
tier_counts = {t: sum(1 for a in scored if a["tier"] == t) for t in TIERS}
|
||||
if tier_counts["peer"] == 0:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "mix",
|
||||
"finding": "No peer-tier accounts. Every relationship in the list is one-directional.",
|
||||
"fix": "Add three or four people at roughly your size. That is where reciprocal "
|
||||
"attention comes from, and it is the part that still pays in a year.",
|
||||
})
|
||||
if tier_counts["huge"] > len(scored) / 2:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "mix",
|
||||
"finding": f"{tier_counts['huge']}/{len(scored)} accounts are huge. Their threads are "
|
||||
"crowded and most comments there are never read.",
|
||||
"fix": "Rebalance toward 'larger' and 'peer'. Being the best comment on a 40-comment "
|
||||
"post beats being the 300th on a 400-comment one.",
|
||||
})
|
||||
|
||||
used = {a["name"]: 0 for a in scored}
|
||||
roster, cursor = [], 0
|
||||
for day in DAYS:
|
||||
slots, huge_today, peer_today = [], 0, 0
|
||||
attempts = 0
|
||||
while len(slots) < per_day and attempts < len(scored) * 4:
|
||||
acct = scored[cursor % len(scored)]
|
||||
cursor += 1
|
||||
attempts += 1
|
||||
if used[acct["name"]] >= MAX_PER_ACCOUNT_PER_WEEK:
|
||||
continue
|
||||
if any(s["name"] == acct["name"] for s in slots):
|
||||
continue
|
||||
if acct["tier"] == "huge" and huge_today >= max(1, per_day // 2):
|
||||
continue
|
||||
slots.append({"name": acct["name"], "tier": acct["tier"],
|
||||
"score": acct["score"], "why": TIERS[acct["tier"]]["note"]})
|
||||
used[acct["name"]] += 1
|
||||
if acct["tier"] == "huge":
|
||||
huge_today += 1
|
||||
if acct["tier"] == "peer":
|
||||
peer_today += 1
|
||||
if per_day >= 2 and peer_today == 0 and tier_counts["peer"]:
|
||||
findings.append({
|
||||
"severity": "info", "area": "mix",
|
||||
"finding": f"{day} has no peer-tier slot after the weekly cap was applied.",
|
||||
"fix": "Add another peer account to the list so every day has one.",
|
||||
})
|
||||
roster.append({"day": day, "comments": slots,
|
||||
"minutes": len(slots) * MINUTES_PER_COMMENT})
|
||||
|
||||
total = sum(len(d["comments"]) for d in roster)
|
||||
exhausted = [n for n, c in used.items() if c >= MAX_PER_ACCOUNT_PER_WEEK]
|
||||
if total < per_day * len(DAYS):
|
||||
findings.append({
|
||||
"severity": "warning", "area": "supply",
|
||||
"finding": f"Only {total} of {per_day * len(DAYS)} weekly slots could be filled without "
|
||||
f"exceeding the {MAX_PER_ACCOUNT_PER_WEEK}-per-account cap.",
|
||||
"fix": f"Add more accounts. You need roughly "
|
||||
f"{-(-per_day * len(DAYS) // MAX_PER_ACCOUNT_PER_WEEK)} to fill the week.",
|
||||
})
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"minutes_per_day": minutes_per_day,
|
||||
"comments_per_day": per_day,
|
||||
"weekly_slots_filled": total,
|
||||
"accounts_scored": scored,
|
||||
"roster": roster,
|
||||
"at_weekly_cap": exhausted,
|
||||
"findings": findings,
|
||||
"comment_rules": [
|
||||
"Add something the post did not say. A counter-example, a number, the case where it "
|
||||
"breaks. Agreement is not a comment.",
|
||||
"Never paste the same comment twice. Identical comments across accounts are the "
|
||||
"definition of inauthentic engagement under User Agreement §8.2.",
|
||||
"Comment because you read it. If you have nothing to add, skip the slot — an empty "
|
||||
"slot costs nothing and a filler comment costs credibility.",
|
||||
"Two to four sentences. A comment longer than the post is a post; go write it.",
|
||||
"Reply to replies on your comment. That thread is where people actually meet you.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
if r["verdict"] == "NO_ACCOUNTS":
|
||||
return "Comment roster: NO_ACCOUNTS\n" + "=" * 40 + "\n" + r["instruction"]
|
||||
lines = [f"Comment roster: {r['verdict']} "
|
||||
f"({r['comments_per_day']}/day, {r['minutes_per_day']} min/day)",
|
||||
"=" * 60]
|
||||
for day in r["roster"]:
|
||||
names = ", ".join(f"{c['name']} [{c['tier']}]" for c in day["comments"]) or "(no slots)"
|
||||
lines.append(f" {day['day']} {names} — {day['minutes']} min")
|
||||
lines.append("\nScored accounts:")
|
||||
for a in r["accounts_scored"]:
|
||||
lines.append(f" {a['score']:>5} {a['name']:<22} {a['tier']:<8} "
|
||||
f"overlap {a['overlap']}/5, {a['posts_per_week']} posts/wk")
|
||||
if r["at_weekly_cap"]:
|
||||
lines.append(f"\nAt the {MAX_PER_ACCOUNT_PER_WEEK}/week cap: {', '.join(r['at_weekly_cap'])}")
|
||||
if r["findings"]:
|
||||
lines.append("\nFindings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
lines.append("\nComment rules:")
|
||||
for c in r["comment_rules"]:
|
||||
lines.append(f" - {c}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Build a weekly LinkedIn commenting roster "
|
||||
"(roster=0 / below-budget=2 / no-accounts=3). Nothing is posted.")
|
||||
ap.add_argument("--account", action="append", default=[],
|
||||
help="name:overlap(1-5):posts_per_week:tier "
|
||||
"(tier = huge|larger|peer|smaller). Repeatable.")
|
||||
ap.add_argument("--minutes-per-day", type=int, default=18,
|
||||
help=f"Minutes per day for commenting (default 18 = "
|
||||
f"{18 // MINUTES_PER_COMMENT} comments).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Build a roster from sample accounts.")
|
||||
args = ap.parse_args()
|
||||
|
||||
specs = SAMPLE_ACCOUNTS if args.sample else args.account
|
||||
minutes = 18 if args.sample else args.minutes_per_day
|
||||
try:
|
||||
accounts = [parse_account(s) for s in specs]
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
|
||||
result = build(accounts, minutes)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
#!/usr/bin/env python3
|
||||
"""outreach_message_builder.py — assemble one LinkedIn message, and refuse the template.
|
||||
|
||||
Cold outreach on LinkedIn fails for one structural reason: the message could have
|
||||
been sent to anyone. This tool will not assemble a message without a
|
||||
person-specific line — something you could only have written after actually
|
||||
looking at what this person did — and it refuses an ask on a first-touch
|
||||
connection note, because the note is for getting into the room, not for selling
|
||||
in the doorway.
|
||||
|
||||
Caps enforced (🟡 third-party-documented; LinkedIn does not publish a limits page):
|
||||
connection note 200 characters (free) / 300 (Premium) — use --premium
|
||||
DM / InMail no practical cap, but this tool warns past 600 characters
|
||||
because reply rate falls off a cliff on a phone screen
|
||||
|
||||
Nothing is sent. The output is text you paste, one person at a time. That is not
|
||||
a limitation of the tool; automated sending is prohibited by LinkedIn's User
|
||||
Agreement §8.2 and is the fastest route to a restricted account.
|
||||
|
||||
Exit codes:
|
||||
0 PASS — assembled and clean
|
||||
2 WARN — assembled, with findings worth fixing
|
||||
3 FAIL — refused: missing the person-specific line, over the cap, or pitching too early
|
||||
|
||||
Stdlib only. No network. Nothing is transmitted anywhere.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
CAPS = {"connection": {"free": 200, "premium": 300},
|
||||
"dm": {"free": 1800, "premium": 1800},
|
||||
"inmail": {"free": 1900, "premium": 1900},
|
||||
"followup": {"free": 1200, "premium": 1200}}
|
||||
DM_COMFORT = 600
|
||||
|
||||
DEAD_PHRASES = [
|
||||
("i came across your profile", "It tells them nothing. Say what you were reading when you found them."),
|
||||
("i'd love to pick your brain", "It asks for unpaid time with no bounded question."),
|
||||
("i would love to pick your brain", "It asks for unpaid time with no bounded question."),
|
||||
("hope this finds you well", "Filler. Delete it and start with the specific line."),
|
||||
("hope you're doing well", "Filler. Delete it and start with the specific line."),
|
||||
("quick question", "It is never quick, and everyone knows it."),
|
||||
("just following up", "Say what changed since last time, or do not follow up."),
|
||||
("touch base", "Says nothing about what you want."),
|
||||
("synergy", "Nobody has ever replied to this word."),
|
||||
("i'll keep it short", "Then keep it short instead of announcing it."),
|
||||
("as a fellow", "Category membership is not a reason to connect."),
|
||||
("i see we're both in", "So are two million other people."),
|
||||
("let's connect", "A request with no reason attached."),
|
||||
("15 minutes of your time", "Fine as a second-touch ask; never in a first-touch note."),
|
||||
]
|
||||
|
||||
PITCH_RE = re.compile(
|
||||
r"\b(our (product|platform|solution|service|tool)|we help companies|book a (call|demo)|"
|
||||
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)
|
||||
|
||||
SAMPLE = {
|
||||
"type": "connection",
|
||||
"recipient": "Priya",
|
||||
"specific_line": "Your teardown of the dbt exposures rollout matched what broke for us at "
|
||||
"step three.",
|
||||
"reason": "I am working the same problem from the platform side and would like to follow "
|
||||
"what you publish.",
|
||||
"ask": "",
|
||||
"premium": False,
|
||||
}
|
||||
|
||||
|
||||
def assemble(parts: dict) -> str:
|
||||
name = (parts.get("recipient") or "").strip()
|
||||
specific = (parts.get("specific_line") or "").strip()
|
||||
reason = (parts.get("reason") or "").strip()
|
||||
ask = (parts.get("ask") or "").strip()
|
||||
chunks = []
|
||||
if name:
|
||||
chunks.append(f"{name} —")
|
||||
if specific:
|
||||
chunks.append(specific)
|
||||
if reason:
|
||||
chunks.append(reason)
|
||||
if ask:
|
||||
chunks.append(ask)
|
||||
return " ".join(chunks).strip()
|
||||
|
||||
|
||||
def validate(text: str, parts: dict, mtype: str, premium: bool) -> list:
|
||||
findings = []
|
||||
low = text.lower()
|
||||
|
||||
def add(sev, check, finding, fix):
|
||||
findings.append({"severity": sev, "check": check, "finding": finding, "fix": fix})
|
||||
|
||||
specific = (parts.get("specific_line") or "").strip()
|
||||
if not specific:
|
||||
add("blocking", "person-specific-line",
|
||||
"No person-specific line. Without it this is a template, and a template is the "
|
||||
"thing being ignored.",
|
||||
"Read one thing they published and quote the part you disagreed with or used. "
|
||||
"If you will not spend three minutes reading, do not spend theirs.")
|
||||
elif len(specific.split()) < 6:
|
||||
add("major", "person-specific-line",
|
||||
f"The specific line is {len(specific.split())} words — too short to prove you read "
|
||||
"anything.",
|
||||
"Name the artifact and the part of it that mattered.")
|
||||
elif not re.search(r"\b(your|you)\b", specific, re.I):
|
||||
add("warning", "person-specific-line",
|
||||
"The specific line does not refer to them at all.",
|
||||
"Anchor it: 'your post on…', 'the talk you gave at…'.")
|
||||
|
||||
cap = CAPS[mtype]["premium" if premium else "free"]
|
||||
n = len(text)
|
||||
if n > cap:
|
||||
add("blocking", "length",
|
||||
f"{n} characters — {n - cap} over the {cap}-character cap for a "
|
||||
f"{mtype} ({'Premium' if premium else 'free'} account).",
|
||||
"Cut the reason, keep the specific line. The reason can wait for the reply.")
|
||||
elif mtype in ("dm", "inmail", "followup") and n > DM_COMFORT:
|
||||
add("warning", "length",
|
||||
f"{n} characters. Past ~{DM_COMFORT} the message is a wall on a phone and reply "
|
||||
"rate drops.",
|
||||
"One specific line, one reason, one bounded ask. Everything else is for the reply.")
|
||||
|
||||
if mtype == "connection":
|
||||
ask = (parts.get("ask") or "").strip()
|
||||
if ask:
|
||||
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.",
|
||||
"Move it. A third-party study of ~13M outreach touches found notes barely move "
|
||||
"acceptance (about 26.4% either way) but roughly double the post-accept reply "
|
||||
"rate — the note earns the conversation, not the meeting.")
|
||||
if PITCH_RE.search(low):
|
||||
add("blocking", "pitch-on-first-touch",
|
||||
"Pitch language in a first-touch connection note.",
|
||||
"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):
|
||||
add("warning", "pitch-without-ask",
|
||||
"Product language with no clear, bounded ask.",
|
||||
"Either make the ask explicit and small, or cut the product language.")
|
||||
|
||||
dead = [(p, why) for p, why in DEAD_PHRASES if p in low]
|
||||
if dead:
|
||||
add("major", "dead-phrases",
|
||||
"Phrases that mark this as bulk: " + "; ".join(f"'{p}' — {why}" for p, why in dead),
|
||||
"Cut each one. What remains is either specific or it is nothing, and nothing is "
|
||||
"better than bulk.")
|
||||
|
||||
if not (parts.get("reason") or "").strip() and mtype == "connection":
|
||||
add("warning", "reason",
|
||||
"No reason to connect stated.",
|
||||
"One clause: what you want to follow, learn, or compare notes on.")
|
||||
|
||||
if parts.get("recipient") and parts["recipient"].strip().lower() in (
|
||||
"there", "team", "sir", "madam", "friend", "connection"):
|
||||
add("major", "salutation",
|
||||
f"'{parts['recipient']}' is a placeholder greeting.",
|
||||
"Use their name, spelled the way they spell it.")
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build(parts: dict, mtype: str, premium: bool) -> dict:
|
||||
text = assemble(parts)
|
||||
findings = validate(text, parts, mtype, premium)
|
||||
blocking = [f for f in findings if f["severity"] == "blocking"]
|
||||
verdict, code = (("FAIL", 3) if blocking
|
||||
else ("WARN", 2) if findings else ("PASS", 0))
|
||||
cap = CAPS[mtype]["premium" if premium else "free"]
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"type": mtype,
|
||||
"premium": premium,
|
||||
"message": text,
|
||||
"chars": len(text),
|
||||
"cap": cap,
|
||||
"findings": findings,
|
||||
"send_rule": ("Paste and send this yourself, to this one person. Nothing here is "
|
||||
"transmitted by the tool, and automating the send would violate "
|
||||
"LinkedIn's User Agreement §8.2."),
|
||||
"followup_rule": ("One follow-up, at least a week later, only if you have something new "
|
||||
"to say. A second follow-up with no new information is the point at "
|
||||
"which you become the thing you were avoiding."),
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
lines = [f"Outreach ({r['type']}): {r['verdict']} "
|
||||
f"[{r['chars']}/{r['cap']} chars"
|
||||
f"{', Premium' if r['premium'] else ''}]",
|
||||
"=" * 56, "", r["message"] or "(nothing assembled)", ""]
|
||||
if r["findings"]:
|
||||
lines.append("Findings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['check']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
else:
|
||||
lines.append("No findings.")
|
||||
lines += ["", r["send_rule"], "", r["followup_rule"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Assemble one LinkedIn outreach message (PASS=0 / WARN=2 / FAIL=3). "
|
||||
"Nothing is sent.")
|
||||
ap.add_argument("--type", choices=sorted(CAPS), default="connection")
|
||||
ap.add_argument("--recipient", help="Their first name.")
|
||||
ap.add_argument("--specific-line", help="The line only this person could receive. Required.")
|
||||
ap.add_argument("--reason", help="Why you want the connection.")
|
||||
ap.add_argument("--ask", default="", help="The ask (never on a first-touch connection note).")
|
||||
ap.add_argument("--premium", action="store_true",
|
||||
help="Premium account (300-char connection notes).")
|
||||
ap.add_argument("--input", help="Read all parts from a JSON file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Build the built-in sample message.")
|
||||
ap.add_argument("--print-schema", action="store_true", help="Print the JSON shape and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_schema:
|
||||
print(json.dumps(SAMPLE, indent=2))
|
||||
return 0
|
||||
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()
|
||||
try:
|
||||
parts = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
mtype = parts.get("type", args.type)
|
||||
premium = bool(parts.get("premium", args.premium))
|
||||
if mtype not in CAPS:
|
||||
print(f"ERROR: unknown message type '{mtype}'", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
parts = {"recipient": args.recipient, "specific_line": args.specific_line,
|
||||
"reason": args.reason, "ask": args.ask}
|
||||
mtype, premium = args.type, args.premium
|
||||
if not any(parts.values()):
|
||||
ap.error("provide --specific-line (or --input / --sample / --print-schema)")
|
||||
|
||||
result = build(parts, mtype, premium)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env python3
|
||||
"""outreach_volume_guard.py — size manual outreach so it stays manual, and legal.
|
||||
|
||||
Three separate things restrict a LinkedIn account, and volume plans usually trip
|
||||
at least one:
|
||||
|
||||
1. LinkedIn enforces a weekly invitation limit (widely observed at about 100
|
||||
invitations per week; LinkedIn does not publish the exact figure and adjusts
|
||||
it per account). Pending invitations count.
|
||||
2. A low acceptance rate is itself a signal. Sustained low acceptance plus high
|
||||
volume is the pattern automated tools produce, and it is what gets reviewed.
|
||||
3. Anything that looks machine-paced — a constant per-day rate, hundreds of
|
||||
touches, identical wording — is prohibited outright by User Agreement §8.2,
|
||||
whether or not a machine actually sent it.
|
||||
|
||||
This tool prices the plan in minutes, checks it against the caps, and refuses the
|
||||
volumes that cannot be produced by a person typing. It never sends anything.
|
||||
|
||||
Exit codes:
|
||||
0 SAFE — plan fits the caps and the time budget
|
||||
2 TIGHT — allowed, with named risks
|
||||
3 OVER — over a cap or over the time budget; the overage is named
|
||||
4 REFUSED — the volume is not humanly manual; this is an automation plan
|
||||
|
||||
Stdlib only. No network. Nothing is transmitted anywhere.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
WEEKLY_INVITE_LIMIT = 100 # observed LinkedIn cap; not published exactly
|
||||
DAILY_MANUAL_CEILING = 25 # above this, per day, it stops being hand-written
|
||||
REFUSE_DAILY = 40 # above this it is an automation plan, not a cadence
|
||||
LOW_ACCEPTANCE = 0.20
|
||||
MINUTES_PER_INVITE = 5 # read their work, write the specific line, send
|
||||
MINUTES_PER_DM = 8
|
||||
|
||||
|
||||
def guard(invites: int, pending: int, dms: int, minutes: int,
|
||||
acceptance: float, days_active: int) -> dict:
|
||||
findings = []
|
||||
per_day = invites / max(1, days_active)
|
||||
|
||||
def add(sev, area, finding, fix):
|
||||
findings.append({"severity": sev, "area": area, "finding": finding, "fix": fix})
|
||||
|
||||
verdict, code = "SAFE", 0
|
||||
|
||||
if per_day > REFUSE_DAILY:
|
||||
add("refusal", "automation-pattern",
|
||||
f"{invites} invitations over {days_active} day(s) is {per_day:.0f}/day. Nobody reads "
|
||||
"that many profiles and writes that many specific lines. This is an automation plan.",
|
||||
"Cut to a volume you would actually type. If the number is the point rather than the "
|
||||
"people, the channel is wrong — that is what advertising is for.")
|
||||
verdict, code = "REFUSED", 4
|
||||
|
||||
total_pipeline = invites + pending
|
||||
if total_pipeline > WEEKLY_INVITE_LIMIT:
|
||||
add("blocking", "invite-limit",
|
||||
f"{invites} new + {pending} pending = {total_pipeline} against a weekly limit observed "
|
||||
f"around {WEEKLY_INVITE_LIMIT}. LinkedIn counts pending invitations, and it does not "
|
||||
"publish the exact figure.",
|
||||
f"Withdraw invitations older than three weeks first, then send at most "
|
||||
f"{max(0, WEEKLY_INVITE_LIMIT - pending)} this week. Note that a withdrawn invitation "
|
||||
"cannot be re-sent to the same person for about three weeks.")
|
||||
if code < 3:
|
||||
verdict, code = "OVER", 3
|
||||
elif total_pipeline > WEEKLY_INVITE_LIMIT * 0.8:
|
||||
add("warning", "invite-limit",
|
||||
f"{total_pipeline} of ~{WEEKLY_INVITE_LIMIT} weekly capacity used, counting "
|
||||
f"{pending} pending.",
|
||||
"Clear stale pending invitations before the next batch.")
|
||||
if code == 0:
|
||||
verdict, code = "TIGHT", 2
|
||||
|
||||
if per_day > DAILY_MANUAL_CEILING and code < 4:
|
||||
add("warning", "pacing",
|
||||
f"{per_day:.0f} invitations/day is above the {DAILY_MANUAL_CEILING} that a person "
|
||||
"can send with a genuinely specific line each.",
|
||||
"Spread it. Uneven, human pacing is also what a real person's activity looks like.")
|
||||
if code == 0:
|
||||
verdict, code = "TIGHT", 2
|
||||
|
||||
if 0 < acceptance < LOW_ACCEPTANCE:
|
||||
add("blocking", "acceptance-rate",
|
||||
f"{acceptance:.0%} acceptance is below the {LOW_ACCEPTANCE:.0%} floor. Sustained low "
|
||||
"acceptance at volume is the signal LinkedIn reviews, and it also means the targeting "
|
||||
"is wrong.",
|
||||
"Stop sending. Fix who you are targeting and what the note says before resuming. "
|
||||
"Rebuild acceptance by warming up first — comment on their posts for two weeks, then "
|
||||
"send.")
|
||||
if code < 3:
|
||||
verdict, code = "OVER", 3
|
||||
|
||||
time_needed = invites * MINUTES_PER_INVITE + dms * MINUTES_PER_DM
|
||||
if time_needed > minutes:
|
||||
add("blocking", "time-budget",
|
||||
f"{invites} invitations ({MINUTES_PER_INVITE} min each) plus {dms} DMs "
|
||||
f"({MINUTES_PER_DM} min each) needs {time_needed} min; the budget is {minutes} min.",
|
||||
f"Either cut to {max(0, (minutes - dms * MINUTES_PER_DM) // MINUTES_PER_INVITE)} "
|
||||
"invitations, or accept that the shortfall gets paid in generic messages — which is "
|
||||
"the same as not sending them.")
|
||||
if code < 3:
|
||||
verdict, code = "OVER", 3
|
||||
|
||||
safe_weekly = min(WEEKLY_INVITE_LIMIT - pending,
|
||||
DAILY_MANUAL_CEILING * days_active,
|
||||
minutes // MINUTES_PER_INVITE if MINUTES_PER_INVITE else 0)
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"plan": {"invites": invites, "pending": pending, "dms": dms,
|
||||
"days_active": days_active, "per_day": round(per_day, 1),
|
||||
"minutes_budget": minutes, "minutes_needed": time_needed,
|
||||
"acceptance_rate": acceptance},
|
||||
"caps": {"weekly_invite_limit_observed": WEEKLY_INVITE_LIMIT,
|
||||
"daily_manual_ceiling": DAILY_MANUAL_CEILING,
|
||||
"refuse_above_per_day": REFUSE_DAILY,
|
||||
"note": "LinkedIn does not publish the exact invitation limit and adjusts it "
|
||||
"per account. These are conservative working numbers."},
|
||||
"safe_volume_this_week": max(0, int(safe_weekly)),
|
||||
"findings": findings,
|
||||
"standing_rules": [
|
||||
"Send by hand, one at a time. Automated sending is prohibited by User Agreement §8.2 "
|
||||
"and no volume target is worth a restricted account.",
|
||||
"Every message carries a line that could only have been written for that person.",
|
||||
"Track acceptance weekly. Falling acceptance means the targeting is wrong, not that "
|
||||
"the volume is too low.",
|
||||
"Warm before you ask: two weeks of genuine comments beats any note.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
p, c = r["plan"], r["caps"]
|
||||
lines = [f"Outreach volume: {r['verdict']}", "=" * 52,
|
||||
f"Plan : {p['invites']} invites (+{p['pending']} pending), {p['dms']} DMs over "
|
||||
f"{p['days_active']} day(s) = {p['per_day']}/day",
|
||||
f"Time : {p['minutes_needed']} min needed / {p['minutes_budget']} min budget",
|
||||
f"Caps : ~{c['weekly_invite_limit_observed']}/week, "
|
||||
f"{c['daily_manual_ceiling']}/day manual ceiling",
|
||||
f"Safe this week: {r['safe_volume_this_week']} invitations", ""]
|
||||
if r["findings"]:
|
||||
lines.append("Findings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
else:
|
||||
lines.append("No findings.")
|
||||
lines.append("\nStanding rules:")
|
||||
for s in r["standing_rules"]:
|
||||
lines.append(f" - {s}")
|
||||
lines.append(f"\n{c['note']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Size manual LinkedIn outreach against the caps "
|
||||
"(safe=0 / tight=2 / over=3 / refused=4). Nothing is sent.")
|
||||
ap.add_argument("--invites", type=int, default=0, help="New connection invitations planned.")
|
||||
ap.add_argument("--pending", type=int, default=0, help="Invitations already pending.")
|
||||
ap.add_argument("--dms", type=int, default=0, help="Direct messages planned.")
|
||||
ap.add_argument("--minutes", type=int, default=120, help="Minutes budgeted this week.")
|
||||
ap.add_argument("--acceptance", type=float, default=0.0,
|
||||
help="Recent acceptance rate as a fraction, e.g. 0.34. 0 = unknown.")
|
||||
ap.add_argument("--days", type=int, default=5, help="Days you will spread this over.")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Run a built-in over-ambitious plan.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
invites, pending, dms, minutes, acceptance, days = 150, 40, 20, 120, 0.14, 5
|
||||
else:
|
||||
invites, pending, dms = args.invites, args.pending, args.dms
|
||||
minutes, acceptance, days = args.minutes, args.acceptance, max(1, args.days)
|
||||
if invites == 0 and dms == 0:
|
||||
ap.error("provide --invites and/or --dms (or use --sample)")
|
||||
if not 0.0 <= acceptance <= 1.0:
|
||||
ap.error("--acceptance must be a fraction between 0 and 1")
|
||||
|
||||
result = guard(invites, pending, dms, minutes, acceptance, max(1, days))
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
98
marketing/linkedin/skills/linkedin-profile/SKILL.md
Normal file
98
marketing/linkedin/skills/linkedin-profile/SKILL.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
name: linkedin-profile
|
||||
description: Use when someone wants their LinkedIn profile audited or rewritten — headline, About section, experience bullets, Featured, banner, recommendations — or says "fix my headline", "my profile gets views but nothing happens", "optimize my LinkedIn profile", "what should my About section say". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the "…see more" fold.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: marketing
|
||||
updated: 2026-08-25
|
||||
---
|
||||
|
||||
# LinkedIn Profile — audit, then rewrite the parts that pay
|
||||
|
||||
A profile is not a CV. A CV is read by someone who already decided to consider you; a
|
||||
profile is read by someone deciding whether to. Three readers, in descending volume: the
|
||||
**scanner** (three seconds, from your comment on someone else's post — sees photo and
|
||||
headline only), the **evaluator** (forty seconds — headline, half the About, Featured), the
|
||||
**decider** (minutes — everything). Almost all traffic is the scanner. Almost all conversion
|
||||
is the decider.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. Audit before rewriting.** Ask them to describe their profile section by section, or
|
||||
have them fill [`assets/profile_worksheet.md`](assets/profile_worksheet.md), then:
|
||||
|
||||
```bash
|
||||
python3 scripts/profile_completeness_auditor.py --input profile.json --output human
|
||||
```
|
||||
|
||||
Exit 0 STRONG / 2 INCOMPLETE / 3 WEAK. It ranks every gap by **points per hour** and prints
|
||||
a first-hour plan — usually Featured, Open To, and skills, which cost minutes and recover
|
||||
real points. Start there, not with the About section they wanted to agonise over.
|
||||
|
||||
**2. Headline next — it is the only string that travels.** It rides along with every comment,
|
||||
search result, and invitation.
|
||||
|
||||
```bash
|
||||
python3 scripts/headline_scorer.py --headline "..." --output human
|
||||
```
|
||||
|
||||
Five dimensions at 20 points: audience, outcome, proof, searchability, clarity. Exit 0 SHIP
|
||||
(≥75) / 2 SHARPEN / 3 REWRITE. It also checks the 220-character cap and whether the first
|
||||
~60 characters — the part that survives in search results and invitation previews — carry
|
||||
anything.
|
||||
|
||||
Iterate here. Two or three passes is normal; the stop condition is exit 0, or the user
|
||||
saying they would say it out loud to a peer.
|
||||
|
||||
**3. About section — write to the fold.** LinkedIn collapses it after roughly 265-300
|
||||
characters. Collect the five parts from the user, then assemble:
|
||||
|
||||
```bash
|
||||
python3 scripts/about_section_builder.py --input about.json --output human
|
||||
```
|
||||
|
||||
It refuses a fold that cuts mid-sentence, a fold carrying no audience and no proof, a
|
||||
missing call to action, and anything over 2,600 characters.
|
||||
|
||||
**4. Experience, Featured, recommendations.** Rewrite duty lists as outcomes. Pin one
|
||||
artifact a stranger could evaluate in sixty seconds. Ask for two specific recommendations by
|
||||
naming the project and offering a first draft.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never invent a credential, a metric, or a role.** Every number on a profile is checkable
|
||||
by someone. If the proof does not exist, the claim is qualitative or it is absent.
|
||||
- **First person.** A profile in third person reads as a press release someone else wrote.
|
||||
- **The fold is the section.** Whatever sits above "…see more" is what most readers get.
|
||||
- **Front-load the headline.** The strongest segment first; everything after is a bonus.
|
||||
- **Do not fetch anything.** The user describes their own profile; nothing is scraped.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Role |
|
||||
|---|---|
|
||||
| [`scripts/headline_scorer.py`](scripts/headline_scorer.py) | Headline 0-100 on audience / outcome / proof / searchability / clarity, plus the 220-char cap and front-load check. |
|
||||
| [`scripts/profile_completeness_auditor.py`](scripts/profile_completeness_auditor.py) | Whole profile 0-100 across 14 weighted checks; fixes ranked by points per hour with a first-hour plan. |
|
||||
| [`scripts/about_section_builder.py`](scripts/about_section_builder.py) | Assembles the About section from named parts and refuses a broken fold, a missing CTA, or an over-length section. |
|
||||
|
||||
## References and assets
|
||||
|
||||
- [`references/profile_architecture.md`](references/profile_architecture.md) — what each section is for, and the three readers (7 sources)
|
||||
- [`references/headline_and_positioning.md`](references/headline_and_positioning.md) — the 220 characters that follow you everywhere (7 sources)
|
||||
|
||||
- [`assets/profile_worksheet.md`](assets/profile_worksheet.md) — fillable section-by-section worksheet
|
||||
- [`assets/example_profile.json`](assets/example_profile.json) — input shape for the auditor
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`linkedin-strategy`** — what to post and how often. This is who you are before you post.
|
||||
If the audience answer is fuzzy, run `positioning_brief.py` there first; the headline
|
||||
falls out of a good brief in ten minutes.
|
||||
- **`product-team/`, `c-level-advisor/`** — career and role strategy. This writes the profile,
|
||||
not the career plan.
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"_comment": "Input shape for profile_completeness_auditor.py. Every key is optional; a missing key is treated as absent. Nothing here is fetched from LinkedIn - you fill it in from your own profile.",
|
||||
"photo": true,
|
||||
"banner_custom": true,
|
||||
"custom_url": true,
|
||||
"headline": "Fractional Head of Data for Series A SaaS | Cut BigQuery spend 62% at Zendesk scale | ex-Stripe | I make dashboards people trust",
|
||||
"about": "Most data teams do not have a data problem. They have a trust problem - nobody believes the dashboard, so everyone rebuilds the number in a spreadsheet. I work with Series A SaaS companies whose analytics stack grew faster than anyone's confidence in it. Cut BigQuery spend 62% at Zendesk scale without dropping a single dashboard. Rebuilt reporting for three Series B teams; two retired their shadow spreadsheets inside a quarter. If your team is re-deriving the same metric in three places, message me.",
|
||||
"current_role": {
|
||||
"title": "Fractional Head of Data",
|
||||
"bullets": [
|
||||
"Cut BigQuery spend 62% while keeping every dashboard live",
|
||||
"Reduced median time-to-trusted-number from 9 days to under 1",
|
||||
"Led the migration of 140 legacy models down to 38"
|
||||
]
|
||||
},
|
||||
"featured_items": 2,
|
||||
"featured_updated_days_ago": 45,
|
||||
"skills": ["analytics engineering", "dbt", "BigQuery", "data governance", "SQL", "Python"],
|
||||
"recommendations_received": 3,
|
||||
"open_to_or_services": true,
|
||||
"education": true,
|
||||
"certifications": 1,
|
||||
"contact_info": true,
|
||||
"days_since_last_post": 6
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Profile Worksheet
|
||||
|
||||
Fill this in before any rewriting. The auditor and the About builder both read from it.
|
||||
|
||||
---
|
||||
|
||||
## Presence
|
||||
|
||||
- [ ] Photo: face, well lit, recognisable at 48px? ______
|
||||
- [ ] Banner: custom, or still the default gradient? ______
|
||||
- [ ] Custom URL set? ______
|
||||
- [ ] Contact info reachable? ______
|
||||
- [ ] Open To / Services block set? ______
|
||||
- [ ] Days since your last post: ______
|
||||
|
||||
## Headline (220 characters)
|
||||
|
||||
Current: ______________________________________________
|
||||
|
||||
- Who is it for (specifically enough to exclude someone)? ______
|
||||
- What changes because of you? ______
|
||||
- One piece of proof (number / ex-company / credential)? ______
|
||||
- Which conventional role or skill term keeps it searchable? ______
|
||||
|
||||
First 60 characters — is the strongest segment there? ______
|
||||
|
||||
## About (fold at ~265-300 characters)
|
||||
|
||||
1. **Hook** — the tension your audience recognises, in their words:
|
||||
|
||||
2. **Audience** — who you are for:
|
||||
|
||||
3. **Proof** — two or three real results, with numbers:
|
||||
|
||||
4. **Approach** — how you work; the part that is yours, not your title's:
|
||||
|
||||
5. **CTA** — who should reach out, and what they get:
|
||||
|
||||
6. **Keywords** you want to be findable for (work them into real sentences, not a list):
|
||||
|
||||
## Current role
|
||||
|
||||
Title: ______
|
||||
|
||||
Rewrite each bullet as an outcome, not a duty:
|
||||
|
||||
| Duty (what it says now) | Outcome (what actually changed) |
|
||||
|---|---|
|
||||
| | |
|
||||
| | |
|
||||
|
||||
## Featured
|
||||
|
||||
- What is pinned now? ______
|
||||
- Last updated? ______
|
||||
- What could a stranger evaluate in 60 seconds? ______
|
||||
|
||||
## Recommendations
|
||||
|
||||
- Received: ______
|
||||
- Two people to ask, and the specific project to ask them about:
|
||||
1. ______
|
||||
2. ______
|
||||
|
||||
---
|
||||
|
||||
**Then run:**
|
||||
|
||||
```bash
|
||||
python3 ../scripts/profile_completeness_auditor.py --input profile.json --output human
|
||||
python3 ../scripts/headline_scorer.py --headline "..." --output human
|
||||
python3 ../scripts/about_section_builder.py --input about.json --output human
|
||||
```
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# Headline and Positioning — 220 characters that follow you everywhere
|
||||
|
||||
The headline is the only string on LinkedIn that travels with you. It rides along
|
||||
with every comment you leave in someone else's thread, every search result you
|
||||
appear in, and every connection request you send. A person may see your headline
|
||||
forty times before they ever open your profile.
|
||||
|
||||
Most headlines are a job title. A job title is the one thing a reader could have
|
||||
guessed.
|
||||
|
||||
---
|
||||
|
||||
## The constraint
|
||||
|
||||
| Limit | Value | Confidence |
|
||||
|---|---|---|
|
||||
| Hard character cap | 220 | 🟡 third-party documented, stable for years |
|
||||
| Visible in search results / invitation previews | ~60-70 characters | 🟡 varies by surface and viewport |
|
||||
| Visible next to a comment | Fewer still, and it truncates mid-word | 🟡 |
|
||||
|
||||
So the headline has a **front-loaded budget**: the first sixty characters do most
|
||||
of the work, and everything after them is a bonus that some readers will see.
|
||||
This is why "Senior Engineer | Ex-Google | Speaker | Mentor | Dog dad" fails —
|
||||
the strongest segment is second and the weakest is where a reader's eye lands.
|
||||
|
||||
## The five things a headline has to do
|
||||
|
||||
`headline_scorer.py` scores these at 20 points each.
|
||||
|
||||
1. **Name the audience.** "for Series A SaaS founders", "for clinical data teams".
|
||||
A headline that could belong to anyone is addressed to no one.
|
||||
2. **Name the outcome.** What changes because of you. "Cut onboarding from six
|
||||
weeks to four days" is an outcome. "Passionate about customer experience" is
|
||||
a mood.
|
||||
3. **Carry one piece of proof.** A number, a prior company (`ex-Stripe`), a
|
||||
credential, a scale figure. One is enough and it has to be true.
|
||||
4. **Stay searchable.** LinkedIn search matches headline text. An invented title
|
||||
("Chief Clarity Officer") ranks for nothing. Keep at least one conventional
|
||||
role or skill term alongside the creative framing.
|
||||
5. **Stay readable.** Three segments maximum. At most one emoji. No buzzword
|
||||
filler — "results-driven", "passionate about", "thought leader" all describe an
|
||||
attitude rather than a capability, and every reader has learned to skip them.
|
||||
|
||||
## Structures that work
|
||||
|
||||
```
|
||||
[Role a recruiter would search] for [specific audience] | [proof] | [the line that is yours]
|
||||
|
||||
Fractional Head of Data for Series A SaaS | Cut BigQuery spend 62% at
|
||||
Zendesk scale | I make dashboards people trust
|
||||
```
|
||||
|
||||
```
|
||||
I help [audience] [outcome] without [the cost they expect]
|
||||
|
||||
I help clinical teams pass MDR audits without a six-month documentation freeze
|
||||
```
|
||||
|
||||
```
|
||||
[Current title] · [the thing you are moving toward] · [proof of the move]
|
||||
|
||||
Backend engineer moving into developer advocacy · 40 conference talks
|
||||
watched, 3 given · I write the docs I wish existed
|
||||
```
|
||||
|
||||
That third pattern matters for career transitions. **State the destination, not
|
||||
only the origin.** A headline that describes only where you have been makes every
|
||||
reader do the imaginative work of placing you somewhere else, and most will not
|
||||
bother.
|
||||
|
||||
## Positioning before wording
|
||||
|
||||
A headline cannot fix a positioning that does not exist. If you cannot answer
|
||||
these three, the headline will keep coming out generic no matter how many times
|
||||
it is rewritten:
|
||||
|
||||
1. Who specifically is this for — specifically enough that someone is excluded?
|
||||
2. What do they get that they would not get from the next person with your title?
|
||||
3. What is the evidence, and is it already public?
|
||||
|
||||
That is the `positioning_brief.py` conversation in `linkedin-strategy`. Run it
|
||||
first when the answers are fuzzy; the headline falls out of a good brief in
|
||||
about ten minutes.
|
||||
|
||||
## Changing the headline mid-career-transition
|
||||
|
||||
Two competing risks, and people usually only see one:
|
||||
|
||||
- Change too early and current colleagues read it as "already leaving".
|
||||
- Change too late and every new reader files you under the old category, which
|
||||
is the category you are trying to leave.
|
||||
|
||||
The resolution is that **the headline is read overwhelmingly by strangers**, and
|
||||
strangers are the audience of a transition. Change it, and let the current role
|
||||
carry the continuity in the Experience section where it belongs.
|
||||
|
||||
## Testing
|
||||
|
||||
You cannot A/B test a headline on LinkedIn — there is one, it applies
|
||||
retroactively to everything, and profile-view counts are too noisy at individual
|
||||
scale. What you can do:
|
||||
|
||||
- Read it out loud. If you would not say it to someone at a conference, cut it.
|
||||
- Show it to one person in the target audience and ask what they think you do.
|
||||
If they paraphrase it back wrong, the headline is wrong, and that single test
|
||||
is worth more than any tool.
|
||||
- Run `headline_scorer.py` for the mechanical faults, then use the person.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Ries, A. & Trout, J. **Positioning: The Battle for Your Mind** — the origin of
|
||||
"positioning is what you do to the mind of the prospect", and why a category
|
||||
claim beats an attribute list.
|
||||
2. Heath, C. & Heath, D. **Made to Stick** — concreteness and the curse of
|
||||
knowledge, which is exactly why practitioners write headlines only other
|
||||
practitioners can parse.
|
||||
3. Nielsen Norman Group. **Microcontent: How to Write Headlines, Page Titles, and
|
||||
Subject Lines** — the front-loading rule for any truncated string.
|
||||
4. Clark, D. **Stand Out** — building a positioning around one distinctive idea
|
||||
rather than a portfolio of competences.
|
||||
5. Ibarra, H. **Working Identity** — why career transitions require acting into
|
||||
the new identity publicly rather than deciding it privately first.
|
||||
6. Zinsser, W. **On Writing Well** — the discipline of cutting every word that
|
||||
does no work, which is the entire craft of a 220-character string.
|
||||
7. LinkedIn Help. **Search and profile visibility** documentation — headline text
|
||||
is matched in search; invented titles are not.
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
# Profile Architecture — what each section is actually for
|
||||
|
||||
A LinkedIn profile is not a CV. A CV is read by someone who has already decided
|
||||
to consider you; a profile is read by someone deciding whether to. The sections
|
||||
are therefore ordered by a different logic, and most people fill them in as if
|
||||
they were the same document.
|
||||
|
||||
The frame that makes this concrete: **a profile has three distinct readers**, and
|
||||
a section that serves none of them is dead weight.
|
||||
|
||||
| Reader | Arrives from | Decides in | Reads |
|
||||
|---|---|---|---|
|
||||
| **The scanner** | Your comment on someone else's post | ~3 seconds | Photo, name, headline. Nothing else. |
|
||||
| **The evaluator** | A search result, a referral, your post | ~40 seconds | Headline, the visible half of About, Featured, current role. |
|
||||
| **The decider** | Already interested, doing diligence | Several minutes | Everything, including recommendations and the gaps in your history. |
|
||||
|
||||
Almost all traffic is the scanner. Almost all conversion is the decider. The
|
||||
sections in between exist to move people down that list.
|
||||
|
||||
---
|
||||
|
||||
## Section by section
|
||||
|
||||
### Photo — for the scanner
|
||||
A face, well lit, roughly filling the frame, recognisable at 48 pixels. That is
|
||||
the entire specification. The common failure is a full-body shot that renders as
|
||||
a smudge at feed size.
|
||||
|
||||
### Banner (1584 × 396) — the most wasted space on the platform
|
||||
The default gradient says nothing. This is the cheapest place to state what you
|
||||
do, name your audience, or show one artifact. Rendering caveat: the profile photo
|
||||
and the mobile layout crop the lower-left corner, so keep text in the upper right
|
||||
and never centre it.
|
||||
|
||||
### Headline — the only string that travels
|
||||
It appears next to every comment you leave, in every search result, in every
|
||||
invitation you send. See `headline_and_positioning.md`; this is the single
|
||||
highest-leverage edit available.
|
||||
|
||||
### About — for the evaluator, and it is truncated
|
||||
LinkedIn collapses it after roughly the first 265-300 characters. Whatever is
|
||||
above that fold is the whole section for most readers. Structure that survives:
|
||||
|
||||
1. **The tension** — the problem your audience recognises, stated in their words.
|
||||
2. **Who you are for** — specific enough that someone could be excluded.
|
||||
3. **Proof** — two or three results, with real numbers.
|
||||
4. **How you work** — the part that is yours rather than your job title's.
|
||||
5. **A call to action** — who should reach out, and what they get.
|
||||
|
||||
Written in first person. A profile written in the third person reads as a press
|
||||
release someone else wrote, which on a personal profile is exactly what it is.
|
||||
|
||||
### Experience — outcomes, not duties
|
||||
A duty list is interchangeable across everyone who has ever held the title.
|
||||
"Responsible for backend services" describes several hundred thousand people.
|
||||
"Cut p99 checkout latency from 1.9s to 340ms by moving the pricing call
|
||||
off the critical path" describes one.
|
||||
|
||||
Two or three bullets per role, and only the roles that support the current
|
||||
positioning. Older roles get a line each. Gaps are fine and unexplained gaps are
|
||||
also fine — most readers do not care, and the ones who do will ask.
|
||||
|
||||
### Featured — the one section you fully control
|
||||
Whatever you pin here is what a visitor sees before your feed. Empty, and they
|
||||
see whatever you last reposted. Put the artifact a buyer, hiring manager, or peer
|
||||
could evaluate in sixty seconds: the talk, the repo, the teardown, the post that
|
||||
did what you want more of.
|
||||
|
||||
Refresh it quarterly. A featured item from three years ago dates the whole
|
||||
profile.
|
||||
|
||||
### Skills — a matching surface, not a personality test
|
||||
Recruiters and LinkedIn's own matching use these. List the ones you would accept
|
||||
an interview on. The endorsement counts matter far less than people assume; the
|
||||
presence of the term matters more.
|
||||
|
||||
### Recommendations — the only text you did not write
|
||||
Two specific recommendations outperform ten generic ones. The way to get a
|
||||
specific one is to ask for a specific one: tell the person the project and the
|
||||
aspect you want them to speak to, and offer to write a first draft they can edit.
|
||||
That request is normal and almost always accepted.
|
||||
|
||||
### Custom URL, Open To / Services, contact info — two-minute fixes
|
||||
Low individual value, near-zero cost, and their absence signals a profile nobody
|
||||
maintains.
|
||||
|
||||
---
|
||||
|
||||
## The ordering rule
|
||||
|
||||
When time is short, work in this order, because it is the order in which readers
|
||||
encounter the sections and drop out:
|
||||
|
||||
1. Headline (every reader, always)
|
||||
2. Photo (every reader, three seconds)
|
||||
3. About opening — the first two sentences only
|
||||
4. Featured (one item, today)
|
||||
5. Current role bullets
|
||||
6. Everything else
|
||||
|
||||
`profile_completeness_auditor.py` implements this as points-per-hour and will
|
||||
usually put Featured and Open To first, because they cost minutes and recover
|
||||
real points.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Nielsen Norman Group. **F-Shaped Pattern for Reading Web Content** and
|
||||
**"How People Read Online"** — why the first lines carry the decision and the
|
||||
rest is skimmed.
|
||||
2. Krug, S. **Don't Make Me Think** (3rd ed.) — scannability as the design
|
||||
constraint for anything a stranger reads without commitment.
|
||||
3. Minto, B. **The Pyramid Principle** — the answer first, support after; the
|
||||
structure the About fold forces on you whether you like it or not.
|
||||
4. Goffman, E. **The Presentation of Self in Everyday Life** (1959) — a profile
|
||||
is a front-stage performance with an audience that knows it is one; the
|
||||
credibility comes from specificity, not polish.
|
||||
5. Heath, C. & Heath, D. **Made to Stick** — concreteness as the property that
|
||||
makes a claim memorable and checkable.
|
||||
6. LinkedIn Help. **Creators Core / Updates to Creator Mode** — what the creator
|
||||
surfaces actually change on a profile.
|
||||
7. Clark, D. **Reinventing You** — sequencing a positioning change so the public
|
||||
record supports the new claim before you make it.
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
#!/usr/bin/env python3
|
||||
"""about_section_builder.py — assemble a LinkedIn About section that survives the fold.
|
||||
|
||||
LinkedIn collapses the About section after roughly the first 265-300 characters and
|
||||
hides the rest behind "…see more". Most people spend that window on a warm-up
|
||||
sentence, so the only part a stranger reads is the part that says nothing.
|
||||
|
||||
This tool assembles the section from named parts, then refuses the ones that break:
|
||||
- anything past the 2,600-character cap
|
||||
- a fold window that does not end on a sentence boundary (a truncated word is the
|
||||
reader's cue to stop)
|
||||
- a fold window carrying no audience and no proof
|
||||
- buzzword filler
|
||||
- a missing call to action (a profile that converts needs a next step)
|
||||
|
||||
It does not invent claims. Every part comes from you; the tool only orders,
|
||||
measures, and refuses.
|
||||
|
||||
Exit codes:
|
||||
0 PASS — assembled and clean
|
||||
2 WARN — assembled, with non-blocking findings
|
||||
3 FAIL — blocking findings; fix and re-run before publishing
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
HARD_LIMIT = 2600
|
||||
FOLD_SAFE = 265 # conservative end of the observed 265-300 truncation window
|
||||
FOLD_OBSERVED = 300
|
||||
|
||||
BUZZWORDS = [
|
||||
"passionate about", "results-driven", "results driven", "detail-oriented",
|
||||
"detail oriented", "self-starter", "team player", "hard worker", "guru",
|
||||
"ninja", "rockstar", "thought leader", "seasoned professional", "dynamic",
|
||||
"proven track record", "wear many hats", "think outside the box",
|
||||
"synergy", "leverage my skills", "world-class", "cutting-edge",
|
||||
]
|
||||
|
||||
PROOF_RE = re.compile(
|
||||
r"\d+\s*(%|x\b|×)|[$€£]\s?\d|\b\d+\s*(k|m|bn)\b|\bex-[A-Z]|"
|
||||
r"\b\d{1,3}(,\d{3})+\b|\b\d+\s*(users|customers|clients|teams|engineers|"
|
||||
r"subscribers|downloads|countries|years)\b", re.I)
|
||||
|
||||
AUDIENCE_RE = re.compile(
|
||||
r"\b(for|helping|i help|we help|i work with|my clients|founders|ctos?|cmos?|"
|
||||
r"engineers|designers|marketers|recruiters|startups|smbs|teams|operators|"
|
||||
r"clinicians|students|investors|product managers|data teams)\b", re.I)
|
||||
|
||||
SAMPLE = {
|
||||
"hook": "Most data teams do not have a data problem. They have a trust problem — "
|
||||
"nobody believes the dashboard, so everyone rebuilds the number in a spreadsheet.",
|
||||
"audience": "I work with Series A SaaS companies whose analytics stack grew faster than "
|
||||
"anyone's confidence in it.",
|
||||
"proof": [
|
||||
"Cut BigQuery spend 62% at Zendesk scale without dropping a single dashboard.",
|
||||
"Rebuilt reporting for three Series B teams; two of them retired their shadow spreadsheets inside a quarter.",
|
||||
],
|
||||
"approach": "I start by finding the number people actually argue about, then work backwards "
|
||||
"through the dbt models to the BigQuery tables that produce it. Usually the fix "
|
||||
"is fewer models and clearer ownership, not more tooling — analytics engineering "
|
||||
"is a data governance problem wearing a modelling costume.",
|
||||
"cta": "If your team is re-deriving the same metric in three places, message me — I will tell "
|
||||
"you in one call whether it is a modelling problem or an ownership problem.",
|
||||
"keywords": ["analytics engineering", "dbt", "BigQuery", "data governance", "Series A SaaS"],
|
||||
}
|
||||
|
||||
ORDER = ["hook", "audience", "proof", "approach", "cta"]
|
||||
|
||||
|
||||
def assemble(parts: dict) -> str:
|
||||
blocks = []
|
||||
for key in ORDER:
|
||||
val = parts.get(key)
|
||||
if not val:
|
||||
continue
|
||||
if isinstance(val, list):
|
||||
blocks.append("\n".join(f"— {v.strip()}" for v in val if v.strip()))
|
||||
else:
|
||||
blocks.append(str(val).strip())
|
||||
body = "\n\n".join(b for b in blocks if b)
|
||||
kws = [k for k in (parts.get("keywords") or []) if k.strip()]
|
||||
if kws:
|
||||
body += "\n\nAreas I work in: " + " · ".join(k.strip() for k in kws)
|
||||
return body
|
||||
|
||||
|
||||
def _fold_window(text: str) -> tuple:
|
||||
"""Return (window, ends_cleanly, boundary_index)."""
|
||||
window = text[:FOLD_SAFE]
|
||||
if len(text) <= FOLD_SAFE:
|
||||
return window, True, len(text)
|
||||
# Last sentence-ending punctuation inside the safe window.
|
||||
boundary = max(window.rfind("."), window.rfind("!"), window.rfind("?"))
|
||||
return window, boundary >= FOLD_SAFE - 90, boundary
|
||||
|
||||
|
||||
def validate(text: str, parts: dict) -> list:
|
||||
findings = []
|
||||
n = len(text)
|
||||
if n > HARD_LIMIT:
|
||||
findings.append({
|
||||
"severity": "blocking", "check": "length",
|
||||
"finding": f"{n} characters — {n - HARD_LIMIT} over the {HARD_LIMIT} cap. "
|
||||
"LinkedIn will truncate or refuse it.",
|
||||
"fix": "Cut the approach block first; it is the part a reader can infer.",
|
||||
})
|
||||
if n < 400:
|
||||
findings.append({
|
||||
"severity": "warning", "check": "length",
|
||||
"finding": f"{n} characters. Under ~400 there is not room for both a claim and its proof.",
|
||||
"fix": "Add one concrete result with a number, or one sentence about who you are for.",
|
||||
})
|
||||
|
||||
window, clean, boundary = _fold_window(text)
|
||||
if not clean:
|
||||
findings.append({
|
||||
"severity": "blocking", "check": "fold",
|
||||
"finding": f"The visible window (first ~{FOLD_SAFE} chars, truncation observed at "
|
||||
f"{FOLD_SAFE}-{FOLD_OBSERVED}) cuts mid-sentence. A reader sees a broken "
|
||||
"thought and stops.",
|
||||
"fix": f"Rewrite the opening so a sentence ends between character "
|
||||
f"{FOLD_SAFE - 90} and {FOLD_SAFE}.",
|
||||
})
|
||||
if not AUDIENCE_RE.search(window) and not PROOF_RE.search(window):
|
||||
findings.append({
|
||||
"severity": "blocking", "check": "fold-content",
|
||||
"finding": "The visible window names no audience and carries no proof — it is a "
|
||||
"warm-up, and the warm-up is all most readers get.",
|
||||
"fix": "Move the sentence that names who you are for, or the one with the number, "
|
||||
"into the first two sentences.",
|
||||
})
|
||||
|
||||
low = text.lower()
|
||||
hits = [b for b in BUZZWORDS if b in low]
|
||||
if hits:
|
||||
findings.append({
|
||||
"severity": "warning", "check": "buzzwords",
|
||||
"finding": f"Filler present: {', '.join(hits)}.",
|
||||
"fix": "Delete each one. If deleting it removes meaning, replace it with the "
|
||||
"specific thing it was standing in for.",
|
||||
})
|
||||
|
||||
if not parts.get("cta"):
|
||||
findings.append({
|
||||
"severity": "blocking", "check": "cta",
|
||||
"finding": "No call to action. The section ends and the reader has nothing to do.",
|
||||
"fix": "One line naming who should get in touch and what they will get from it.",
|
||||
})
|
||||
|
||||
if not PROOF_RE.search(text):
|
||||
findings.append({
|
||||
"severity": "warning", "check": "proof",
|
||||
"finding": "No number, prior company, or scale signal anywhere in the section.",
|
||||
"fix": "One real, checkable data point. A range is fine; an invented figure is not.",
|
||||
})
|
||||
|
||||
first_person = len(re.findall(r"\bI\b|\bmy\b|\bme\b", text))
|
||||
if first_person < 2:
|
||||
findings.append({
|
||||
"severity": "warning", "check": "voice",
|
||||
"finding": "Written in third person or with no first-person voice. On a personal "
|
||||
"profile that reads as a press release someone else wrote.",
|
||||
"fix": "Write it as you would say it: 'I work with…', not 'Alex is a…'.",
|
||||
})
|
||||
|
||||
kws = [k.strip().lower() for k in (parts.get("keywords") or []) if k.strip()]
|
||||
# Check against the prose only — the trailing "Areas I work in" list would
|
||||
# otherwise satisfy every keyword and make this check vacuous.
|
||||
prose = low.split("\n\nareas i work in:")[0]
|
||||
missing = [k for k in kws if k not in prose]
|
||||
if missing:
|
||||
findings.append({
|
||||
"severity": "warning", "check": "keywords",
|
||||
"finding": f"Declared keywords not present in the body: {', '.join(missing)} "
|
||||
"(they appear only in the trailing list).",
|
||||
"fix": "Work the important ones into a real sentence. A keyword list at the bottom "
|
||||
"is weaker than the same term used in context.",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def build(parts: dict) -> dict:
|
||||
text = assemble(parts)
|
||||
findings = validate(text, parts)
|
||||
blocking = [f for f in findings if f["severity"] == "blocking"]
|
||||
warnings = [f for f in findings if f["severity"] == "warning"]
|
||||
verdict, code = (("FAIL", 3) if blocking else ("WARN", 2) if warnings else ("PASS", 0))
|
||||
window, clean, _ = _fold_window(text)
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"about": text,
|
||||
"chars": len(text),
|
||||
"limit": HARD_LIMIT,
|
||||
"visible_before_see_more": window,
|
||||
"fold_ends_on_sentence": clean,
|
||||
"findings": blocking + warnings,
|
||||
"note": ("The 2,600-character cap and the ~265-300 truncation window are "
|
||||
"third-party-documented, not published by LinkedIn. The tool enforces the "
|
||||
"conservative end of the window on purpose."),
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
lines = [f"About section: {r['verdict']} ({r['chars']}/{r['limit']} chars)", "=" * 56,
|
||||
"", "VISIBLE BEFORE \"…see more\":",
|
||||
f" {r['visible_before_see_more']}",
|
||||
f" [ends on a sentence: {'yes' if r['fold_ends_on_sentence'] else 'NO'}]", ""]
|
||||
if r["findings"]:
|
||||
lines.append("Findings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['check']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
else:
|
||||
lines.append("No findings.")
|
||||
lines += ["", "-" * 56, "FULL SECTION", "-" * 56, r["about"], "", r["note"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Assemble and validate a LinkedIn About section "
|
||||
"(PASS=0 / WARN=2 / FAIL=3).")
|
||||
ap.add_argument("--hook", help="Opening line: the tension or observation.")
|
||||
ap.add_argument("--audience", help="Who you are for, in their words.")
|
||||
ap.add_argument("--proof", action="append", default=[],
|
||||
help="A real, checkable result. Repeatable.")
|
||||
ap.add_argument("--approach", help="How you work — the part that is yours.")
|
||||
ap.add_argument("--cta", help="Who should reach out and what they get.")
|
||||
ap.add_argument("--keyword", action="append", default=[],
|
||||
help="A term you want to be findable for. Repeatable.")
|
||||
ap.add_argument("--input", help="Read all parts from a JSON file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Build the built-in sample section.")
|
||||
ap.add_argument("--print-schema", action="store_true",
|
||||
help="Print the input JSON shape and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_schema:
|
||||
print(json.dumps(SAMPLE, indent=2))
|
||||
return 0
|
||||
if args.sample:
|
||||
parts = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
try:
|
||||
parts = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
parts = {"hook": args.hook, "audience": args.audience, "proof": args.proof,
|
||||
"approach": args.approach, "cta": args.cta, "keywords": args.keyword}
|
||||
if not any(parts.values()):
|
||||
ap.error("provide at least --hook (or use --input / --sample / --print-schema)")
|
||||
|
||||
result = build(parts)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env python3
|
||||
"""headline_scorer.py — score a LinkedIn headline 0-100 on the five things it must do.
|
||||
|
||||
The headline is the single highest-leverage string on LinkedIn: it rides along with
|
||||
every comment you leave, every search result you appear in, and every connection
|
||||
request you send. Most of them are a job title, which tells a reader nothing they
|
||||
could not have guessed.
|
||||
|
||||
Five dimensions, 20 points each:
|
||||
AUDIENCE — does it name who this person is for?
|
||||
OUTCOME — does it name what changes for that audience?
|
||||
PROOF — is there a specific, checkable signal (number, company, credential)?
|
||||
SEARCHABILITY — does it contain role/skill terms a recruiter or buyer would type?
|
||||
CLARITY — front-loaded, readable, free of buzzword filler, within limits
|
||||
|
||||
Limits enforced (🟡 third-party-documented, LinkedIn does not publish a limits page):
|
||||
220 characters hard cap; the first ~60-70 characters are what survive in search
|
||||
results and invitation previews, so the load-bearing words go there.
|
||||
|
||||
Exit codes:
|
||||
0 SHIP (>= 75) — good enough to publish
|
||||
2 SHARPEN (50-74) — usable, but named gaps cost real visibility
|
||||
3 REWRITE (< 50) — start over; the findings say what is missing
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
HARD_LIMIT = 220
|
||||
FRONT_LOAD = 60 # characters that survive in search results / invite previews
|
||||
|
||||
# Filler that reads as self-description rather than evidence.
|
||||
BUZZWORDS = [
|
||||
"guru", "ninja", "rockstar", "wizard", "evangelist", "thought leader",
|
||||
"visionary", "passionate about", "results-driven", "results driven",
|
||||
"detail-oriented", "detail oriented", "self-starter", "go-getter",
|
||||
"dynamic", "synergy", "disruptor", "innovator", "serial entrepreneur",
|
||||
"world-class", "world class", "best-in-class", "growth hacker",
|
||||
"seasoned", "proven track record", "hard worker", "team player",
|
||||
]
|
||||
|
||||
# Words that signal an audience is being named.
|
||||
AUDIENCE_MARKERS = [
|
||||
"for ", "helping", "i help", "we help", "to ", "founders", "ctos", "cto",
|
||||
"cmos", "engineers", "designers", "marketers", "recruiters", "startups",
|
||||
"smbs", "smes", "enterprises", "teams", "b2b", "b2c", "saas", "agencies",
|
||||
"nonprofits", "students", "clinicians", "operators", "pms", "product managers",
|
||||
"developers", "data teams", "hr", "sales teams", "investors",
|
||||
]
|
||||
|
||||
# Words that signal an outcome / transformation rather than a job description.
|
||||
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 ",
|
||||
]
|
||||
|
||||
# Terms recruiters and buyers actually type into LinkedIn search.
|
||||
SEARCH_TERMS = [
|
||||
"engineer", "developer", "architect", "manager", "director", "head of",
|
||||
"vp", "founder", "consultant", "designer", "analyst", "scientist",
|
||||
"marketer", "writer", "researcher", "advisor", "coach", "lead",
|
||||
"python", "react", "kubernetes", "aws", "gcp", "azure", "sql", "ml",
|
||||
"ai", "llm", "security", "devops", "sre", "data", "product", "ux",
|
||||
"seo", "content", "finance", "legal", "clinical", "regulatory",
|
||||
"recruiter", "sales", "customer success", "operations", "platform",
|
||||
]
|
||||
|
||||
SAMPLE_GOOD = ("Fractional Head of Data for Series A/B SaaS | Cut BigQuery spend 62% at "
|
||||
"Zendesk scale | ex-Stripe | I make dashboards people trust")
|
||||
SAMPLE_WEAK = "Senior Software Engineer | Passionate about technology | Team player"
|
||||
|
||||
|
||||
def _find(text_low: str, needles: list) -> list:
|
||||
return [n for n in needles if n in text_low]
|
||||
|
||||
|
||||
def _has_proof(text: str) -> list:
|
||||
"""Specific checkable signals: numbers, %/$, ex-Company, named credentials."""
|
||||
signals = []
|
||||
if re.search(r"\d+\s*(%|x\b|×)", text):
|
||||
signals.append("percentage or multiple")
|
||||
if re.search(r"[$€£]\s?\d", text) or re.search(r"\d+\s?(k|m|bn|b)\b", text, re.I):
|
||||
signals.append("money or magnitude")
|
||||
if re.search(r"\bex-[A-Z][\w&.-]+", text):
|
||||
signals.append("prior company (ex-)")
|
||||
if re.search(r"\b(phd|md|mba|cpa|pmp|cissp|cfa|rn|jd)\b", text, re.I):
|
||||
signals.append("credential")
|
||||
if re.search(r"\b(author|speaker|patent|award|forbes|ycombinator|y combinator|"
|
||||
r"techstars|open[- ]source maintainer)\b", text, re.I):
|
||||
signals.append("third-party proof")
|
||||
if re.search(r"\b\d{1,3}(,\d{3})+\b|\b\d+\s*(users|customers|clients|teams|"
|
||||
r"engineers|downloads|subscribers)\b", text, re.I):
|
||||
signals.append("scale number")
|
||||
return signals
|
||||
|
||||
|
||||
def score_headline(text: str) -> dict:
|
||||
raw = text.strip()
|
||||
low = raw.lower()
|
||||
findings, dims = [], {}
|
||||
|
||||
# --- AUDIENCE -----------------------------------------------------------
|
||||
aud = _find(low, AUDIENCE_MARKERS)
|
||||
dims["audience"] = 20 if len(aud) >= 2 else (12 if aud else 0)
|
||||
if not aud:
|
||||
findings.append({
|
||||
"severity": "blocking", "dimension": "audience",
|
||||
"finding": "No audience named. A reader cannot tell whether this person is for them.",
|
||||
"fix": "Name the group in plain words: 'for Series A SaaS founders', 'for clinical data teams'.",
|
||||
})
|
||||
|
||||
# --- OUTCOME ------------------------------------------------------------
|
||||
out = _find(low, OUTCOME_MARKERS)
|
||||
dims["outcome"] = 20 if len(out) >= 2 else (12 if out else 0)
|
||||
if not out:
|
||||
findings.append({
|
||||
"severity": "blocking", "dimension": "outcome",
|
||||
"finding": "States a role, not a result. Titles are interchangeable; outcomes are not.",
|
||||
"fix": "Add what changes because of you: 'cut onboarding from 6 weeks to 4 days'.",
|
||||
})
|
||||
|
||||
# --- PROOF --------------------------------------------------------------
|
||||
proof = _has_proof(raw)
|
||||
dims["proof"] = 20 if len(proof) >= 2 else (12 if proof else 0)
|
||||
if not proof:
|
||||
findings.append({
|
||||
"severity": "major", "dimension": "proof",
|
||||
"finding": "No checkable signal. Every claim here is self-assessed.",
|
||||
"fix": "One number, one prior company, or one credential. Real, or leave it out.",
|
||||
})
|
||||
|
||||
# --- SEARCHABILITY ------------------------------------------------------
|
||||
terms = _find(low, SEARCH_TERMS)
|
||||
dims["searchability"] = 20 if len(terms) >= 3 else (13 if len(terms) == 2 else
|
||||
(7 if terms else 0))
|
||||
if len(terms) < 2:
|
||||
findings.append({
|
||||
"severity": "major", "dimension": "searchability",
|
||||
"finding": f"Only {len(terms)} recognizable search term(s). "
|
||||
"LinkedIn search matches headline text; invented job titles do not rank.",
|
||||
"fix": "Keep at least one conventional role or skill term alongside the creative framing.",
|
||||
})
|
||||
|
||||
# --- CLARITY ------------------------------------------------------------
|
||||
clarity = 20
|
||||
hits = _find(low, BUZZWORDS)
|
||||
if hits:
|
||||
clarity -= min(10, 4 * len(hits))
|
||||
findings.append({
|
||||
"severity": "major", "dimension": "clarity",
|
||||
"finding": f"Buzzword filler: {', '.join(hits)}. These describe an attitude, not a capability.",
|
||||
"fix": "Delete them. The space buys you a real number or a real audience.",
|
||||
})
|
||||
pipes = raw.count("|") + raw.count("•") + raw.count("·")
|
||||
if pipes > 3:
|
||||
clarity -= 5
|
||||
findings.append({
|
||||
"severity": "minor", "dimension": "clarity",
|
||||
"finding": f"{pipes} separators. Past three, it reads as a list of keywords rather than a claim.",
|
||||
"fix": "Keep three segments: who you help / what changes / one proof.",
|
||||
})
|
||||
emoji = len(re.findall(r"[\U0001F300-\U0001FAFF☀-➿]", raw))
|
||||
if emoji > 2:
|
||||
clarity -= 4
|
||||
findings.append({
|
||||
"severity": "minor", "dimension": "clarity",
|
||||
"finding": f"{emoji} emoji. They survive truncation and crowd out words that carry meaning.",
|
||||
"fix": "At most one, and only if it separates segments.",
|
||||
})
|
||||
caps_words = [w for w in raw.split() if len(w) > 3 and w.isupper()]
|
||||
if len(caps_words) > 1:
|
||||
clarity -= 3
|
||||
findings.append({
|
||||
"severity": "minor", "dimension": "clarity",
|
||||
"finding": "Multiple ALL-CAPS words read as shouting and hurt scannability.",
|
||||
"fix": "Sentence case. Emphasis comes from specificity, not capitals.",
|
||||
})
|
||||
dims["clarity"] = max(0, clarity)
|
||||
|
||||
# --- Length + front-loading (structural, reported alongside the score) ---
|
||||
length = len(raw)
|
||||
front = raw[:FRONT_LOAD]
|
||||
over = max(0, length - HARD_LIMIT)
|
||||
if over:
|
||||
findings.append({
|
||||
"severity": "blocking", "dimension": "length",
|
||||
"finding": f"{length} characters — {over} over the 220-character cap. LinkedIn will refuse it.",
|
||||
"fix": f"Cut {over} characters. Start with the segment carrying the least proof.",
|
||||
})
|
||||
front_has_proof = bool(_has_proof(front)) or bool(_find(front.lower(), AUDIENCE_MARKERS))
|
||||
if not front_has_proof:
|
||||
findings.append({
|
||||
"severity": "major", "dimension": "front-load",
|
||||
"finding": f"The first {FRONT_LOAD} characters — the part that survives in search "
|
||||
f"results and invitation previews — carry no audience and no proof: "
|
||||
f"\"{front}\"",
|
||||
"fix": "Move the strongest segment first. Everything after it is a bonus, not a plan.",
|
||||
})
|
||||
|
||||
total = sum(dims.values())
|
||||
if over: # a headline LinkedIn will not accept cannot ship
|
||||
total = min(total, 49)
|
||||
verdict, code = (("SHIP", 0) if total >= 75 else
|
||||
("SHARPEN", 2) if total >= 50 else ("REWRITE", 3))
|
||||
|
||||
return {
|
||||
"headline": raw,
|
||||
"score": total,
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"dimensions": dims,
|
||||
"length": {"chars": length, "limit": HARD_LIMIT, "over_by": over,
|
||||
"front_loaded_preview": front},
|
||||
"signals": {"audience": aud, "outcome": out, "proof": proof, "search_terms": terms},
|
||||
"findings": sorted(findings,
|
||||
key=lambda f: {"blocking": 0, "major": 1, "minor": 2}[f["severity"]]),
|
||||
"note": ("Character limits are documented by third parties, not by an official LinkedIn "
|
||||
"limits page — treat 220 as reliable and the ~60-70 front-load window as an "
|
||||
"estimate that shifts with UI changes."),
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
lines = [
|
||||
f"Headline score: {r['score']}/100 — {r['verdict']}",
|
||||
"=" * 52,
|
||||
f"\"{r['headline']}\"",
|
||||
f"{r['length']['chars']}/{r['length']['limit']} chars"
|
||||
+ (f" (OVER BY {r['length']['over_by']})" if r["length"]["over_by"] else ""),
|
||||
f"Search/invite preview: \"{r['length']['front_loaded_preview']}\"",
|
||||
"",
|
||||
"Dimensions (20 each):",
|
||||
]
|
||||
for k, v in r["dimensions"].items():
|
||||
bar = "#" * (v // 2) + "." * (10 - v // 2)
|
||||
lines.append(f" {k:<14} {v:>2}/20 [{bar}]")
|
||||
if r["findings"]:
|
||||
lines.append("\nFindings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['dimension']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
else:
|
||||
lines.append("\nNo findings. Ship it.")
|
||||
lines.append(f"\n{r['note']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Score a LinkedIn headline 0-100 (SHIP=0 / SHARPEN=2 / REWRITE=3).")
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument("--headline", help="The headline text to score.")
|
||||
src.add_argument("--input", help="Read the headline from a file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true",
|
||||
help="Score a built-in strong sample headline.")
|
||||
ap.add_argument("--sample-weak", action="store_true",
|
||||
help="Score a built-in weak sample headline (shows the failure modes).")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
text = SAMPLE_GOOD
|
||||
elif args.sample_weak:
|
||||
text = SAMPLE_WEAK
|
||||
elif args.headline:
|
||||
text = args.headline
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
else:
|
||||
ap.error("one of --headline, --input, --sample, or --sample-weak is required")
|
||||
|
||||
result = score_headline(text)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
#!/usr/bin/env python3
|
||||
"""profile_completeness_auditor.py — score a LinkedIn profile 0-100 and rank the fixes.
|
||||
|
||||
Takes a JSON description of the profile YOU control (never scraped, never fetched —
|
||||
you fill it in or dictate it) and returns a weighted completeness score plus a fix
|
||||
list ordered by points-per-hour, so the first hour of work is the one that moves
|
||||
the most.
|
||||
|
||||
Weights are set by what the section actually does for discovery and conversion,
|
||||
not by how prominent it looks. The headline and the About opening are worth more
|
||||
than education because they are what a stranger reads before deciding to care.
|
||||
|
||||
Input JSON (every key optional; missing = absent):
|
||||
{
|
||||
"photo": true, "banner_custom": false, "custom_url": true,
|
||||
"headline": "...", "about": "...",
|
||||
"current_role": {"title": "...", "bullets": ["cut X 40%", "..."]},
|
||||
"featured_items": 2, "featured_updated_days_ago": 400,
|
||||
"skills": ["python", "sql"], "recommendations_received": 1,
|
||||
"open_to_or_services": false, "education": true, "certifications": 0,
|
||||
"contact_info": true, "days_since_last_post": 90
|
||||
}
|
||||
|
||||
Exit codes:
|
||||
0 STRONG (>= 80)
|
||||
2 INCOMPLETE (50-79) — fix list returned
|
||||
3 WEAK (< 50) — the profile is costing you every visit it receives
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
# (key, weight, effort_hours, label)
|
||||
CHECKS = [
|
||||
("photo", 8, 0.5, "Profile photo"),
|
||||
("banner", 5, 0.5, "Custom banner"),
|
||||
("headline", 14, 1.0, "Headline that is not just a job title"),
|
||||
("about", 14, 1.5, "About section with a self-contained opening"),
|
||||
("experience", 12, 2.0, "Current role written as outcomes, not duties"),
|
||||
("featured", 8, 0.5, "Featured section with something recent"),
|
||||
("skills", 6, 0.3, "Skills listed and relevant"),
|
||||
("recommendations", 8, 1.0, "Recommendations received"),
|
||||
("custom_url", 3, 0.1, "Custom profile URL"),
|
||||
("open_to", 4, 0.2, "Open To / Services block set"),
|
||||
("education", 3, 0.2, "Education filled in"),
|
||||
("certifications", 2, 0.5, "Certifications listed"),
|
||||
("contact_info", 3, 0.2, "A reachable contact method"),
|
||||
("activity", 10, 1.0, "Posted in the last 30 days"),
|
||||
]
|
||||
|
||||
WHY = {
|
||||
"photo": "Profiles without a photo read as abandoned or fake; a stranger's first "
|
||||
"judgement happens before they read a word.",
|
||||
"banner": "The default blue banner is 1,584x396 pixels of unused positioning. It is the "
|
||||
"cheapest place to state what you do.",
|
||||
"headline": "It travels with every comment, search result, and invitation you send. A bare "
|
||||
"job title spends that space saying nothing a reader could not guess.",
|
||||
"about": "LinkedIn truncates it after roughly the first 265-300 characters. If those do not "
|
||||
"stand alone, the rest is never read.",
|
||||
"experience": "Duty lists are interchangeable across everyone with the same title. Outcomes "
|
||||
"are the only part a reader cannot get elsewhere.",
|
||||
"featured": "The one place you choose what a visitor sees first. Empty, and they see whatever "
|
||||
"you last reposted.",
|
||||
"skills": "Skills are a matching surface for search and for recruiter filters.",
|
||||
"recommendations": "The only text on the profile written by someone other than you. Two "
|
||||
"specific ones beat ten generic.",
|
||||
"custom_url": "Shareable, memorable, and it is a two-minute fix.",
|
||||
"open_to": "Tells LinkedIn's matching systems and human visitors what you want. Absent, they "
|
||||
"guess — usually wrong.",
|
||||
"education": "A weak signal on its own, but it is a common filter and a common icebreaker.",
|
||||
"certifications": "Only worth points where the certification is a gate in your field.",
|
||||
"contact_info": "A profile that converts interest into a conversation needs a way to start one.",
|
||||
"activity": "A profile with no recent activity converts a visit into nothing. Consistency, not "
|
||||
"volume, is the signal.",
|
||||
}
|
||||
|
||||
SAMPLE = {
|
||||
"photo": True,
|
||||
"banner_custom": False,
|
||||
"custom_url": True,
|
||||
"headline": "Senior Software Engineer at Acme",
|
||||
"about": "Experienced engineer passionate about building great software.",
|
||||
"current_role": {"title": "Senior Software Engineer",
|
||||
"bullets": ["Responsible for backend services",
|
||||
"Worked with cross-functional teams"]},
|
||||
"featured_items": 0,
|
||||
"featured_updated_days_ago": None,
|
||||
"skills": ["python", "sql", "aws"],
|
||||
"recommendations_received": 0,
|
||||
"open_to_or_services": False,
|
||||
"education": True,
|
||||
"certifications": 0,
|
||||
"contact_info": True,
|
||||
"days_since_last_post": 210,
|
||||
}
|
||||
|
||||
OUTCOME_WORDS = ("cut", "grew", "reduced", "increased", "shipped", "launched", "saved",
|
||||
"doubled", "migrated", "led", "%", "x", "from", "to")
|
||||
|
||||
|
||||
def evaluate_check(key: str, p: dict) -> tuple:
|
||||
"""Return (earned_fraction 0..1, detail string)."""
|
||||
if key == "photo":
|
||||
return (1.0, "present") if p.get("photo") else (0.0, "missing")
|
||||
if key == "banner":
|
||||
return (1.0, "custom") if p.get("banner_custom") else (0.0, "default LinkedIn banner")
|
||||
if key == "headline":
|
||||
h = (p.get("headline") or "").strip()
|
||||
if not h:
|
||||
return 0.0, "empty"
|
||||
title_only = len(h) < 60 and "|" not in h and "for " not in h.lower()
|
||||
return (0.4, f"job-title-shaped ({len(h)} chars) — run headline_scorer.py") if title_only \
|
||||
else (1.0, f"{len(h)} chars, structured")
|
||||
if key == "about":
|
||||
a = (p.get("about") or "").strip()
|
||||
if not a:
|
||||
return 0.0, "empty"
|
||||
if len(a) < 300:
|
||||
return 0.4, f"{len(a)} chars — too short to say anything specific"
|
||||
if len(a) < 600:
|
||||
return 0.7, f"{len(a)} chars — thin but usable"
|
||||
return 1.0, f"{len(a)} chars"
|
||||
if key == "experience":
|
||||
role = p.get("current_role") or {}
|
||||
bullets = role.get("bullets") or []
|
||||
if not role.get("title"):
|
||||
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)]
|
||||
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":
|
||||
n = p.get("featured_items") or 0
|
||||
if not n:
|
||||
return 0.0, "empty"
|
||||
age = p.get("featured_updated_days_ago")
|
||||
if age is not None and age > 365:
|
||||
return 0.5, f"{n} item(s), last updated {age} days ago — stale"
|
||||
return 1.0, f"{n} item(s)"
|
||||
if key == "skills":
|
||||
n = len(p.get("skills") or [])
|
||||
return (1.0, f"{n} listed") if n >= 5 else (n / 5.0, f"only {n} listed")
|
||||
if key == "recommendations":
|
||||
n = p.get("recommendations_received") or 0
|
||||
return (1.0, f"{n} received") if n >= 2 else (n / 2.0, f"{n} received")
|
||||
if key == "custom_url":
|
||||
return (1.0, "set") if p.get("custom_url") else (0.0, "still the default /in/name-8a3f2b")
|
||||
if key == "open_to":
|
||||
return (1.0, "set") if p.get("open_to_or_services") else (0.0, "not set")
|
||||
if key == "education":
|
||||
return (1.0, "present") if p.get("education") else (0.0, "missing")
|
||||
if key == "certifications":
|
||||
n = p.get("certifications") or 0
|
||||
return (1.0, f"{n} listed") if n else (0.0, "none listed")
|
||||
if key == "contact_info":
|
||||
return (1.0, "present") if p.get("contact_info") else (0.0, "missing")
|
||||
if key == "activity":
|
||||
d = p.get("days_since_last_post")
|
||||
if d is None:
|
||||
return 0.0, "no posting activity reported"
|
||||
if d <= 30:
|
||||
return 1.0, f"last post {d} days ago"
|
||||
if d <= 90:
|
||||
return 0.4, f"last post {d} days ago — the profile reads as dormant"
|
||||
return 0.0, f"last post {d} days ago — effectively inactive"
|
||||
return 0.0, "unknown check"
|
||||
|
||||
|
||||
def audit(profile: dict) -> dict:
|
||||
rows, fixes, total = [], [], 0.0
|
||||
for key, weight, effort, label in CHECKS:
|
||||
frac, detail = evaluate_check(key, profile)
|
||||
earned = round(weight * frac, 2)
|
||||
total += earned
|
||||
rows.append({"check": key, "label": label, "weight": weight,
|
||||
"earned": earned, "detail": detail})
|
||||
lost = round(weight - earned, 2)
|
||||
if lost >= 0.5:
|
||||
fixes.append({
|
||||
"check": key, "label": label, "points_available": lost,
|
||||
"effort_hours": effort,
|
||||
"points_per_hour": round(lost / effort, 1),
|
||||
"current": detail,
|
||||
"why": WHY[key],
|
||||
})
|
||||
fixes.sort(key=lambda f: (-f["points_per_hour"], -f["points_available"]))
|
||||
score = round(total)
|
||||
verdict, code = (("STRONG", 0) if score >= 80 else
|
||||
("INCOMPLETE", 2) if score >= 50 else ("WEAK", 3))
|
||||
first_hour = []
|
||||
budget = 1.0
|
||||
for f in fixes:
|
||||
if f["effort_hours"] <= budget:
|
||||
first_hour.append(f["label"])
|
||||
budget -= f["effort_hours"]
|
||||
return {
|
||||
"score": score,
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"checks": rows,
|
||||
"fixes_by_leverage": fixes,
|
||||
"first_hour_plan": first_hour,
|
||||
"points_recoverable_in_first_hour": round(
|
||||
sum(f["points_available"] for f in fixes if f["label"] in first_hour), 1),
|
||||
"note": ("Scores your own profile from your own description. Nothing is fetched, "
|
||||
"scraped, or sent. Weights reflect discovery and conversion impact, not "
|
||||
"LinkedIn's internal 'profile strength' meter, which is a different and "
|
||||
"undocumented measure."),
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
lines = [f"Profile completeness: {r['score']}/100 — {r['verdict']}", "=" * 56]
|
||||
for row in r["checks"]:
|
||||
mark = "OK " if row["earned"] >= row["weight"] - 0.01 else "-- "
|
||||
lines.append(f" {mark}{row['label']:<48} {row['earned']:>5}/{row['weight']:<3} {row['detail']}")
|
||||
lines.append("\nFixes ranked by points per hour:")
|
||||
for f in r["fixes_by_leverage"]:
|
||||
lines.append(f" +{f['points_available']:<5} pts ~{f['effort_hours']}h "
|
||||
f"({f['points_per_hour']} pts/h) {f['label']}")
|
||||
lines.append(f" now : {f['current']}")
|
||||
lines.append(f" why : {f['why']}")
|
||||
if r["first_hour_plan"]:
|
||||
lines.append(f"\nFirst hour: {', '.join(r['first_hour_plan'])} "
|
||||
f"(+{r['points_recoverable_in_first_hour']} points)")
|
||||
lines.append(f"\n{r['note']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Audit a LinkedIn profile 0-100 and rank fixes by points per hour "
|
||||
"(STRONG=0 / INCOMPLETE=2 / WEAK=3).")
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument("--input", help="Profile JSON file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true",
|
||||
help="Audit a built-in sample profile (a typical unoptimized one).")
|
||||
ap.add_argument("--print-schema", action="store_true",
|
||||
help="Print the input JSON schema/sample and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_schema:
|
||||
print(json.dumps(SAMPLE, indent=2))
|
||||
return 0
|
||||
if args.sample:
|
||||
profile = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
try:
|
||||
profile = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
ap.error("--input or --sample is required (see --print-schema)")
|
||||
|
||||
result = audit(profile)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
100
marketing/linkedin/skills/linkedin-skills/SKILL.md
Normal file
100
marketing/linkedin/skills/linkedin-skills/SKILL.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
name: linkedin-skills
|
||||
description: Use when someone wants to grow an organic LinkedIn presence — a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on "grow my LinkedIn", "fix my headline", "write a LinkedIn post", "what should I post about", "LinkedIn strategy", "connection request", "my reach dropped". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.
|
||||
context: fork
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: marketing
|
||||
updated: 2026-08-25
|
||||
build_pattern: "Domain orchestrator — deterministic lane router + policy gate, five managed sub-skills"
|
||||
distinct_from: "marketing-skill/x-twitter-growth (X/Twitter-specific); marketing-skill/social-content (multi-platform calendars); marketing-skill/social-media-analyzer (cross-platform reporting); productivity/andreessen (venture judgement, not audience building)"
|
||||
---
|
||||
|
||||
# LinkedIn — Organic Presence Orchestrator
|
||||
|
||||
> **Portability + safety:** 17 stdlib-only Python scripts. **No LinkedIn credentials, no API
|
||||
> calls, no scraping, nothing auto-sent.** Every output is text the account holder posts
|
||||
> themselves — automated posting, connecting, and commenting are prohibited by LinkedIn's
|
||||
> User Agreement §8.2, and a restricted account ends the whole project.
|
||||
|
||||
Building an authentic presence over months, not generating a post. The five lanes cover what
|
||||
actually moves: who you are (profile), what you stand for (strategy), what you publish
|
||||
(content), who you talk to (engagement), and what any of it did (analytics).
|
||||
|
||||
## Step 1 — Gate, before anything is drafted
|
||||
|
||||
```bash
|
||||
python3 scripts/linkedin_policy_gate.py --text "<the user's own words>" --output human
|
||||
```
|
||||
|
||||
- **exit 4 REFUSE** — a named rule is broken (automation, scraping, pods, bulk DMs, fake
|
||||
identity, fabricated proof, a prohibited automation platform). Do not draft it: name the
|
||||
rule, offer the substitute the gate prints, and route there instead. A REFUSE outranks any
|
||||
route below.
|
||||
- **exit 3 CONSTRAIN** — proceed, and state the constraint out loud in your reply.
|
||||
- **exit 0 ALLOW** — proceed.
|
||||
|
||||
## Step 2 — Route
|
||||
|
||||
```bash
|
||||
python3 scripts/linkedin_goal_router.py --text "<the goal>" --output human
|
||||
```
|
||||
|
||||
Exit 0 routes — invoke that lane, surfacing any `prerequisite` as a question first.
|
||||
Exit 2 is ambiguous — ask **one** question naming both candidates, with a recommendation.
|
||||
Exit 3 has no signal — ask what they want to walk away with, rather than guessing.
|
||||
|
||||
| Lane | Typical ask |
|
||||
|---|---|
|
||||
| `linkedin-profile` | "fix my headline", "my profile gets views but nothing happens" |
|
||||
| `linkedin-strategy` | "what should I post about", "how often", "should I start a newsletter" |
|
||||
| `linkedin-content` | "write this post", "turn my talk into posts", "is this hook any good" |
|
||||
| `linkedin-engagement` | "who should I comment on", "write a connection note" |
|
||||
| `linkedin-analytics` | "why did reach drop", "what's working", "should I test this" |
|
||||
|
||||
## Step 3 — Forcing questions
|
||||
|
||||
One at a time, each with a recommended answer. Stop as soon as the lane can run honestly.
|
||||
`/cs:grill-linkedin` walks all five with the recommendation and the canon behind each.
|
||||
|
||||
1. What has to be true in 90 days for this to have been worth it? *(Not a follower count.)*
|
||||
2. Who is this for, specifically enough that someone is excluded?
|
||||
3. How many minutes a week will you protect — measured from a bad week?
|
||||
4. What proof already exists? *(If none, the first pillar is process, not results.)*
|
||||
5. What will you not post about?
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Nothing is automated and nothing is sent.** No credentials, no API, no scraping.
|
||||
- **Refuse, then substitute.** Every REFUSE names the compliant alternative.
|
||||
- **The account holder is the author of record.** They read every line before it ships.
|
||||
- **No claim they cannot substantiate.** A real number, a bounded range, or nothing.
|
||||
- **Never silently chain lanes.** Offer the next one as a question.
|
||||
- **Cite the confidence level.** LinkedIn-official is 🟢, third-party studies are 🟡,
|
||||
folklore is 🔴 and gets named as folklore.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Role |
|
||||
|---|---|
|
||||
| [`scripts/linkedin_policy_gate.py`](scripts/linkedin_policy_gate.py) | ALLOW / CONSTRAIN / REFUSE against User Agreement §8.2 + Community Policies. 7 refusal rules, each with a substitute. |
|
||||
| [`scripts/linkedin_goal_router.py`](scripts/linkedin_goal_router.py) | Deterministic five-lane classifier: route (0) / ask (2) / no-signal (3), with cross-lane prerequisites. |
|
||||
|
||||
## References and assets
|
||||
|
||||
- [`references/linkedin_platform_canon.md`](references/linkedin_platform_canon.md) — how the feed ranks, evidence-graded (7 sources)
|
||||
- [`references/policy_and_account_safety.md`](references/policy_and_account_safety.md) — §8.2, prohibited software, restriction triggers (7 sources)
|
||||
- [`assets/linkedin_operating_agreement.md`](assets/linkedin_operating_agreement.md) — the standing rules in one page, agreed before work starts
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`marketing-skill/x-twitter-growth`** — X/Twitter mechanics. Different platform and rules.
|
||||
- **`marketing-skill/social-content` / `social-media-manager`** — multi-platform brand
|
||||
calendars. This is one person's presence on one platform, in depth.
|
||||
- **`marketing-skill/social-media-analyzer`** — cross-platform campaign reporting.
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0 · 5 lanes · 17 stdlib scripts · nothing auto-sent
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# LinkedIn Operating Agreement
|
||||
|
||||
One page. Read it before the first post; it decides what this work can and cannot be.
|
||||
|
||||
---
|
||||
|
||||
## What this plugin will do
|
||||
|
||||
- Audit and rewrite your profile, headline, and About section.
|
||||
- Build a positioning brief, content pillars, a cadence you can hold, and a newsletter plan.
|
||||
- Draft, lint, and repurpose posts in any native format.
|
||||
- Build a commenting roster and write outreach messages one person at a time.
|
||||
- Analyse your own exported post data and refuse to over-conclude from it.
|
||||
|
||||
## What it will never do
|
||||
|
||||
- **Log into LinkedIn.** It holds no credentials and makes no API calls.
|
||||
- **Send anything.** No posts, invitations, messages, comments, or likes. Every output is
|
||||
text you paste yourself.
|
||||
- **Scrape.** No profile data, no post data, no email finding, no list building.
|
||||
- **Automate activity.** No scheduling tools that act as you, no browser extensions, no
|
||||
named third-party automation platforms.
|
||||
- **Join or simulate an engagement pod.**
|
||||
- **Invent a number, a client, a result, a credential, or a quote.**
|
||||
|
||||
Those are not preferences. Automated activity, scraping, and inauthentic engagement are
|
||||
prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding
|
||||
asset that took months to build.
|
||||
|
||||
---
|
||||
|
||||
## What you are agreeing to
|
||||
|
||||
**1. You are the author of record.**
|
||||
Your name is on it. You read every line before it publishes, and you cut anything you would
|
||||
not say out loud in a room of peers. Drafting help is normal; unread output is how people
|
||||
end up publishing something untrue about their own work.
|
||||
|
||||
**2. Every claim is one you can substantiate.**
|
||||
A real number, a bounded range, or a qualitative statement. If the proof does not exist yet,
|
||||
the post is about the process — which is a legitimate post and it ages better than the
|
||||
alternative.
|
||||
|
||||
**3. You check what you are allowed to say.**
|
||||
Employment agreement, client NDA, and any sector rules (financial promotion, medical claims,
|
||||
securities disclosure) sit outside LinkedIn's policies and bind you anyway. Named third
|
||||
parties should consent.
|
||||
|
||||
**4. You will hold a cadence you can actually pay for.**
|
||||
Priced from a bad week, not a good one. A cadence abandoned in week five is worse than one
|
||||
never started, because the abandonment is visible on your profile.
|
||||
|
||||
**5. You measure the outcome, not the applause.**
|
||||
The 90-day criteria are things another person could verify: conversations you did not start,
|
||||
specific references, invitations. Follower count is not on the list.
|
||||
|
||||
---
|
||||
|
||||
## What you should expect
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **First visible change** | Profile fixes, same day |
|
||||
| **First reliable signal** | 6-8 weeks of consistent posting and commenting |
|
||||
| **Enough data to analyse** | 10+ posts, and even then descriptively |
|
||||
| **Enough data to test a hypothesis** | 20-60 posts, often more than a quarter allows |
|
||||
| **A pattern you can act on** | Usually later than you want, and the honest answer in the meantime is "not yet" |
|
||||
|
||||
This is slower than the shortcuts promise. The shortcuts put the account at risk to save a
|
||||
few months, and that is bad arithmetic.
|
||||
|
||||
---
|
||||
|
||||
## Signature
|
||||
|
||||
- Objective (one of six): ______________________
|
||||
- Audience (specific enough to exclude someone): ______________________
|
||||
- Minutes per week I will protect: ______
|
||||
- Two things I will not post about: ______________________
|
||||
- Date: ______________________
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# LinkedIn Platform Canon — what is known, what is inferred, what is folklore
|
||||
|
||||
Every quantitative claim below carries a confidence level. This matters more on
|
||||
LinkedIn than on most platforms: the advice ecosystem is dominated by people
|
||||
selling LinkedIn advice, almost none of the numbers come from LinkedIn, and the
|
||||
platform changes the surfaces those numbers describe without announcing it.
|
||||
|
||||
**Confidence key**
|
||||
- 🟢 **High** — stated by LinkedIn in official documentation, engineering
|
||||
publications, or peer-reviewed papers authored by LinkedIn engineers.
|
||||
- 🟡 **Medium** — large-N third-party studies of public posts. Directionally
|
||||
useful, methodologically opaque, and the sample is always public posts from
|
||||
accounts that opted into being measured.
|
||||
- 🔴 **Low / folklore** — repeated widely, sourced nowhere. Listed so you can
|
||||
recognise it, not so you can use it.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the feed actually ranks — 🟢
|
||||
|
||||
LinkedIn's feed is a multi-stage retrieval-and-ranking system, not a chronological
|
||||
timeline and not a single "algorithm". The public record is unusually good here
|
||||
because LinkedIn's engineering organisation publishes:
|
||||
|
||||
- **LiRank** (Borisyuk et al., KDD 2024, arXiv:2402.06859) describes the
|
||||
production ranking stack for Feed, Ads CTR, and Job recommendations —
|
||||
Residual DCN architecture, multi-task objectives, and isotonic calibration.
|
||||
The relevant fact for a creator is structural: **the model optimises several
|
||||
objectives at once**, and no single engagement action is "the" ranking signal.
|
||||
- **Dwell time** is an explicit feed-ranking objective. LinkedIn's engineering
|
||||
blog documents two measures — dwell "on the feed" (starting when at least half
|
||||
of an update is visible during a scroll) and dwell "after the click" — and
|
||||
operationalises them as a *Long Dwell* classifier predicting whether a member's
|
||||
dwell will exceed a context-dependent threshold.
|
||||
|
||||
**What follows for a creator, and what does not.**
|
||||
|
||||
Follows: a post that holds attention is being measured on that, so a post worth
|
||||
reading all the way through is not merely a nice-to-have. Text that rewards the
|
||||
expand click is doing something the ranker can see.
|
||||
|
||||
Does *not* follow: that you should pad posts to increase dwell. Dwell is measured
|
||||
against a *context-dependent percentile*, not an absolute seconds count, and
|
||||
padding also depresses the completion and interaction signals that sit alongside
|
||||
it in a multi-task model. "Add fluff to raise dwell time" is 🔴 folklore built on
|
||||
a 🟢 fact.
|
||||
|
||||
## 2. Comments are the expensive signal — 🟡 (mechanism 🟢)
|
||||
|
||||
A reaction costs a reader one tap. A substantive comment costs them thirty
|
||||
seconds and a small reputational exposure in front of their own network. Third-
|
||||
party studies consistently find comment-heavy posts travel further, and the
|
||||
mechanism is consistent with a multi-objective ranker that also predicts
|
||||
downstream sessions: a comment creates a notification, a return visit, and a
|
||||
thread other people can enter.
|
||||
|
||||
The practical version: **write posts that give a competent reader something to
|
||||
add.** A post that is complete, correct, and closed invites agreement, and
|
||||
agreement is the cheapest and least valuable response you can earn.
|
||||
|
||||
## 3. Reach is falling and the base has shifted — 🟡
|
||||
|
||||
Richard van der Blom's annual *Algorithm Insights* report (Just Connecting) is
|
||||
the most-cited third-party longitudinal study, drawing on over a million public
|
||||
posts. Recent editions report substantial year-over-year declines in organic
|
||||
views, engagement, and follower growth, and an average post reaching roughly
|
||||
8-12% of a creator's followers.
|
||||
|
||||
Treat the *direction* as reliable and the *specific percentages* as indicative.
|
||||
The sample is public posts, the methodology is not independently auditable, and
|
||||
"reach" is measured through the same impressions counter whose definition
|
||||
LinkedIn has revised more than once.
|
||||
|
||||
What the trend means practically: **follower count is a worse proxy for
|
||||
distribution every year.** A focused audience of a few thousand people who
|
||||
actually work in your field will out-perform a large unfocused one, because
|
||||
relevance-based retrieval has to decide *who* to show a post to, and an unfocused
|
||||
follower graph gives it nothing to work with.
|
||||
|
||||
## 4. External links in the post body — 🟡, contested
|
||||
|
||||
Third-party analysis (van der Blom, 2026 edition, ~1.3M posts) reports a body
|
||||
link reducing median reach by roughly 19%, and much larger suppression for links
|
||||
placed in comments in some measurements. **LinkedIn has never confirmed a link
|
||||
penalty**, and a plausible non-punitive explanation exists: a link that takes a
|
||||
reader off-platform truncates dwell, and dwell is a ranking objective.
|
||||
|
||||
The practical guidance is the same under either explanation, which is why it is
|
||||
safe to follow: put the link in the first comment, say "link in the comments" in
|
||||
the post, and keep it in the body only when the click *is* the goal and you
|
||||
accept the reach cost.
|
||||
|
||||
## 5. The first 60-90 minutes — 🟡
|
||||
|
||||
Early engagement correlates strongly with eventual reach in every third-party
|
||||
dataset. The causal story is unproven and probably bidirectional (good posts get
|
||||
early engagement *and* early engagement helps distribution). Either way the
|
||||
behavioural implication holds: **be available to reply for an hour after you
|
||||
post.** Replying is also the cheapest way to add comments to your own post
|
||||
honestly.
|
||||
|
||||
🔴 The "golden hour" as a precise, engineered window with a hard cutoff is
|
||||
folklore. Nobody outside LinkedIn knows the decay function.
|
||||
|
||||
## 6. Hashtags — 🟡, declining relevance
|
||||
|
||||
LinkedIn has deprecated hashtag-following surfaces over time and the platform has
|
||||
moved toward semantic retrieval. Two or three topical hashtags remain a cheap,
|
||||
harmless topic signal. Ten hashtags signal reach-chasing to human readers, which
|
||||
is the cost that actually matters now.
|
||||
|
||||
## 7. What LinkedIn does *not* publish — worth knowing
|
||||
|
||||
- The exact weekly invitation limit (widely observed around 100, adjusted per
|
||||
account).
|
||||
- The exact character position where "…see more" truncates, per surface.
|
||||
- The full newsletter eligibility criteria (the >150 followers/connections
|
||||
threshold is published; "a set of criteria, all of which must be met" is not).
|
||||
- Any per-signal ranking weight.
|
||||
|
||||
Anyone quoting a precise figure for these is quoting an observation, not a
|
||||
specification. Cite it that way.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Borisyuk, F. et al. **"LiRank: Industrial Large Scale Ranking Models at
|
||||
LinkedIn."** KDD 2024. arXiv:2402.06859. — production ranking architecture,
|
||||
multi-task objectives.
|
||||
2. LinkedIn Engineering Blog. **"Understanding feed dwell time to improve
|
||||
LinkedIn feed ranking."** linkedin.com/blog/engineering/feed/understanding-feed-dwell-time
|
||||
— the two dwell measures and the Long Dwell classifier.
|
||||
3. LinkedIn. **User Agreement**, §8.2 "Don'ts."
|
||||
linkedin.com/legal/user-agreement — the binding rules on automation, scraping,
|
||||
and inauthentic engagement.
|
||||
4. LinkedIn. **Professional Community Policies.**
|
||||
linkedin.com/legal/professional-community-policies — authenticity, spam, and
|
||||
engagement-bait rules.
|
||||
5. van der Blom, R. **Algorithm Insights** (annual, Just Connecting) — the
|
||||
longest-running third-party longitudinal study of LinkedIn organic
|
||||
performance. 🟡 sample is public posts; methodology not independently audited.
|
||||
6. LinkedIn Help. **"LinkedIn Newsletter access criteria."** — the >150
|
||||
followers/connections threshold and the existence of unpublished criteria.
|
||||
7. Nielsen Norman Group. **"How People Read Online"** and the F-shaped reading
|
||||
pattern research — why the visible first lines carry the decision, on any
|
||||
platform with a truncated preview.
|
||||
|
||||
---
|
||||
|
||||
**Rule of thumb for anything not on this page:** if a claim about LinkedIn comes
|
||||
with a precise percentage and no named study, it is 🔴. Say so out loud rather
|
||||
than repeating it, and give the user the version of the advice that holds
|
||||
regardless of whether the number is true.
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
# Policy and Account Safety — the rules that end the project if you break them
|
||||
|
||||
Organic LinkedIn growth is a compounding asset with a single point of failure:
|
||||
the account. A restriction resets it to zero and there is no appeals process you
|
||||
can schedule around. This is why this plugin gates every lane through
|
||||
`linkedin_policy_gate.py` before drafting anything, and why it has no LinkedIn
|
||||
credentials at all.
|
||||
|
||||
None of this is legal advice. It is a summary of published rules, written so an
|
||||
agent can refuse the right things for the right reasons.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the User Agreement actually prohibits (§8.2 "Don'ts")
|
||||
|
||||
LinkedIn's User Agreement prohibits members from, among other things:
|
||||
|
||||
- **Developing, supporting, or using software, devices, scripts, robots, or any
|
||||
other means or processes** — including crawlers, browser plugins and add-ons —
|
||||
**to scrape the Services or otherwise copy profiles and other data.**
|
||||
- **Using bots or other automated methods** to access the Services, add or
|
||||
download contacts, send or redirect messages, or **create, comment on, like,
|
||||
share, or re-share posts, or otherwise drive inauthentic engagement.**
|
||||
- Creating a false identity, misrepresenting your identity, or using someone
|
||||
else's account.
|
||||
- Posting inaccurate information, or content that violates the Professional
|
||||
Community Policies.
|
||||
|
||||
Read the second bullet carefully: **it names commenting, liking, and sharing.**
|
||||
The prohibition is not limited to sending messages. An engagement pod that
|
||||
coordinates real humans to reciprocally comment on schedule is squarely inside
|
||||
"otherwise drive inauthentic engagement", and the fact that a human pressed the
|
||||
key does not take it outside the rule.
|
||||
|
||||
## 2. Third-party tools
|
||||
|
||||
LinkedIn maintains a Help article on **Prohibited Software and Extensions**
|
||||
stating that it does not permit third-party software — crawlers, bots, browser
|
||||
plug-ins, or extensions — that scrapes, modifies the appearance of, or automates
|
||||
activity on LinkedIn. It notes two consequences: accounts may be restricted or
|
||||
closed, and the tools themselves may stop working without notice.
|
||||
|
||||
The named-tool list in `linkedin_policy_gate.py` (Dux-Soup, PhantomBuster,
|
||||
Expandi, Linked Helper, Meet Alfred, Waalaxy, Octopus CRM, Lempod, and others) is
|
||||
not an official LinkedIn blacklist — it is a list of tools whose advertised
|
||||
function is exactly what the policy prohibits. The gate refuses on the function,
|
||||
and names the tool only because that is how people describe what they want.
|
||||
|
||||
**The supported path exists:** LinkedIn's own scheduler for posts, and LinkedIn's
|
||||
Marketing Developer Platform for partners with API access. If a workflow can be
|
||||
done through those, it is fine.
|
||||
|
||||
## 3. What actually triggers a restriction
|
||||
|
||||
LinkedIn does not publish its enforcement thresholds. Observable patterns from
|
||||
LinkedIn's own Help documentation on account restrictions and from widespread
|
||||
reporting:
|
||||
|
||||
| Trigger | Why it fires |
|
||||
|---|---|
|
||||
| High invitation volume with low acceptance | The signature of untargeted bulk invites. Acceptance rate is the discriminator, not volume alone. |
|
||||
| Many "I don't know this person" / spam reports | Recipient-side signal, and the most damaging one. |
|
||||
| Machine-regular activity patterns | Constant per-hour rates, activity at 03:00 local, identical intervals. |
|
||||
| Identical message or comment text at volume | Directly matches the "inauthentic engagement" language. |
|
||||
| Detected automation extension | Automated detection of the prohibited-software class. |
|
||||
| Profile data inconsistent with a real person | Stock photo, no history, sudden high-volume activity. |
|
||||
|
||||
The practical read: **restrictions correlate with looking automated more than
|
||||
with volume itself.** A person sending 20 genuinely personal invitations a week
|
||||
for a year is invisible. A person sending 200 identical ones in a day is not.
|
||||
|
||||
## 4. Rate limits worth knowing (🟡 observed, not published)
|
||||
|
||||
- **Connection invitations:** a weekly limit widely observed around 100, adjusted
|
||||
per account. **Pending invitations count against it**, so a backlog of
|
||||
un-actioned invites silently shrinks the allowance.
|
||||
- **Withdrawn invitations** cannot be re-sent to the same person for roughly
|
||||
three weeks.
|
||||
- **Messaging:** no published cap for first-degree connections; InMail credits
|
||||
are metered by subscription.
|
||||
|
||||
`outreach_volume_guard.py` uses conservative working numbers and says explicitly
|
||||
that they are observations.
|
||||
|
||||
## 5. Content rules that cost reach rather than the account
|
||||
|
||||
The Professional Community Policies commit LinkedIn to reducing distribution of
|
||||
several content classes. Relevant to organic creators:
|
||||
|
||||
- **Engagement bait** — "comment X for the guide", "like if you agree", "tag
|
||||
three people". Explicitly named.
|
||||
- **Spam and unsolicited commercial content**, including repetitive posting.
|
||||
- **Misleading or false content**, which for a professional audience includes
|
||||
fabricated metrics and invented case studies.
|
||||
|
||||
These do not usually restrict an account. They make the work not work, which for
|
||||
a compounding organic strategy is nearly as bad.
|
||||
|
||||
## 6. Things this plugin refuses even where LinkedIn permits them
|
||||
|
||||
Three refusals are editorial rather than legal, and they are held anyway:
|
||||
|
||||
1. **Fabricated proof.** Inventing a metric, a client, or a testimonial. Legal
|
||||
exposure aside (FTC endorsement rules apply to testimonials in many
|
||||
jurisdictions), a professional audience contains people who can check.
|
||||
2. **Ghostwriting an executive's account without their knowledge.** Ghostwriting
|
||||
with the account holder's review is normal and fine; the account holder is the
|
||||
author of record either way, which means they have to have read it.
|
||||
3. **Posting confidential or identifying detail about employers, clients, or
|
||||
individuals without consent.** Employment agreements, NDAs, and sector rules
|
||||
(financial promotion, medical claims, securities disclosure) sit outside
|
||||
LinkedIn's policies entirely and bind you anyway.
|
||||
|
||||
## 7. Regional obligations the plugin surfaces but cannot resolve
|
||||
|
||||
- **GDPR / UK GDPR** — sending unsolicited B2B messages, and any processing of
|
||||
contact data outside LinkedIn, has a legal basis question attached. Scraping
|
||||
member data to build a list has a much harder one.
|
||||
- **EU Digital Services Act** and comparable regimes — platform transparency and
|
||||
reporting obligations that affect what recourse you have, not what you may do.
|
||||
- **FTC Endorsement Guides** (US) and equivalents — disclosure obligations for
|
||||
paid or incentivised endorsements, including on personal profiles.
|
||||
|
||||
Where any of these are in play, the answer is a named human — legal, compliance,
|
||||
or the client — not a tool.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. LinkedIn. **User Agreement**, §8.2 "Don'ts."
|
||||
linkedin.com/legal/user-agreement
|
||||
2. LinkedIn Help. **"Prohibited software and extensions."**
|
||||
linkedin.com/help/linkedin/answer/a1341387
|
||||
3. LinkedIn Help. **"Automated activity on LinkedIn."**
|
||||
linkedin.com/help/linkedin/answer/a1340567
|
||||
4. LinkedIn Help. **"Account restrictions."**
|
||||
linkedin.com/help/linkedin/answer/a1340522
|
||||
5. LinkedIn. **Professional Community Policies.**
|
||||
linkedin.com/legal/professional-community-policies — authenticity, spam,
|
||||
engagement bait, misinformation.
|
||||
6. LinkedIn. **Marketing Developer Platform** documentation — the supported
|
||||
programmatic path, and the scope it is actually granted for.
|
||||
7. US Federal Trade Commission. **Guides Concerning the Use of Endorsements and
|
||||
Testimonials in Advertising** (16 CFR Part 255) — why an invented testimonial
|
||||
is a legal problem and not only an editorial one.
|
||||
|
||||
---
|
||||
|
||||
**The one-line version:** you can build a serious LinkedIn presence entirely
|
||||
inside the rules, it takes longer than the shortcuts promise, and the shortcuts
|
||||
put the compounding asset at risk to save a few months. That trade is bad
|
||||
arithmetic, and it is the reason this plugin refuses rather than warns.
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
#!/usr/bin/env python3
|
||||
"""linkedin_goal_router.py — deterministic lane classifier for the linkedin domain.
|
||||
|
||||
Scores a LinkedIn goal against the five sub-skill lanes using the same two-signal
|
||||
threshold discipline as the commercial / research-ops / product-team orchestrators.
|
||||
Emits a routing decision an agent can branch on mechanically instead of guessing.
|
||||
|
||||
Exit codes:
|
||||
0 confident route emitted (route_to set)
|
||||
2 ambiguous — ask ONE clarifying question naming the top two lanes
|
||||
3 no signal — do not guess; ask the user to restate the goal with a deliverable
|
||||
|
||||
Stdlib only. Deterministic: same text in, same route out.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
SIGNALS = {
|
||||
"PROFILE": {
|
||||
"skill": "linkedin-profile",
|
||||
"path": "marketing/linkedin/skills/linkedin-profile",
|
||||
"keywords": [
|
||||
"profile", "headline", "about section", "about me", "summary section",
|
||||
"banner", "featured section", "experience section", "bio", "tagline",
|
||||
"profile photo", "skills section", "recommendations", "open to work",
|
||||
"creator mode", "custom url", "profile audit", "optimize my profile",
|
||||
],
|
||||
},
|
||||
"STRATEGY": {
|
||||
"skill": "linkedin-strategy",
|
||||
"path": "marketing/linkedin/skills/linkedin-strategy",
|
||||
"keywords": [
|
||||
"strategy", "content pillars", "pillar", "positioning", "cadence",
|
||||
"posting schedule", "how often should i post", "calendar", "editorial calendar",
|
||||
"newsletter", "thought leadership", "career change", "career transition",
|
||||
"personal brand", "audience", "niche", "90 day", "quarter", "roadmap",
|
||||
"goals", "objective", "grow my following", "grow an audience",
|
||||
],
|
||||
},
|
||||
"CONTENT": {
|
||||
"skill": "linkedin-content",
|
||||
"path": "marketing/linkedin/skills/linkedin-content",
|
||||
"keywords": [
|
||||
"write a post", "draft a post", "post idea", "post ideas", "hook",
|
||||
"carousel", "document post", "pdf post", "story post", "how-to post",
|
||||
"opinion post", "listicle", "caption", "video script", "poll",
|
||||
"article", "repurpose", "turn this into", "rewrite this post",
|
||||
"edit my post", "review my post", "first line", "content", "copy",
|
||||
],
|
||||
},
|
||||
"ENGAGEMENT": {
|
||||
"skill": "linkedin-engagement",
|
||||
"path": "marketing/linkedin/skills/linkedin-engagement",
|
||||
"keywords": [
|
||||
"comment", "commenting", "reply", "replies", "dm", "message",
|
||||
"connection request", "connection note", "invite", "inmail", "outreach",
|
||||
"networking", "cold message", "follow up", "who should i engage",
|
||||
"engagement strategy", "groups", "community", "reach out", "warm intro",
|
||||
],
|
||||
},
|
||||
"ANALYTICS": {
|
||||
"skill": "linkedin-analytics",
|
||||
"path": "marketing/linkedin/skills/linkedin-analytics",
|
||||
"keywords": [
|
||||
"analytics", "analyze my posts", "which posts", "performance",
|
||||
"impressions", "engagement rate", "reach dropped", "what's working",
|
||||
"what is working", "export", "benchmark", "pattern", "why did this post",
|
||||
"top posts", "experiment", "test", "measure", "metrics", "dashboard",
|
||||
"followers gained", "profile views",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Cross-lane dependencies used when the router says ASK or when a lane is chosen:
|
||||
# these are stated as prerequisites, not silently chained.
|
||||
PREREQS = {
|
||||
"CONTENT": ("STRATEGY", "Posts without pillars are noise. If no positioning brief exists, "
|
||||
"offer linkedin-strategy first — but never chain silently."),
|
||||
"ANALYTICS": ("CONTENT", "Pattern mining needs a body of posts. Under ~20 posts, the honest "
|
||||
"answer is 'not enough data yet' — say so rather than fitting noise."),
|
||||
"ENGAGEMENT": ("PROFILE", "Comments and DMs drive profile visits. A weak headline wastes "
|
||||
"every visit engagement earns."),
|
||||
}
|
||||
|
||||
SAMPLE_GOAL = ("I'm moving from backend engineering into developer advocacy and want to build "
|
||||
"a real audience over the next two quarters — what should I be posting about, "
|
||||
"and how often?")
|
||||
|
||||
|
||||
def score(text: str) -> dict:
|
||||
low = text.lower()
|
||||
scores, hits = {}, {}
|
||||
for lane, spec in SIGNALS.items():
|
||||
matched = [kw for kw in spec["keywords"] if kw in low]
|
||||
scores[lane] = len(matched)
|
||||
hits[lane] = matched
|
||||
return {"scores": scores, "hits": hits}
|
||||
|
||||
|
||||
def decide(scores: dict) -> dict:
|
||||
ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
(top_lane, top), (second_lane, second) = ranked[0], ranked[1]
|
||||
if top == 0:
|
||||
return {"decision": "NO_SIGNAL", "exit": 3}
|
||||
if top >= 2 and (second == 0 or top >= 2 * second):
|
||||
return {"decision": "ROUTE", "lane": top_lane, "exit": 0}
|
||||
candidates = [top_lane] + ([second_lane] if second > 0 else [])
|
||||
return {"decision": "ASK", "candidates": candidates, "exit": 2}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Deterministic lane router for LinkedIn goals "
|
||||
"(route=0 / ask=2 / no-signal=3).")
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument("--text", help="Goal or inquiry text to classify.")
|
||||
src.add_argument("--input", help="Read goal text from a file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true",
|
||||
help="Classify a built-in sample goal and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
text = SAMPLE_GOAL
|
||||
elif args.text:
|
||||
text = args.text
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
else:
|
||||
ap.error("one of --text, --input, or --sample is required")
|
||||
|
||||
result = score(text)
|
||||
verdict = decide(result["scores"])
|
||||
out = {
|
||||
"goal": text.strip()[:300],
|
||||
"scores": {k: v for k, v in result["scores"].items() if v},
|
||||
"decision": verdict["decision"],
|
||||
"policy_gate": ("Run linkedin_policy_gate.py on the same text before drafting anything. "
|
||||
"A REFUSE there outranks any route here."),
|
||||
}
|
||||
|
||||
if verdict["decision"] == "ROUTE":
|
||||
lane = verdict["lane"]
|
||||
out["route_to"] = SIGNALS[lane]["skill"]
|
||||
out["skill_path"] = SIGNALS[lane]["path"]
|
||||
out["matched_signals"] = result["hits"][lane]
|
||||
if lane in PREREQS:
|
||||
prereq_lane, why = PREREQS[lane]
|
||||
out["prerequisite"] = {
|
||||
"lane": SIGNALS[prereq_lane]["skill"],
|
||||
"why": why,
|
||||
"rule": "Offer it as a question. Never chain silently.",
|
||||
}
|
||||
elif verdict["decision"] == "ASK":
|
||||
out["candidates"] = [
|
||||
{"lane": lane, "skill": SIGNALS[lane]["skill"], "score": result["scores"][lane]}
|
||||
for lane in verdict["candidates"]
|
||||
]
|
||||
out["instruction"] = ("Ask ONE clarifying question naming both candidate lanes, with a "
|
||||
"recommended answer and the reason. Never guess silently.")
|
||||
else:
|
||||
out["instruction"] = ("No lane signal. Ask the user what they want to walk away with — "
|
||||
"a rewritten profile, a posting plan, a drafted post, an outreach "
|
||||
"message, or a read on their numbers. Do not route on fuzz.")
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(out, indent=2))
|
||||
else:
|
||||
print(f"Decision: {out['decision']}")
|
||||
if "route_to" in out:
|
||||
print(f"Route to: {out['route_to']} ({out['skill_path']})")
|
||||
print(f"Signals : {', '.join(out['matched_signals'])}")
|
||||
if "prerequisite" in out:
|
||||
print(f"Prereq : {out['prerequisite']['lane']} — {out['prerequisite']['why']}")
|
||||
elif "candidates" in out:
|
||||
print("Ambiguous: " + " vs ".join(c["skill"] for c in out["candidates"]))
|
||||
print(out["instruction"])
|
||||
else:
|
||||
print(out["instruction"])
|
||||
print(f"\nPolicy : {out['policy_gate']}")
|
||||
return verdict["exit"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
#!/usr/bin/env python3
|
||||
"""linkedin_policy_gate.py — refuse LinkedIn tactics that break the User Agreement.
|
||||
|
||||
Every lane in the `linkedin` plugin passes through this gate before any drafting
|
||||
work happens. It classifies a described tactic against LinkedIn's User Agreement
|
||||
(§8.2 "Don'ts"), the Prohibited Software and Extensions policy, and the
|
||||
Professional Community Policies, then returns ALLOW / CONSTRAIN / REFUSE.
|
||||
|
||||
The point is not legal advice. The point is that an account restriction ends the
|
||||
organic-growth project, so the tactics that risk one are refused up front instead
|
||||
of drafted and regretted. A REFUSE always names the rule and offers the compliant
|
||||
substitute — this gate never just says no.
|
||||
|
||||
Deterministic: same text in, same verdict out. Stdlib only. No network calls,
|
||||
no LinkedIn API access, nothing is ever sent anywhere.
|
||||
|
||||
Exit codes:
|
||||
0 ALLOW — nothing in the request trips a rule; proceed
|
||||
3 CONSTRAIN — allowed, but only under the named constraints (proceed and honor them)
|
||||
4 REFUSE — breaks a named rule; do not draft it, offer the substitute instead
|
||||
|
||||
Usage:
|
||||
python3 linkedin_policy_gate.py --text "auto-connect with 500 recruiters a week"
|
||||
python3 linkedin_policy_gate.py --input plan.md --output human
|
||||
python3 linkedin_policy_gate.py --sample
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refusal rules. Each carries the policy anchor and the compliant substitute,
|
||||
# because a gate that only blocks teaches the user nothing.
|
||||
# ---------------------------------------------------------------------------
|
||||
REFUSE_RULES = [
|
||||
{
|
||||
"id": "P1-AUTOMATION",
|
||||
"title": "Automated activity on LinkedIn",
|
||||
"anchor": "LinkedIn User Agreement §8.2 (bots/automated methods to access the "
|
||||
"Services, add contacts, send messages, create/comment/like/share posts)",
|
||||
"patterns": [
|
||||
r"\bauto[- ]?(connect|invite|dm|message|like|comment|follow|endorse|post|apply)\w*",
|
||||
r"\bautomat(e|ed|ing|ion)\b(?!.{0,40}\bis (prohibited|banned|against)\b)",
|
||||
r"\bbot\b", r"\bbots\b", r"\bheadless browser\b", r"\bselenium\b", r"\bpuppeteer\b",
|
||||
r"\bbrowser (extension|plugin|add-?on)\b.{0,40}\b(linkedin|connect|message)\b",
|
||||
r"\bscript that (logs? in|clicks?|sends?|connects?)\b",
|
||||
],
|
||||
"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.",
|
||||
},
|
||||
{
|
||||
"id": "P2-SCRAPING",
|
||||
"title": "Scraping or bulk-copying member data",
|
||||
"anchor": "LinkedIn User Agreement §8.2 (crawlers/scrapers to copy profiles or other "
|
||||
"data) + Prohibited Software and Extensions policy",
|
||||
"patterns": [
|
||||
r"\bscrap(e|ed|er|ing)\b", r"\bcrawl(er|ing)?\b",
|
||||
r"\b(harvest|extract|mine)\b.{0,30}\b(email|profile|contact|lead|member)s?\b",
|
||||
r"\bexport\b.{0,25}\b(connections?|leads?|profiles?|members?)\b.{0,25}\b(list|database|csv)\b",
|
||||
r"\bemail finder\b", r"\bfind (their|his|her) email\b",
|
||||
r"\bbuild(ing)? a (lead )?(list|database)\b.{0,30}\bfrom linkedin\b",
|
||||
],
|
||||
"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 "
|
||||
"other members' data.",
|
||||
},
|
||||
{
|
||||
"id": "P3-INAUTHENTIC",
|
||||
"title": "Inauthentic engagement (pods, bought signals)",
|
||||
"anchor": "LinkedIn User Agreement §8.2 (drive inauthentic engagement) + Professional "
|
||||
"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)",
|
||||
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",
|
||||
r"\blike[- ]?for[- ]?like\b", r"\bcomment[- ]?for[- ]?comment\b",
|
||||
],
|
||||
"substitute": "Build a real reciprocity list instead: "
|
||||
"`linkedin-engagement/scripts/comment_target_planner.py` picks accounts "
|
||||
"whose audience overlaps yours and budgets genuine daily comments. Slower, "
|
||||
"and it survives an audit.",
|
||||
},
|
||||
{
|
||||
"id": "P4-IDENTITY",
|
||||
"title": "Fake identity, duplicate accounts, impersonation",
|
||||
"anchor": "LinkedIn User Agreement §8.2 (create a false identity, misrepresent your "
|
||||
"identity, use another's account) + Professional Community Policies",
|
||||
"patterns": [
|
||||
r"\bfake (profile|account|persona|identity)\b",
|
||||
r"\b(second|burner|dummy|alt|multiple) (linkedin )?accounts?\b",
|
||||
r"\bimpersonat(e|ing|ion)\b",
|
||||
r"\bpretend(ing)? to be\b", r"\bpose as\b",
|
||||
r"\bghost(write|writing)\b.{0,30}\bwithout\b.{0,20}\b(disclos|know)\w*",
|
||||
r"\bpost as (?:my|the) (ceo|founder|boss|client)\b.{0,40}\bwithout\b",
|
||||
],
|
||||
"substitute": "One real profile, your real name. Ghostwriting for an executive is fine "
|
||||
"when that executive knows and approves every post — the account holder is "
|
||||
"the author of record.",
|
||||
},
|
||||
{
|
||||
"id": "P5-BULK-MESSAGING",
|
||||
"title": "Bulk or unsolicited mass messaging",
|
||||
"anchor": "LinkedIn User Agreement §8.2 (send or redirect messages by automated means; "
|
||||
"spam) + Professional Community Policies (no spam/unsolicited commercial content)",
|
||||
"patterns": [
|
||||
r"\b(mass|bulk|blast|spray)\b.{0,20}\b(dm|message|inmail|invite|connection)s?\b",
|
||||
r"\b(dm|message|inmail)\b.{0,20}\b(everyone|all my connections|the whole list|1000|500)\b",
|
||||
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",
|
||||
],
|
||||
"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.",
|
||||
},
|
||||
{
|
||||
"id": "P6-FABRICATION",
|
||||
"title": "Fabricated credentials, metrics, or social proof",
|
||||
"anchor": "LinkedIn User Agreement §8.2 (post inaccurate information) + Professional "
|
||||
"Community Policies (no false or misleading content); FTC endorsement rules "
|
||||
"apply to testimonials",
|
||||
"patterns": [
|
||||
r"\b(make up|invent|fabricate|fake)\b.{0,30}\b(metric|number|result|case study|"
|
||||
r"testimonial|client|revenue|stat|credential|degree|certification)s?\b",
|
||||
r"\b(inflate|exaggerate)\b.{0,25}\b(number|revenue|result|headcount|arr|mrr)s?\b",
|
||||
r"\bpretend (i|we) (have|had|built|grew|raised)\b",
|
||||
r"\bsay (i|we) (have|had) \d+.{0,20}\b(clients?|customers?|users?)\b.{0,25}\bwe don'?t\b",
|
||||
],
|
||||
"substitute": "Use a real number, a bounded range, or a qualitative claim. "
|
||||
"If the proof does not exist yet, the post is about the process, not the "
|
||||
"result — that is a legitimate post and it ages well.",
|
||||
},
|
||||
{
|
||||
"id": "P7-PROHIBITED-TOOLS",
|
||||
"title": "Named third-party automation tools",
|
||||
"anchor": "LinkedIn Help — Prohibited Software and Extensions (third-party software that "
|
||||
"scrapes, modifies, or automates activity on LinkedIn is not permitted)",
|
||||
"patterns": [
|
||||
r"\b(dux[- ]?soup|phantom ?buster|expandi|linked ?helper|meet ?alfred|waalaxy|"
|
||||
r"octopus ?crm|lempod|zopto|we[- ]?connect|prospectin|salesflow|closely|"
|
||||
r"linkedin ?helper|texau|captain ?data)\b",
|
||||
],
|
||||
"substitute": "Native LinkedIn scheduling and LinkedIn's own Marketing Developer "
|
||||
"Platform partners are the supported path. This plugin never logs into "
|
||||
"your account at all — it hands you text.",
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constraint rules. Legitimate tactics that go wrong at volume or without a
|
||||
# disclosure. These proceed, but the constraint is printed and must be honored.
|
||||
# ---------------------------------------------------------------------------
|
||||
CONSTRAIN_RULES = [
|
||||
{
|
||||
"id": "C1-OUTREACH-VOLUME",
|
||||
"title": "Manual outreach at campaign scale",
|
||||
"patterns": [
|
||||
r"\b(outreach|connection request|invite|cold dm|cold message|inmail)\b",
|
||||
r"\bnetworking (campaign|push|sprint)\b",
|
||||
],
|
||||
"constraint": "Manual send only, one person at a time, every message carrying a line "
|
||||
"that could only have been written for that person. Run "
|
||||
"`outreach_volume_guard.py` before sending — LinkedIn enforces an "
|
||||
"invitation limit (roughly 100/week for most accounts) and withdrawn "
|
||||
"invites still count against it.",
|
||||
},
|
||||
{
|
||||
"id": "C2-ENGAGEMENT-BAIT",
|
||||
"title": "Engagement bait",
|
||||
"patterns": [
|
||||
r"\bcomment ['\"][\w ]{1,24}['\"]\s*(below|and i'?ll|to get|for the|if you)\b",
|
||||
r"\bcomment ['\"]?\w{1,14}['\"]? below\b",
|
||||
r"\band i'?ll (dm|send) (you )?(the|it|a|my)\b",
|
||||
r"\b(like|repost|share) (this )?if you\b",
|
||||
r"\bagree\?\s*$", r"\btag (someone|3|three|a friend)\b",
|
||||
r"\bdm me ['\"]\w+['\"]\b",
|
||||
],
|
||||
"constraint": "LinkedIn's Professional Community Policies name engagement bait as "
|
||||
"content it demotes. Ask a real question the post has earned instead — "
|
||||
"`post_linter.py` flags bait patterns as blocking findings.",
|
||||
},
|
||||
{
|
||||
"id": "C4-EMPLOYER-CONTEXT",
|
||||
"title": "Posting about an employer, client, or regulated topic",
|
||||
"patterns": [
|
||||
r"\b(my employer|my company|our client|my client|internal|confidential|"
|
||||
r"under nda|customer data|patient|clinical|financial advice|investment advice)\b",
|
||||
r"\b(layoff|acquisition|funding round|earnings)\b",
|
||||
],
|
||||
"constraint": "Check the employment agreement, the client NDA, and any sector rules "
|
||||
"(financial promotion, medical claims, securities disclosure) before "
|
||||
"posting. Named third parties should consent. When unsure, describe the "
|
||||
"pattern without the identifying detail.",
|
||||
},
|
||||
]
|
||||
|
||||
# Always returned, on every verdict. These are not triggered by wording — they
|
||||
# hold for every piece of LinkedIn work this plugin produces.
|
||||
STANDING_CONSTRAINTS = [
|
||||
"Author of record: the account holder publishes it, so the account holder reads "
|
||||
"every line. Cut anything you would not say out loud in a room of peers.",
|
||||
"Nothing is auto-sent. This plugin has no LinkedIn credentials and makes no API "
|
||||
"calls; every output is text you paste and post yourself.",
|
||||
"No claim you cannot substantiate. A real number, a bounded range, or a "
|
||||
"qualitative statement — never a placeholder metric that ships.",
|
||||
]
|
||||
|
||||
SAMPLE_TEXT = ("I want to grow to 20k followers in six months. Plan: use Dux-Soup to "
|
||||
"auto-connect with 500 recruiters a week, join a comment pod for the first "
|
||||
"90 minutes, and blast the same DM to everyone who accepts.")
|
||||
|
||||
|
||||
def _scan(text: str, rules: list, key: str) -> list:
|
||||
low = text.lower()
|
||||
hits = []
|
||||
for rule in rules:
|
||||
matched = []
|
||||
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:
|
||||
matched.append(snippet)
|
||||
if matched:
|
||||
hits.append({
|
||||
"id": rule["id"],
|
||||
"title": rule["title"],
|
||||
"matched": matched[:5],
|
||||
"anchor": rule.get("anchor", ""),
|
||||
key: rule[key],
|
||||
})
|
||||
return hits
|
||||
|
||||
|
||||
def evaluate(text: str) -> dict:
|
||||
refusals = _scan(text, REFUSE_RULES, "substitute")
|
||||
constraints = _scan(text, CONSTRAIN_RULES, "constraint")
|
||||
if refusals:
|
||||
verdict, code = "REFUSE", 4
|
||||
elif constraints:
|
||||
verdict, code = "CONSTRAIN", 3
|
||||
else:
|
||||
verdict, code = "ALLOW", 0
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"refusals": refusals,
|
||||
"constraints": constraints,
|
||||
"input_preview": text.strip()[:280],
|
||||
"standing_constraints": STANDING_CONSTRAINTS,
|
||||
"disclaimer": ("Deterministic pattern check against LinkedIn's published policies, not "
|
||||
"legal advice and not exhaustive. A clean ALLOW does not certify a plan; "
|
||||
"it means nothing in the text tripped a known rule."),
|
||||
}
|
||||
|
||||
|
||||
def render_human(result: dict) -> str:
|
||||
out = [f"LinkedIn policy gate: {result['verdict']}", "=" * 46]
|
||||
if result["refusals"]:
|
||||
out.append("\nREFUSED — these break a named LinkedIn rule:\n")
|
||||
for r in result["refusals"]:
|
||||
out.append(f" [{r['id']}] {r['title']}")
|
||||
out.append(f" matched : {', '.join(r['matched'])}")
|
||||
out.append(f" rule : {r['anchor']}")
|
||||
out.append(f" instead : {r['substitute']}\n")
|
||||
if result["constraints"]:
|
||||
out.append("\nALLOWED UNDER CONSTRAINT — proceed, but honor these:\n")
|
||||
for c in result["constraints"]:
|
||||
out.append(f" [{c['id']}] {c['title']}")
|
||||
out.append(f" matched : {', '.join(c['matched'])}")
|
||||
out.append(f" honor : {c['constraint']}\n")
|
||||
if result["verdict"] == "ALLOW":
|
||||
out.append("\nNothing in this request trips a known rule. Proceed.\n")
|
||||
out.append("\nStanding constraints (always apply):")
|
||||
for sc in result["standing_constraints"]:
|
||||
out.append(f" - {sc}")
|
||||
out.append("")
|
||||
out.append(result["disclaimer"])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Classify a LinkedIn tactic against the User Agreement: "
|
||||
"ALLOW (0) / CONSTRAIN (3) / REFUSE (4).")
|
||||
src = ap.add_mutually_exclusive_group()
|
||||
src.add_argument("--text", help="The tactic, plan, or request to check.")
|
||||
src.add_argument("--input", help="Read the text from a file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true",
|
||||
help="Run the built-in sample (a plan that trips four rules).")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
text = SAMPLE_TEXT
|
||||
elif args.text:
|
||||
text = args.text
|
||||
elif args.input:
|
||||
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
else:
|
||||
ap.error("one of --text, --input, or --sample is required")
|
||||
|
||||
result = evaluate(text)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
99
marketing/linkedin/skills/linkedin-strategy/SKILL.md
Normal file
99
marketing/linkedin/skills/linkedin-strategy/SKILL.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
name: linkedin-strategy
|
||||
description: Use when someone needs a LinkedIn plan rather than a post — content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on "what should I post about", "how often should I post", "LinkedIn content strategy", "should I start a LinkedIn newsletter", "grow my following", "90-day plan". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: marketing
|
||||
updated: 2026-08-25
|
||||
---
|
||||
|
||||
# LinkedIn Strategy — brief, cadence, newsletter
|
||||
|
||||
Three decisions, in this order. Out of order is why most LinkedIn efforts produce a feed of
|
||||
unrelated observations and stop in week five.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. The brief — an editorial constitution, not a wish.** Walk the five questions one at a
|
||||
time, each with a recommended answer, then validate:
|
||||
|
||||
```bash
|
||||
python3 scripts/positioning_brief.py --input brief.json --output human
|
||||
```
|
||||
|
||||
It refuses on the two things that make everything downstream impossible: an objective that
|
||||
is not one of the six real ones (`career-change`, `consulting`, `thought-leadership`,
|
||||
`hiring`, `fundraising`, `community`), and an audience too broad to exclude anyone. It also
|
||||
refuses fewer than two exclusions — **a positioning that excludes nothing is availability.**
|
||||
|
||||
Pillars: two to four, shares summing to 100, at least one backed by proof that already
|
||||
exists, at least one at 10-20% as the experimental slot. Every pillar needs a "why you"; if
|
||||
anyone could post it, cut it.
|
||||
|
||||
The script emits observable 90-day criteria. Follower count is deliberately absent — it moves
|
||||
for reasons unrelated to whether the objective is being met.
|
||||
|
||||
**2. The cadence — priced against a bad week.**
|
||||
|
||||
```bash
|
||||
python3 scripts/cadence_planner.py --minutes 240 --stage starting --target-posts 3 --output human
|
||||
```
|
||||
|
||||
Exit 0 fits / 2 below the 90-minute floor / 3 over budget with the overage named. Every
|
||||
activity is priced in minutes including the reply window, which is part of the post and not
|
||||
an extra. Allocation shifts with stage: from a standing start **60% of the budget belongs in
|
||||
other people's comment sections**, because a post published to nobody reaches nobody.
|
||||
|
||||
Below 90 minutes a week it refuses to plan a posting schedule and returns a comment-only
|
||||
week: a cadence abandoned in week five is worse than one never started, because the
|
||||
abandonment is visible on the profile. Every plan ships with a minimum viable week.
|
||||
|
||||
**3. The newsletter — only if the promise can be paid.**
|
||||
|
||||
```bash
|
||||
python3 scripts/newsletter_planner.py --followers 1800 --cadence biweekly \
|
||||
--minutes-per-month 420 --pillar "..." --output human
|
||||
```
|
||||
|
||||
Refuses below LinkedIn's published 150-follower evaluation floor, and refuses a cadence
|
||||
whose six-month cost exceeds the budget. Emits a 12-issue arc rotating issue types across
|
||||
pillars, and a stop rule written before issue one.
|
||||
|
||||
## Rules
|
||||
|
||||
- **One objective.** Two objectives serve neither; the audiences overlap less than they look.
|
||||
- **The exclusion list is the positioning.** Refuse to skip it.
|
||||
- **Price the week from a bad week, not a good one.**
|
||||
- **Consistency over volume.** A skipped week is fine; a skipped month resets you.
|
||||
- **Review the brief quarterly, not weekly.** A brief revised monthly is a mood.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Role |
|
||||
|---|---|
|
||||
| [`scripts/positioning_brief.py`](scripts/positioning_brief.py) | Validates objective, audience, 2-4 proof-backed pillars, and the exclusion list; emits observable 90-day criteria. |
|
||||
| [`scripts/cadence_planner.py`](scripts/cadence_planner.py) | Prices the week in minutes, allocates by stage, refuses over-budget targets, emits the minimum viable week. |
|
||||
| [`scripts/newsletter_planner.py`](scripts/newsletter_planner.py) | Eligibility + six-month sustainability gate, 12-issue arc across pillars, stop rule. |
|
||||
|
||||
## References and assets
|
||||
|
||||
- [`references/objective_to_pillars.md`](references/objective_to_pillars.md) — the six objectives and the pillar arithmetic (7 sources)
|
||||
- [`references/cadence_and_consistency.md`](references/cadence_and_consistency.md) — what a post really costs and why the floor exists (7 sources)
|
||||
- [`references/newsletter_playbook.md`](references/newsletter_playbook.md) — eligibility, cadence as a promise, the stop rule (7 sources)
|
||||
|
||||
- [`assets/positioning_brief_template.md`](assets/positioning_brief_template.md) — fillable brief
|
||||
- [`assets/example_brief.json`](assets/example_brief.json) — a passing brief, for the validator
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`linkedin-content`** — drafts and lints individual posts. This decides what they are about
|
||||
and how many there are. Posts without pillars are noise; offer this first, never chain silently.
|
||||
- **`linkedin-analytics`** — tells you what happened. This decides what to do next.
|
||||
- **`marketing-skill/content-strategy`** — company-level content marketing. This is one
|
||||
person's own presence.
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"_comment": "A brief that passes positioning_brief.py at exit 0. Note the audience is specific enough to exclude someone, three of four pillars carry proof that already exists, one pillar sits at 10% as the experimental slot, and there are three reasoned exclusions.",
|
||||
"objective": "consulting",
|
||||
"audience": "heads of data at Series A-B SaaS companies who have three analysts and no analytics engineer, and whose CEO does not trust the dashboard",
|
||||
"pillars": [
|
||||
{
|
||||
"name": "Trust debt in analytics",
|
||||
"why_you": "I have rebuilt this at three companies and measured it each time",
|
||||
"proof": "the 6-vs-35-days handoff measurement, published",
|
||||
"share": 40
|
||||
},
|
||||
{
|
||||
"name": "dbt and modelling decisions",
|
||||
"why_you": "I maintain two open-source dbt packages",
|
||||
"proof": "public repos plus a conference talk",
|
||||
"share": 35
|
||||
},
|
||||
{
|
||||
"name": "Hiring the first analytics engineer",
|
||||
"why_you": "I have written three of these job specs and run the loops",
|
||||
"proof": "two hires who are still in role two years later",
|
||||
"share": 15
|
||||
},
|
||||
{
|
||||
"name": "Field notes and experiments",
|
||||
"why_you": "unproven; testing whether the audience wants this",
|
||||
"proof": "",
|
||||
"share": 10
|
||||
}
|
||||
],
|
||||
"exclusions": [
|
||||
"generic AI commentary - I have no edge there and neither does anyone posting it",
|
||||
"hot takes on other companies' layoffs",
|
||||
"vendor comparison posts that would compromise client confidentiality"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# Positioning Brief
|
||||
|
||||
The editorial constitution. When a post idea does not fit a pillar, the answer is not to add
|
||||
a pillar — it is to not post it, or to put it in the experimental slot and see.
|
||||
|
||||
Review quarterly. A brief revised monthly is a mood.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective — pick exactly one
|
||||
|
||||
- [ ] `career-change` — move into a different role or field
|
||||
- [ ] `consulting` — generate consulting or freelance work
|
||||
- [ ] `thought-leadership` — be cited as a credible voice on one specific thing
|
||||
- [ ] `hiring` — attract candidates to a team you are building
|
||||
- [ ] `fundraising` — build investor and operator awareness ahead of a raise
|
||||
- [ ] `community` — build a durable group around a shared problem
|
||||
|
||||
Two objectives serve neither. If both matter, run one this quarter and the other next.
|
||||
|
||||
## 2. Audience
|
||||
|
||||
Specific enough that a real person could be excluded. Role + company stage + the problem
|
||||
they have *this quarter*.
|
||||
|
||||
> ______________________________________________________________
|
||||
|
||||
Bad: "business leaders", "professionals in tech", "my network".
|
||||
Good: "heads of data at Series A-B SaaS with three analysts, no analytics engineer, and a
|
||||
CEO who does not trust the dashboard".
|
||||
|
||||
## 3. Pillars — two to four, shares summing to 100
|
||||
|
||||
| Pillar | Why you (your specific standing) | Proof that already exists | Share |
|
||||
|---|---|---|---|
|
||||
| | | | % |
|
||||
| | | | % |
|
||||
| | | | % |
|
||||
| | | | % |
|
||||
|
||||
Rules:
|
||||
- At least one pillar must rest on proof that **already exists**.
|
||||
- At least one pillar at **10-20%** — the experimental slot. Next quarter's main pillar
|
||||
comes from here.
|
||||
- Empty "why you" means anyone could post it. Cut the pillar.
|
||||
|
||||
## 4. Exclusions — at least two
|
||||
|
||||
What you will not post about, and why. This is the actual positioning.
|
||||
|
||||
1. ______________________________________________________________
|
||||
2. ______________________________________________________________
|
||||
3. ______________________________________________________________
|
||||
|
||||
The trending topic you have no edge on belongs here.
|
||||
|
||||
## 5. 90-day criteria
|
||||
|
||||
Filled in by `positioning_brief.py` from the objective. Every one is observable by someone
|
||||
other than you. Follower count is deliberately absent.
|
||||
|
||||
- [ ] ______________________________________________________________
|
||||
- [ ] ______________________________________________________________
|
||||
- [ ] ______________________________________________________________
|
||||
|
||||
## 6. Budget
|
||||
|
||||
- Minutes per week I will protect (from a bad week): ______
|
||||
- Stage: `starting` / `rebuilding` / `established`
|
||||
- Formats I can actually produce: ______
|
||||
|
||||
---
|
||||
|
||||
**Then run:**
|
||||
|
||||
```bash
|
||||
python3 ../scripts/positioning_brief.py --input brief.json --output human
|
||||
python3 ../scripts/cadence_planner.py --minutes <n> --stage <stage> --output human
|
||||
```
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# Cadence and Consistency — the variable that actually compounds
|
||||
|
||||
Every LinkedIn strategy that fails, fails the same way: it was designed for a
|
||||
week the person happened to have free. Week five arrives, the plan needs six
|
||||
hours, ninety minutes exist, and the plan is abandoned rather than reduced.
|
||||
|
||||
`cadence_planner.py` exists to make that failure visible in week zero, when it is
|
||||
still an arithmetic problem.
|
||||
|
||||
---
|
||||
|
||||
## What one post actually costs
|
||||
|
||||
Time estimates people use are drafting time. Real cost includes revision and the
|
||||
reply window.
|
||||
|
||||
| Activity | Minutes | What is in there |
|
||||
|---|---|---|
|
||||
| Text post | 25 | Draft 15, revise and lint 10 |
|
||||
| Image post | 30 | Above plus asset and alt text |
|
||||
| Document carousel | 90 | Outline, 8-12 slides, export, cover |
|
||||
| Native video | 120 | Script, record, edit, captions |
|
||||
| Article | 180 | It is an essay |
|
||||
| Newsletter issue | 150 | An essay with a standing promise attached |
|
||||
| One substantive comment | 6 | Read the post properly, write something worth reading |
|
||||
| Replying to your own post | 20 | Per published post, in the hours after |
|
||||
| One outreach message | 5 | Read their work, write the specific line |
|
||||
|
||||
The line people leave out is the last-but-one. **Replying to comments on your own
|
||||
post is part of the post**, not an optional extra: it is where readers actually
|
||||
meet you, and skipping it wastes the distribution the post earned.
|
||||
|
||||
## Why the allocation shifts with stage
|
||||
|
||||
| Stage | Engagement share | Reasoning |
|
||||
|---|---|---|
|
||||
| **starting** (<~1k followers, or restarting) | 60% | Your posts have almost no distribution. A substantive comment on a post that already has an audience is the only lever that works from zero. |
|
||||
| **rebuilding** (audience exists, went quiet) | 45% | Reach recovers with consistency, not with one big swing. |
|
||||
| **established** (posts reach non-connections) | 30% | Distribution works; the constraint is now what you publish. |
|
||||
|
||||
The counter-intuitive part: **from a standing start, most of your budget belongs
|
||||
in other people's comment sections.** People resist this because commenting feels
|
||||
like helping someone else. It is the cheapest distribution available, and it is
|
||||
the only one that works before anyone follows you.
|
||||
|
||||
## The floor
|
||||
|
||||
Under 90 minutes a week, `cadence_planner.py` refuses to plan a posting schedule
|
||||
and returns a comment-only week instead. This is a deliberate refusal, not a
|
||||
limitation:
|
||||
|
||||
- A cadence abandoned in week five is worse than one never started, because the
|
||||
abandonment is visible on the profile — a burst of posts followed by silence
|
||||
reads as a failed attempt, which is exactly the impression you were trying to
|
||||
avoid.
|
||||
- Commenting degrades gracefully. A week with no time costs you a week. A missed
|
||||
publishing slot costs you the schedule.
|
||||
|
||||
## The minimum viable week
|
||||
|
||||
Every plan ships with the subset that survives a bad week:
|
||||
|
||||
1. One text post, on the same day each week.
|
||||
2. One substantive comment per weekday.
|
||||
3. Reply to every comment on your own post within 24 hours.
|
||||
|
||||
That is roughly two hours and it is enough to compound. Everything above it is
|
||||
acceleration, and acceleration is optional in a way consistency is not.
|
||||
|
||||
## Same day, same time
|
||||
|
||||
The schedule is the product; the topic varies. Two reasons, one soft and one
|
||||
mechanical:
|
||||
|
||||
- **Soft:** a returning reader learns when you appear. That is the beginning of
|
||||
an audience rather than a series of impressions.
|
||||
- **Mechanical:** it makes your own data comparable. If you post at random times,
|
||||
time-of-day confounds every comparison you will ever want to make — see
|
||||
`linkedin-analytics/references/evidence_thresholds.md`.
|
||||
|
||||
## Batching, and its one real risk
|
||||
|
||||
Writing four posts in one sitting is more efficient than four separate sittings,
|
||||
and it protects the cadence against a bad week. The risk is that batched posts
|
||||
drift toward the abstract, because the specific detail that makes a post good
|
||||
usually comes from the day you had.
|
||||
|
||||
The working compromise: batch the *drafting*, keep a running note of specifics as
|
||||
they happen, and let each post steal one from the note.
|
||||
|
||||
## Streaks are a trap, consistency is not
|
||||
|
||||
A skipped week is fine. A skipped month resets you to the starting stage, because
|
||||
the audience's memory is shorter than anyone's ego expects. The failure mode to
|
||||
avoid is not the missed week — it is treating the missed week as proof the whole
|
||||
thing failed, and stopping.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Clear, J. **Atomic Habits** — systems over goals, and designing for the bad
|
||||
day rather than the good one.
|
||||
2. Fogg, B.J. **Tiny Habits** — behaviour = motivation × ability × prompt;
|
||||
lowering the ability cost is what the minimum viable week does.
|
||||
3. Newport, C. **Deep Work** — fixed-schedule productivity, and why the budget
|
||||
should be declared before the plan rather than after.
|
||||
4. Reinertsen, D. **The Principles of Product Development Flow** — queues and
|
||||
work-in-progress limits; a content plan is a queue and overloading it stalls it.
|
||||
5. Orbit Media. **Annual Blogger Survey** — the long-running longitudinal record
|
||||
showing that publishing frequency and time-per-post both rose while typical
|
||||
returns did not, which is the case for choosing a sustainable floor.
|
||||
6. Parkinson, C.N. **Parkinson's Law** (*The Economist*, 1955) — work expands to
|
||||
fill the time available, which is why a timeboxed post cost is more honest
|
||||
than an open-ended one.
|
||||
7. Kahneman, D. & Tversky, A. **"Intuitive Prediction: Biases and Corrective
|
||||
Procedures"** (1979) — the planning fallacy, the specific bias that makes
|
||||
every content calendar optimistic.
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# Newsletter Playbook — a standing promise, priced before it is made
|
||||
|
||||
A LinkedIn newsletter notifies every subscriber on every issue. That notification
|
||||
is the whole value and the whole risk: it is a standing promise about frequency
|
||||
and subject, made to people who opted in on the strength of the first issue.
|
||||
|
||||
Most LinkedIn newsletters are abandoned after four issues. Not because the topic
|
||||
was wrong — because the cadence was chosen against a month the author happened to
|
||||
have free.
|
||||
|
||||
---
|
||||
|
||||
## Eligibility — what LinkedIn actually says
|
||||
|
||||
LinkedIn Help states that members and Pages with **more than 150 followers and/or
|
||||
connections** are eligible to be *evaluated* for newsletter access, and that
|
||||
access is granted based on **a set of criteria, all of which must be met** —
|
||||
criteria LinkedIn does not publish in full. Authors can run up to five
|
||||
newsletters at a time.
|
||||
|
||||
Two things follow:
|
||||
|
||||
- 150 is a floor for evaluation, not a guarantee of access. 🟢
|
||||
- The tool checks the published floor and says explicitly that LinkedIn, not the
|
||||
tool, decides. Anyone stating the complete criteria list is guessing. 🔴
|
||||
|
||||
Creator-mode access and newsletter access have been coupled and decoupled over
|
||||
time; existing authors have retained access across those changes. Check the
|
||||
current Help article rather than trusting any secondary source, including this
|
||||
one.
|
||||
|
||||
## Sustainability, over six months
|
||||
|
||||
`newsletter_planner.py` prices cadence × issue cost against a **six-month**
|
||||
horizon, not a good month. Six months is the horizon because that is roughly when
|
||||
a newsletter starts to have a returning readership rather than a launch audience.
|
||||
|
||||
| Cadence | Issues/month | At 150 min/issue |
|
||||
|---|---|---|
|
||||
| Weekly | ~4.3 | ~645 min/month |
|
||||
| Every two weeks | ~2.15 | ~322 min/month |
|
||||
| Monthly | 1 | 150 min/month |
|
||||
|
||||
If the budget does not cover it, the tool refuses and names the cadence that
|
||||
fits. **Dropping cadence before launch is free. Dropping it after launch is a
|
||||
broken promise to people who opted in to a frequency.**
|
||||
|
||||
Under 20% headroom, it warns: one busy month breaks the cadence. The mitigations
|
||||
that actually work are banking two issues before launch, and keeping one
|
||||
low-cost format (the roundup) in reserve for a bad month.
|
||||
|
||||
## Shape — twelve issues, not twelve essays
|
||||
|
||||
An arc that alternates issue *types* is easier to sustain and better to read than
|
||||
twelve variations of the same essay:
|
||||
|
||||
| Type | What it is | Cost |
|
||||
|---|---|---|
|
||||
| **framework** | A repeatable way to make one decision. The issue people forward. | High |
|
||||
| **teardown** | One real artifact examined in public, with permission or anonymised. | High |
|
||||
| **field-note** | What you actually did this fortnight, including what failed. | Low |
|
||||
| **counter-take** | The received wisdom in your field, and where it breaks. | Medium |
|
||||
| **reader-question** | One question a reader asked, answered at length. | Low |
|
||||
| **roundup** | What you read and what changed your mind. | Lowest |
|
||||
|
||||
The planner rotates types across pillars, offsetting the cycles so pillar/type
|
||||
pairs do not repeat in lockstep.
|
||||
|
||||
## Naming
|
||||
|
||||
Name it after the problem it solves, not after yourself. "The Analytics Trust
|
||||
Letter" tells a stranger whether to subscribe. "Alex's Newsletter" requires them
|
||||
to already know who Alex is, which is the audience you already have.
|
||||
|
||||
The subtitle does the qualifying work: who it is for, and how often.
|
||||
|
||||
## The stop rule, written before issue one
|
||||
|
||||
This is the part everyone skips, and it is the reason abandoned newsletters sit
|
||||
on profiles for years.
|
||||
|
||||
- If three consecutive issues land below half the median engagement of your
|
||||
regular posts, the format is not earning its cost. Move the material back to
|
||||
posts.
|
||||
- If you miss two scheduled issues in a quarter, drop the cadence one step rather
|
||||
than trying to catch up. Subscribers notice frequency, not effort.
|
||||
- **Ending it deliberately, with a final issue that says so, costs nothing.**
|
||||
Letting it go quiet is the version people remember.
|
||||
|
||||
## Newsletter versus posts
|
||||
|
||||
A newsletter is worth it when the material genuinely needs length *and* an
|
||||
audience is already asking for the next one. It is not a growth tactic on its
|
||||
own — the notification reaches people who already subscribed.
|
||||
|
||||
Under ~500 followers the planner warns rather than refuses: eligible, but the
|
||||
feedback is too sparse to tell you whether the topic is right, which is the main
|
||||
thing a newsletter is supposed to teach you.
|
||||
|
||||
## Repurposing between the two
|
||||
|
||||
The clean direction is **newsletter → posts**: an issue yields two or three
|
||||
standalone posts over the following fortnight, each linking to the issue in the
|
||||
first comment. Run them through `repurpose_splitter.py` with a ledger so the same
|
||||
unit does not go out twice.
|
||||
|
||||
The reverse direction — stitching old posts into an issue — works only if the
|
||||
issue adds a synthesis the posts did not have. Otherwise subscribers are being
|
||||
notified about something they already read.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. LinkedIn Help. **"LinkedIn Newsletter access criteria."** — the >150
|
||||
followers/connections threshold, and the existence of unpublished criteria.
|
||||
2. LinkedIn Help. **"Manage a newsletter on LinkedIn"** and **"Newsletters on
|
||||
LinkedIn FAQ"** — the five-newsletter limit, cadence declaration, subscriber
|
||||
notification behaviour.
|
||||
3. LinkedIn Help. **"Updates to Creator Mode."** — the coupling and decoupling of
|
||||
creator tools and newsletter access over time.
|
||||
4. Kleon, A. **Show Your Work!** — the case for a recurring, low-ceremony
|
||||
publishing habit over occasional set pieces.
|
||||
5. Handley, A. **Everybody Writes** — editorial calendars, the "bigger, braver"
|
||||
standard for anything that lands in a notification.
|
||||
6. Clear, J. **Atomic Habits** — the two-minute rule and habit stacking, applied
|
||||
here as the reserve low-cost issue format.
|
||||
7. Doerr, J. **Measure What Matters** — committing the stop condition in advance,
|
||||
while it is still a decision rather than a defeat.
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# Objective to Pillars — turning "grow my LinkedIn" into an editorial constitution
|
||||
|
||||
"Build my personal brand" is not an objective. It is a wish with no failure
|
||||
condition, which means no post can ever be judged against it and no post can ever
|
||||
be refused. That is why feeds drift: without a stated objective, every idea is
|
||||
equally on-topic.
|
||||
|
||||
---
|
||||
|
||||
## The six real objectives
|
||||
|
||||
Each one implies a different audience, a different proof requirement, and a
|
||||
different definition of a good week. `positioning_brief.py` accepts only these
|
||||
six, on purpose.
|
||||
|
||||
| Objective | The audience that matters | Proof it demands | The thing people get wrong |
|
||||
|---|---|---|---|
|
||||
| **career-change** | Practitioners and hiring managers in the *target* field | Evidence of doing the new thing, however small | Writing to their current colleagues, who cannot hire them into the new role |
|
||||
| **consulting** | The person who signs the invoice | One artifact a buyer can evaluate in 60 seconds | Writing for peers, who are flattering and never buy |
|
||||
| **thought-leadership** | Practitioners who could disagree competently | A position that could be wrong | Writing agreeable summaries nobody argues with |
|
||||
| **hiring** | The specific person you want to hire | The team's real working conditions, stated publicly | Job-ad language, which repels the candidates worth having |
|
||||
| **fundraising** | Operators and angels in the category | A dated public track record of the thesis | Starting three weeks before the raise, which is legible |
|
||||
| **community** | People with the same problem, who would recognise each other | A recurring format they anticipate | Broadcasting instead of convening |
|
||||
|
||||
**Pick one.** Two objectives produce a feed that serves neither, because the
|
||||
audiences do not overlap as much as they appear to. If two genuinely matter, run
|
||||
one for a quarter and then the other; sequencing works, blending does not.
|
||||
|
||||
## Pillars: two to four, and the arithmetic matters
|
||||
|
||||
A pillar is a recurring subject you have standing to speak on. The brief requires
|
||||
each one to carry three fields:
|
||||
|
||||
- **name** — what it is about, in the audience's language
|
||||
- **why you** — the specific standing you have. If anyone could post it, cut it.
|
||||
- **proof** — the thing that already exists: shipped work, a measurement, a repo,
|
||||
a hire, a talk. Empty proof marks the pillar experimental.
|
||||
- **share** — the percentage of your output. This is a budget, and its job is to
|
||||
decide what gets cut in a busy week.
|
||||
|
||||
Rules the tool enforces, and why:
|
||||
|
||||
- **Two to four.** Under two is a monologue; over four is a magazine nobody
|
||||
subscribed to, and it prevents any single association forming.
|
||||
- **Shares sum to 100.** If they do not, there is no budget, only preferences.
|
||||
- **At least one pillar proof-backed.** Otherwise every pillar is a claim you
|
||||
would have to invent evidence for — see the fabrication refusal in
|
||||
`policy_and_account_safety.md`.
|
||||
- **At least one pillar at 10-20%.** The experimental slot. Next quarter's main
|
||||
pillar comes from here, and without it the plan can only shrink.
|
||||
|
||||
## The exclusion list is the actual positioning
|
||||
|
||||
`positioning_brief.py` refuses a brief with fewer than two exclusions, and this
|
||||
is the rule people push back on hardest.
|
||||
|
||||
A positioning that excludes nothing is not a positioning; it is availability.
|
||||
Naming two or three topics you will not post about does three things: it settles
|
||||
the "should I comment on this news cycle" question in advance, it stops the feed
|
||||
drifting toward whatever is trending, and it makes the pillars mean something by
|
||||
contrast.
|
||||
|
||||
Good exclusions are specific and reasoned:
|
||||
|
||||
- "Generic AI commentary — I have no edge there and neither does anyone posting it."
|
||||
- "Hot takes on other companies' layoffs."
|
||||
- "Vendor comparisons that would compromise client confidentiality."
|
||||
|
||||
Bad exclusions are performative: "politics", "negativity". Nobody was going to
|
||||
post those anyway.
|
||||
|
||||
## Success criteria that someone else could verify
|
||||
|
||||
The brief emits observable 90-day criteria per objective. They share one
|
||||
property: **another person could check them.**
|
||||
|
||||
Follower count is deliberately not among them. It moves for reasons unrelated to
|
||||
whether the objective is being met — one post reaching an adjacent audience adds
|
||||
followers who will never engage again, and a quarter of excellent, well-targeted
|
||||
work can add very few. Optimising the number you can see instead of the outcome
|
||||
you want is the most common way a LinkedIn strategy fails while appearing to work.
|
||||
|
||||
Better criteria, by shape:
|
||||
|
||||
- **Conversations you did not start.** Inbound is the honest signal.
|
||||
- **Specific references.** Someone citing a specific post in their first message.
|
||||
- **Invitations.** Podcast, panel, guest post, referral.
|
||||
- **The sentence test.** Can you name the one idea people now associate with you?
|
||||
|
||||
## Reviewing the brief
|
||||
|
||||
Quarterly, not weekly. A brief revised every month is not a constitution; it is a
|
||||
mood. At the review, three questions:
|
||||
|
||||
1. Which pillar produced the outcomes, as opposed to the engagement?
|
||||
2. Did the experimental pillar earn promotion, or should it be replaced?
|
||||
3. Has the audience description got vaguer? (It always drifts toward broader.
|
||||
Drag it back.)
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. Ries, A. & Trout, J. **Positioning: The Battle for Your Mind** — a position is
|
||||
defined by what it excludes.
|
||||
2. Porter, M. **"What Is Strategy?"** *Harvard Business Review*, 1996 — strategy
|
||||
is the deliberate choice of what not to do; the exclusion list is that idea at
|
||||
the scale of one feed.
|
||||
3. Clark, D. **Stand Out** — building a public identity around one distinctive
|
||||
idea rather than a portfolio of competences.
|
||||
4. Ibarra, H. **Working Identity** — career transitions proceed by public action
|
||||
in the new field, not by private decision followed by announcement.
|
||||
5. Kleon, A. **Show Your Work!** — process as publishable material, which is what
|
||||
makes an unproven pillar survivable before the proof exists.
|
||||
6. Doerr, J. **Measure What Matters** — objectives paired with observable key
|
||||
results; the 90-day criteria are that pattern at personal scale.
|
||||
7. Godin, S. **This Is Marketing** — "the smallest viable audience", the direct
|
||||
answer to an audience description that keeps drifting broader.
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
#!/usr/bin/env python3
|
||||
"""cadence_planner.py — size a LinkedIn week against the hours that actually exist.
|
||||
|
||||
Content plans do not fail on ideas. They fail in week five, when the plan assumed
|
||||
six hours and the person has ninety minutes. This tool prices every activity in
|
||||
minutes, allocates the real budget by stage, and refuses a target it cannot pay
|
||||
for — naming the overage rather than quietly shrinking the plan.
|
||||
|
||||
It also emits a minimum viable week: the subset to keep when the week collapses.
|
||||
Consistency is the compounding variable, so the fallback matters more than the
|
||||
ideal plan.
|
||||
|
||||
Allocation shifts with stage on purpose. From a standing start, most of the budget
|
||||
belongs in other people's comment sections — a post published to nobody reaches
|
||||
nobody, and a substantive comment on a well-read post is the cheapest distribution
|
||||
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
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Minutes per unit of work, drafting + revision + posting included.
|
||||
COST = {
|
||||
"text-post": 25,
|
||||
"document-carousel": 90,
|
||||
"native-video": 120,
|
||||
"image-post": 30,
|
||||
"article": 180,
|
||||
"newsletter-issue": 150,
|
||||
"comment": 6,
|
||||
"outreach-message": 5,
|
||||
"own-post-replies": 20, # per published post, in the first hours after posting
|
||||
}
|
||||
|
||||
FLOOR_MINUTES = 90
|
||||
|
||||
STAGES = {
|
||||
"starting": {
|
||||
"label": "Under ~1,000 followers, or restarting after a long gap",
|
||||
"engagement_share": 0.60,
|
||||
"why": "Your own posts have almost no distribution yet. Comments on posts that already "
|
||||
"have an audience are the only lever that works from zero.",
|
||||
"comment_floor_per_day": 5,
|
||||
},
|
||||
"rebuilding": {
|
||||
"label": "An audience exists but has gone quiet",
|
||||
"engagement_share": 0.45,
|
||||
"why": "Reach recovers with consistency, not with a big swing. Split the budget while "
|
||||
"the cadence re-establishes.",
|
||||
"comment_floor_per_day": 3,
|
||||
},
|
||||
"established": {
|
||||
"label": "Posts reliably reach people who are not your connections",
|
||||
"engagement_share": 0.30,
|
||||
"why": "Distribution is working; the constraint is now the quality and frequency of "
|
||||
"what you publish.",
|
||||
"comment_floor_per_day": 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def plan(minutes: int, stage: str, target_posts: int, formats: list,
|
||||
outreach_per_week: int) -> dict:
|
||||
spec = STAGES[stage]
|
||||
findings = []
|
||||
|
||||
if minutes < FLOOR_MINUTES:
|
||||
return {
|
||||
"verdict": "BELOW_FLOOR",
|
||||
"exit_code": 2,
|
||||
"minutes_available": minutes,
|
||||
"floor": FLOOR_MINUTES,
|
||||
"recommendation": {
|
||||
"posts_per_week": 0,
|
||||
"comments_per_week": max(1, minutes // COST["comment"]),
|
||||
"note": "Under 90 minutes a week, publishing on a schedule will break before it "
|
||||
"compounds. Spend the whole budget on substantive comments in other "
|
||||
"people's threads: it builds the same recognition, costs a fraction of "
|
||||
"the time, and stops cleanly when a week disappears.",
|
||||
},
|
||||
"when_to_revisit": "Come back to publishing when you can protect 2 hours a week for "
|
||||
"eight consecutive weeks.",
|
||||
"rule": "A cadence you abandon in week five is worse than a cadence you never started, "
|
||||
"because the abandoned one is visible on your profile.",
|
||||
}
|
||||
|
||||
engagement_budget = round(minutes * spec["engagement_share"])
|
||||
creation_budget = minutes - engagement_budget
|
||||
|
||||
outreach_cost = outreach_per_week * COST["outreach-message"]
|
||||
if outreach_cost > engagement_budget * 0.5:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "outreach",
|
||||
"finding": f"{outreach_per_week} outreach messages cost {outreach_cost} min — over "
|
||||
f"half the {engagement_budget}-min engagement budget.",
|
||||
"fix": "Outreach converts far better after someone has seen you in their feed or "
|
||||
"comments. Shift the balance toward comments until then.",
|
||||
})
|
||||
comment_budget = max(0, engagement_budget - outreach_cost)
|
||||
comments = comment_budget // COST["comment"]
|
||||
comments_per_day = round(comments / 7, 1)
|
||||
|
||||
chosen = [f for f in formats if f in COST] or ["text-post"]
|
||||
avg_create = sum(COST[f] for f in chosen) / len(chosen)
|
||||
cost_per_post = avg_create + COST["own-post-replies"]
|
||||
affordable = int(creation_budget // cost_per_post)
|
||||
|
||||
verdict, code = "FITS", 0
|
||||
if target_posts:
|
||||
if target_posts > affordable:
|
||||
need = round(target_posts * cost_per_post + engagement_budget)
|
||||
findings.append({
|
||||
"severity": "blocking", "area": "capacity",
|
||||
"finding": f"{target_posts} posts/week in {', '.join(chosen)} costs about "
|
||||
f"{round(target_posts * cost_per_post)} min of creation "
|
||||
f"({round(cost_per_post)} min each including replying to your own "
|
||||
f"comments). With engagement, the week needs ~{need} min; you have "
|
||||
f"{minutes}.",
|
||||
"fix": f"Either drop to {affordable} post(s)/week, move to a cheaper format "
|
||||
f"(text-post at {COST['text-post']} min), or find {need - minutes} more "
|
||||
"minutes. Do not solve it by skipping the reply window — replying to "
|
||||
"early comments is part of the post, not an extra.",
|
||||
})
|
||||
verdict, code = "OVER_BUDGET", 3
|
||||
else:
|
||||
affordable = target_posts
|
||||
|
||||
if affordable == 0 and verdict == "FITS":
|
||||
findings.append({
|
||||
"severity": "warning", "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 "
|
||||
"expensive format.",
|
||||
})
|
||||
|
||||
if comments_per_day < spec["comment_floor_per_day"]:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "engagement",
|
||||
"finding": f"{comments_per_day} comments/day is below the {spec['comment_floor_per_day']} "
|
||||
f"floor for the '{stage}' stage. {spec['why']}",
|
||||
"fix": "Shift one post's worth of time into comments. At this stage comments buy "
|
||||
"more reach per minute than publishing does.",
|
||||
})
|
||||
|
||||
minimum_week = ["1 substantive comment per weekday (30 min total)"]
|
||||
if affordable >= 1:
|
||||
minimum_week.insert(0, "1 text post, published on the same day each week")
|
||||
minimum_week.append("Reply to every comment on your own post within 24 hours")
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"stage": stage,
|
||||
"stage_label": spec["label"],
|
||||
"minutes_available": minutes,
|
||||
"budget_split": {"creation": creation_budget, "engagement": engagement_budget,
|
||||
"engagement_share": spec["engagement_share"], "why": spec["why"]},
|
||||
"weekly_plan": {
|
||||
"posts": affordable,
|
||||
"formats": chosen,
|
||||
"minutes_per_post_including_replies": round(cost_per_post),
|
||||
"comments": int(comments),
|
||||
"comments_per_day": comments_per_day,
|
||||
"outreach_messages": outreach_per_week,
|
||||
},
|
||||
"minimum_viable_week": minimum_week,
|
||||
"findings": findings,
|
||||
"rules": [
|
||||
"Same day, same time, every week. The schedule is the product; the topic varies.",
|
||||
"Reply time is post time. Block the 60-90 minutes after publishing — early comments "
|
||||
"are where a post either travels or dies.",
|
||||
"A skipped week is fine. A skipped month resets you to the starting stage.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
if r["verdict"] == "BELOW_FLOOR":
|
||||
rec = r["recommendation"]
|
||||
return "\n".join([
|
||||
f"Cadence: BELOW_FLOOR ({r['minutes_available']} min/week, floor {r['floor']})",
|
||||
"=" * 56, rec["note"], "",
|
||||
f"This week: {rec['comments_per_week']} substantive comments, 0 posts.",
|
||||
f"Revisit : {r['when_to_revisit']}", "", r["rule"]])
|
||||
w = r["weekly_plan"]
|
||||
b = r["budget_split"]
|
||||
lines = [f"Cadence: {r['verdict']} ({r['minutes_available']} min/week, stage: {r['stage']})",
|
||||
"=" * 56,
|
||||
f"{r['stage_label']}",
|
||||
f"Split: {b['creation']} min creating / {b['engagement']} min engaging "
|
||||
f"({int(b['engagement_share'] * 100)}% engagement)",
|
||||
f" why: {b['why']}", "",
|
||||
"Weekly plan:",
|
||||
f" posts : {w['posts']} ({', '.join(w['formats'])}, "
|
||||
f"~{w['minutes_per_post_including_replies']} min each incl. replies)",
|
||||
f" comments : {w['comments']} (~{w['comments_per_day']}/day)",
|
||||
f" outreach msgs : {w['outreach_messages']}"]
|
||||
if r["findings"]:
|
||||
lines.append("\nFindings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
lines.append("\nMinimum viable week (what survives a bad week):")
|
||||
for m in r["minimum_viable_week"]:
|
||||
lines.append(f" - {m}")
|
||||
lines.append("\nRules:")
|
||||
for rule in r["rules"]:
|
||||
lines.append(f" - {rule}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Size a sustainable LinkedIn week (fits=0 / below-floor=2 / over-budget=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,
|
||||
help="Posts per week you want. Omit to be told what fits.")
|
||||
ap.add_argument("--format", action="append", default=[], choices=sorted(
|
||||
k for k in COST if k not in ("comment", "outreach-message", "own-post-replies")),
|
||||
help="Format(s) you intend to publish. Repeatable. Default text-post.")
|
||||
ap.add_argument("--outreach", type=int, default=0,
|
||||
help="Manual outreach messages per week (default 0).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Run a built-in example week.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
minutes, stage, target, formats, outreach = 240, "starting", 3, ["text-post"], 10
|
||||
else:
|
||||
if args.minutes is None:
|
||||
ap.error("--minutes is required (or use --sample)")
|
||||
minutes, stage = args.minutes, args.stage
|
||||
target, formats, outreach = args.target_posts, args.format, args.outreach
|
||||
|
||||
result = plan(minutes, stage, target, formats, outreach)
|
||||
result["inputs"] = {"minutes": minutes, "stage": stage, "target_posts": target,
|
||||
"formats": formats or ["text-post"], "outreach": outreach}
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env python3
|
||||
"""newsletter_planner.py — gate a LinkedIn newsletter, then lay out its first arc.
|
||||
|
||||
A LinkedIn newsletter notifies every subscriber on every issue. That is a standing
|
||||
promise about cadence and topic, and it is the reason most of them are abandoned
|
||||
after four issues: the promise was made against a month the author happened to
|
||||
have free.
|
||||
|
||||
This tool gates the promise before it is made:
|
||||
|
||||
ELIGIBILITY LinkedIn evaluates access for members and Pages with more than 150
|
||||
followers and/or connections, among other criteria it does not
|
||||
publish in full. Below that, the answer is "not yet".
|
||||
SUSTAINABILITY Cadence x issue cost, checked against a six-month horizon rather
|
||||
than a good week. Six months is the horizon because that is roughly
|
||||
when a newsletter starts to have a returning readership.
|
||||
SHAPE Issues mapped across the declared pillars with rotating issue types,
|
||||
so the arc is not twelve variations of the same essay.
|
||||
STOP RULE The condition under which you slow down or end it, written before
|
||||
issue one, when it is still a decision rather than a defeat.
|
||||
|
||||
Exit codes:
|
||||
0 green — eligible, sustainable, arc emitted
|
||||
2 eligible but thin (named risks; proceed knowingly)
|
||||
3 refused — not eligible yet, or the cadence cannot be sustained
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
ELIGIBILITY_FLOOR = 150 # LinkedIn Help: newsletters evaluated above 150 followers/connections
|
||||
THIN_AUDIENCE = 500
|
||||
ISSUE_MINUTES_DEFAULT = 150
|
||||
HORIZON_MONTHS = 6
|
||||
|
||||
CADENCES = {
|
||||
"weekly": {"issues_per_month": 4.3, "label": "Weekly"},
|
||||
"biweekly": {"issues_per_month": 2.15, "label": "Every two weeks"},
|
||||
"monthly": {"issues_per_month": 1.0, "label": "Monthly"},
|
||||
}
|
||||
|
||||
# Rotating issue shapes so the arc does not become twelve identical essays.
|
||||
ISSUE_TYPES = [
|
||||
("framework", "A repeatable way to make one decision. The issue people forward."),
|
||||
("teardown", "One real artifact examined in public — with permission, or anonymised."),
|
||||
("field-note", "What you actually did this fortnight, including what failed."),
|
||||
("counter-take", "The received wisdom in your field, and where it breaks."),
|
||||
("reader-question", "One question a reader asked, answered at length."),
|
||||
("roundup", "What you read and what changed your mind. Cheapest issue to produce — "
|
||||
"keep one in reserve for a bad month."),
|
||||
]
|
||||
|
||||
SAMPLE = {
|
||||
"followers": 1800,
|
||||
"cadence": "biweekly",
|
||||
"minutes_per_month": 420,
|
||||
"issue_minutes": 150,
|
||||
"pillars": ["Trust debt in analytics", "dbt and modelling decisions",
|
||||
"Hiring the first analytics engineer"],
|
||||
"issues": 12,
|
||||
}
|
||||
|
||||
|
||||
def build(followers: int, cadence: str, minutes_per_month: int, issue_minutes: int,
|
||||
pillars: list, issues: int) -> dict:
|
||||
findings = []
|
||||
spec = CADENCES[cadence]
|
||||
|
||||
if followers < ELIGIBILITY_FLOOR:
|
||||
return {
|
||||
"verdict": "NOT_ELIGIBLE",
|
||||
"exit_code": 3,
|
||||
"followers": followers,
|
||||
"floor": ELIGIBILITY_FLOOR,
|
||||
"finding": f"{followers} followers/connections. LinkedIn evaluates newsletter access "
|
||||
f"above {ELIGIBILITY_FLOOR}, and applies further criteria it does not "
|
||||
"publish in full.",
|
||||
"instead": "Publish the same material as regular posts on the same day each week. "
|
||||
"It builds the audience the newsletter will need, and it costs less to "
|
||||
"abandon if the topic turns out to be wrong.",
|
||||
"recheck_at": ELIGIBILITY_FLOOR,
|
||||
}
|
||||
|
||||
if followers < THIN_AUDIENCE:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "audience",
|
||||
"finding": f"{followers} followers. Eligible, but a newsletter to a small list has a "
|
||||
"high abandonment rate — the feedback is too sparse to tell you whether "
|
||||
"the topic is right.",
|
||||
"fix": "Consider running the format as posts for another quarter. The newsletter "
|
||||
"converts better when there is already an audience asking for the next one.",
|
||||
})
|
||||
|
||||
monthly_cost = spec["issues_per_month"] * issue_minutes
|
||||
sustainable = monthly_cost <= minutes_per_month
|
||||
if not sustainable:
|
||||
affordable = [c for c, s in CADENCES.items()
|
||||
if s["issues_per_month"] * issue_minutes <= minutes_per_month]
|
||||
return {
|
||||
"verdict": "UNSUSTAINABLE",
|
||||
"exit_code": 3,
|
||||
"cadence": cadence,
|
||||
"monthly_cost_minutes": round(monthly_cost),
|
||||
"monthly_budget_minutes": minutes_per_month,
|
||||
"horizon_months": HORIZON_MONTHS,
|
||||
"finding": f"{spec['label']} at {issue_minutes} min/issue costs about "
|
||||
f"{round(monthly_cost)} min/month against a {minutes_per_month}-min budget. "
|
||||
f"Over {HORIZON_MONTHS} months that is a "
|
||||
f"{round(monthly_cost * HORIZON_MONTHS)}-minute commitment you cannot pay.",
|
||||
"instead": (f"Drop to {', '.join(affordable)}." if affordable else
|
||||
"Cut the issue cost — a 60-minute field-note format is a real newsletter; "
|
||||
"a 150-minute essay you skip is not."),
|
||||
"rule": "Cadence is the promise. Missing it is more damaging than never having made it, "
|
||||
"because subscribers opted in to a frequency.",
|
||||
}
|
||||
|
||||
headroom = round((minutes_per_month - monthly_cost) / minutes_per_month * 100)
|
||||
if headroom < 20:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "sustainability",
|
||||
"finding": f"Only {headroom}% headroom. One busy month breaks the cadence.",
|
||||
"fix": "Bank two issues before launching, and keep the roundup format in reserve as "
|
||||
"the low-cost issue for a bad month.",
|
||||
})
|
||||
|
||||
pillars = [p for p in pillars if str(p).strip()] or ["(no pillars declared)"]
|
||||
if pillars == ["(no pillars declared)"]:
|
||||
findings.append({
|
||||
"severity": "warning", "area": "shape",
|
||||
"finding": "No pillars declared, so the arc cannot be balanced against your positioning.",
|
||||
"fix": "Run positioning_brief.py first. A newsletter with no pillars drifts within "
|
||||
"three issues.",
|
||||
})
|
||||
|
||||
arc = []
|
||||
for i in range(issues):
|
||||
# Offset the pillar cycle by the type cycle so pillar/type pairs do not
|
||||
# repeat in lockstep every LCM(pillars, types) issues.
|
||||
pillar = pillars[(i + i // len(ISSUE_TYPES)) % len(pillars)]
|
||||
itype, why = ISSUE_TYPES[i % len(ISSUE_TYPES)]
|
||||
arc.append({"issue": i + 1, "pillar": pillar, "type": itype, "shape": why})
|
||||
|
||||
verdict, code = ("THIN", 2) if findings else ("GREEN", 0)
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"eligibility": {"followers": followers, "floor": ELIGIBILITY_FLOOR, "eligible": True,
|
||||
"note": "LinkedIn also applies criteria it does not publish; eligibility "
|
||||
"is evaluated by LinkedIn, not by this tool."},
|
||||
"cadence": {"choice": cadence, "label": spec["label"],
|
||||
"issues_per_month": spec["issues_per_month"],
|
||||
"monthly_cost_minutes": round(monthly_cost),
|
||||
"monthly_budget_minutes": minutes_per_month,
|
||||
"headroom_pct": headroom},
|
||||
"arc": arc,
|
||||
"findings": findings,
|
||||
"stop_rule": [
|
||||
"Write it now, before issue one.",
|
||||
"If three consecutive issues land below half the median engagement of your regular "
|
||||
"posts, the format is not earning its cost — move the material back to posts.",
|
||||
"If you miss two scheduled issues in a quarter, drop the cadence one step rather "
|
||||
"than trying to catch up. Subscribers notice frequency, not effort.",
|
||||
"Ending it deliberately with a final issue costs nothing. Letting it go quiet is "
|
||||
"the version people remember.",
|
||||
],
|
||||
"naming_rule": "Name the newsletter after the problem it solves, not after yourself. "
|
||||
"'The Analytics Trust Letter' tells a stranger whether to subscribe; "
|
||||
"'Alex's Newsletter' does not.",
|
||||
}
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
if r["verdict"] in ("NOT_ELIGIBLE", "UNSUSTAINABLE"):
|
||||
lines = [f"Newsletter: {r['verdict']}", "=" * 52, r["finding"], "",
|
||||
f"Instead: {r['instead']}"]
|
||||
if "rule" in r:
|
||||
lines += ["", r["rule"]]
|
||||
return "\n".join(lines)
|
||||
c = r["cadence"]
|
||||
lines = [f"Newsletter: {r['verdict']}", "=" * 52,
|
||||
f"Eligibility : {r['eligibility']['followers']} followers "
|
||||
f"(floor {r['eligibility']['floor']}) — {r['eligibility']['note']}",
|
||||
f"Cadence : {c['label']} — {c['monthly_cost_minutes']} of "
|
||||
f"{c['monthly_budget_minutes']} min/month, {c['headroom_pct']}% headroom", ""]
|
||||
if r["findings"]:
|
||||
lines.append("Findings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
lines.append("")
|
||||
lines.append("Arc:")
|
||||
for a in r["arc"]:
|
||||
lines.append(f" #{a['issue']:<3} {a['type']:<15} {a['pillar']}")
|
||||
lines.append("\nStop rule:")
|
||||
for s in r["stop_rule"]:
|
||||
lines.append(f" - {s}")
|
||||
lines.append(f"\n{r['naming_rule']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Gate and plan a LinkedIn newsletter (green=0 / thin=2 / refused=3).")
|
||||
ap.add_argument("--followers", type=int, help="Followers and/or connections.")
|
||||
ap.add_argument("--cadence", choices=sorted(CADENCES), default="biweekly")
|
||||
ap.add_argument("--minutes-per-month", type=int, default=300,
|
||||
help="Minutes per month you will protect for the newsletter.")
|
||||
ap.add_argument("--issue-minutes", type=int, default=ISSUE_MINUTES_DEFAULT,
|
||||
help=f"Minutes one issue really takes (default {ISSUE_MINUTES_DEFAULT}).")
|
||||
ap.add_argument("--pillar", action="append", default=[],
|
||||
help="A content pillar from the positioning brief. Repeatable.")
|
||||
ap.add_argument("--issues", type=int, default=12, help="Issues to lay out (default 12).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Run a built-in example.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
s = SAMPLE
|
||||
followers, cadence = s["followers"], s["cadence"]
|
||||
mpm, im, pillars, issues = (s["minutes_per_month"], s["issue_minutes"],
|
||||
s["pillars"], s["issues"])
|
||||
else:
|
||||
if args.followers is None:
|
||||
ap.error("--followers is required (or use --sample)")
|
||||
followers, cadence = args.followers, args.cadence
|
||||
mpm, im, pillars, issues = (args.minutes_per_month, args.issue_minutes,
|
||||
args.pillar, args.issues)
|
||||
if issues < 1 or issues > 52:
|
||||
ap.error("--issues must be between 1 and 52")
|
||||
|
||||
result = build(followers, cadence, mpm, im, pillars, issues)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
#!/usr/bin/env python3
|
||||
"""positioning_brief.py — validate a LinkedIn positioning brief before any post is written.
|
||||
|
||||
Posting without a brief produces a feed of unrelated observations that reads as
|
||||
noise even when each individual post is good. This tool does not invent the brief
|
||||
— that is a conversation, and the SKILL.md walks it. It enforces the four things a
|
||||
brief has to survive:
|
||||
|
||||
1. An objective that is one of the six real ones, not "build my brand".
|
||||
2. An audience specific enough that someone could be excluded from it.
|
||||
3. Two to four pillars whose shares sum to 100, at least one backed by proof you
|
||||
already have, and at least one small enough to be an experiment.
|
||||
4. An exclusion list. A positioning that excludes nothing positions nothing.
|
||||
|
||||
It then emits observable 90-day criteria for the declared objective, so "is this
|
||||
working" has an answer that does not depend on how the week felt.
|
||||
|
||||
Exit codes:
|
||||
0 brief is sound
|
||||
2 brief has fixable defects (each one named)
|
||||
3 brief is not usable — the objective or audience is too vague to proceed
|
||||
|
||||
Stdlib only. No network. Deterministic.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
OBJECTIVES = {
|
||||
"career-change": {
|
||||
"label": "Move into a different role or field",
|
||||
"criteria": [
|
||||
"≥3 inbound conversations with people who hold the target role (not recruiters)",
|
||||
"≥1 referral or intro offered without you asking",
|
||||
"Profile headline and About describe the target role, not the current one",
|
||||
],
|
||||
"audience_hint": "hiring managers and practitioners in the target field, not your "
|
||||
"current colleagues",
|
||||
},
|
||||
"consulting": {
|
||||
"label": "Generate consulting or freelance work",
|
||||
"criteria": [
|
||||
"≥5 qualified inbound enquiries (they named a budget, timeline, or scoped problem)",
|
||||
"≥1 closed engagement traceable to a post or a comment thread",
|
||||
"Featured section contains one artifact a buyer can evaluate in 60 seconds",
|
||||
],
|
||||
"audience_hint": "the person who signs the invoice, not the person who does the work",
|
||||
},
|
||||
"thought-leadership": {
|
||||
"label": "Be cited as a credible voice on one specific thing",
|
||||
"criteria": [
|
||||
"≥3 posts where practitioners in the field argued with you substantively in comments",
|
||||
"≥1 invitation (podcast, panel, guest post) that referenced a specific post",
|
||||
"You can name the one sentence people now associate with you",
|
||||
],
|
||||
"audience_hint": "practitioners who could disagree with you competently",
|
||||
},
|
||||
"hiring": {
|
||||
"label": "Attract candidates to a team you are building",
|
||||
"criteria": [
|
||||
"≥10 candidate conversations sourced without an agency",
|
||||
"≥2 candidates who referenced a specific post in their first message",
|
||||
"The team's actual working conditions are described somewhere public",
|
||||
],
|
||||
"audience_hint": "the specific engineer/designer/seller you want, not 'talent'",
|
||||
},
|
||||
"fundraising": {
|
||||
"label": "Build investor and operator awareness ahead of a raise",
|
||||
"criteria": [
|
||||
"≥5 investor or operator conversations initiated by them",
|
||||
"A public track record of the thesis dated before the raise, not after",
|
||||
"No confidential metric published that the cap table would object to",
|
||||
],
|
||||
"audience_hint": "operators and angels in your category, not generalist VC accounts",
|
||||
},
|
||||
"community": {
|
||||
"label": "Build a durable group around a shared problem",
|
||||
"criteria": [
|
||||
"≥20 named people who reliably show up in your comments",
|
||||
"≥1 recurring format the audience anticipates",
|
||||
"Conversation continues without you starting it",
|
||||
],
|
||||
"audience_hint": "people with the same problem, who would recognise each other",
|
||||
},
|
||||
}
|
||||
|
||||
VAGUE_AUDIENCE = [
|
||||
"everyone", "professionals", "people", "business leaders", "the industry",
|
||||
"anyone interested", "my network", "b2b", "companies", "the market",
|
||||
"decision makers", "tech people", "the community",
|
||||
]
|
||||
|
||||
SAMPLE = {
|
||||
"objective": "consulting",
|
||||
"audience": "heads of data at Series A-B SaaS companies who have three analysts and no "
|
||||
"analytics engineer, and whose CEO does not trust the dashboard",
|
||||
"pillars": [
|
||||
{"name": "Trust debt in analytics", "why_you": "I have rebuilt this at three companies",
|
||||
"proof": "the 6-vs-35-days handoff measurement", "share": 40},
|
||||
{"name": "dbt and modelling decisions", "why_you": "I maintain two OSS dbt packages",
|
||||
"proof": "public repos + a conference talk", "share": 35},
|
||||
{"name": "Hiring the first analytics engineer",
|
||||
"why_you": "I have written three of these job specs",
|
||||
"proof": "two hires who are still in role", "share": 15},
|
||||
{"name": "Field notes / experiments", "why_you": "unproven, testing the appetite",
|
||||
"proof": "", "share": 10},
|
||||
],
|
||||
"exclusions": [
|
||||
"generic AI commentary — I have no edge there",
|
||||
"hot takes on other companies' layoffs",
|
||||
"vendor comparison posts that would compromise client confidentiality",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def validate(brief: dict) -> dict:
|
||||
findings, blocking = [], []
|
||||
|
||||
def add(sev, field, msg, fix):
|
||||
entry = {"severity": sev, "field": field, "finding": msg, "fix": fix}
|
||||
(blocking if sev == "blocking" else findings).append(entry)
|
||||
|
||||
obj = (brief.get("objective") or "").strip().lower()
|
||||
if obj not in OBJECTIVES:
|
||||
add("blocking", "objective",
|
||||
f"'{brief.get('objective')}' is not one of the six real objectives "
|
||||
f"({', '.join(OBJECTIVES)}).",
|
||||
"Pick the one that would make you stop posting if it were achieved. "
|
||||
"'Build my brand' is not an objective; it is a side effect of one.")
|
||||
|
||||
aud = (brief.get("audience") or "").strip()
|
||||
aud_low = aud.lower()
|
||||
if not aud:
|
||||
add("blocking", "audience", "No audience declared.",
|
||||
"Name them so specifically that a real person could be excluded.")
|
||||
elif len(aud.split()) < 6 or any(v == aud_low or aud_low.startswith(v) for v in VAGUE_AUDIENCE):
|
||||
add("blocking", "audience",
|
||||
f"'{aud}' is too broad to exclude anyone, which means it cannot guide a single "
|
||||
"editorial decision.",
|
||||
"Add the situation they are in, not just their title: role + company stage + the "
|
||||
"problem they have this quarter."
|
||||
+ (f" For {obj}, aim at {OBJECTIVES[obj]['audience_hint']}." if obj in OBJECTIVES else ""))
|
||||
|
||||
pillars = brief.get("pillars") or []
|
||||
if not 2 <= len(pillars) <= 4:
|
||||
add("blocking", "pillars",
|
||||
f"{len(pillars)} pillar(s). Under two is a monologue; over four is a magazine "
|
||||
"nobody subscribed to.",
|
||||
"Two to four. If a fifth matters that much, it is displacing one of the others.")
|
||||
else:
|
||||
total = sum(int(p.get("share") or 0) for p in pillars)
|
||||
if abs(total - 100) > 2:
|
||||
add("major", "pillars", f"Shares sum to {total}, not 100.",
|
||||
"Rebalance. The share is a budget: it decides what gets cut in a busy week.")
|
||||
backed = [p for p in pillars if (p.get("proof") or "").strip()]
|
||||
if not backed:
|
||||
add("blocking", "pillars",
|
||||
"No pillar has a proof asset behind it. Every pillar is a claim you would have "
|
||||
"to invent evidence for.",
|
||||
"At least one pillar must rest on something that already exists: a shipped "
|
||||
"project, a measurement, a repo, a hire, a talk.")
|
||||
elif len(backed) < len(pillars) - 1:
|
||||
add("major", "pillars",
|
||||
f"Only {len(backed)}/{len(pillars)} pillars are proof-backed.",
|
||||
"One unproven experimental pillar is healthy. Two or more means you are "
|
||||
"positioning on ambition rather than track record.")
|
||||
experimental = [p for p in pillars if int(p.get("share") or 0) <= 20]
|
||||
if not experimental:
|
||||
add("major", "pillars",
|
||||
"Every pillar is a major commitment; there is no small slot to test something new.",
|
||||
"Keep one pillar at 10-20%. It is where next quarter's main pillar comes from.")
|
||||
for p in pillars:
|
||||
if not (p.get("why_you") or "").strip():
|
||||
add("major", "pillars",
|
||||
f"Pillar '{p.get('name', '?')}' has no 'why you'. Anyone could post it.",
|
||||
"State the specific standing you have. If there is none, cut the pillar.")
|
||||
|
||||
exclusions = [e for e in (brief.get("exclusions") or []) if str(e).strip()]
|
||||
if len(exclusions) < 2:
|
||||
add("blocking", "exclusions",
|
||||
f"{len(exclusions)} exclusion(s). A positioning that excludes nothing is not a "
|
||||
"positioning — it is availability.",
|
||||
"Name at least two topics you will not post about, and why. The trending one you "
|
||||
"have no edge on is usually the first.")
|
||||
|
||||
all_findings = blocking + findings
|
||||
if blocking:
|
||||
verdict, code = ("NOT_USABLE", 3) if any(
|
||||
f["field"] in ("objective", "audience") for f in blocking) else ("DEFECTIVE", 2)
|
||||
elif findings:
|
||||
verdict, code = "DEFECTIVE", 2
|
||||
else:
|
||||
verdict, code = "SOUND", 0
|
||||
|
||||
out = {
|
||||
"verdict": verdict,
|
||||
"exit_code": code,
|
||||
"objective": obj if obj in OBJECTIVES else None,
|
||||
"objective_label": OBJECTIVES[obj]["label"] if obj in OBJECTIVES else None,
|
||||
"audience": aud,
|
||||
"pillar_mix": [{"name": p.get("name"), "share": p.get("share"),
|
||||
"proof_backed": bool((p.get("proof") or "").strip())}
|
||||
for p in pillars],
|
||||
"exclusions": exclusions,
|
||||
"findings": all_findings,
|
||||
"rule": ("The brief is the editorial constitution. When a post idea does not fit a "
|
||||
"pillar, the answer is not to add a pillar — it is to not post it, or to "
|
||||
"put it in the experimental slot and see."),
|
||||
}
|
||||
if obj in OBJECTIVES:
|
||||
out["ninety_day_criteria"] = OBJECTIVES[obj]["criteria"]
|
||||
out["criteria_rule"] = ("Observable by someone other than you. Follower count is not on "
|
||||
"this list on purpose — it moves for reasons unrelated to whether "
|
||||
"the objective is being met.")
|
||||
return out
|
||||
|
||||
|
||||
def render_human(r: dict) -> str:
|
||||
lines = [f"Positioning brief: {r['verdict']}", "=" * 52]
|
||||
if r["objective_label"]:
|
||||
lines.append(f"Objective : {r['objective']} — {r['objective_label']}")
|
||||
lines.append(f"Audience : {r['audience'] or '(none)'}")
|
||||
if r["pillar_mix"]:
|
||||
lines.append("Pillars :")
|
||||
for p in r["pillar_mix"]:
|
||||
mark = "proof-backed" if p["proof_backed"] else "UNPROVEN"
|
||||
lines.append(f" {str(p['share']):>3}% {p['name']} [{mark}]")
|
||||
if r["exclusions"]:
|
||||
lines.append("Will not post about:")
|
||||
for e in r["exclusions"]:
|
||||
lines.append(f" - {e}")
|
||||
if r["findings"]:
|
||||
lines.append("\nFindings:")
|
||||
for f in r["findings"]:
|
||||
lines.append(f" [{f['severity'].upper():<8}] {f['field']}: {f['finding']}")
|
||||
lines.append(f" fix → {f['fix']}")
|
||||
else:
|
||||
lines.append("\nNo findings.")
|
||||
if r.get("ninety_day_criteria"):
|
||||
lines.append("\n90-day success criteria:")
|
||||
for c in r["ninety_day_criteria"]:
|
||||
lines.append(f" [ ] {c}")
|
||||
lines.append(f" ({r['criteria_rule']})")
|
||||
lines.append(f"\n{r['rule']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Validate a LinkedIn positioning brief "
|
||||
"(sound=0 / defective=2 / not-usable=3).")
|
||||
ap.add_argument("--objective", choices=sorted(OBJECTIVES))
|
||||
ap.add_argument("--audience", help="Who this is for, specifically.")
|
||||
ap.add_argument("--pillar", action="append", default=[],
|
||||
help="name:why_you:proof:share — repeatable. Empty proof = experimental.")
|
||||
ap.add_argument("--exclude", action="append", default=[],
|
||||
help="A topic you will not post about. Repeatable.")
|
||||
ap.add_argument("--input", help="Read the whole brief from a JSON file ('-' for stdin).")
|
||||
ap.add_argument("--output", choices=["json", "human"], default="json")
|
||||
ap.add_argument("--sample", action="store_true", help="Validate a built-in sample brief.")
|
||||
ap.add_argument("--print-schema", action="store_true", help="Print the JSON shape and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_schema:
|
||||
print(json.dumps(SAMPLE, indent=2))
|
||||
return 0
|
||||
if args.sample:
|
||||
brief = SAMPLE
|
||||
elif args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
||||
try:
|
||||
brief = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
|
||||
return 4
|
||||
else:
|
||||
pillars = []
|
||||
for spec in args.pillar:
|
||||
parts = spec.split(":")
|
||||
while len(parts) < 4:
|
||||
parts.append("")
|
||||
name, why, proof, share = parts[0], parts[1], parts[2], parts[3]
|
||||
try:
|
||||
share_val = int(share) if share.strip() else 0
|
||||
except ValueError:
|
||||
print(f"ERROR: pillar share must be an integer: '{spec}'", file=sys.stderr)
|
||||
return 4
|
||||
pillars.append({"name": name.strip(), "why_you": why.strip(),
|
||||
"proof": proof.strip(), "share": share_val})
|
||||
brief = {"objective": args.objective, "audience": args.audience,
|
||||
"pillars": pillars, "exclusions": args.exclude}
|
||||
if not brief["objective"] and not brief["audience"]:
|
||||
ap.error("provide --objective and --audience (or --input / --sample)")
|
||||
|
||||
result = validate(brief)
|
||||
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
|
||||
return result["exit_code"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Reference in a new issue