feat(linkedin-studio): honest newsletter distribution + profile-SEO + outreach pipeline
Step 17 (Wave 4 S4) of the audit remediation. Applies research/03 §D5 + the two S2 residual fixes folded in. No new commands/agents (counts stay 27/19). Newsletter (commands/newsletter.md): new "Distribution channel" section after Step 10 teaching the HONEST native-newsletter mechanics — bypasses organic feed ranking via ONE deduplicated notification per subscriber per edition (NOT a three-touchpoint blast); the mass invite fires once → ~1-2K follower floor (wait until you can spend it); realistic cold-start 0-100 subs months 1-3; discloses non-export / no-canonical / no-read-analytics / per-subscriber decay; explicit below-vs-above-floor decision rule. Sourced to research/03 D5. Profile (commands/profile.md): new "Profile SEO" section — headline as the highest-weight search field + a per-section keyword-target table (headline/about/experience/skills/featured), consistency-over-stuffing rule. Outreach (commands/outreach.md): Step 8c persists the pipeline board to tracked state via the new recordOutreachContact mutation (mirrors Step 16's recordFirstHourPlan): additive last_outreach_date/outreach_active scalars + a non-R-initial ## Outreach Pipeline section in state-updater.mjs + config/state-file.template.md + --record-outreach CLI branch. +7 tests (state-updater 26→33, full hook suite 83→90). Residual 1 (growth-playbook:216): 9:16 "distribution boost" → 4:5/1:1 guidance (9:16 mobile-only opt-in; "immersive distribution" = uncorroborated heuristic). Residual 2 (video-strategy-guide:300): "3-second test determines 70% retention" → "front-load value for muted autoplay" (three-second hook is folklore, not a LinkedIn signal). Verify: grep checks 1-5 pass; test-runner.sh exit 0 (stat-consistency green); state-updater 33/33. [skip-docs] — tre-doc + version bump deferred to Step 21. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
743867f90a
commit
0b4e1bd097
8 changed files with 284 additions and 3 deletions
|
|
@ -1495,6 +1495,71 @@ Edition complete. Visible in /linkedin:calendar; mark live via /linkedin:calenda
|
|||
|
||||
---
|
||||
|
||||
## Distribution channel — native LinkedIn Newsletter vs a long-form post (honest mechanics)
|
||||
|
||||
A long edition can ship two ways: as a **regular long-form post** (what Steps 8–10
|
||||
deliver — pasted into the feed, ranked organically), or published through
|
||||
LinkedIn's **native Newsletter feature** (subscribe-able, with its own notification
|
||||
channel). They are not the same distribution, and the native newsletter is **not a
|
||||
strict upgrade** — it earns its place only above a follower floor. This section is
|
||||
the honest decision surface; it sells nothing.
|
||||
|
||||
**What the native newsletter actually does (and doesn't):**
|
||||
|
||||
- **It bypasses organic feed ranking** for the subscriber notification — that is the
|
||||
real, defensible benefit. When you publish an edition, each subscriber gets **one
|
||||
notification** routed to their preferred channel (in-app / push / email). The
|
||||
channels are **deduplicated** — LinkedIn's own FAQ states that if you get an in-app
|
||||
or push notification you should **not** also expect an email for the same event. So
|
||||
it is **one deduplicated notification per subscriber per edition — not three
|
||||
guaranteed touchpoints.** Treat any "3× notification" / "hits everyone on every
|
||||
channel" framing as oversold. (The edition is still **also** posted to your feed and
|
||||
can resurface via engagement and interest sections.)
|
||||
- **The mass invite fires once.** On your **first** edition LinkedIn auto-invites all
|
||||
your current connections/followers to subscribe (and invites each new follower once,
|
||||
thereafter). That one-time "invite all followers" blast **spends at the size you
|
||||
launch at** — launch with a small base and you permanently burn the blast on a tiny
|
||||
audience. This is why there is a **~1–2K follower floor**: below it, **wait** — you
|
||||
are not yet able to spend the launch blast well. (The floor aligns with the existing
|
||||
~1K `/linkedin:monetize` and `/linkedin:outreach` unlocks.)
|
||||
- **Cold-start is slow — don't inflate it.** A genuine zero-audience start is roughly
|
||||
**0–100 subscribers in months 1–3.** The viral "0→9K in 7 days" / "0→10K" newsletter
|
||||
case studies were **not** cold starts — they leveraged existing audiences or long
|
||||
grinds. Plan for the slow floor, not the screenshot. Cadence: **weekly** is common
|
||||
among top performers; **biweekly** is a safe default for original analysis.
|
||||
|
||||
**Honest downsides to disclose before you commit:**
|
||||
|
||||
- **Subscribers are non-exportable** — pure platform lock-in. If LinkedIn ever sunsets
|
||||
the feature, you lose the whole list with no portability.
|
||||
- **No canonical control** — LinkedIn outranks your **own site** for the same article.
|
||||
Harmful if you are trying to build an owned property (your domain/email list).
|
||||
- **No read/open/unsubscribe analytics** — you cannot see opens, click detail, or who
|
||||
left. (Consistent with the boundary the plugin states elsewhere: there is no
|
||||
self-serve post-analytics API for a personal profile.)
|
||||
- **Per-subscriber reach decays** as the list grows — a bigger list does not mean every
|
||||
edition reaches everyone; notification delivery is not guaranteed.
|
||||
|
||||
**Decision rule:**
|
||||
|
||||
- **Below ~1–2K followers** → ship the edition as a normal long-form post (Steps 8–10)
|
||||
and **keep building the base** with short-form / documents. Do **not** spend the
|
||||
one-time launch blast yet.
|
||||
- **At/above ~1–2K followers, posting regularly** → the native newsletter is worth it:
|
||||
publish the **first** edition deliberately (that is when the blast fires), then hold a
|
||||
steady weekly/biweekly cadence. Frame it as bypass-the-feed reach **with** the
|
||||
lock-in / no-canonical / no-analytics / decay caveats above understood — not as a
|
||||
guaranteed multi-touch megaphone.
|
||||
|
||||
> **Sourcing.** Mechanics (5-newsletter max, 2-week cooldown, auto-invite, feed
|
||||
> resurfacing, notification dedup) are from LinkedIn's own help docs; the follower
|
||||
> floor and cold-start ranges are practitioner-sourced (medium confidence) — see
|
||||
> `docs/remediation/research/03-coverage-gap-specs.md` §D5. Date every figure when you
|
||||
> repeat it; the email-delivery behavior (dedup vs "always emailed") is genuinely
|
||||
> contested and a one-edition live test would resolve it.
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (16 phases including v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`)
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,37 @@ Day 30:
|
|||
- Update speaker bio with new event
|
||||
```
|
||||
|
||||
### Step 8c: Persist the pipeline to tracked state
|
||||
|
||||
The boards above are the working view; the **tracked record** lives in plugin
|
||||
state so the pipeline survives across sessions (and shows the same partner the
|
||||
next time you run `/linkedin:outreach`). After you add or advance a row, persist
|
||||
it with the additive `recordOutreachContact` mutation — the same state pattern
|
||||
`/linkedin:firsthour` uses (`recordFirstHourPlan`). It writes a newest-first row
|
||||
to a `## Outreach Pipeline` section in `~/.claude/linkedin-studio.local.md` and
|
||||
sets `last_outreach_date` / `outreach_active`, **without touching any existing
|
||||
field** (a missing field is inserted, never required up front):
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs" --record-outreach \
|
||||
--date "YYYY-MM-DD HH:MM" \
|
||||
--track collab \
|
||||
--partner "@name" \
|
||||
--stage pitched \
|
||||
--next "follow up if no reply" \
|
||||
--due YYYY-MM-DD
|
||||
```
|
||||
|
||||
- `--track` — `collab` or `speaking` (the row is tagged so one board carries both).
|
||||
- `--stage` — `warming` / `pitched` / `in-production` / `delivered` / `follow-up`.
|
||||
- `--next` + `--due` — the next action and when it falls due (drives the
|
||||
follow-up surfacing in `/linkedin:calendar` and the Step 10 dashboard).
|
||||
|
||||
Re-running with the same partner appends a fresh dated row, so the section is a
|
||||
running history of where each contact stands — not a single mutable cell. Read
|
||||
the section back at Step 0 of the next outreach session to reconstruct the
|
||||
board.
|
||||
|
||||
## Step 9: Network & Progression
|
||||
|
||||
### Step 9a: Collab — Inner Circle Model
|
||||
|
|
|
|||
|
|
@ -38,6 +38,40 @@ LinkedIn's 150B parameter foundation model evaluates five criteria:
|
|||
| **Network** | Connected to professionals in this space? | MEDIUM - social proof |
|
||||
| **Engagement Patterns** | Do you comment on posts about your topics? | MEDIUM - active participation |
|
||||
|
||||
## Profile SEO — your profile is also a search surface
|
||||
|
||||
Topic-relevance ranking (above) governs **content distribution**. Separately,
|
||||
your profile is **indexed by LinkedIn search** — when someone searches a topic, a
|
||||
role, or a skill, LinkedIn keyword-matches profile fields to decide who surfaces.
|
||||
The two reinforce each other: the same keywords that tell the relevance model
|
||||
what you're expert in are the ones that make you findable. Optimize for both.
|
||||
|
||||
**The headline is your highest-weight search field.** It is keyword-matched, shown
|
||||
in every search result and connection suggestion, and renders under your name
|
||||
across the site — so it does the most SEO work per character. Lead with the plain
|
||||
words people actually search (the role, the domain, the audience), not a clever
|
||||
tagline. "AI Advisor · public-sector AI governance · Microsoft Copilot" is more
|
||||
findable than "Turning chaos into clarity ✨".
|
||||
|
||||
**Per-section keyword targets** (place the terms a searcher would type, in the
|
||||
words they'd type them — not synonyms only you use):
|
||||
|
||||
| Section | Keyword target | Why it ranks |
|
||||
|---------|----------------|--------------|
|
||||
| **Headline** | 3–4 primary topic terms + audience + role | Highest-weight search field; always visible |
|
||||
| **About** | Same primary terms, front-loaded in the first 2–3 lines, then 5–8 supporting terms naturally across the body | Indexed for search; first lines double as the relevance model's expertise signal |
|
||||
| **Experience (titles + body)** | The searchable job title (not an internal-only label) + 2–3 domain terms per role | Job titles are weighted in search; an internal title nobody searches is invisible |
|
||||
| **Skills** | Your top 3 skills = your 3 core content topics, exact-match to common search terms | Matched directly against recruiter/search skill filters |
|
||||
| **Featured** | Posts whose titles carry your topic terms | Reinforces the topic association for both search and relevance |
|
||||
|
||||
**Rule of thumb:** pick your 3–5 core topics once, then make the *same* terms
|
||||
appear — in the searcher's own words — in the headline, the About opener, the
|
||||
skills, and your recent post topics. Keyword **consistency across sections**
|
||||
beats keyword **stuffing in any one section**: LinkedIn rewards a coherent
|
||||
expertise signal, and a profile crammed with unrelated terms reads as noise to
|
||||
both the search index and the relevance model. Avoid buzzwords nobody searches
|
||||
("thought leader", "guru", "ninja") — they cost a keyword slot and return nothing.
|
||||
|
||||
## Profile Audit Walkthrough
|
||||
|
||||
Guide the user through each section using AskUserQuestion for interactive feedback.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ content_series_active: ""
|
|||
last_firsthour_date: null # "YYYY-MM-DD HH:MM" of the most recent first-hour plan
|
||||
firsthour_active: false # true while a first-hour loop is in progress
|
||||
|
||||
# Outreach (collab + speaking pipeline)
|
||||
last_outreach_date: null # "YYYY-MM-DD HH:MM" of the most recent outreach contact
|
||||
outreach_active: false # true while an outreach pipeline is being worked
|
||||
|
||||
# Profile
|
||||
expertise_areas:
|
||||
- "general"
|
||||
|
|
@ -68,3 +72,8 @@ expertise_areas:
|
|||
|
||||
<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->
|
||||
<!-- Format: ### [YYYY-MM-DD HH:MM] topic -->
|
||||
|
||||
## Outreach Pipeline
|
||||
|
||||
<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->
|
||||
<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan } from '../state-updater.mjs';
|
||||
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } from '../state-updater.mjs';
|
||||
|
||||
const SAMPLE_STATE = `---
|
||||
last_post_date: "2026-04-05"
|
||||
|
|
@ -365,3 +365,77 @@ describe('recordFirstHourPlan', () => {
|
|||
assert.ok(result.changes.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordOutreachContact', () => {
|
||||
const CONTACT_OPTS = {
|
||||
contactDate: '2026-05-30 14:00',
|
||||
track: 'collab',
|
||||
partner: '@bigvoice',
|
||||
stage: 'pitched',
|
||||
nextAction: 'follow up if no reply',
|
||||
dueDate: '2026-06-06'
|
||||
};
|
||||
|
||||
test('creates a non-R-initial Outreach Pipeline section when absent', () => {
|
||||
// SAMPLE_STATE has no ## Outreach Pipeline section → must be created (additive)
|
||||
assert.ok(!SAMPLE_STATE.includes('## Outreach Pipeline'));
|
||||
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.content.includes('## Outreach Pipeline'));
|
||||
});
|
||||
|
||||
test('records track, partner, stage, next action and due in the entry', () => {
|
||||
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.content.includes('[2026-05-30 14:00]'));
|
||||
assert.ok(result.content.includes('@bigvoice'));
|
||||
assert.ok(result.content.includes('collab'));
|
||||
assert.ok(result.content.includes('pitched'));
|
||||
assert.ok(result.content.includes('follow up if no reply'));
|
||||
assert.ok(result.content.includes('2026-06-06'));
|
||||
});
|
||||
|
||||
test('is additive — inserts last_outreach_date when the field is absent', () => {
|
||||
assert.ok(!/^last_outreach_date:/m.test(SAMPLE_STATE));
|
||||
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^last_outreach_date: "2026-05-30 14:00"$/m);
|
||||
});
|
||||
|
||||
test('updates an existing last_outreach_date without duplicating it', () => {
|
||||
const withField = SAMPLE_STATE.replace(
|
||||
'last_post_date: "2026-04-05"',
|
||||
'last_post_date: "2026-04-05"\nlast_outreach_date: null'
|
||||
);
|
||||
const result = recordOutreachContact(withField, { ...CONTACT_OPTS, contactDate: '2026-05-31 09:00' });
|
||||
assert.notEqual(result, null);
|
||||
const hits = result.content.match(/^last_outreach_date:/gm) || [];
|
||||
assert.equal(hits.length, 1, 'field must not be duplicated');
|
||||
assert.match(result.content, /^last_outreach_date: "2026-05-31 09:00"$/m);
|
||||
});
|
||||
|
||||
test('leaves existing fields and sections untouched (round-trip)', () => {
|
||||
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
|
||||
assert.match(result.content, /^follower_count: 850$/m);
|
||||
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
|
||||
});
|
||||
|
||||
test('gracefully defaults empty optional fields', () => {
|
||||
const result = recordOutreachContact(SAMPLE_STATE, {
|
||||
contactDate: '2026-05-30 14:00',
|
||||
partner: 'minimal-contact'
|
||||
});
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.content.includes('## Outreach Pipeline'));
|
||||
assert.ok(result.content.includes('minimal-contact'));
|
||||
});
|
||||
|
||||
test('returns a changes array describing what changed', () => {
|
||||
const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(Array.isArray(result.changes));
|
||||
assert.ok(result.changes.length > 0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -259,6 +259,63 @@ export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', ta
|
|||
return { content, changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an outreach contact / pipeline entry deterministically. Additive: an
|
||||
* absent scalar is inserted (never required up front), an absent section is
|
||||
* created, and no existing field is touched. Mirrors recordFirstHourPlan:
|
||||
* scalar replace + newest-first section append. The section name is
|
||||
* deliberately non-`R`-initial ("Outreach …") so it falls outside
|
||||
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
|
||||
*
|
||||
* @param {string} stateContent - Full state file content
|
||||
* @param {{ contactDate: string, track?: string, partner?: string, stage?: string, nextAction?: string, dueDate?: string }} opts
|
||||
* @returns {{ content: string, changes: string[] } | null}
|
||||
*/
|
||||
export function recordOutreachContact(stateContent, { contactDate, track = '', partner = '', stage = '', nextAction = '', dueDate = '' }) {
|
||||
let content = stateContent;
|
||||
const changes = [];
|
||||
|
||||
// 1. last_outreach_date — replace in place, else insert after last_firsthour_date
|
||||
// if present, else after last_post_date (additive — never required up front)
|
||||
if (/^last_outreach_date: .*/m.test(content)) {
|
||||
content = replaceField(content, 'last_outreach_date', `"${contactDate}"`);
|
||||
} else if (/^last_firsthour_date: .*/m.test(content)) {
|
||||
content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
|
||||
} else if (/^last_post_date: .*/m.test(content)) {
|
||||
content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
|
||||
}
|
||||
changes.push(`last_outreach_date → ${contactDate}`);
|
||||
|
||||
// 2. outreach_active flag — only touch if the field is declared (additive)
|
||||
if (/^outreach_active: .*/m.test(content)) {
|
||||
content = replaceField(content, 'outreach_active', 'true');
|
||||
changes.push('outreach_active → true');
|
||||
}
|
||||
|
||||
// 3. Build the pipeline entry block
|
||||
const fmt = (v) => (v && String(v).trim() ? String(v).trim() : '_(none)_');
|
||||
const heading = `### [${contactDate}] ${partner || '(contact)'}${track ? ` — ${track}` : ''}`.trimEnd();
|
||||
const entry = [
|
||||
heading,
|
||||
`- **Stage:** ${fmt(stage)}`,
|
||||
`- **Next action:** ${fmt(nextAction)}`,
|
||||
`- **Due:** ${fmt(dueDate)}`,
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
// 4. Append to ## Outreach Pipeline (newest first); create the section if absent (additive)
|
||||
if (/^## Outreach Pipeline\b/m.test(content)) {
|
||||
content = content.replace(/^(## Outreach Pipeline\n\n?)/m, `$1${entry}\n`);
|
||||
} else {
|
||||
const trimmed = content.replace(/\s*$/, '');
|
||||
content = `${trimmed}\n\n## Outreach Pipeline\n\n<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->\n<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->\n\n${entry}\n`;
|
||||
}
|
||||
changes.push(`Outreach Pipeline += ${contactDate} "${partner || '(contact)'}"`);
|
||||
|
||||
if (content === stateContent) return null;
|
||||
return { content, changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* I/O wrapper: read state file, apply update function, write atomically.
|
||||
* @param {function(string): {content: string}|null} updateFn - Pure update function
|
||||
|
|
@ -311,11 +368,22 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||
draftComments: splitList(getArg('--comments')),
|
||||
plan: splitList(getArg('--plan'))
|
||||
}));
|
||||
} else if (args.includes('--record-outreach')) {
|
||||
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
|
||||
writeState(content => recordOutreachContact(content, {
|
||||
contactDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
track: getArg('--track') || '',
|
||||
partner: getArg('--partner') || '',
|
||||
stage: getArg('--stage') || '',
|
||||
nextAction: getArg('--next') || '',
|
||||
dueDate: getArg('--due') || ''
|
||||
}));
|
||||
} else {
|
||||
console.log('Usage:');
|
||||
console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post');
|
||||
console.log(' node state-updater.mjs --prune [days]');
|
||||
console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04');
|
||||
console.log(' node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"');
|
||||
console.log(' node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in
|
|||
**If using video:**
|
||||
- Optimal length: 60 seconds (2026 sweet spot — 30% completion rate minimum for any distribution)
|
||||
- Always add captions (85% watch with sound off)
|
||||
- Use vertical 9:16 format (1080x1920) for immersive feed distribution boost
|
||||
- **4:5 (1080×1350) or 1:1 (1080×1080) preferred** for broad feed distribution on a desktop-heavy professional audience; 9:16 is delivered mobile-only and crops to 1:1 on desktop — reserve it for the opt-in vertical video tab. No official 4:5-vs-9:16 engagement study exists, so treat the older "immersive distribution" framing as an uncorroborated heuristic, not a reach lever (see `references/linkedin-formats.md`).
|
||||
|
||||
### Text-Only Posts
|
||||
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ LinkedIn's algorithm weights **completion rate** above all other video metrics.
|
|||
- Use "open loops" — tease what's coming ("and the third one surprised me...")
|
||||
- Vary pacing to prevent monotony
|
||||
- Place visual changes every 5-7 seconds
|
||||
- Strong hook → the 3-second test determines 70% of retention
|
||||
- Front-load value for muted autoplay — ~85% watch without sound, so the opening must read and land with the audio off. (The "three-second hook" is cross-platform folklore, not a LinkedIn-named ranking signal; LinkedIn's only official "3 seconds" is the minimum video length. Open strong because attention is scarce, not because a fixed-second rule sets retention.)
|
||||
|
||||
### Vertical Video Preference
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue