@sellable/mcp 0.1.256 → 0.1.257
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/engage-discovery.d.ts +21 -0
- package/dist/tools/engage-discovery.js +136 -9
- package/dist/tools/registry.d.ts +8 -0
- package/package.json +1 -1
- package/skills/create-post/SKILL.md +15 -4
- package/skills/create-post/references/hook-research-playbook.md +148 -1
- package/skills/create-post/references/post-file-contract.md +5 -0
- package/skills/create-post/references/post-validation.md +34 -0
|
@@ -7,6 +7,7 @@ export type EngagementPost = {
|
|
|
7
7
|
name: string;
|
|
8
8
|
headline: string;
|
|
9
9
|
profileUrl: string;
|
|
10
|
+
followerCount: number | null;
|
|
10
11
|
};
|
|
11
12
|
engagement: {
|
|
12
13
|
likes: number;
|
|
@@ -14,6 +15,16 @@ export type EngagementPost = {
|
|
|
14
15
|
shares: number;
|
|
15
16
|
total: number;
|
|
16
17
|
};
|
|
18
|
+
reachSignals: {
|
|
19
|
+
targetFollowerMin: number | null;
|
|
20
|
+
targetFollowerMax: number | null;
|
|
21
|
+
followerBandFit: "in_target_band" | "below_target_band" | "above_target_band" | "unknown";
|
|
22
|
+
weightedEngagement: number;
|
|
23
|
+
engagementPer1kFollowers: number | null;
|
|
24
|
+
weightedEngagementPer1kFollowers: number | null;
|
|
25
|
+
reachPenaltyMultiplier: number;
|
|
26
|
+
reachAdjustedScore: number | null;
|
|
27
|
+
};
|
|
17
28
|
contentPreview: string;
|
|
18
29
|
};
|
|
19
30
|
export type SearchEngagementPostsInput = {
|
|
@@ -22,6 +33,8 @@ export type SearchEngagementPostsInput = {
|
|
|
22
33
|
maxAgeDays?: number;
|
|
23
34
|
minTotalEngagement?: number;
|
|
24
35
|
maxPosts?: number;
|
|
36
|
+
targetFollowerMin?: number;
|
|
37
|
+
targetFollowerMax?: number;
|
|
25
38
|
excludePostUrls?: string[];
|
|
26
39
|
};
|
|
27
40
|
export type SearchEngagementPostsResponse = {
|
|
@@ -63,6 +76,14 @@ export declare const engageDiscoveryToolDefinitions: {
|
|
|
63
76
|
type: string;
|
|
64
77
|
description: string;
|
|
65
78
|
};
|
|
79
|
+
targetFollowerMin: {
|
|
80
|
+
type: string;
|
|
81
|
+
description: string;
|
|
82
|
+
};
|
|
83
|
+
targetFollowerMax: {
|
|
84
|
+
type: string;
|
|
85
|
+
description: string;
|
|
86
|
+
};
|
|
66
87
|
excludePostUrls: {
|
|
67
88
|
type: string;
|
|
68
89
|
items: {
|
|
@@ -27,6 +27,14 @@ export const engageDiscoveryToolDefinitions = [
|
|
|
27
27
|
type: "number",
|
|
28
28
|
description: "Max posts to return after filtering (default 25).",
|
|
29
29
|
},
|
|
30
|
+
targetFollowerMin: {
|
|
31
|
+
type: "number",
|
|
32
|
+
description: "Optional lower bound for the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
|
|
33
|
+
},
|
|
34
|
+
targetFollowerMax: {
|
|
35
|
+
type: "number",
|
|
36
|
+
description: "Optional upper bound for the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
|
|
37
|
+
},
|
|
30
38
|
excludePostUrls: {
|
|
31
39
|
type: "array",
|
|
32
40
|
items: { type: "string" },
|
|
@@ -63,6 +71,101 @@ function safeNumber(value) {
|
|
|
63
71
|
const n = typeof value === "number" ? value : Number(value);
|
|
64
72
|
return Number.isFinite(n) ? n : 0;
|
|
65
73
|
}
|
|
74
|
+
function parseFollowerCount(value) {
|
|
75
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
76
|
+
return Math.round(value);
|
|
77
|
+
}
|
|
78
|
+
if (typeof value !== "string")
|
|
79
|
+
return null;
|
|
80
|
+
const cleaned = value.trim().toLowerCase().replace(/,/g, "");
|
|
81
|
+
if (!cleaned)
|
|
82
|
+
return null;
|
|
83
|
+
const match = cleaned.match(/(\d+(?:\.\d+)?)\s*([kmb])?/);
|
|
84
|
+
if (!match)
|
|
85
|
+
return null;
|
|
86
|
+
const base = Number(match[1]);
|
|
87
|
+
if (!Number.isFinite(base) || base <= 0)
|
|
88
|
+
return null;
|
|
89
|
+
const suffix = match[2] || "";
|
|
90
|
+
const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
|
|
91
|
+
return Math.round(base * multiplier);
|
|
92
|
+
}
|
|
93
|
+
function extractFollowerCount(post) {
|
|
94
|
+
const candidates = [
|
|
95
|
+
post?.author?.followerCount,
|
|
96
|
+
post?.author?.followersCount,
|
|
97
|
+
post?.author?.followers,
|
|
98
|
+
post?.author?.follower_count,
|
|
99
|
+
post?.author?.linkedinFollowers,
|
|
100
|
+
post?.author?.stats?.followers,
|
|
101
|
+
post?.followerCount,
|
|
102
|
+
post?.followersCount,
|
|
103
|
+
post?.followers,
|
|
104
|
+
post?.authorFollowerCount,
|
|
105
|
+
];
|
|
106
|
+
for (const candidate of candidates) {
|
|
107
|
+
const parsed = parseFollowerCount(candidate);
|
|
108
|
+
if (parsed !== null)
|
|
109
|
+
return parsed;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
function normalizeFollowerBound(value) {
|
|
114
|
+
const parsed = parseFollowerCount(value);
|
|
115
|
+
return parsed && parsed > 0 ? parsed : null;
|
|
116
|
+
}
|
|
117
|
+
function followerBandFit(followerCount, targetFollowerMin, targetFollowerMax) {
|
|
118
|
+
if (!followerCount)
|
|
119
|
+
return "unknown";
|
|
120
|
+
if (targetFollowerMin && followerCount < targetFollowerMin) {
|
|
121
|
+
return "below_target_band";
|
|
122
|
+
}
|
|
123
|
+
if (targetFollowerMax && followerCount > targetFollowerMax) {
|
|
124
|
+
return "above_target_band";
|
|
125
|
+
}
|
|
126
|
+
if (targetFollowerMin || targetFollowerMax)
|
|
127
|
+
return "in_target_band";
|
|
128
|
+
return "unknown";
|
|
129
|
+
}
|
|
130
|
+
function bandMultiplier(fit, followerCount, targetFollowerMax) {
|
|
131
|
+
if (fit === "in_target_band")
|
|
132
|
+
return 1;
|
|
133
|
+
if (fit === "below_target_band")
|
|
134
|
+
return 0.75;
|
|
135
|
+
if (fit === "unknown")
|
|
136
|
+
return 0.4;
|
|
137
|
+
if (!followerCount || !targetFollowerMax)
|
|
138
|
+
return 0.45;
|
|
139
|
+
if (followerCount <= targetFollowerMax * 2)
|
|
140
|
+
return 0.65;
|
|
141
|
+
if (followerCount <= targetFollowerMax * 5)
|
|
142
|
+
return 0.35;
|
|
143
|
+
return 0.15;
|
|
144
|
+
}
|
|
145
|
+
function reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax) {
|
|
146
|
+
const weightedEngagement = engagement.likes + engagement.comments * 4 + engagement.shares * 12;
|
|
147
|
+
const fit = followerBandFit(followerCount, targetFollowerMin, targetFollowerMax);
|
|
148
|
+
const engagementPer1kFollowers = followerCount
|
|
149
|
+
? Number(((engagement.total / followerCount) * 1000).toFixed(3))
|
|
150
|
+
: null;
|
|
151
|
+
const weightedEngagementPer1kFollowers = followerCount
|
|
152
|
+
? Number(((weightedEngagement / followerCount) * 1000).toFixed(3))
|
|
153
|
+
: null;
|
|
154
|
+
const reachPenaltyMultiplier = bandMultiplier(fit, followerCount, targetFollowerMax);
|
|
155
|
+
const reachAdjustedScore = weightedEngagementPer1kFollowers === null
|
|
156
|
+
? null
|
|
157
|
+
: Number((weightedEngagementPer1kFollowers * reachPenaltyMultiplier).toFixed(3));
|
|
158
|
+
return {
|
|
159
|
+
targetFollowerMin,
|
|
160
|
+
targetFollowerMax,
|
|
161
|
+
followerBandFit: fit,
|
|
162
|
+
weightedEngagement,
|
|
163
|
+
engagementPer1kFollowers,
|
|
164
|
+
weightedEngagementPer1kFollowers,
|
|
165
|
+
reachPenaltyMultiplier,
|
|
166
|
+
reachAdjustedScore,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
66
169
|
export async function searchEngagementPosts(input) {
|
|
67
170
|
const api = getApi();
|
|
68
171
|
const keywords = (input.keywords || [])
|
|
@@ -79,6 +182,10 @@ export async function searchEngagementPosts(input) {
|
|
|
79
182
|
const maxPosts = typeof input.maxPosts === "number" && input.maxPosts > 0
|
|
80
183
|
? Math.min(50, input.maxPosts)
|
|
81
184
|
: 25;
|
|
185
|
+
const targetFollowerMin = normalizeFollowerBound(input.targetFollowerMin);
|
|
186
|
+
const targetFollowerMax = normalizeFollowerBound(input.targetFollowerMax);
|
|
187
|
+
const useTargetFollowerBand = Boolean(targetFollowerMin || targetFollowerMax);
|
|
188
|
+
const collectLimit = useTargetFollowerBand ? 50 : maxPosts;
|
|
82
189
|
const exclude = new Set((input.excludePostUrls || [])
|
|
83
190
|
.map((u) => normalizePostUrl(u))
|
|
84
191
|
.filter(Boolean));
|
|
@@ -87,11 +194,13 @@ export async function searchEngagementPosts(input) {
|
|
|
87
194
|
keywords: keywords.map((keyword) => ({ keyword })),
|
|
88
195
|
page,
|
|
89
196
|
});
|
|
90
|
-
const rawPosts = Array.isArray(response?.
|
|
91
|
-
? response.
|
|
92
|
-
: Array.isArray(response?.
|
|
93
|
-
? response.
|
|
94
|
-
:
|
|
197
|
+
const rawPosts = useTargetFollowerBand && Array.isArray(response?.posts)
|
|
198
|
+
? response.posts
|
|
199
|
+
: Array.isArray(response?.topPostsForLLM)
|
|
200
|
+
? response.topPostsForLLM
|
|
201
|
+
: Array.isArray(response?.posts)
|
|
202
|
+
? response.posts
|
|
203
|
+
: [];
|
|
95
204
|
const now = Date.now();
|
|
96
205
|
const oldestMs = now - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
97
206
|
let tooOld = 0;
|
|
@@ -111,6 +220,7 @@ export async function searchEngagementPosts(input) {
|
|
|
111
220
|
const comments = safeNumber(p?.engagement?.comments);
|
|
112
221
|
const shares = safeNumber(p?.engagement?.shares);
|
|
113
222
|
const total = likes + comments + shares;
|
|
223
|
+
const engagement = { likes, comments, shares, total };
|
|
114
224
|
if (total < minTotalEngagement) {
|
|
115
225
|
tooLowEngagement += 1;
|
|
116
226
|
continue;
|
|
@@ -121,6 +231,7 @@ export async function searchEngagementPosts(input) {
|
|
|
121
231
|
tooOld += 1;
|
|
122
232
|
continue;
|
|
123
233
|
}
|
|
234
|
+
const followerCount = extractFollowerCount(p);
|
|
124
235
|
kept.push({
|
|
125
236
|
postId: String(p?.id || ""),
|
|
126
237
|
url,
|
|
@@ -132,15 +243,31 @@ export async function searchEngagementPosts(input) {
|
|
|
132
243
|
name: String(p?.author?.name || ""),
|
|
133
244
|
headline: String(p?.author?.headline || ""),
|
|
134
245
|
profileUrl: String(p?.author?.profileUrl || ""),
|
|
246
|
+
followerCount,
|
|
135
247
|
},
|
|
136
|
-
engagement
|
|
248
|
+
engagement,
|
|
249
|
+
reachSignals: reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax),
|
|
137
250
|
contentPreview: previewText(String(p?.content || ""), 220),
|
|
138
251
|
});
|
|
139
|
-
if (kept.length >=
|
|
252
|
+
if (kept.length >= collectLimit)
|
|
140
253
|
break;
|
|
141
254
|
}
|
|
142
|
-
|
|
143
|
-
|
|
255
|
+
if (useTargetFollowerBand) {
|
|
256
|
+
kept.sort((a, b) => {
|
|
257
|
+
const scoreDelta = (b.reachSignals.reachAdjustedScore ?? -1) -
|
|
258
|
+
(a.reachSignals.reachAdjustedScore ?? -1);
|
|
259
|
+
if (scoreDelta !== 0)
|
|
260
|
+
return scoreDelta;
|
|
261
|
+
return b.engagement.total - a.engagement.total;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// Sort by total engagement desc for a predictable shortlist.
|
|
266
|
+
kept.sort((a, b) => b.engagement.total - a.engagement.total);
|
|
267
|
+
}
|
|
268
|
+
if (kept.length > maxPosts) {
|
|
269
|
+
kept.length = maxPosts;
|
|
270
|
+
}
|
|
144
271
|
return {
|
|
145
272
|
success: true,
|
|
146
273
|
posts: kept,
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -1742,6 +1742,14 @@ export declare const allTools: ({
|
|
|
1742
1742
|
type: string;
|
|
1743
1743
|
description: string;
|
|
1744
1744
|
};
|
|
1745
|
+
targetFollowerMin: {
|
|
1746
|
+
type: string;
|
|
1747
|
+
description: string;
|
|
1748
|
+
};
|
|
1749
|
+
targetFollowerMax: {
|
|
1750
|
+
type: string;
|
|
1751
|
+
description: string;
|
|
1752
|
+
};
|
|
1745
1753
|
excludePostUrls: {
|
|
1746
1754
|
type: string;
|
|
1747
1755
|
items: {
|
package/package.json
CHANGED
|
@@ -321,6 +321,7 @@ The research worker must return a compact packet only:
|
|
|
321
321
|
- full adapted hook blocks
|
|
322
322
|
- market belief map: resonating ideas, implicit beliefs, audience wants, resentments, fears, and credible controversy angles
|
|
323
323
|
- premise inputs: real scenes, observed tensions, reader value openings, and proof gaps
|
|
324
|
+
- reach-normalized signal notes, including follower-band fit when available
|
|
324
325
|
- exact phrase patterns and sentence shapes
|
|
325
326
|
- body structures and exact body language moves
|
|
326
327
|
- rendered mobile and desktop preview records
|
|
@@ -334,11 +335,11 @@ the exact extracted phrase shapes.
|
|
|
334
335
|
Default flow:
|
|
335
336
|
|
|
336
337
|
1. Convert the idea into 3-8 search keywords.
|
|
337
|
-
2. Call `mcp__sellable__search_engagement_posts` with an explicit multi-month window. Default to `maxAgeDays: 120`, tightening to 30-60 days only when the topic is trend-sensitive.
|
|
338
|
-
3. Shortlist
|
|
339
|
-
4. Because search results may only include previews, call `mcp__sellable__fetch_linkedin_posts` for shortlisted authors/profile URLs and match recent posts by URL/activity ID when full text is needed.
|
|
338
|
+
2. Call `mcp__sellable__search_engagement_posts` with an explicit multi-month window. Default to `maxAgeDays: 120`, tightening to 30-60 days only when the topic is trend-sensitive. When the user gives a follower range, pass it as `targetFollowerMin` and `targetFollowerMax`; for example, "8k-20k followers" means `targetFollowerMin: 8000` and `targetFollowerMax: 20000`.
|
|
339
|
+
3. Shortlist posts by topic fit, rendered hook strength, content pattern replicability, creator repeat evidence, weighted engagement quality, and reach-normalized signal quality. Do not sort by raw engagement alone, and do not let the numeric reach score choose the final hook by itself.
|
|
340
|
+
4. Because search results may only include previews, call `mcp__sellable__fetch_linkedin_posts` for shortlisted authors/profile URLs and match recent posts by URL/activity ID when full text is needed. If a promising source is missing follower count and the user requested reach normalization, call `mcp__sellable__fetch_linkedin_profile` on a bounded shortlist before treating the source as a keeper.
|
|
340
341
|
5. If full text cannot be matched, record `full_text_unavailable` and use only the preview. Do not invent missing body details.
|
|
341
|
-
6. Weigh shares/reposts above comments, comments above reactions, and reactions as weak reach unless paired with stronger signals. If shares/reposts are unavailable, record `repost_data_unavailable`.
|
|
342
|
+
6. Weigh shares/reposts above comments, comments above reactions, and reactions as weak reach unless paired with stronger signals. Normalize engagement against follower count when available: record `authorFollowerCount`, `targetFollowerBand`, `followerBandFit`, `engagementPer1kFollowers`, `weightedEngagementPer1kFollowers`, `reachPenaltyMultiplier`, `reachAdjustedScore`, and `baselineLift` when repeated creator posts make it possible. If follower count is unavailable, record `follower_count_unavailable`; if shares/reposts are unavailable, record `repost_data_unavailable`.
|
|
342
343
|
7. Penalize lead-magnet or giveaway mechanics unless the user explicitly asks for a lead magnet post.
|
|
343
344
|
8. Build a market belief map before hook generation: what the space is rewarding, what the audience implicitly believes, what they want permission to say, what they resent or fear, what they will argue with, and which controversial angle the user can credibly own. If the user's raw idea is internally coherent but not attached to a live market tension, do not draft from the internal idea alone; present stronger directions or rewrite the draft angle around the external tension.
|
|
344
345
|
9. Extract premise inputs: real story/scene possibilities, observed tensions, useful reader takeaways, and proof gaps. A good hook cannot rescue a premise with no value.
|
|
@@ -356,7 +357,9 @@ Record provenance:
|
|
|
356
357
|
- search window
|
|
357
358
|
- source post URLs
|
|
358
359
|
- authors/profile URLs
|
|
360
|
+
- author follower counts and target follower-band fit when available
|
|
359
361
|
- engagement totals and available likes/comments/shares breakdown
|
|
362
|
+
- reach-normalized scoring fields and confidence notes
|
|
360
363
|
- creator repeat evidence
|
|
361
364
|
- lead-magnet or engagement-bait penalties
|
|
362
365
|
- story mechanism when relevant
|
|
@@ -369,6 +372,11 @@ Record provenance:
|
|
|
369
372
|
- exact phrase patterns and sentence shapes
|
|
370
373
|
- body structures and body language patterns
|
|
371
374
|
- why each pattern fits the user's idea and voice
|
|
375
|
+
- `whyTheHookCarries`: why the selected hook works from the words, tension, and
|
|
376
|
+
content pattern independent of the source creator's reach
|
|
377
|
+
- `whyTheReachEvidenceIsTrustworthy`: follower-band fit, reach-adjusted score,
|
|
378
|
+
baseline lift, share/comment quality, or why a large-account example is only
|
|
379
|
+
secondary pattern evidence
|
|
372
380
|
|
|
373
381
|
## Step 1.5: Research Learning Report
|
|
374
382
|
|
|
@@ -394,6 +402,9 @@ Research status:
|
|
|
394
402
|
- keywords:
|
|
395
403
|
- full-text coverage:
|
|
396
404
|
- repost/share data:
|
|
405
|
+
- target follower band:
|
|
406
|
+
- reach-normalized winners:
|
|
407
|
+
- big-account examples used only as secondary pattern evidence:
|
|
397
408
|
|
|
398
409
|
Best source examples:
|
|
399
410
|
1. author, URL, engagement, why kept, why not copied
|
|
@@ -28,6 +28,7 @@ Worker owns:
|
|
|
28
28
|
- tracked-person post fetches
|
|
29
29
|
- full-text matching by URL/activity ID
|
|
30
30
|
- duplicate removal
|
|
31
|
+
- follower-band and reach-normalized signal scoring
|
|
31
32
|
- lead-magnet, giveaway, engagement-bait, and off-voice filtering
|
|
32
33
|
- market belief mapping across kept and rejected examples
|
|
33
34
|
- premise input extraction: real scenes, observed tensions, reader value, proof gaps
|
|
@@ -58,6 +59,7 @@ Research packet:
|
|
|
58
59
|
- exact phrase patterns: max 20
|
|
59
60
|
- body patterns: max 8
|
|
60
61
|
- source URLs and author profile URLs
|
|
62
|
+
- reach-normalized signal notes
|
|
61
63
|
- rendered preview records
|
|
62
64
|
- confidence gaps
|
|
63
65
|
- save recommendations
|
|
@@ -74,6 +76,13 @@ Use `mcp__sellable__search_engagement_posts` with practical constraints:
|
|
|
74
76
|
|
|
75
77
|
- explicit `maxAgeDays: 120` by default so research covers the past 30-120 days, not only this week's posts
|
|
76
78
|
- tighten to 30-60 days for trend-sensitive topics; widen only when the topic has low volume
|
|
79
|
+
- when the user gives a follower range, pass it as `targetFollowerMin` and
|
|
80
|
+
`targetFollowerMax` so the tool can expose reach-normalized signals and
|
|
81
|
+
prioritize comparable creators
|
|
82
|
+
- if a source hook looks promising but follower count is missing, use
|
|
83
|
+
`mcp__sellable__fetch_linkedin_profile` on a bounded shortlist before calling
|
|
84
|
+
the source a keeper; if profile fetch is unavailable or too slow, mark
|
|
85
|
+
`follower_count_unavailable`
|
|
77
86
|
- high enough engagement to matter
|
|
78
87
|
- narrow enough to match the idea
|
|
79
88
|
- enough result depth to see whether a creator has repeated winners, not just one viral outlier
|
|
@@ -82,14 +91,19 @@ Record every keyword, filter, search window, page, and result count.
|
|
|
82
91
|
|
|
83
92
|
## Weighted Signals
|
|
84
93
|
|
|
85
|
-
Rank source posts with a weighted signal view. Do not sort by total engagement
|
|
94
|
+
Rank source posts with a weighted signal view. Do not sort by total engagement
|
|
95
|
+
alone. Use the numeric score to decide which sources deserve study; do not let
|
|
96
|
+
the number choose the final hook by itself.
|
|
86
97
|
|
|
87
98
|
Use this scoring shape:
|
|
88
99
|
|
|
89
100
|
```text
|
|
90
101
|
hook_score =
|
|
91
102
|
topic_fit
|
|
103
|
+
+ rendered_mobile_preview_strength
|
|
92
104
|
+ hook_clarity
|
|
105
|
+
+ content_pattern_replicability
|
|
106
|
+
+ reach_adjusted_evidence
|
|
93
107
|
+ creator_repeat_success
|
|
94
108
|
+ repost_share_strength
|
|
95
109
|
+ comment_quality
|
|
@@ -107,6 +121,135 @@ Guidance:
|
|
|
107
121
|
- If share or repost data is unavailable, record `repost_data_unavailable` instead of inventing it.
|
|
108
122
|
- Prefer creators with repeated high-performing posts in the same lane over one-off viral posts.
|
|
109
123
|
|
|
124
|
+
## Reach-Normalized Hook Scoring
|
|
125
|
+
|
|
126
|
+
Raw engagement is not the clearest signal when source creators have wildly
|
|
127
|
+
different audience sizes. A post from a 100k+ follower creator can win on
|
|
128
|
+
distribution even when the hook is ordinary. A post from a creator near the
|
|
129
|
+
user's follower range that overperforms is a clearer signal that LinkedIn and
|
|
130
|
+
readers rewarded the hook/premise.
|
|
131
|
+
|
|
132
|
+
This calculation is a source-quality filter, not the final creative decision.
|
|
133
|
+
After reach is controlled, the LLM still chooses the hooks to steal based on the
|
|
134
|
+
visible hook, rendered first-screen promise, content pattern, premise fit, and
|
|
135
|
+
whether the idea can carry without the source creator's distribution.
|
|
136
|
+
|
|
137
|
+
When the user gives a target range, default to that range. For example, if the
|
|
138
|
+
user says "8k to 20k followers," call `mcp__sellable__search_engagement_posts`
|
|
139
|
+
with:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
targetFollowerMin: 8000
|
|
143
|
+
targetFollowerMax: 20000
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
If the user gives no range, use the user's known follower count from memory or
|
|
147
|
+
ask only when the decision depends on it. If no follower count is available, use
|
|
148
|
+
raw engagement as a fallback and mark `target_follower_band_unknown`.
|
|
149
|
+
|
|
150
|
+
For every shortlisted source post, record:
|
|
151
|
+
|
|
152
|
+
- `authorFollowerCount`
|
|
153
|
+
- `targetFollowerBand`
|
|
154
|
+
- `followerBandFit`: `in_target_band`, `below_target_band`,
|
|
155
|
+
`above_target_band`, or `unknown`
|
|
156
|
+
- `engagementPer1kFollowers`
|
|
157
|
+
- `weightedEngagementPer1kFollowers`
|
|
158
|
+
- `reachAdjustedScore`
|
|
159
|
+
- `creatorBaselineMedianEngagement` when repeated posts from that creator are
|
|
160
|
+
available
|
|
161
|
+
- `baselineLift`: this post's engagement divided by the creator's recent median
|
|
162
|
+
engagement in the same lane, or `creator_baseline_unavailable`
|
|
163
|
+
- `confidence`: `high`, `medium`, or `low`
|
|
164
|
+
- `normalizationNotes`
|
|
165
|
+
|
|
166
|
+
Use this Harvest-compatible calculation when follower counts are available:
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
weightedEngagement =
|
|
170
|
+
likes
|
|
171
|
+
+ (comments * 4)
|
|
172
|
+
+ (shares * 12)
|
|
173
|
+
|
|
174
|
+
engagementPer1kFollowers =
|
|
175
|
+
totalEngagement / authorFollowerCount * 1000
|
|
176
|
+
|
|
177
|
+
weightedEngagementPer1kFollowers =
|
|
178
|
+
weightedEngagement / authorFollowerCount * 1000
|
|
179
|
+
|
|
180
|
+
reachPenaltyMultiplier =
|
|
181
|
+
1.00 when in target follower band
|
|
182
|
+
0.75 when below target band
|
|
183
|
+
0.65 when above target band but <= 2x target max
|
|
184
|
+
0.35 when above target band but <= 5x target max
|
|
185
|
+
0.15 when above target band and > 5x target max
|
|
186
|
+
0.40 when follower count is unknown
|
|
187
|
+
|
|
188
|
+
reachAdjustedScore =
|
|
189
|
+
weightedEngagementPer1kFollowers * reachPenaltyMultiplier
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
When repeated posts from the same creator are available, calculate:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
creatorBaselineMedianEngagement =
|
|
196
|
+
median weightedEngagement across recent same-lane posts
|
|
197
|
+
|
|
198
|
+
baselineLift =
|
|
199
|
+
post weightedEngagement / creatorBaselineMedianEngagement
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Use this scoring shape when deciding which hooks to study:
|
|
203
|
+
|
|
204
|
+
```text
|
|
205
|
+
hook_score =
|
|
206
|
+
topic_fit
|
|
207
|
+
+ rendered_mobile_preview_strength
|
|
208
|
+
+ hook_clarity
|
|
209
|
+
+ same_follower_band_bonus
|
|
210
|
+
+ weighted_engagement_per_1k_followers
|
|
211
|
+
+ creator_baseline_lift
|
|
212
|
+
+ repost_share_strength
|
|
213
|
+
+ substantive_comment_quality
|
|
214
|
+
+ creator_repeat_success
|
|
215
|
+
- celebrity_reach_penalty
|
|
216
|
+
- tiny_sample_penalty
|
|
217
|
+
- lead_magnet_penalty
|
|
218
|
+
- engagement_bait_penalty
|
|
219
|
+
- off_voice_penalty
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Rules:
|
|
223
|
+
|
|
224
|
+
- Prefer in-band creators when the hook is strong and the engagement is real.
|
|
225
|
+
- Use near-band creators as secondary evidence.
|
|
226
|
+
- Penalize creators far above the target range, especially accounts above 5x
|
|
227
|
+
the target max or above 100k followers when the target range is 8k-20k.
|
|
228
|
+
- Do not automatically reject large-account posts; they can still teach body
|
|
229
|
+
structure, topic heat, or category language. Do not treat them as primary hook
|
|
230
|
+
proof unless they also overperform their creator baseline.
|
|
231
|
+
- Large-account hooks can be selected only when the hook carries without the
|
|
232
|
+
creator: the rendered mobile opening is strong, the premise is reusable by the
|
|
233
|
+
user, the body pattern does not depend on celebrity authority, and the source
|
|
234
|
+
either beats baseline or has unusually high reach-adjusted quality after the
|
|
235
|
+
penalty.
|
|
236
|
+
- Do not overvalue tiny-account anomalies. Very high engagement per follower
|
|
237
|
+
with low absolute engagement is interesting but lower confidence.
|
|
238
|
+
- When follower data is unavailable, do not invent it. Mark
|
|
239
|
+
`follower_count_unavailable`, lower confidence, and rely more on creator repeat
|
|
240
|
+
evidence, repost/share strength, comment quality, and rendered preview quality.
|
|
241
|
+
- A source hook is strongest when it is in/near the target follower band, has
|
|
242
|
+
real absolute engagement, beats the creator's apparent baseline, has
|
|
243
|
+
share/comment quality, and passes rendered mobile preview.
|
|
244
|
+
|
|
245
|
+
Final hook selection must explain both:
|
|
246
|
+
|
|
247
|
+
- `whyTheHookCarries`: the visible words, specificity, tension, and first-screen
|
|
248
|
+
promise that work independent of reach
|
|
249
|
+
- `whyTheReachEvidenceIsTrustworthy`: follower-band fit, reach-adjusted score,
|
|
250
|
+
baseline lift, share/comment quality, or why a large-account example is only
|
|
251
|
+
secondary pattern evidence
|
|
252
|
+
|
|
110
253
|
Penalize lead-magnet and engagement-bait mechanics unless the user explicitly asks for that style:
|
|
111
254
|
|
|
112
255
|
- `comment "template"` / `comment "guide"` / `comment "playbook"`
|
|
@@ -283,7 +426,10 @@ For each shortlisted source post, record:
|
|
|
283
426
|
- URL
|
|
284
427
|
- author
|
|
285
428
|
- author profile URL when available
|
|
429
|
+
- author follower count and follower-band fit when available
|
|
286
430
|
- engagement totals and available likes/comments/shares breakdown
|
|
431
|
+
- reach-normalized metrics: engagement per 1k followers, weighted engagement
|
|
432
|
+
per 1k followers, reach-adjusted score, baseline lift, and confidence
|
|
287
433
|
- creator repeat evidence
|
|
288
434
|
- visible hook text or preview
|
|
289
435
|
- rendered preview fields from the section above
|
|
@@ -296,6 +442,7 @@ For each shortlisted source post, record:
|
|
|
296
442
|
- proof/story dependency
|
|
297
443
|
- lead magnet or engagement bait penalty
|
|
298
444
|
- weighted signal notes
|
|
445
|
+
- reach-normalized signal notes
|
|
299
446
|
- replicability score
|
|
300
447
|
- track person recommendation: `yes`, `no`, or `ask_user`
|
|
301
448
|
- tracking reason when recommended
|
|
@@ -59,6 +59,11 @@ Hook research files must preserve:
|
|
|
59
59
|
- source post URLs
|
|
60
60
|
- author/profile URLs
|
|
61
61
|
- engagement totals
|
|
62
|
+
- author follower counts when available, target follower band, follower-band
|
|
63
|
+
fit, engagement per 1k followers, weighted engagement per 1k followers,
|
|
64
|
+
reach penalty multiplier, reach-adjusted score, baseline lift when available,
|
|
65
|
+
why the hook carries independent of creator reach, and normalization
|
|
66
|
+
confidence notes
|
|
62
67
|
- full-text availability
|
|
63
68
|
- source hook preview measurements, including text basis, char count including
|
|
64
69
|
newlines, physical/content line counts, longest nonblank line, blank-line
|
|
@@ -11,6 +11,7 @@ Every saved draft needs a validation receipt. A draft without this receipt is no
|
|
|
11
11
|
- `candidateHooksConsidered`
|
|
12
12
|
- `selectedHook`
|
|
13
13
|
- `selectedHookWhy`
|
|
14
|
+
- `sourceHookReachAudit`
|
|
14
15
|
- `proofClaimsUsed`
|
|
15
16
|
- `proofClaimSources`
|
|
16
17
|
- `storyFilesConsulted`
|
|
@@ -77,6 +78,10 @@ Each candidate should include:
|
|
|
77
78
|
- premise tension opened
|
|
78
79
|
- reader value implied
|
|
79
80
|
- source pattern
|
|
81
|
+
- source pattern reach signals: author follower count when available, target
|
|
82
|
+
follower band, follower-band fit, engagement per 1k followers, weighted
|
|
83
|
+
engagement per 1k followers, reach-adjusted score, baseline lift, and
|
|
84
|
+
confidence
|
|
80
85
|
- score
|
|
81
86
|
- `renderedPreview` using `references/linkedin-preview-rendering.md`
|
|
82
87
|
- literal mobile and desktop rendered preview blocks
|
|
@@ -153,6 +158,35 @@ If `selectedControversy` is missing, if the audience belief is only a generic
|
|
|
153
158
|
label like "founders want growth," or if `credibleWhyUs` depends on borrowed
|
|
154
159
|
proof from another creator, save as `needs_revision`.
|
|
155
160
|
|
|
161
|
+
## Source Hook Reach Audit
|
|
162
|
+
|
|
163
|
+
Before a draft can be `ready`, validate that the selected source hook pattern
|
|
164
|
+
was chosen for a reach-normalized reason, not raw audience size.
|
|
165
|
+
|
|
166
|
+
Record:
|
|
167
|
+
|
|
168
|
+
- `targetFollowerBand`
|
|
169
|
+
- `authorFollowerCount` for each source pattern when available
|
|
170
|
+
- `followerBandFit`: `in_target_band`, `below_target_band`,
|
|
171
|
+
`above_target_band`, or `unknown`
|
|
172
|
+
- `engagementPer1kFollowers`
|
|
173
|
+
- `weightedEngagementPer1kFollowers`
|
|
174
|
+
- `reachAdjustedScore`
|
|
175
|
+
- `reachPenaltyMultiplier`
|
|
176
|
+
- `creatorBaselineMedianEngagement` or `creator_baseline_unavailable`
|
|
177
|
+
- `baselineLift` or `creator_baseline_unavailable`
|
|
178
|
+
- `bigAccountPenaltyApplied`
|
|
179
|
+
- `whyTheHookCarries`
|
|
180
|
+
- `followerCountUnavailableSources`
|
|
181
|
+
- `whyThisHookIsAReachAdjustedSignal`
|
|
182
|
+
|
|
183
|
+
If the user asked for a follower range and the selected source pattern is from a
|
|
184
|
+
large account above the target range, the receipt must explain why it survived
|
|
185
|
+
the celebrity reach penalty. Use large-account hooks as secondary pattern
|
|
186
|
+
evidence unless they clearly overperform the creator's baseline or the hook
|
|
187
|
+
itself carries after reach is controlled. A draft cannot be `ready` when the
|
|
188
|
+
selected hook is justified only by raw total engagement or follower count.
|
|
189
|
+
|
|
156
190
|
## LinkedIn Preview Audit
|
|
157
191
|
|
|
158
192
|
Audit the selected hook and top candidates against
|