
Content Ideas
- 42 installs
- 80 repo stars
- Updated May 30, 2026
- bradautomates/content-ideas
content-ideas is a Claude Code skill that scrapes tracked competitors across social platforms, scores what is performing, and turns it into differentiated content ideas backed by engagement data.
About
This skill builds a daily For You feed for content creators by scraping tracked competitors across social platforms and scoring what is performing. A developer or creator uses it to get competitor research, trending-topic ideas in their niche, and content briefs backed by real engagement data. It outputs a self-contained HTML page with Posts and Ideas tabs and captures reactions for future personalization.
- Scrapes tracked competitors across X, Instagram, TikTok, and YouTube and scores what is performing
- Turns competitor engagement data into differentiated content ideas and video/post briefs
- Outputs a dated, self-contained HTML 'For You' feed with Posts and Ideas tabs you can react to
Content Ideas by the numbers
- 42 all-time installs (skills.sh)
- Ranked #1,360 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
content-ideas capabilities & compatibility
requires a ScrapeCreators API key; 100 free calls, no card
- Capabilities
- content ideation · competitor research · social scraping
- Use cases
- marketing · copywriting · web scraping
- Runs
- Runs locally
- Pricing
- Bring your own API key
What content-ideas says it does
Scrapes tracked competitors across
one ScrapeCreators API key covers all four platforms — X, Instagram, TikTok, and YouTube (including transcripts)
The output is a single self-contained HTML page (two tabs: **Posts**
npx skills add https://github.com/bradautomates/content-ideas --skill content-ideasAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 42 |
|---|---|
| repo stars | ★ 80 |
| Last updated | May 30, 2026 |
| Repository | bradautomates/content-ideas ↗ |
What it does
Scrape tracked competitors across social platforms and turn what is performing into differentiated content ideas and briefs.
Who is it for?
Content creators wanting daily competitor research and content ideas backed by real engagement data
Skip if: Users without a ScrapeCreators API key or who do not want social scraping
When should I use this skill?
When the user wants competitor/creator research, a content feed, trending-topic ideas, or video/post briefs from what is performing
What you get
A dated, self-contained HTML For You feed of scored posts and actionable content ideas backed by real engagement data.
- dated HTML For You feed
- content ideas and post/video briefs
By the numbers
- 4 platforms covered
- 100 free ScrapeCreators calls
- version 2.2.0
Files
content-ideas
Your For You page. Scrapes every platform where your tracked creators publish, scores what's performing, and turns it into content ideas you can act on. Designed to run daily — each run creates a dated feed under $CONTENT_HOME/research/.
The output is a single self-contained HTML page (two tabs: Posts — one sortable, filterable feed merging tracked-account posts and discovered niche outliers — and Ideas) that you can open in a browser, react to, and keep. Reactions are captured for future personalization.
Resolve the skill directory
Everything this skill runs lives under its own folder. The skill installs the same way on Claude Code and Codex, so resolve SKILL_DIR against both plugin caches (and a plain repo checkout) once, before anything else:
# 1) Codex plugin cache, or a repo cloned into ~/.codex/skills/ (latest wins on upgrade).
SKILL_DIR="$(ls -d "$HOME/.codex/plugins/cache/"*/content-ideas/*/skills/content-ideas/ "$HOME/.codex/skills/"*/skills/content-ideas/ 2>/dev/null | sort -V | tail -1)"
SKILL_DIR="${SKILL_DIR%/}"
# 2) Claude Code plugin cache.
if [ -z "$SKILL_DIR" ] || [ ! -f "$SKILL_DIR/scripts/scrape.py" ]; then
CLAUDE_ROOT="$(ls -d "$HOME/.claude/plugins/cache/content-ideas/content-ideas/"*/ 2>/dev/null | sort -V | tail -1)"
CLAUDE_ROOT="${CLAUDE_ROOT%/}"
[ -n "$CLAUDE_ROOT" ] && [ -f "$CLAUDE_ROOT/skills/content-ideas/scripts/scrape.py" ] && SKILL_DIR="$CLAUDE_ROOT/skills/content-ideas"
fi
# 3) Plugin root passed by the host, or a repo checkout / local dev.
if [ -z "$SKILL_DIR" ] || [ ! -f "$SKILL_DIR/scripts/scrape.py" ]; then
for dir in "${CLAUDE_PLUGIN_ROOT:-}/skills/content-ideas" "${CLAUDE_PLUGIN_ROOT:-}" "${GEMINI_EXTENSION_DIR:-}/skills/content-ideas" "./skills/content-ideas" "."; do
[ -n "$dir" ] && [ -f "$dir/scripts/scrape.py" ] && SKILL_DIR="$dir" && break
done
fi
echo "$SKILL_DIR"If you can already see this file's path, just use its directory. The two scripts you'll call are $SKILL_DIR/scripts/scrape.py and $SKILL_DIR/scripts/generate_feed.py. The renderer template is $SKILL_DIR/assets/for-you-template.html (the generator finds it automatically).
Resolve the content home
All persistent files this skill reads and writes — the brand/ profile and the dated research/ runs — live under one stable base, never the current working directory. The skill runs daily and is invoked from anywhere, so the base must be the same every time or it loses the profile and the run history. Resolve it once and capture the concrete path:
CONTENT_HOME="${CONTENT_HOME:-$HOME/Documents/Content}"
mkdir -p "$CONTENT_HOME/brand" "$CONTENT_HOME/research"
echo "$CONTENT_HOME"Throughout this guide every brand/... and research/... path is relative to $CONTENT_HOME (so brand/profile.md means $CONTENT_HOME/brand/profile.md). Use the printed absolute path for every Read/Write of those files — the file tools don't expand shell variables, so writing a bare brand/profile.md would land it in the wrong directory. (Credentials stay separate, in ~/.config/content/.env.) The scrape/generate scripts read CONTENT_HOME themselves, so a relative research/{today} passed to them resolves here too.
---
Step 0: First-run setup
Run this before anything else, even if the user gave a topic. Detect first run by checking whether ~/.config/content/.env exists and contains SETUP_COMPLETE=true. Check silently. If it's already set up, skip to Step 1.
0a. Welcome + API key
Setup has three quick parts: an API key, your profile (built from your own channels), and the competitors you want to track. Only the key is required — the rest the skill bootstraps for you and you can refine any time. Nothing to install; one ScrapeCreators API key covers all four platforms — X, Instagram, TikTok, and YouTube (including transcripts).
Show this as a normal message, then call AskUserQuestion (don't repeat the welcome inside the modal):
I turn your social presence into a daily For You feed: I build a profile from
your own channels, track the competitors you pick, and surface what's
performing as content ideas backed by real engagement. I just need a
ScrapeCreators API key (one key covers all four platforms; 100 free calls, no
card).
AskUserQuestion — "Add your ScrapeCreators API key?"
- Open scrapecreators.com to grab a free key
- I'll paste a key now
- Skip for now
If they pick "Open scrapecreators.com", run open https://scrapecreators.com, then ask them to paste the key. When the user pastes a key, write ~/.config/content/.env (create dirs; append, don't clobber other keys):
SCRAPECREATORS_API_KEY={key}
SETUP_COMPLETE=trueIf they skip, write only SETUP_COMPLETE=true.
0b. Manual alternative
If they'd rather configure by hand, tell them to add those two lines to ~/.config/content/.env. Offer to write the file if they paste the key here.
0c. Build your brand profile
This is what personalizes everything: ideas get framed against your niche, pillars, and goal, and checked against what you've already posted. Build it from the user's own presence rather than a long questionnaire.
Ask for their own channels (AskUserQuestion: "Set up your profile now?" → I'll share my handles / Skip — I'll add it later). When they share handles — free-form across any platforms (@me on X, a YouTube channel, a TikTok, etc.) — normalize them into the {platform: [handle]} shape and scrape them like competitors, but over a much wider window (--days 90, the max) so you characterize their work from a full quarter, not just recent posts:
python3 "$SKILL_DIR/scripts/scrape.py" \
'{"x": ["me"], "youtube": ["@mychannel"]}' \
--pillars "" --days 90From the returned posts (plus comments/transcripts), draft the profile:
- Niche, Audience, Voice Notes — infer from recurring topics, framing, tone.
- Content Pillars — the 3–5 themes their posts actually cluster into. These
drive --pillars on every future run, so get them right.
- My Social Profiles — handle, follower count, bio, and a one-line content-
style note per platform, taken from the scrape.
- Target Platforms / Research Channels — the platforms they're active on.
- Search Terms — concrete keywords from their top topics.
Two things you can't scrape — ask (AskUserQuestion), then fold the answers in:
- Content Goal — why they post (lead gen / awareness / growth / thought
leadership / selling…), where they drive traffic, and what they're promoting.
- Pillar confirmation — show the 3–5 pillars you inferred and let them
edit or confirm before writing.
Write brand/profile.md per the schema in FILE-SCHEMAS.md. If the scrape returned enough of their own posts, also write an initial brand/my-content.md (performance summary, what's working, topics covered, and audience requests distilled from their comments) — this powers anti-cannibalization and the "your audience is asking for" banner from day one.
If they skipped (or there's no API key yet to scrape with), don't block: build a minimal brand/profile.md from a 2–3 question Q&A (niche, rough pillars, goal), note that re-running setup with a key auto-enriches it, and move on.
0d. Track competitors
Ask who they want to track (AskUserQuestion: list them now / skip and use an example). If they list handles, create brand/tracked-accounts/{platform}.md files per the schema in the plugin's FILE-SCHEMAS.md. If they skip, run a small example so they see the shape, and tell them they can add real competitors later.
End of first-run setup. Then continue with the user's original request.
---
Step 1: Load context
1a. Ingest the previous run's feedback into taste memory
Before anything else, fold the last run's reactions into your memory — this is what makes each run better than the one before. List the dated subfolders of $CONTENT_HOME/research/ (YYYY-MM-DD) and take the most recent one. If it has a feedback.json, read it and distill each entry in reviews[] (▲ "more like this" / ▼ "less" / a note) into the generalizable taste signal, not the one-off:
- "▲ on three contrarian takes in the user's niche" → "gravitates toward
contrarian takes"; "▼ on listicles" → "listicle formats don't land." A note often states the reason directly — use it.
- Record these to your project memory (the auto-memory you maintain) as the
user's content taste — the same place 1b recalls from. Update an existing taste note rather than duplicating it; let a single ▼ inform, not override, an established preference. Don't record one-off reactions with no pattern, anything already obvious from brand/profile.md, or post/run specifics (those live in research/). Taste only.
If there's no prior dated folder, no feedback.json, or no reactions in it, skip silently. If auto-memory isn't available in this environment, skip too — the reactions stay in feedback.json for whenever it is. (The current run's reactions are ingested by the next run, the same way — there's no end-of-run distillation step.)
1b. Recall taste and load brand context
Read whatever brand context exists (all optional — degrade gracefully):
brand/profile.md— niche, pillars, search terms, content goal, audiencebrand/tracked-accounts/*.md— tracked creators per platformbrand/my-content.md— the user's own content performance + audience requests
Recall the user's content taste from your memory. This skill stores an evolving taste profile in your project memory (the auto-memory you maintain). Before generating ideas, recall what you know about what this user gravitates toward — preferred topics, formats, angles, creators they keep saving, and what doesn't land for them. If relevant taste signals are already surfaced in context, use them; if not and memory is available, look for taste notes tagged for this skill. This is the single most important personalization input: engagement metrics measure what audiences like, taste memory measures what this user likes. If auto-memory isn't available, fall back to engagement signals alone (and to brand/my-content.md if present).
If there are no tracked accounts and no topic filter, ask for handles or a topic before scraping.
1c. Refresh your own content (my-content.md)
Before generating ideas, bring brand/my-content.md up to date — this is the per-run counterpart to the one-time build in Step 0c, and it's what keeps anti-cannibalization and the "your audience is asking for" banner honest as the user keeps posting. (my-content.md is declared updated each run in FILE-SCHEMAS.md; this is the step that does it.)
Take the user's own handles from the ## My Social Profiles section of the brand/profile.md you just loaded, normalize them into the {platform: [handle]} shape, and re-scrape them over a window wide enough to catch their own cadence (--days 30 — a creator's own posts are sparser than the merged competitor feed, but keep it "recent," not the 90-day profile build from Step 0c):
python3 "$SKILL_DIR/scripts/scrape.py" \
'{"x": ["me"], "youtube": ["@mychannel"]}' \
--pillars "<pillars from profile.md>" --days 30The scraper already pulls comments on the top posts, so the returned data carries the audience replies you need. Rewrite brand/my-content.md from it per the schema in FILE-SCHEMAS.md (performance summary, what's working / not, topics covered, and audience requests distilled from the comments) — it's replaced, not appended. Use this fresh version, not the copy you read in 1b, for the rest of the run.
Best-effort — never block the feed. If profile.md has no own handles (the user skipped profile setup), or the scrape returns nothing or errors, keep the existing my-content.md and continue. This refresh is an enrichment, not a gate.
---
Step 2: Create the daily run folder
List existing dated subfolders of $CONTENT_HOME/research/ (YYYY-MM-DD). The most recent one that is not today is the last-run date — pass it as --since in Step 3 so the scrape only keeps posts on/after that day. If there are no prior dated folders, there's no --since.
Either way, the scraper enforces a recency window so the daily feed never surfaces stale posts: by default it keeps only the last 7 days (--days). --since can only narrow that window, never widen it — so first runs and long-gap runs are both bounded to a week by default. (The script's hard cap is 90 days; for the daily feed keep it tight — a month at most. The 90-day window is for one-off profile builds in Step 0c, not the daily feed.)
Create $CONTENT_HOME/research/{today}/.
If `$CONTENT_HOME/research/{today}/feed-data.json` already exists, ask whether to:
- Refresh — re-pull and rebuild (reuse the same
--since/--days) - Expand — widen the window: drop
--sinceand/or raise--days(keep the
feed within ~30 days) when the user wants more than the last week
- View — just (re)open the existing feed (skip to Step 6)
---
Step 3: Scrape competitors
Build a JSON object mapping each platform to its tracked handles. Pass content pillars (from brand/profile.md, or the user's niche/topic) via --pillars so the script scores relevance, and the last-run date via --since. Leave --days at its default (7) unless the user asks for a wider window, then raise it (max 31).
python3 "$SKILL_DIR/scripts/scrape.py" \
'{"x": ["h1","h2"], "instagram": ["h3"], "youtube": ["@h4"]}' \
--pillars "<the user's content pillars>" \
--since 2026-04-15 \
--days 7Tell the user this takes a few minutes; progress streams to stderr. The script fetches all accounts in parallel, drops anything outside the recency window, scores engagement and relevance, flags outliers, and pulls comments/transcripts on top posts. It returns:
{ "results": { "x": { "h1": [ {post}, ... ] } }, "errors": [] }Each post has text, url, author, date, platform, engagement, score (weighted), relevance (0–1 vs pillars), baseline (Nx the account average), outlier (bool), and — on top posts — comments / transcript.
On errors: report which accounts failed and proceed with what came back.
Ad-hoc: fetch specific posts by URL
When the user hands you specific post URLs (a competitor's viral post, a link they saw), use URL mode instead of profile mode. It returns a flat [post] array with the same shape:
python3 "$SKILL_DIR/scripts/scrape.py" urls "https://x.com/u/status/1" "https://www.tiktok.com/@u/video/2" --pillars "..."---
Step 4: Review the scored data
The script pre-computes score, baseline, relevance, and outlier. Identify the top-performing posts and the topics/themes/angles driving engagement — especially high-relevance ones. This is the raw material for the Ideas tab.
---
Step 5: Build the feed
Two tabs. Everything shown has proven engagement. Build a FEED_DATA object and write it (Step 6). Field-by-field structure is in the plugin's FILE-SCHEMAS.md (feed-data.json).
Tab 1 — Posts. One flat posts[] array merging two sources into a single sortable, filterable feed (the page handles sorting and grouping client-side — do not pre-sort or pre-group):
- Tracked-account posts — every post from tracked accounts (no engagement
gate). Set performance / performanceDirection vs the account baseline (e.g. "+210% vs baseline", "up").
- Discovered niche outliers — statistical outliers (
outlier: true, z-score
2+, or baseline 2x+). Set zScore and a why line.
Per post, regardless of source, provide: a 1–3 sentence text summary, url, handle + displayName (creator filter), platform, an engagement object, a hook callout when notable, and the two fields that make the feed work — timestamp (ISO 8601, drives Recent sort + relative time) and sortValue (numeric total engagement/reach, drives the default Popular sort). A post is flagged as an outlier (intensity-scaled badge + accent bar) whenever it has a zScore or performanceDirection: "up" — so a tracked post that beat its baseline shows as an outlier too.
Tab 2 — Ideas. The one place you editorialize (label it as AI suggestion). Generate up to 10 ideas, each with: a specific differentiated angle, real evidence from competitor performance, and clear differentiation from what competitors already covered.
For the generative craft — turning a topic into a differentiated angle, writing hooks, classifying funnel stage (TOFU/MOFU/BOFU), aligning CTAs, repurposing across platforms, and producing a full brief — read `references/content-strategy.md`. The short version to keep in mind while building this tab:
- Make YOUR version, never repackage. A good angle answers at least one of:
what do you know the original creator doesn't (expertise), what have you done the audience hasn't seen (access), or where do you disagree (contrarian)?
- Anti-cannibalization. When
brand/my-content.mdexists, don't re-pitch a
topic the user already covered unless the angle has a genuine differentiator (more depth, different format, an update, a response to feedback). Note prior coverage explicitly.
- Own-audience demand wins. Requests from the user's own audience
(brand/my-content.md) outrank competitor signals — foreground them in the brief's "why now."
- Taste memory biases selection. An idea that aligns with the taste signals
you recalled in Step 1 (topics/formats/angles this user gravitates toward) is a stronger pick than one justified by engagement alone — and worth calling out ("this fits a pattern you keep coming back to"). Conversely, deprioritize anything that matches a recorded "doesn't land" signal.
---
Step 6: Write and open the feed
Write the feed data to $CONTENT_HOME/research/{today}/feed-data.json — a JSON object with keys meta, posts, ideas (see FILE-SCHEMAS.md). Do not write HTML yourself; the generator embeds this JSON into the template.
Then render it. Default to the live server (lets the user react to items, which saves to feedback.json for future personalization):
python3 "$SKILL_DIR/scripts/generate_feed.py" "$CONTENT_HOME/research/{today}"This starts a local server and automatically opens the feed in the user's default browser. Still hand the user the http://localhost:<port> URL the command prints, so they can reopen it if the tab closes. (Pass --no-browser to suppress the auto-open; the URL is printed either way.) The command runs in the foreground until the user stops it with Ctrl+C, so run it in the background if you need to keep working.
In a headless/no-display environment, write a self-contained file instead and point the user at it (the page lets them download their reactions):
python3 "$SKILL_DIR/scripts/generate_feed.py" "$CONTENT_HOME/research/{today}" --static
# → $CONTENT_HOME/research/{today}/for-you.htmlThen present a short text summary (post count, how many are outliers, a couple of standout posts) and the page location.
---
Step 7: Offer next steps
The user reacts to the feed in the browser; their reactions save to research/{today}/feedback.json on their own — automatically in server mode, or via the page's download button in static mode. There's no "done" signal and nothing for you to read or distill now: the file just accumulates reactions, and the next run folds them into taste memory at Step 1a. This keeps the workflow simple and, crucially, captures reactions the user makes after this conversation has ended.
Offer to: dig deeper on any idea, add/remove tracked accounts, or rerun with a different topic focus.
---
Notes
- Reactions / feedback → taste memory. The feed page lets the user mark
items (▲ more like this / ▼ less / a note) across both tabs. In server mode these save to research/{date}/feedback.json automatically as the user clicks; in static mode the user downloads that file into the run folder. The file is just an accumulating list of reactions — no status, no submit step. The next run reads the previous run's feedback.json at Step 1a and distills it into your project memory so future runs are personalized — there is no taste file; taste lives in auto-memory.
- No API key = no run. Both profile and URL mode require
SCRAPECREATORS_API_KEY — every platform, including YouTube transcripts, goes through ScrapeCreators. If the key is missing, the script returns an error; stop and show setup instructions rather than inventing data.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>For You</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #FAF6F1;
--surface: #FFFFFF;
--surface-hover: #FDF9F5;
--border: #E8E0D8;
--border-light: #EDE6DE;
--text: #1A1A1A;
--text-secondary: #6B6560;
--text-tertiary: #9B9490;
--accent: #D4572A;
--accent-light: #FFF0EB;
--accent-hover: #B8461F;
--positive: #1D7A4E;
--positive-light: #EDFAF3;
--warning: #B8860B;
--warning-light: #FFF8E6;
--tag-bg: #F0ECE6;
--picks-bg: #FFFAF7;
--picks-border: #F0D6C8;
--picks-gradient: linear-gradient(135deg, #D4572A 0%, #B8860B 100%);
--platform-x: #1A8CD8;
--platform-x-bg: #E8F4FD;
--platform-reddit: #FF4500;
--platform-reddit-bg: #FFF0EB;
--platform-youtube: #CC0000;
--platform-youtube-bg: #FFEBEE;
--platform-linkedin: #0A66C2;
--platform-linkedin-bg: #E8F0FE;
--platform-instagram: #C13584;
--platform-instagram-bg: #FDE8F0;
--heading-font: 'DM Sans', sans-serif;
--body-font: 'DM Sans', sans-serif;
--mono-font: 'IBM Plex Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--body-font);
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.shell {
max-width: 680px;
margin: 0 auto;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
min-height: 100vh;
background: var(--bg);
}
/* ── Sticky Header ── */
.top-bar {
position: sticky;
top: 0;
z-index: 100;
background: rgba(250, 246, 241, 0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.top-bar-title {
padding: 14px 20px 0;
}
.top-bar-title h1 {
font-family: var(--heading-font);
font-size: 1.65rem;
font-weight: 800;
color: var(--text);
line-height: 1.2;
}
.top-bar-subtitle {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 3px;
}
/* ── Tab Nav ── */
.tab-nav {
display: flex;
margin-top: 10px;
overflow-x: auto;
scrollbar-width: none;
}
.tab-nav::-webkit-scrollbar { display: none; }
.tab-btn {
flex: 1;
min-width: max-content;
padding: 12px 16px;
font-family: var(--body-font);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
position: relative;
transition: color 0.2s;
white-space: nowrap;
}
.tab-btn:hover { color: var(--text); background: rgba(26,26,26,0.03); }
.tab-btn.active { color: var(--text); font-weight: 700; }
.tab-btn.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 3px;
background: var(--accent);
border-radius: 2px;
}
/* ── Sections ── */
.feed-section { display: none; }
.feed-section.active { display: block; }
.section-label {
padding: 10px 20px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
/* ── Feed Card (base) ── */
.card {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
transition: background 0.15s;
cursor: pointer;
}
.card:hover { background: var(--surface-hover); }
.card-header {
display: flex;
gap: 12px;
align-items: flex-start;
}
.card-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #fff;
}
.avatar-x { background: #1A8CD8; }
.avatar-x::after { content: '\1D54F'; font-size: 18px; }
.avatar-reddit { background: var(--platform-reddit); }
.avatar-reddit::after { content: 'R'; }
.avatar-youtube { background: var(--platform-youtube); }
.avatar-youtube::after { content: '\25B6'; font-size: 14px; }
.avatar-linkedin { background: var(--platform-linkedin); }
.avatar-linkedin::after { content: 'in'; font-size: 13px; font-weight: 800; }
.avatar-instagram { background: var(--platform-instagram); }
.avatar-instagram::after { content: 'IG'; font-size: 12px; font-weight: 800; }
.avatar-tiktok { background: #010101; }
.avatar-tiktok::after { content: 'TT'; font-size: 12px; font-weight: 800; }
.card-body { flex: 1; min-width: 0; }
.card-source {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 2px;
}
.card-source .handle { color: var(--text); font-weight: 700; }
.card-source .sub-handle { color: var(--text-secondary); font-family: var(--mono-font); font-size: 12px; }
.card-source .dot { margin: 0 2px; }
.card-source .time { color: var(--text-tertiary); }
.card-title {
font-family: var(--heading-font);
font-size: 1.05rem;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
line-height: 1.35;
}
.card-text {
font-size: 14px;
color: var(--text);
line-height: 1.6;
margin-bottom: 8px;
}
.card-text a { color: var(--accent); text-decoration: none; }
.card-text a:hover { text-decoration: underline; }
.card-stats {
font-family: var(--mono-font);
font-size: 12px;
color: var(--text-secondary);
margin-top: 8px;
}
/* Engagement bar */
.engagement {
display: flex;
gap: 0;
margin-top: 8px;
max-width: 400px;
}
.eng-btn {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
font-family: var(--mono-font);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
padding: 4px 0;
cursor: default;
transition: color 0.15s;
}
.eng-btn svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
}
.eng-btn.reply:hover { color: var(--platform-x); }
.eng-btn.repost:hover { color: var(--positive); }
.eng-btn.heart:hover { color: var(--accent); }
.eng-btn.views:hover { color: var(--platform-x); }
.eng-btn.bookmark:hover { color: var(--warning); }
/* Tags row */
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 10px;
}
.tag {
font-size: 0.7rem;
font-weight: 600;
padding: 3px 9px;
border-radius: 4px;
letter-spacing: 0.02em;
}
.tag-why { background: var(--tag-bg); color: var(--text-secondary); }
.tag-angle { background: var(--accent-light); color: var(--accent); }
.tag-tofu { background: var(--accent-light); color: var(--accent); }
.tag-mofu { background: var(--warning-light); color: var(--warning); }
.tag-bofu { background: var(--positive-light); color: var(--positive); }
.tag-zscore {
font-family: var(--mono-font);
background: var(--accent-light);
color: var(--accent);
font-weight: 600;
}
.tag-platform {
background: var(--surface);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.tag-platform a { color: inherit; text-decoration: none; }
.tag-bookmarked {
background: var(--warning-light);
color: var(--warning);
font-weight: 700;
}
/* Why / insight blocks */
.insight-block {
margin-top: 8px;
padding: 10px 12px;
background: var(--surface);
border-radius: 10px;
border: 1px solid var(--border);
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.insight-block strong { color: var(--text); font-weight: 600; }
/* Embedded quote (comment) */
.quote-block {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.quote-row {
background: var(--bg);
border-radius: 10px;
padding: 10px 14px;
border: 1px solid var(--border-light);
}
.quote-handle {
font-family: var(--mono-font);
font-size: 0.72rem;
font-weight: 600;
color: var(--text-secondary);
display: block;
margin-bottom: 3px;
}
.quote-text {
font-size: 13.5px;
color: var(--text);
line-height: 1.5;
display: block;
}
.quote-engagement {
font-family: var(--mono-font);
font-size: 0.68rem;
color: var(--text-tertiary);
margin-top: 4px;
display: block;
}
.quote-signal {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
margin-top: 4px;
display: inline-block;
}
/* Hook callout */
.hook-block {
margin-top: 10px;
padding: 10px 14px;
background: var(--accent-light);
border-left: 3px solid var(--accent);
border-radius: 0 10px 10px 0;
font-size: 14px;
color: var(--text);
line-height: 1.5;
font-style: italic;
}
.hook-label {
font-style: normal;
font-weight: 700;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
display: block;
margin-bottom: 2px;
}
/* CTA line */
.cta-line {
font-size: 13px;
color: var(--text);
background: var(--positive-light);
padding: 6px 10px;
border-radius: 6px;
margin-top: 8px;
}
.cta-line strong { color: var(--positive); }
/* ── Posts Control Bar (sort + filter tokens) ── */
.control-bar {
padding: 10px 20px 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
border-top: 1px solid var(--border-light);
}
/* Sort: minimal segmented control */
.seg {
display: inline-flex;
background: var(--tag-bg);
border-radius: 999px;
padding: 3px;
}
.seg-btn {
border: none;
background: none;
font-family: var(--body-font);
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
padding: 5px 14px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn.active {
background: var(--surface);
color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,0.07);
}
/* Filter tokens: "Label │ Value ×" pills */
.ft-wrap { position: relative; display: inline-flex; }
.filter-token {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
cursor: pointer;
font-family: var(--body-font);
font-size: 12.5px;
line-height: 1;
transition: border-color 0.15s, background 0.15s;
user-select: none;
}
.filter-token:hover { border-color: var(--text-tertiary); }
.ft-wrap.open .filter-token { border-color: var(--text-secondary); }
.ft-label { color: var(--text-secondary); font-weight: 500; }
.ft-sep { width: 1px; height: 13px; background: var(--border); }
.ft-value { color: var(--text); font-weight: 700; }
.ft-chevron {
width: 12px; height: 12px; flex-shrink: 0;
stroke: var(--text-tertiary); stroke-width: 2; fill: none;
stroke-linecap: round; stroke-linejoin: round;
transition: transform 0.15s;
}
.ft-wrap.open .ft-chevron { transform: rotate(180deg); }
.ft-clear {
display: none;
align-items: center; justify-content: center;
width: 16px; height: 16px; padding: 0; margin: -1px -2px -1px 0;
border: none; border-radius: 50%; background: none;
color: var(--text-tertiary); cursor: pointer; font-size: 15px; line-height: 1;
}
.ft-clear:hover { background: var(--tag-bg); color: var(--text); }
.filter-token.is-set { border-color: var(--text-tertiary); background: var(--surface-hover); }
.filter-token.is-set .ft-chevron { display: none; }
.filter-token.is-set .ft-clear { display: inline-flex; }
/* Filter dropdown menu */
.ft-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
min-width: 168px;
max-height: 300px;
overflow-y: auto;
padding: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
display: none;
}
.ft-wrap.open .ft-menu { display: block; }
.ft-opt {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.ft-opt:hover { background: var(--surface-hover); }
.ft-check {
width: 15px; height: 15px; flex-shrink: 0;
stroke: var(--accent); stroke-width: 2.4; fill: none;
stroke-linecap: round; stroke-linejoin: round;
opacity: 0;
}
.ft-opt.selected { font-weight: 600; }
.ft-opt.selected .ft-check { opacity: 1; }
/* Outliers: plain toggle pill */
.ctrl-toggle {
font-family: var(--body-font);
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 14px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.ctrl-toggle:hover { border-color: var(--text-tertiary); }
.ctrl-toggle.active {
background: var(--accent-light);
border-color: var(--accent);
color: var(--accent);
}
.ctrl-spacer { flex: 1; }
.ctrl-count {
font-family: var(--mono-font);
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
/* ── Outlier accent + intensity (1–4) ── */
.card.ob1 { box-shadow: inset 4px 0 0 0 #F0C0A8; }
.card.ob2 { box-shadow: inset 4px 0 0 0 #E39468; }
.card.ob3 { box-shadow: inset 4px 0 0 0 var(--accent); }
.card.ob4 { box-shadow: inset 4px 0 0 0 var(--accent-hover); background: var(--accent-light); }
.card.ob4:hover { background: #FFE7DC; }
.outlier-badge {
font-family: var(--mono-font);
font-size: 0.7rem;
font-weight: 700;
padding: 3px 9px;
border-radius: 4px;
letter-spacing: 0.01em;
display: inline-flex;
align-items: center;
}
.outlier-badge.ob1 { background: var(--accent-light); color: var(--accent); }
.outlier-badge.ob2 { background: #FBD9C8; color: var(--accent-hover); }
.outlier-badge.ob3 { background: var(--accent); color: #fff; }
.outlier-badge.ob4 { background: var(--accent-hover); color: #fff; }
/* ── Audience Requests Highlight (top of Ideas tab) ── */
.audience-highlight {
margin: 12px 20px 16px;
padding: 16px 18px;
background: var(--accent-light);
border-left: 3px solid var(--accent);
border-radius: 0 10px 10px 0;
}
.audience-highlight-title {
font-family: var(--heading-font);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: 10px;
}
.audience-highlight .quote-block { margin-top: 0; }
.audience-highlight .quote-row { background: rgba(255,255,255,0.6); }
/* ── Past Coverage Callout (inside idea cards) ── */
.past-coverage {
margin-top: 8px;
padding: 10px 12px;
background: var(--tag-bg);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.past-coverage strong { color: var(--text); font-weight: 600; }
/* ── Ideas Section (elevated) ── */
.picks-card {
margin: 12px 20px;
border: 1px solid transparent;
border-radius: 12px;
background: var(--picks-bg);
overflow: hidden;
position: relative;
}
.picks-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
padding: 1px;
background: var(--picks-gradient);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.picks-inner { padding: 18px 20px; }
.picks-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: 10px;
}
.picks-title {
font-family: var(--heading-font);
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
line-height: 1.35;
}
.picks-concept {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 12px;
}
.picks-detail { display: grid; gap: 8px; }
.picks-detail-item {
padding: 10px 12px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--picks-border);
}
.picks-detail-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 3px;
}
.picks-detail-text {
font-size: 13px;
color: var(--text);
line-height: 1.5;
}
.picks-hook { font-style: italic; color: var(--accent); }
.picks-repurpose {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 4px;
}
.repurpose-tag {
font-size: 0.68rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: var(--accent-light);
color: var(--accent);
}
/* Collapsible content within picks */
.picks-toggle { cursor: pointer; user-select: none; }
.picks-expand {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease;
}
.picks-card.open .picks-expand { max-height: 1500px; }
.expand-hint {
font-size: 12px;
color: var(--accent);
font-weight: 500;
margin-top: 4px;
}
.picks-card.open .expand-hint { display: none; }
/* ── Patterns (footer) ── */
.patterns-section {
padding: 20px;
border-bottom: 1px solid var(--border);
}
.patterns-title {
font-family: var(--heading-font);
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.pattern-item {
display: flex;
gap: 10px;
padding: 8px 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.pattern-bullet {
color: var(--accent);
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
/* ── Footer ── */
.feed-footer {
padding: 24px 20px 48px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
}
/* ── Empty state ── */
.empty-state {
padding: 40px 20px;
text-align: center;
font-size: 14px;
color: var(--text-tertiary);
}
/* ── Print ── */
@media print {
:root {
--bg: #FFFFFF;
--surface: #F8F8F8;
--surface-hover: #F0F0F0;
--border: #DDD;
--border-light: #EEE;
}
body { background: white; color: #111; }
.shell { border: none; max-width: 100%; }
.top-bar { position: static; background: white; backdrop-filter: none; }
.feed-section { display: block !important; }
.tab-nav, .control-bar { display: none; }
.card { display: block !important; box-shadow: none; }
.picks-expand { max-height: none !important; overflow: visible !important; }
.expand-hint { display: none; }
.card:hover { background: transparent; }
.picks-card::before { display: none; }
.picks-card { border: 1px solid #ddd; }
}
/* ── Responsive ── */
@media (max-width: 640px) {
.shell { border: none; }
.tab-btn { font-size: 13px; padding: 10px 12px; }
.picks-inner { padding: 14px 16px; }
.picks-title { font-size: 1rem; }
.engagement { max-width: 100%; }
.card { padding: 12px 16px; }
.control-bar { padding: 9px 16px 11px; gap: 7px; }
.picks-card { margin: 10px 16px; }
.patterns-section { padding: 16px; }
.top-bar-title { padding: 12px 16px 0; }
}
@media (min-width: 1024px) {
.shell {
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
}
</style>
</head>
<body>
<div class="shell" id="feed-root"></div>
<script>
// Feed data + feedback config are injected here by generate_feed.py.
// Opening this raw template directly will show the empty state.
/*__EMBEDDED_DATA__*/
</script>
<script>
// ── SVG Icon Paths ──
const SVG_REPLY = '<svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.58-8 8-8h4.498c4.42 0 8 3.58 8 8v2.947c0 4.42-3.58 8-8 8h-1.15l-4.898 3.27a.75.75 0 01-1.2-.6v-2.67h-.25c-4.42 0-8-3.58-8-8V10z"/></svg>';
const SVG_REPOST = '<svg viewBox="0 0 24 24"><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.791-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.791 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"/></svg>';
const SVG_HEART = '<svg viewBox="0 0 24 24"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91z"/></svg>';
const SVG_BOOKMARK = '<svg viewBox="0 0 24 24"><path d="M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5z"/></svg>';
const SVG_EYE = '<svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>';
// ── Helpers ──
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmtNum(n) {
if (n == null) return '\u2014';
return n.toLocaleString();
}
function boldMd(s) {
if (!s) return '';
return esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
}
function renderComments(comments) {
if (!comments || !comments.length) return '';
var html = '<div class="quote-block">';
for (var i = 0; i < comments.length; i++) {
var c = comments[i];
html += '<div class="quote-row">';
html += '<span class="quote-handle">' + esc(c.handle) + '</span>';
html += '<span class="quote-text">' + esc(c.text) + '</span>';
if (c.engagement) html += '<span class="quote-engagement">' + esc(c.engagement) + '</span>';
if (c.signal) html += '<span class="quote-signal">' + esc(c.signal) + '</span>';
html += '</div>';
}
html += '</div>';
return html;
}
function renderHook(hookText) {
if (!hookText) return '';
return '<div class="hook-block"><span class="hook-label">Hook</span>' + esc(hookText) + '</div>';
}
function renderEngagement(eng) {
if (!eng) return '';
// Each metric carries its own icon. Views (eye) and saves/bookmarks
// (bookmark) are distinct — never render views under the save icon.
// `saves` and `bookmarks` are aliases for the same slot across platforms
// (TikTok/IG = saves, X = bookmarks). A metric is omitted when null/absent
// so each platform shows only the metrics it actually has.
var saves = eng.bookmarks != null ? eng.bookmarks : eng.saves;
var slots = [
{ val: eng.replies, icon: SVG_REPLY, cls: 'reply' },
{ val: eng.reposts, icon: SVG_REPOST, cls: 'repost' },
{ val: eng.likes, icon: SVG_HEART, cls: 'heart' },
{ val: eng.views, icon: SVG_EYE, cls: 'views' },
{ val: saves, icon: SVG_BOOKMARK, cls: 'bookmark' }
];
var html = '<div class="engagement">';
for (var i = 0; i < slots.length; i++) {
if (slots[i].val == null) continue;
html += '<span class="eng-btn ' + slots[i].cls + '">' + slots[i].icon + ' ' + fmtNum(slots[i].val) + '</span>';
}
html += '</div>';
return html;
}
// ── Posts: shared helpers ──
var PLATFORM_LABELS = { x: 'X', reddit: 'Reddit', youtube: 'YouTube', linkedin: 'LinkedIn', instagram: 'Instagram', tiktok: 'TikTok' };
// Relative time ("6h", "2d") from an ISO timestamp; falls back to a
// provided `time` string, then to ''. Computed at render so it always
// reads relative to "now".
function relTime(post) {
if (post.time) return post.time;
if (!post.timestamp) return '';
var t = Date.parse(post.timestamp);
if (isNaN(t)) return '';
var s = (Date.now() - t) / 1000;
if (s < 0) return 'now';
if (s < 60) return Math.floor(s) + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
if (s < 604800) return Math.floor(s / 86400) + 'd';
if (s < 2629800) return Math.floor(s / 604800) + 'w';
return Math.floor(s / 2629800) + 'mo';
}
// A post is an outlier if it carries a z-score or beats its baseline.
// Returns {tier:1-4, label} or null. Tier drives badge color + flame count
// and the card's accent bar; it scales with z-score, or (lacking that) the
// percentage parsed from the performance string.
function outlierInfo(post) {
var z = post.zScore;
var up = post.performanceDirection === 'up';
if (z == null && !up) return null;
if (z != null) {
var tier = z >= 4 ? 4 : z >= 3 ? 3 : z >= 2 ? 2 : 1;
return { tier: tier, label: 'z ' + z.toFixed(1) };
}
var pct = 0;
if (post.performance) {
var m = post.performance.match(/(\d+(?:\.\d+)?)/);
if (m) pct = parseFloat(m[1]);
}
var ptier = pct >= 100 ? 4 : pct >= 50 ? 3 : pct >= 20 ? 2 : 1;
return { tier: ptier, label: post.performance || '+vs baseline' };
}
function creatorKey(post) { return (post.handle || post.displayName || '').trim(); }
function renderPostCard(post) {
var info = outlierInfo(post);
var obCls = info ? ' ob' + info.tier : '';
var recent = post.timestamp ? (Date.parse(post.timestamp) || 0) : 0;
var pop = typeof post.sortValue === 'number' ? post.sortValue : -1;
var html = '<div class="card' + obCls + '" data-href="' + esc(post.url) + '"'
+ ' data-platform="' + esc(post.platform || '') + '"'
+ ' data-creator="' + esc(creatorKey(post)) + '"'
+ ' data-outlier="' + (info ? '1' : '0') + '"'
+ ' data-popular="' + pop + '"'
+ ' data-recent="' + recent + '">';
html += '<div class="card-header">';
html += '<div class="card-avatar avatar-' + esc(post.platform) + '"></div>';
html += '<div class="card-body">';
// Source line: display name (bold), optional secondary handle, time.
html += '<div class="card-source">';
var name = post.displayName || post.handle || '';
html += '<span class="handle">' + esc(name) + '</span>';
if (post.handle && post.displayName && post.handle !== post.displayName) {
html += ' <span class="sub-handle">' + esc(post.handle) + '</span>';
}
var rt = relTime(post);
if (rt) html += '<span class="dot">·</span><span class="time">' + esc(rt) + '</span>';
html += '</div>';
html += '<div class="card-title">' + esc(post.title) + '</div>';
if (post.text) html += '<div class="card-text">' + esc(post.text) + '</div>';
// Engagement bar, or pre-formatted stats fallback.
if (post.engagement) html += renderEngagement(post.engagement);
else if (post.stats) html += '<div class="card-stats">' + esc(post.stats) + '</div>';
// Tags: outlier badge (intensity-scaled), platform link, bookmarked.
html += '<div class="tag-row">';
if (info) {
html += '<span class="outlier-badge ob' + info.tier + '">' + esc(info.label) + '</span>';
}
if (post.url) {
var host = '';
try { host = new URL(post.url).hostname.replace('www.', ''); } catch (e) { host = post.platform || 'link'; }
html += '<span class="tag tag-platform"><a href="' + esc(post.url) + '">' + esc(host) + '</a></span>';
}
if (post.bookmarked) html += '<span class="tag tag-bookmarked">Bookmarked</span>';
html += '</div>';
html += renderHook(post.hook);
if (post.why) html += '<div class="insight-block"><strong>Why it\'s here:</strong> ' + esc(post.why) + '</div>';
html += renderComments(post.comments);
html += '</div></div></div>';
return html;
}
function renderPosts(posts) {
if (!posts || !posts.length) {
return '<div class="empty-state">No posts yet. Add tracked accounts to <code>brand/tracked-accounts/</code> or run a scrape to populate this feed.</div>';
}
var html = '';
for (var i = 0; i < posts.length; i++) html += renderPostCard(posts[i]);
return html;
}
var SVG_CHEVRON = '<svg class="ft-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>';
var SVG_CHECK = '<svg class="ft-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
// One filter token ("Label │ Value ×") with its dropdown menu.
// opts: [{value, label}]. multi → checkbox semantics; single → radio.
function renderFilterToken(filter, label, opts, multi) {
var menu = '';
for (var i = 0; i < opts.length; i++) {
menu += '<div class="ft-opt" data-value="' + esc(opts[i].value) + '">'
+ SVG_CHECK + '<span>' + esc(opts[i].label) + '</span></div>';
}
return '<div class="ft-wrap" data-filter="' + esc(filter) + '" data-multi="' + (multi ? '1' : '0') + '">'
+ '<div class="filter-token">'
+ '<span class="ft-label">' + esc(label) + '</span>'
+ '<span class="ft-sep"></span>'
+ '<span class="ft-value">All</span>'
+ SVG_CHEVRON
+ '<button class="ft-clear" aria-label="Clear ' + esc(label) + ' filter">×</button>'
+ '</div>'
+ '<div class="ft-menu">' + menu + '</div>'
+ '</div>';
}
// Build the sort + filter control bar from the posts actually present.
function renderControlBar(posts) {
// Distinct platforms (in a stable, known order) and creators.
var order = ['x', 'reddit', 'youtube', 'linkedin', 'instagram', 'tiktok'];
var present = {}, creators = {};
for (var i = 0; i < posts.length; i++) {
if (posts[i].platform) present[posts[i].platform] = true;
var k = creatorKey(posts[i]);
if (k) creators[k] = posts[i].displayName || posts[i].handle || k;
}
var platforms = order.filter(function (p) { return present[p]; });
var html = '<div class="control-bar" id="control-bar">';
html += '<div class="seg" id="sort-seg">'
+ '<button class="seg-btn active" data-sort="popular">Popular</button>'
+ '<button class="seg-btn" data-sort="recent">Recent</button>'
+ '</div>';
// Creator filter (single-select) — the "grouping" control.
var keys = Object.keys(creators).sort(function (a, b) {
return creators[a].toLowerCase() < creators[b].toLowerCase() ? -1 : 1;
});
if (keys.length > 1) {
var creatorOpts = keys.map(function (k) { return { value: k, label: creators[k] }; });
html += renderFilterToken('creator', 'Creator', creatorOpts, false);
}
// Platform filter (multi-select).
if (platforms.length > 1) {
var platOpts = platforms.map(function (p) { return { value: p, label: PLATFORM_LABELS[p] || p }; });
html += renderFilterToken('platform', 'Platform', platOpts, true);
}
html += '<button class="ctrl-toggle" id="outlier-toggle" aria-pressed="false">Outliers</button>';
html += '<span class="ctrl-spacer"></span>';
html += '<span class="ctrl-count" id="ctrl-count"></span>';
html += '</div>';
return html;
}
function renderIdeas(ideas) {
if (!ideas) return '<div class="empty-state">No ideas generated.</div>';
var html = '';
// Audience requests banner
if (ideas.audienceRequests && ideas.audienceRequests.length) {
html += '<div class="audience-highlight">';
html += '<div class="audience-highlight-title">Your Audience Is Asking For</div>';
html += renderComments(ideas.audienceRequests);
html += '</div>';
}
// Idea cards
if (ideas.items) {
for (var i = 0; i < ideas.items.length; i++) {
var idea = ideas.items[i];
html += '<div class="picks-card"' + (idea.sourceUrl ? ' data-href="' + esc(idea.sourceUrl) + '"' : '') + '>';
html += '<div class="picks-inner picks-toggle" onclick="this.closest(\'.picks-card\').classList.toggle(\'open\')">';
html += '<div class="picks-badge">★ Idea ' + (i + 1) + '</div>';
html += '<div class="picks-title">' + esc(idea.title) + '</div>';
html += '<div class="picks-concept">' + esc(idea.concept) + '</div>';
if (idea.funnel) {
html += '<div class="tag-row"><span class="tag tag-' + esc(idea.funnel) + '">' + esc(idea.funnel.toUpperCase()) + '</span></div>';
}
html += '<div class="expand-hint">Click to expand full brief ↓</div>';
html += '</div>';
// Expandable brief
html += '<div class="picks-expand"><div class="picks-detail" style="padding: 0 20px 18px;">';
if (idea.pastCoverage) {
html += '<div class="past-coverage"><strong>Related:</strong> ' + esc(idea.pastCoverage) + '</div>';
}
var b = idea.brief;
if (b) {
if (b.whyNow) {
html += '<div class="picks-detail-item"><div class="picks-detail-label">Why Now</div>';
html += '<div class="picks-detail-text">' + esc(b.whyNow) + '</div></div>';
}
if (b.audienceAsking) {
html += '<div class="picks-detail-item"><div class="picks-detail-label">What the Audience Is Asking For</div>';
html += '<div class="picks-detail-text">';
html += renderComments(b.audienceAsking.comments);
if (b.audienceAsking.text) html += '<div style="margin-top:8px">' + esc(b.audienceAsking.text) + '</div>';
html += '</div></div>';
}
if (b.differentiator) {
html += '<div class="picks-detail-item"><div class="picks-detail-label">Differentiator</div>';
html += '<div class="picks-detail-text">' + esc(b.differentiator) + '</div></div>';
}
if (b.suggestedHook) {
html += '<div class="picks-detail-item"><div class="picks-detail-label">Suggested Hook</div>';
html += '<div class="picks-detail-text picks-hook">' + esc(b.suggestedHook) + '</div></div>';
}
if (b.howToAction) {
html += '<div class="picks-detail-item"><div class="picks-detail-label">How to Action This</div>';
html += '<div class="picks-detail-text">' + esc(b.howToAction) + '</div></div>';
}
if (b.repurposeAs && b.repurposeAs.length) {
html += '<div class="picks-detail-item"><div class="picks-detail-label">Repurpose As</div>';
html += '<div class="picks-repurpose">';
for (var r = 0; r < b.repurposeAs.length; r++) {
html += '<span class="repurpose-tag">' + esc(b.repurposeAs[r]) + '</span>';
}
html += '</div></div>';
}
}
html += '</div></div></div>';
}
}
// Patterns
if (ideas.patterns && ideas.patterns.length) {
html += '<div class="patterns-section">';
html += '<div class="patterns-title">→ Patterns</div>';
for (var j = 0; j < ideas.patterns.length; j++) {
html += '<div class="pattern-item"><span class="pattern-bullet">•</span><span>' + boldMd(ideas.patterns[j]) + '</span></div>';
}
html += '</div>';
}
return html;
}
// ── Main Render ──
// Resolve the flat posts list. Prefer the current `posts[]` schema; fall
// back to merging legacy `competitors[]` + `topPerformers[]` so older
// feed-data.json files still render.
function collectPosts(d) {
if (Array.isArray(d.posts)) return d.posts;
var out = [];
function push(p, extra) {
out.push({
title: p.title, text: p.text, url: p.url,
handle: extra.handle, displayName: extra.displayName, platform: extra.platform,
timestamp: p.timestamp || null, sortValue: p.sortValue, time: p.time || null,
engagement: p.engagement || null, stats: p.stats || null,
zScore: p.zScore != null ? p.zScore : null,
performance: p.performance || null, performanceDirection: p.performanceDirection || null,
why: p.why || null, hook: p.hook || null,
bookmarked: !!p.bookmarked, comments: p.comments || []
});
}
(d.competitors || []).forEach(function (g) {
(g.posts || []).forEach(function (p) {
push(p, { handle: g.handle, displayName: g.displayName, platform: g.platform });
});
});
(d.topPerformers || []).forEach(function (p) {
push(p, { handle: p.handle, displayName: p.displayName || p.handle, platform: p.platform });
});
return out;
}
function renderFeed() {
var d = typeof FEED_DATA !== 'undefined' ? FEED_DATA : null;
if (!d) {
document.getElementById('feed-root').innerHTML = '<div class="empty-state">No feed data embedded. Generate this page with generate_feed.py.</div>';
return;
}
var m = d.meta || {};
document.title = 'For You \u2014 ' + (m.date || '');
var posts = collectPosts(d);
var postsCount = posts.length;
var ideasCount = d.ideas && d.ideas.items ? d.ideas.items.length : 0;
var shell = document.getElementById('feed-root');
shell.innerHTML =
'<div class="top-bar">'
+ '<div class="top-bar-title">'
+ '<h1>For You</h1>'
+ '<div class="top-bar-subtitle">' + esc(m.subtitle || '') + '</div>'
+ '</div>'
+ '<nav class="tab-nav">'
+ '<button class="tab-btn active" data-tab="posts">Posts' + (postsCount ? ' (' + postsCount + ')' : '') + '</button>'
+ '<button class="tab-btn" data-tab="ideas">Ideas' + (ideasCount ? ' (' + ideasCount + ')' : '') + '</button>'
+ '</nav>'
+ (postsCount ? renderControlBar(posts) : '')
+ '</div>'
+ '<div class="feed-section active" data-section="posts">'
+ '<div id="posts-list">' + renderPosts(posts) + '</div>'
+ '<div class="empty-state" id="posts-empty" style="display:none">No posts match these filters.</div>'
+ '</div>'
+ '<div class="feed-section" data-section="ideas">' + renderIdeas(d.ideas) + '</div>'
+ '<div class="feed-footer">' + esc(m.footer || '') + '</div>';
initInteractions();
setupPostControls();
}
// ── Interactions ──
function initInteractions() {
var controlBar = document.getElementById('control-bar');
// Tab switching (control bar belongs to the Posts tab only)
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.feed-section').forEach(function(s) { s.classList.remove('active'); });
btn.classList.add('active');
document.querySelector('[data-section="' + btn.dataset.tab + '"]').classList.add('active');
if (controlBar) controlBar.style.display = btn.dataset.tab === 'posts' ? '' : 'none';
});
});
// Click-to-navigate on post cards
document.querySelectorAll('.card[data-href]').forEach(function(el) {
el.addEventListener('click', function(e) {
window.open(el.dataset.href, '_blank');
});
});
// Ideas cards: title click navigates, rest toggles
document.querySelectorAll('.picks-card[data-href] .picks-title').forEach(function(title) {
title.style.cursor = 'pointer';
title.style.textDecoration = 'underline';
title.style.textDecorationColor = 'var(--accent-light)';
title.addEventListener('click', function(e) {
e.stopPropagation();
window.open(title.closest('.picks-card').dataset.href, '_blank');
});
});
// Print: expand all picks
window.addEventListener('beforeprint', function() {
document.querySelectorAll('.picks-card').forEach(function(c) { c.classList.add('open'); });
});
}
// ── Sort + filter (operate on already-rendered cards so feedback controls
// stay attached: sorting reorders DOM nodes, filtering toggles display) ──
function setupPostControls() {
var list = document.getElementById('posts-list');
if (!list) return;
var cards = Array.prototype.slice.call(list.querySelectorAll('.card'));
if (!cards.length) return;
// creator: '' = all. platform: null = all; else a set of selected values.
var state = { sort: 'popular', creator: '', platforms: null, outliersOnly: false };
function apply() {
// Sort: reorder nodes (feedback bars ride along inside each card).
var key = state.sort === 'recent' ? 'recent' : 'popular';
cards.sort(function (a, b) {
return (parseFloat(b.dataset[key]) || 0) - (parseFloat(a.dataset[key]) || 0);
});
cards.forEach(function (c) { list.appendChild(c); });
// Filter (AND). null platform set = no platform filter (show all).
var visible = 0;
cards.forEach(function (c) {
var ok = true;
if (state.platforms && !state.platforms[c.dataset.platform]) ok = false;
if (state.creator && c.dataset.creator !== state.creator) ok = false;
if (state.outliersOnly && c.dataset.outlier !== '1') ok = false;
c.style.display = ok ? '' : 'none';
if (ok) visible++;
});
var countEl = document.getElementById('ctrl-count');
if (countEl) countEl.textContent = visible + ' post' + (visible === 1 ? '' : 's');
var empty = document.getElementById('posts-empty');
if (empty) empty.style.display = visible ? 'none' : 'block';
}
// Reflect a token's current selection into its label/value/×.
function refreshToken(wrap) {
var filter = wrap.dataset.filter;
var token = wrap.querySelector('.filter-token');
var valEl = wrap.querySelector('.ft-value');
var opts = wrap.querySelectorAll('.ft-opt');
var isSet = false, text = 'All';
if (filter === 'creator') {
isSet = !!state.creator;
opts.forEach(function (o) {
var on = o.dataset.value === state.creator;
o.classList.toggle('selected', on);
if (on) text = o.textContent.trim();
});
} else if (filter === 'platform') {
var sel = state.platforms;
isSet = !!sel;
var names = [];
opts.forEach(function (o) {
var on = sel && sel[o.dataset.value];
o.classList.toggle('selected', !!on);
if (on) names.push(o.textContent.trim());
});
if (sel) text = names.length === 1 ? names[0] : names.length + ' platforms';
}
valEl.textContent = text;
token.classList.toggle('is-set', isSet);
}
function closeMenus(except) {
document.querySelectorAll('.ft-wrap.open').forEach(function (w) {
if (w !== except) w.classList.remove('open');
});
}
// Sort segmented control
document.querySelectorAll('#sort-seg .seg-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('#sort-seg .seg-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
state.sort = btn.dataset.sort;
apply();
});
});
// Filter tokens (custom dropdowns)
document.querySelectorAll('.ft-wrap').forEach(function (wrap) {
var token = wrap.querySelector('.filter-token');
var filter = wrap.dataset.filter;
var multi = wrap.dataset.multi === '1';
// Open/close menu (ignore clicks on the clear ×).
token.addEventListener('click', function (e) {
if (e.target.closest('.ft-clear')) return;
var willOpen = !wrap.classList.contains('open');
closeMenus(wrap);
wrap.classList.toggle('open', willOpen);
});
// Clear button → reset this filter.
wrap.querySelector('.ft-clear').addEventListener('click', function (e) {
e.stopPropagation();
if (filter === 'creator') state.creator = '';
else if (filter === 'platform') state.platforms = null;
refreshToken(wrap);
apply();
});
// Option clicks.
wrap.querySelectorAll('.ft-opt').forEach(function (opt) {
opt.addEventListener('click', function () {
var v = opt.dataset.value;
if (filter === 'creator') {
state.creator = (state.creator === v) ? '' : v; // toggle off if re-picked
wrap.classList.remove('open');
} else if (filter === 'platform') {
// Start from "all selected" the first time one is toggled.
if (!state.platforms) {
state.platforms = {};
wrap.querySelectorAll('.ft-opt').forEach(function (o) { state.platforms[o.dataset.value] = true; });
}
if (state.platforms[v]) delete state.platforms[v];
else state.platforms[v] = true;
var n = Object.keys(state.platforms).length;
var total = wrap.querySelectorAll('.ft-opt').length;
if (n === 0 || n === total) state.platforms = null; // none or all → no filter
}
refreshToken(wrap);
apply();
});
});
refreshToken(wrap);
});
// Outliers toggle
var outlierBtn = document.getElementById('outlier-toggle');
if (outlierBtn) outlierBtn.addEventListener('click', function () {
state.outliersOnly = !state.outliersOnly;
outlierBtn.classList.toggle('active', state.outliersOnly);
outlierBtn.setAttribute('aria-pressed', state.outliersOnly ? 'true' : 'false');
apply();
});
// Close menus on outside click.
document.addEventListener('click', function (e) {
if (!e.target.closest('.ft-wrap')) closeMenus(null);
});
apply(); // initial sort (Popular) + counts
}
// ── Boot ──
renderFeed();
if (window.__initFeedback) window.__initFeedback();
</script>
<!-- ── Feedback layer (additive; injected by generate_feed.py via FEEDBACK_MODE) ── -->
<style>
.fb {
margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-light);
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
}
/* Inside an Ideas card the bar is full-width, so pad it to the card gutters. */
.fb-picks { margin-top: 0; padding: 12px 20px 16px; }
.fb-btn {
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer; border: 1px solid var(--border); background: var(--surface);
border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 500;
line-height: 1; color: var(--text-secondary); font-family: var(--body-font);
transition: background .12s, border-color .12s, color .12s; white-space: nowrap;
}
.fb-btn svg {
width: 15px; height: 15px; flex-shrink: 0; fill: none;
stroke: currentColor; stroke-width: 1.9;
stroke-linecap: round; stroke-linejoin: round;
}
.fb-up:hover, .fb-up.active-up {
background: var(--positive-light); border-color: var(--positive); color: var(--positive);
}
.fb-down:hover, .fb-down.active-down,
.fb-note-toggle:hover, .fb-note-toggle.active {
background: var(--accent-light); border-color: var(--accent); color: var(--accent);
}
.fb-note {
width: 100%; margin-top: 8px; border: 1px solid var(--border); border-radius: 10px;
padding: 9px 12px; font-family: var(--body-font); font-size: 13px; line-height: 1.5;
color: var(--text); background: var(--surface); resize: vertical;
min-height: 40px; display: none;
}
.fb-note.show { display: block; }
.fb-note:focus { outline: none; border-color: var(--accent); }
@media (max-width: 640px) { .fb-picks { padding: 10px 16px 14px; } }
.fb-bar {
position: fixed; bottom: 18px; right: 18px; z-index: 50;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,.10); padding: 10px 14px; display: flex;
align-items: center; gap: 12px; font-size: 13px; color: var(--text-secondary);
}
.fb-bar button {
cursor: pointer; border: none; border-radius: 8px; padding: 7px 14px;
font-family: var(--body-font); font-size: 13px; font-weight: 600;
background: var(--picks-gradient); color: #fff;
}
.fb-bar .fb-count { font-variant-numeric: tabular-nums; }
.fb-saved { color: var(--positive); }
@media print { .fb, .fb-bar { display: none !important; } }
</style>
<script>
// Reaction icons (stroke style, sized to match the engagement row above).
var FB_ICON_UP = '<svg viewBox="0 0 24 24"><path d="M7 10v11M2 13a2 2 0 0 1 2-2h3v10H4a2 2 0 0 1-2-2v-6zM7 11l4.2-7.6a1.7 1.7 0 0 1 3.1 1.3l-1 4.3H20a2 2 0 0 1 2 2.4l-1.3 6A2 2 0 0 1 18.7 21H7"/></svg>';
var FB_ICON_DOWN = '<svg viewBox="0 0 24 24"><path d="M17 14V3M22 11a2 2 0 0 1-2 2h-3V3h3a2 2 0 0 1 2 2v6zM17 13l-4.2 7.6a1.7 1.7 0 0 1-3.1-1.3l1-4.3H4a2 2 0 0 1-2-2.4l1.3-6A2 2 0 0 1 5.3 3H17"/></svg>';
var FB_ICON_NOTE = '<svg viewBox="0 0 24 24"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 8.5 8.5 0 0 1-3.8-.9L3 20.5l1.5-5.2A8.5 8.5 0 1 1 21 11.5z"/></svg>';
// Post-render feedback enhancer. Runs after the main renderer so the
// original card markup is untouched — we just append controls and persist
// reactions to feedback.json (server mode) or a downloadable file (static).
window.__initFeedback = function () {
var mode = (typeof FEEDBACK_MODE !== 'undefined') ? FEEDBACK_MODE : null;
if (!mode) return;
var state = (typeof FEEDBACK_STATE !== 'undefined' && FEEDBACK_STATE) ? FEEDBACK_STATE : {};
var dirty = false;
function fbEsc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function idFor(el, tab, label) {
// Stable id: prefer the post URL, else tab + label text.
var href = el.getAttribute('data-href');
return (tab + '::' + (href || label || '')).slice(0, 400);
}
function attach(el, tab, label) {
if (el.dataset.fbReady) return;
el.dataset.fbReady = '1';
var id = idFor(el, tab, label);
var existing = state[id] || {};
// Append into the card's content container so the controls line up with
// the text/engagement above them (not the card's outer padding edge).
var host = el, picks = el.classList.contains('picks-card');
if (el.classList.contains('card')) host = el.querySelector('.card-body') || el;
var bar = document.createElement('div');
bar.className = picks ? 'fb fb-picks' : 'fb';
bar.innerHTML =
'<button class="fb-btn fb-up' + (existing.rating === 'up' ? ' active-up' : '') + '" title="More like this">' + FB_ICON_UP + '<span>More like this</span></button>'
+ '<button class="fb-btn fb-down' + (existing.rating === 'down' ? ' active-down' : '') + '" title="Less like this">' + FB_ICON_DOWN + '<span>Less</span></button>'
+ '<button class="fb-btn fb-note-toggle' + (existing.note ? ' active' : '') + '" title="Add a note">' + FB_ICON_NOTE + '<span>Note</span></button>'
+ '<textarea class="fb-note' + (existing.note ? ' show' : '') + '" placeholder="What worked or didn\'t? (saved automatically)">' + fbEsc(existing.note) + '</textarea>';
// Stop card-level navigation when interacting with feedback controls.
bar.addEventListener('click', function (e) { e.stopPropagation(); });
host.appendChild(bar);
var up = bar.querySelector('.fb-up'), down = bar.querySelector('.fb-down');
var note = bar.querySelector('.fb-note'), toggle = bar.querySelector('.fb-note-toggle');
function record() {
state[id] = {
item_id: id, tab: tab, label: label,
rating: up.classList.contains('active-up') ? 'up' : (down.classList.contains('active-down') ? 'down' : null),
note: note.value.trim()
};
toggle.classList.toggle('active', !!note.value.trim());
dirty = true;
if (mode === 'server') scheduleSave();
updateCount();
}
up.addEventListener('click', function () {
down.classList.remove('active-down');
up.classList.toggle('active-up'); record();
});
down.addEventListener('click', function () {
up.classList.remove('active-up');
down.classList.toggle('active-down'); record();
});
toggle.addEventListener('click', function () { note.classList.toggle('show'); if (note.classList.contains('show')) note.focus(); });
note.addEventListener('input', record);
}
// Walk every card across both tabs.
document.querySelectorAll('.feed-section[data-section="posts"] .card').forEach(function (el) {
var t = el.querySelector('.card-title'); attach(el, 'posts', t ? t.textContent : '');
});
document.querySelectorAll('.feed-section[data-section="ideas"] .picks-card').forEach(function (el) {
var t = el.querySelector('.picks-title'); attach(el, 'ideas', t ? t.textContent : '');
});
// Floating save/status bar.
var bar = document.createElement('div');
bar.className = 'fb-bar';
bar.innerHTML = '<span class="fb-count"></span>'
+ (mode === 'server' ? '<span class="fb-saved" style="display:none">Saved ✓</span>'
: '<button class="fb-export">Download feedback</button>');
document.body.appendChild(bar);
var countEl = bar.querySelector('.fb-count');
function reactions() { return Object.keys(state).map(function (k) { return state[k]; }).filter(function (r) { return r.rating || r.note; }); }
function updateCount() {
var n = reactions().length;
countEl.textContent = n ? (n + ' reaction' + (n === 1 ? '' : 's')) : 'React to tune future feeds';
}
updateCount();
function payload() { return { reviews: reactions() }; }
var saveTimer = null;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
fetch('/api/feedback', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload())
}).then(function () {
var s = bar.querySelector('.fb-saved');
if (s) { s.style.display = 'inline'; setTimeout(function () { s.style.display = 'none'; }, 1200); }
}).catch(function () {});
}, 600);
}
if (mode === 'static') {
bar.querySelector('.fb-export').addEventListener('click', function () {
var blob = new Blob([JSON.stringify(payload(), null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'feedback.json';
document.body.appendChild(a); a.click(); a.remove();
});
}
};
// The boot call in the render script runs before this block defines
// __initFeedback, so invoke it here now that the definition exists.
// (Idempotent: attach() skips cards that already have a .fb bar.)
window.__initFeedback();
</script>
</body>
</html>
Content Strategy
Domain knowledge for turning content research into actionable plans. This skill takes what performed well (outliers, competitor posts, trending topics) and helps create YOUR version — differentiated, strategically positioned, and tailored to the user's brand context.
The core insight: the gap between "interesting content exists" and "I know what to make" is where most creators stall. A feed of 50 items is useless if none of them are translated into a concrete plan the user can act on. This skill bridges that gap.
Taste memory is the taste signal. The user's evolving content taste lives in your project memory (auto-memory), not in a file — recall it before recommending. It accumulates from the user's reactions to past feeds: topics they gravitate toward, formats they prefer, creators they save, angles that resonate, and what doesn't land. Engagement metrics measure audience behavior; taste memory measures this user's behavior. Recommendations that align with recalled taste are worth more than engagement alone.
---
Usage Context
This skill generates full production briefs — complete video briefs with title, angle, differentiator, hook, funnel + CTA, and repurposing angles. Actionable enough to start scripting from. Used when generating content ideas from feed data, planning video concepts, or producing briefs from any command or conversation.
---
Title & Angle Generation
The difference between a topic and a content angle is the difference between "budgeting tips" and "I tracked every dollar I spent for a year — here's the $4,000 leak I never saw coming." Topics are generic. Angles are specific, opinionated, and rooted in the creator's perspective.
The Angle Principle
Never repackage the same topic. Create YOUR version:
- What do you know that the original creator doesn't? (expertise depth)
- What have you experienced that the audience hasn't seen? (unique access)
- What do you believe that contradicts the original? (contrarian take)
If you can't answer at least one of these, the content isn't differentiated enough to justify making.
Deriving Angles from Brand Context
When brand/profile.md is available, use these fields to generate angles:
| Brand Context Field | How It Shapes the Angle |
|---|---|
| Niche | Sets the lens — "as someone who's coached 200 first-time marathoners..." |
| Content pillars | The topic should connect to at least one pillar |
| Content goal | Lead gen angles differ from brand building angles |
| Audience | Enterprise audience wants different framing than hobbyists |
| Taste memory | Recalled auto-memory taste signals — preferred topics, formats, angles |
Use recalled taste memory to bias angle selection toward what this user actually gravitates toward. An angle that aligns with learned preferences is more likely to resonate than one based on engagement data alone.
Without brand context, angles stay generic. Flag this: "Angle is general — build a brand profile in brand/profile.md for personalized angles."
---
Differentiator Articulation
A differentiator is the specific reason the user's version of this content would be better or different than what already exists. Three types:
Expertise Depth
The user knows more about this topic than the original creator. They can go deeper, show edge cases, demonstrate production-grade implementation instead of a demo.
Signal phrases: "Unlike the surface-level version, you can show...", "Your experience with [specific technology] means you can demonstrate...", "The original skips [critical detail] that your audience needs."
Unique Access / Experience
The user has done something the audience hasn't seen. Real client work, production deployments, specific results with numbers.
Signal phrases: "You've actually built this for clients...", "Your [case study / project] is the proof point...", "You can show real results where the original only shows a demo."
Contrarian Perspective
The user disagrees with the original or sees a risk/angle others miss.
Signal phrases: "The original misses [critical concern]...", "Your take is that [contrarian position]...", "Everyone is covering the upside — you can cover the risk."
Anti-Cannibalization
When brand/my-content.md exists, every new angle MUST be checked against the Topics Covered table. If the user already covered a topic, the angle must have an explicit differentiator. Valid differentiators:
- More depth — the original was a surface-level overview, this goes deep
- Different format — covered as a tutorial, now doing a reaction/debate
- Updated — original is outdated, new data/tools/changes warrant a refresh
- Responding to feedback — audience comments requested a specific angle
- Different platform — covered on YouTube, now adapting for X/LinkedIn
Invalid: "Same topic, better execution." If the only differentiator is "I'll do it better this time," it's not differentiated enough. Drop the idea or find a genuine angle shift.
When recommending a topic the user already covered, explicitly note it: "You covered this on {date} — '{title}' ({Nx avg}). This angle differs because {specific reason}." If the original performed well (>1.5x avg), the bar for re-covering is higher — the audience already got value from the first version.
When Brand Context Is Unavailable
If no brand/profile.md exists, differentiator articulation is limited to generic framing: "Your version could go deeper on...", "A practitioner perspective would add..." Flag the gap explicitly rather than guessing at what makes the user unique.
---
Hook Crafting
Hook crafting generates new hooks for a specific content idea. This is different from hook classification (identifying what mechanism an existing hook uses) — crafting starts from the content angle and produces opening lines the user can use.
Process
1. Start from the angle, not the topic. The hook should reflect what makes YOUR version different. 2. Choose a mechanism that fits the content type. Bold claims work for contrarian takes. Curiosity gaps work for tutorials. Transformation previews work for case studies. 3. Write 2-3 variants using different structural forms. A bold claim as a statement, a question, and a command gives the user options.
Formula Multiplication
Generate 3+ structural variants from one hook — the same mechanism expressed through different forms:
- Statement: "SEO is dead."
- Command: "Stop investing in SEO."
- Question: "What if SEO is actually dead?"
- Conditional: "If you're still relying on SEO, you're already behind."
Statements and commands account for ~70% of top-performing hooks. Start there.
Platform-Specific Hook Adaptation
The same hook needs different execution per platform:
- YouTube: First 5-8 seconds determine retention. The hook IS the thumbnail + title + opening line working together.
- X: The hook is the entire tweet or the first line before "Show more."
- Instagram/TikTok: Visual + text overlay in the first 1-3 seconds. The spoken hook often differs from the caption.
- LinkedIn: The first 2-3 lines before the "see more" fold. If they don't click, the algorithm buries it.
---
Funnel Positioning
Categorize content by funnel stage to drive CTA selection and help balance the content mix across awareness, trust, and conversion.
| Stage | Purpose | Classification Signals | Content Characteristics |
|---|---|---|---|
| TOFU (Top) | Awareness / reach / authority | Trending topic, broad appeal, high search volume, news reaction, bigger-picture positioning | Hot takes, trend reactions, "X is replacing Y" narratives, broad how-tos, industry commentary. Audience: anyone in the niche |
| MOFU (Middle) | Consideration / trust / expertise | Speaks to specific ICP, demonstrates implementation skill, educational depth, qualifies audience | Tutorials, walkthroughs, contrarian takes that filter audience, prescriptive frameworks, "how to build X for Y role". Audience: potential buyers/clients |
| BOFU (Bottom) | Conversion / proof / objection removal | Case study, testimonial, measurable results, before/after with real numbers, social proof | Client stories, anonymized case studies, "I saved X company Y hours", client interviews, ROI breakdowns. Audience: people ready to buy |
Classification Logic
Assign by primary intent:
- TOFU — rides a trend, broad appeal, high potential impressions, no specific buyer qualification
- MOFU — teaches a specific skill, addresses a specific role's pain point, demonstrates expertise that builds trust
- BOFU — shows proof/results, tells a client story, provides social proof, removes purchase objections
If an idea spans two stages, classify by the dominant signal and note the secondary stage. Most content should skew TOFU/MOFU — BOFU is lower volume but higher conversion.
---
CTA Alignment
CTA Types
| Type | Text Pattern | When It Works |
|---|---|---|
| follow | "Follow for more [topic]" | Growing audience, series content |
| save | "Save this for later" | Reference content, lists, tutorials |
| share | "Send this to someone who..." | Relatable content, useful tips |
| comment | "Drop a [emoji] if you agree" | Engagement boosting |
| link | "Link in bio" | Conversion, lead gen |
| next-video | "Part 2 coming" / "Follow for part 2" | Series hooks, retention |
CTA by Funnel Stage
| Stage | Primary CTAs | Goal |
|---|---|---|
| TOFU | Follow, subscribe, newsletter signup | Capture attention into owned audience |
| MOFU | Book a call, join workshop, free consultation | Move from audience to prospect |
| BOFU | Direct sales, "let's work together", proposal link | Convert prospect to client |
CTA Placement
| Placement | Effect |
|---|---|
| End (last 3-5 seconds) | Standard. Works for tutorials and stories. |
| Middle (during value delivery) | Higher conversion — viewer is engaged but not done. |
| Caption only (not spoken) | Non-intrusive. Works for entertainment content. |
| Multiple (caption + spoken + text) | Aggressive but effective for conversion-focused content. |
Platform CTA Conventions
| Platform | Most Effective CTA Types |
|---|---|
| TikTok | Follow + comment + save. "Part 2" hooks drive follows. |
| Save + share. Saves boost Explore distribution. | |
| YouTube | Subscribe + like. Algorithm weights subscriber engagement. |
| X | Bookmark + retweet. "Bookmark this thread" is high-signal. |
Brand-Context-Aware CTAs
When brand/profile.md includes a content goal, align CTA suggestions to it:
| Content Goal | CTA Lean |
|---|---|
| Lead generation | "Book a [consultation type]", "DM me [keyword]" |
| Brand building | "Follow for more [topic]", "Subscribe" |
| Product/course sales | "Link in bio to [product]", "Join the waitlist" |
| Email list growth | "Comment [KEYWORD] and I'll send you [lead magnet]" |
---
Repurposing Angles
Generate platform-specific adaptation angles for content ideas, particularly those sourced from text-first platforms (X, Reddit) heading to video/visual platforms.
Per-Angle Structure
| Component | Description |
|---|---|
| Target platform | From user's target platforms in brand/profile.md |
| Format | Best-performing format for that platform from research data |
| Hook adaptation | Adapt the source hook to the target platform's conventions |
| What to add/change | Platform-specific adjustments (length, visual style, CTA) |
Platform-Specific Adaptation Rules
| From X Tweet | To TikTok | To Instagram | To YouTube |
|---|---|---|---|
| Thread | Talking-head summarizing key points (30-60s) | Carousel with one point per slide | Long-form deep dive or Shorts version |
| Hot take | Stitch/reaction format | Bold-claim reel with text overlay | Community post or Shorts |
| Tutorial thread | Step-by-step screen recording (15-60s) | Tutorial reel or carousel walkthrough | Full tutorial video (5-15 min) |
| Data/stat | "Did you know" format with stat as hook | Infographic carousel | Data breakdown video with visuals |
| Bookmark-bait list | "Save this" listicle with quick cuts | Carousel list (one item per slide) | Compilation or ranked list video |
Angle Quality
A good repurposing angle matches the target platform's dominant format, adapts the hook (not copy-pastes it), adds platform-appropriate value, and acknowledges different audience expectations. "Post it on TikTok" is not an angle. "30-second talking-head with the stat as the opening hook, save CTA" is.
---
Brief Generation
A video brief is the bridge between "this is an interesting piece of content" and "I know what to make." Briefs should be actionable enough to start scripting from.
Video Brief Format
Each brief contains:
| Component | What It Answers | How to Generate |
|---|---|---|
| Video concept | What am I making? | Title + angle from Title & Angle Generation |
| Why now | Why should I make this today? | Reference specific data from the feed — engagement numbers, competitor activity, news timing, recalled taste |
| Your differentiator | Why will mine be better? | From Differentiator Articulation — expertise, access, or contrarian angle |
| Suggested hook | How do I open? | From Hook Crafting — 1-2 specific opening lines |
| Funnel position + CTA | Where does this fit in my strategy? | From Funnel Positioning + CTA Alignment |
| Repurpose as | What else can I make from this? | From Repurposing Angles — platform-specific adaptations |
Taste-Informed Briefs
Recall the user's taste memory before generating briefs — accumulated taste signals (topics, formats, creators, angles the user gravitates toward). Use it for:
- Selection: A topic that aligns with learned preferences is a stronger
pick than one based on engagement alone.
- "Why now": Taste can justify timing — "This aligns with a pattern
you've been consistently drawn to."
- Angle shaping: If taste signals show the user prefers a specific format
or angle type, shape the brief to match.
Briefs from Own Audience Requests
When brand/my-content.md contains Audience Requests, demand from the user's own audience is the highest-confidence signal for brief generation. These are people who already follow and engage with the user asking for specific content.
Why it's the strongest signal: Competitor comment demand says "someone's audience wants this." Own audience demand says "YOUR audience wants this." The conversion from idea to engaged viewer is nearly guaranteed.
Brief adjustments for own-audience requests:
- Video concept — derived directly from the request, not abstracted
- Why now — cite the request: "Your audience asked for this {N} times
across recent posts. Top comment: '{quote}' ({engagement})"
- Hook — can reference the request directly: "In my last video on X,
the most common question was... Here's the answer."
- Differentiator — writes itself. The audience asked YOU specifically.
Your existing content is the context they're building on.
Briefs from Audience Signals
When an idea is driven by comment demand or a bookmarked post, the brief shifts to foreground the demand signal:
- Video concept — derived from the demand signal (the comment's request
or the bookmarked post's angle), not just the source post's topic.
- Why now — cite the specific demand. For comments: "Direct audience
demand: [quote] received [N likes]. [N similar comments across the feed]." For bookmarks: "You bookmarked this — here's the angle that differentiates your version."
- What the audience is asking for — the actual comment quote(s) with
engagement numbers, or the bookmark context showing why you saved it. This field makes the demand visible and concrete.
- Differentiator — why the user is the right person to answer this
specific request. Reference brand context.
- Suggested hook — can address the demand directly: "Someone asked me
[paraphrased question]. Here's the answer..." or "I bookmarked this post last week and it's been living in my head. Here's why I think they got it half right."
Not every idea has an audience signal. Outlier-based ideas may derive their "Why now" from engagement data and timing. But when comment or bookmark signal exists, foreground it — it's the strongest evidence an idea has real demand.
Quality Bar
A brief passes the quality bar if:
- The user could hand it to a scriptwriter and they'd know what to write
- The angle is specific enough that two creators given the same brief would
make noticeably different content
- The hook is a real opening line, not a description of what the hook should do
- The CTA connects to the user's actual business goal, not a generic "subscribe"
Without Brand Context
Briefs without brand context are weaker but still useful. The concept and hook can be generic; the differentiator defaults to "practitioner depth" or "hands-on demo"; the CTA stays generic. Flag what's missing: "Brief is general — brand context from brand/profile.md would sharpen the angle and CTA."
---
Key Principles
- Generate, don't classify. This skill creates new hooks, titles, angles,
and briefs. Classification of existing content is a separate concern.
- Brand context makes everything better. Every section works without
brand/profile.md, but the output is significantly sharper with it. Always note when brand context would improve the result.
- Specificity over safety. "knife skills tutorial" is safe but useless.
"I had a pro chef grade my knife skills — here's everything I'd been doing wrong" is specific and actionable. Push toward specificity.
- One brief should be enough. The user shouldn't need to ask follow-up
questions to start producing. If they do, the brief was too vague.
- Taste memory is taste. Your recalled taste memory captures what this
user gravitates toward — not what audiences like, what they like. When signals exist, they should visibly shape recommendations. A pick that connects to a learned pattern is worth calling out.
#!/usr/bin/env python3
"""Generate and serve the For You feed page.
Reads a run directory containing `feed-data.json`, embeds it (plus any existing
`feedback.json`) into the For You template, and either serves it on a tiny HTTP
server (regenerating on each load, capturing reactions to feedback.json) or
writes a self-contained `for-you.html` with `--static`.
Mirrors skill-creator's eval-viewer/generate_review.py: embed-into-template +
serve-or-static, stdlib only.
Usage:
python3 generate_feed.py <run-dir> # serve + capture feedback
python3 generate_feed.py <run-dir> --static # write run-dir/for-you.html
python3 generate_feed.py <run-dir> --static out.html
python3 generate_feed.py <run-dir> --feed path/to/feed-data.json --port 3119
"""
import argparse
import json
import os
import signal
import subprocess
import sys
import time
import webbrowser
from functools import partial
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.resolve()))
from lib.env import content_home # noqa: E402
DEFAULT_TEMPLATE = Path(__file__).parent.parent / "assets" / "for-you-template.html"
PLACEHOLDER = "/*__EMBEDDED_DATA__*/"
DEFAULT_PORT = 3119
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_feed(feed_path):
"""Load feed-data.json. Returns the parsed object or None."""
if feed_path.exists():
try:
return json.loads(feed_path.read_text())
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: could not read {feed_path}: {e}", file=sys.stderr)
return None
def load_feedback_state(feedback_path):
"""Build an item_id -> review map from feedback.json (for prepopulation)."""
state = {}
if feedback_path.exists():
try:
data = json.loads(feedback_path.read_text())
for r in data.get("reviews", []):
item_id = r.get("item_id")
if item_id:
state[item_id] = r
except (json.JSONDecodeError, OSError):
pass
return state
# ---------------------------------------------------------------------------
# HTML generation
# ---------------------------------------------------------------------------
def generate_html(feed, feedback_state, mode, template_path=DEFAULT_TEMPLATE):
"""Embed feed + feedback config into the template, returning HTML text."""
template = template_path.read_text()
embedded = (
f"const FEED_DATA = {json.dumps(feed)};\n"
f"const FEEDBACK_MODE = {json.dumps(mode)};\n"
f"const FEEDBACK_STATE = {json.dumps(feedback_state)};"
)
return template.replace(PLACEHOLDER, embedded)
# ---------------------------------------------------------------------------
# HTTP server (stdlib only)
# ---------------------------------------------------------------------------
def _kill_port(port):
"""Best-effort kill of any process already listening on `port`."""
try:
result = subprocess.run(["lsof", "-ti", f":{port}"], capture_output=True, text=True, timeout=5)
for pid_str in result.stdout.strip().split("\n"):
if pid_str.strip():
try:
os.kill(int(pid_str.strip()), signal.SIGTERM)
except (ProcessLookupError, ValueError):
pass
if result.stdout.strip():
time.sleep(0.5)
except subprocess.TimeoutExpired:
pass
except FileNotFoundError:
print("Note: lsof not found, cannot check if port is in use", file=sys.stderr)
class FeedHandler(BaseHTTPRequestHandler):
"""Serves the feed HTML and persists reactions to feedback.json.
The page is regenerated on each load so re-running the scraper (which
rewrites feed-data.json) shows up on refresh without a server restart.
"""
def __init__(self, feed_path, feedback_path, *args, **kwargs):
self.feed_path = feed_path
self.feedback_path = feedback_path
super().__init__(*args, **kwargs)
def _send(self, code, body, content_type="application/json"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path in ("/", "/index.html"):
feed = load_feed(self.feed_path)
state = load_feedback_state(self.feedback_path)
html = generate_html(feed, state, "server").encode("utf-8")
self._send(200, html, "text/html; charset=utf-8")
elif self.path == "/api/feedback":
data = self.feedback_path.read_bytes() if self.feedback_path.exists() else b"{}"
self._send(200, data)
else:
self.send_error(404)
def do_POST(self):
if self.path == "/api/feedback":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
try:
data = json.loads(body)
if not isinstance(data, dict) or "reviews" not in data:
raise ValueError("Expected JSON object with 'reviews' key")
self.feedback_path.write_text(json.dumps(data, indent=2) + "\n")
self._send(200, b'{"ok":true}')
except (json.JSONDecodeError, OSError, ValueError) as e:
self._send(500, json.dumps({"error": str(e)}).encode())
else:
self.send_error(404)
def log_message(self, fmt, *args):
pass # keep the terminal quiet
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(description="Generate/serve the For You feed page")
parser.add_argument("run_dir", type=Path, help="Run directory (contains feed-data.json)")
parser.add_argument("--feed", type=Path, default=None, help="Path to feed-data.json (default: <run-dir>/feed-data.json)")
parser.add_argument("--template", type=Path, default=DEFAULT_TEMPLATE, help="Template HTML path")
parser.add_argument("--port", "-p", type=int, default=DEFAULT_PORT, help=f"Server port (default {DEFAULT_PORT})")
parser.add_argument("--static", "-s", nargs="?", const="", default=None,
help="Write a self-contained HTML instead of serving. "
"Optional path; defaults to <run-dir>/for-you.html")
parser.add_argument("--no-browser", action="store_true", help="Don't auto-open the browser")
args = parser.parse_args(argv)
# A relative run-dir is resolved under CONTENT_HOME, not the cwd, so the
# feed lands beside the brand/ and research/ the rest of the skill uses.
run_dir = args.run_dir
if not run_dir.is_absolute():
run_dir = content_home() / run_dir
run_dir = run_dir.resolve()
feed_path = (args.feed or run_dir / "feed-data.json").resolve()
feedback_path = run_dir / "feedback.json"
if not feed_path.exists():
print(f"Error: feed data not found at {feed_path}", file=sys.stderr)
return 1
# Static mode: write a standalone file (feedback downloads as a file in-browser).
if args.static is not None:
out = Path(args.static).resolve() if args.static else run_dir / "for-you.html"
feed = load_feed(feed_path)
state = load_feedback_state(feedback_path)
html = generate_html(feed, state, "static", args.template)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html)
print(f"\n For You page written to: {out}\n")
return 0
# Server mode.
port = args.port
_kill_port(port)
handler = partial(FeedHandler, feed_path, feedback_path)
try:
server = HTTPServer(("127.0.0.1", port), handler)
except OSError:
server = HTTPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
url = f"http://localhost:{port}"
print("\n For You Feed")
print(" ─────────────────────────────────")
print(f" URL: {url}")
print(f" Feed: {feed_path}")
print(f" Feedback: {feedback_path}")
print("\n Press Ctrl+C to stop.\n")
if not args.no_browser:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
server.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
"""content-ideas scraper library.
Split out of the original monolithic scrape.py into small, testable modules:
- http — retrying GET + ScrapeCreators query helper
- log — stderr progress (keeps stdout clean for JSON)
- dates — date parsing/normalization helpers
- x / instagram / tiktok / youtube — per-platform fetchers, all via the
ScrapeCreators API (profile posts, single post by URL, comments,
transcript)
- platforms — builds the fetcher registries from the per-platform modules
- scoring — per-platform weighted engagement score
- relevance — token-overlap relevance against content pillars
- analyze — per-account baselines + outlier flags
- urls — platform detection + handle extraction from a post URL
- env — API key loading (env var > ~/.config/content/.env)
- pipeline — orchestration (scrape_all, filter_since, fetch_urls)
"""
"""Per-account analysis: engagement score, relevance, baseline, outlier flag."""
import math
from .relevance import score_relevance
from .scoring import score_engagement
OUTLIER_THRESHOLD = 2.0 # z-score units above the account mean
def analyze_results(results, pillar_tokens):
"""Score, baseline, and flag outliers across all scraped data, in place.
`results` is the {platform: {handle: [posts]}} structure. Each post gains
`score`, `relevance`, `baseline` (Nx the account mean), and `outlier`.
"""
for handles in results.values():
for posts in handles.values():
if not posts:
continue
scores = []
for post in posts:
post["score"] = score_engagement(post)
post["relevance"] = score_relevance(post, pillar_tokens)
scores.append(post["score"])
mean = sum(scores) / len(scores) if scores else 0
std = math.sqrt(sum((s - mean) ** 2 for s in scores) / len(scores)) if len(scores) > 1 else 0
for post in posts:
post["baseline"] = round(post["score"] / mean, 1) if mean > 0 else 0
post["outlier"] = (post["score"] > mean + OUTLIER_THRESHOLD * std) if std > 0 else False
"""Date parsing/normalization helpers shared across platform fetchers."""
from datetime import datetime, timedelta, timezone
def days_ago(n):
"""Return the UTC date `n` days before today as a YYYY-MM-DD string."""
return (datetime.now(timezone.utc) - timedelta(days=n)).strftime("%Y-%m-%d")
def timestamp_to_date(ts):
"""Convert a unix timestamp (int/str) to a UTC YYYY-MM-DD string, or None."""
if ts in (None, ""):
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
def parse_x_date(created_at):
"""Parse an X/Twitter `created_at` string to YYYY-MM-DD, or None.
Format example: 'Wed Mar 20 12:34:56 +0000 2026'
"""
if not created_at:
return None
try:
dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
return None
"""API key loading and persistent-storage paths.
The API key comes from an environment variable or the .env file (env wins).
The persistent base dir (brand/ + research/) is resolved by `content_home()`.
"""
import os
from pathlib import Path
ENV_PATH = Path.home() / ".config" / "content" / ".env"
KEY_NAME = "SCRAPECREATORS_API_KEY"
CONTENT_HOME_VAR = "CONTENT_HOME"
DEFAULT_CONTENT_HOME = Path.home() / "Documents" / "Content"
def content_home():
"""Resolve the persistent base dir holding brand/ and research/.
Honors the CONTENT_HOME env var; defaults to ~/Documents/Content. This is
deliberately NOT the current working directory: the skill is invoked from
anywhere and runs daily, so brand/ (the user's identity) and research/ (the
dated history that feeds taste memory) must be found again on the next run
regardless of where the terminal happens to be.
"""
override = os.environ.get(CONTENT_HOME_VAR, "").strip()
return Path(override).expanduser() if override else DEFAULT_CONTENT_HOME
def load_api_key(env_path=ENV_PATH):
"""Return the ScrapeCreators API key from the env var or .env file ('' if none)."""
api_key = os.environ.get(KEY_NAME, "")
if api_key:
return api_key
if env_path and Path(env_path).exists():
for line in Path(env_path).read_text().splitlines():
if line.startswith(f"{KEY_NAME}="):
return line.split("=", 1)[1].strip().strip("'\"")
return ""
"""HTTP layer: retrying GET and the ScrapeCreators query helper.
Kept dependency-free (urllib) so the runtime needs no third-party packages.
"""
import json
import sys
import time
import urllib.error
import urllib.request
SC_BASE = "https://api.scrapecreators.com"
TIMEOUT = 30
MAX_RETRIES = 3
RETRY_DELAY = 2.0
def request(url, headers, retries=MAX_RETRIES):
"""GET request with retry. Returns parsed JSON or None.
Retries on 429 (with exponential backoff) and transient network errors.
Gives up immediately on other 4xx responses.
"""
req = urllib.request.Request(url, headers=headers)
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if 400 <= e.code < 500 and e.code != 429:
sys.stderr.write(f"[scrape] HTTP {e.code} for {url}\n")
return None
if attempt < retries - 1:
delay = RETRY_DELAY * (2 ** attempt) if e.code == 429 else RETRY_DELAY
time.sleep(delay)
except (urllib.error.URLError, OSError, TimeoutError):
if attempt < retries - 1:
time.sleep(RETRY_DELAY)
return None
def sc_get(path, params, api_key):
"""ScrapeCreators API GET. Returns parsed JSON or None."""
qs = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in params.items())
url = f"{SC_BASE}{path}?{qs}"
headers = {"x-api-key": api_key, "User-Agent": "content/1.0"}
return request(url, headers)
"""Instagram fetchers: profile posts, single post, comments, transcript."""
from . import dates
from .http import sc_get
from .urls import extract_handle_from_url
MAX_COMMENTS = 10
def _engagement(item):
return {
"likes": item.get("like_count", 0),
"comments": item.get("comment_count", 0),
"views": item.get("play_count") or item.get("ig_play_count", 0),
}
def _caption_text(caption):
if isinstance(caption, dict):
return caption.get("text", "")
return str(caption) if caption else ""
def fetch_profile(handle, api_key):
"""GET /v2/instagram/user/posts — recent posts/reels for a handle."""
data = sc_get("/v2/instagram/user/posts", {"handle": handle, "trim": "true"}, api_key)
if not data:
return []
posts = []
for item in (data.get("items") or []):
code = item.get("code", "")
posts.append({
"text": _caption_text(item.get("caption")),
"url": f"https://www.instagram.com/{handle}/p/{code}/" if code else "",
"author": handle,
"date": dates.timestamp_to_date(item.get("taken_at")),
"platform": "instagram",
"engagement": _engagement(item),
})
return posts
def fetch_post(url, api_key):
"""GET /v2/instagram/post — a single post by URL."""
data = sc_get("/v2/instagram/post", {"url": url}, api_key)
if not data:
return None
handle = (data.get("user") or {}).get("username", "")
return {
"text": _caption_text(data.get("caption")),
"url": url,
"author": handle or extract_handle_from_url(url, "instagram") or "",
"date": dates.timestamp_to_date(data.get("taken_at")),
"platform": "instagram",
"engagement": _engagement(data),
}
def fetch_comments(post_url, api_key):
"""GET /v2/instagram/post/comments — top comments for a post."""
data = sc_get("/v2/instagram/post/comments", {"url": post_url}, api_key)
if not data:
return []
comments = []
for c in (data.get("comments") or [])[:MAX_COMMENTS]:
user = c.get("user") or {}
comments.append({
"author": user.get("username", ""),
"text": c.get("text", ""),
"likes": c.get("comment_like_count", 0),
})
return comments
def fetch_transcript(post_url, api_key):
"""GET /v2/instagram/media/transcript — spoken content of a reel."""
data = sc_get("/v2/instagram/media/transcript", {"url": post_url}, api_key)
if not data:
return None
transcripts = data.get("transcripts") or []
if transcripts and isinstance(transcripts, list):
texts = [t.get("text", "") for t in transcripts if isinstance(t, dict) and t.get("text")]
return " ".join(texts) if texts else None
return None
"""Progress logging to stderr so it doesn't corrupt JSON on stdout."""
import sys
def log(msg):
"""Write a progress line to stderr and flush."""
sys.stderr.write(f"{msg}\n")
sys.stderr.flush()
"""Orchestration: parallel profile scraping, URL-mode fetching, enrichment.
These functions tie the per-platform fetchers, scoring, and analysis together.
Progress goes to stderr via log(); only the caller writes JSON to stdout.
"""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from .analyze import analyze_results
from .log import log
from .platforms import (
COMMENT_FETCHERS,
POST_FETCHERS,
PROFILE_FETCHERS,
TRANSCRIPT_FETCHERS,
)
from .relevance import score_relevance
from .scoring import score_engagement
from .urls import detect_platform
MAX_WORKERS = 5
TOP_N_COMMENTS = 3 # fetch comments for top N posts per account
TOP_N_TRANSCRIPTS = 3 # fetch transcripts for top N video posts per account
def filter_since(results, since):
"""Drop posts dated before `since` (YYYY-MM-DD). Undated posts are kept."""
if not since:
return
for handles in results.values():
for handle, posts in handles.items():
handles[handle] = [p for p in posts if not p.get("date") or p["date"] >= since]
def _engagement_sum(post):
"""Cheap engagement heuristic for ranking which posts to enrich."""
return sum(v for v in post.get("engagement", {}).values() if isinstance(v, (int, float)))
def _index_by_url(results):
"""Map post URL -> post object across the whole results structure."""
url_to_post = {}
for handles in results.values():
for posts in handles.values():
for post in posts:
if post.get("url"):
url_to_post[post["url"]] = post
return url_to_post
def _top_posts(posts, n):
return sorted(posts, key=_engagement_sum, reverse=True)[:n]
def scrape_all(config, api_key, since=None):
"""Scrape all platforms in parallel. Returns (results, errors).
results: {platform: {handle: [posts]}}
"""
results = {}
errors = []
tasks = []
for platform, handles in config.items():
if platform not in PROFILE_FETCHERS:
errors.append(f"Unknown platform: {platform}")
continue
for handle in handles:
tasks.append((platform, handle))
log(f"⏳ Fetching posts from {len(tasks)} account(s)...")
def _fetch(platform, handle):
log(f" ⏳ {platform}/{handle}")
try:
posts = PROFILE_FETCHERS[platform](handle, api_key)
log(f" ✓ {platform}/{handle} — {len(posts)} posts")
return platform, handle, posts, None
except Exception as e: # noqa: BLE001 — per-account failures shouldn't abort the run
log(f" ✗ {platform}/{handle} — {e}")
return platform, handle, [], str(e)
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = [pool.submit(_fetch, p, h) for p, h in tasks]
for f in as_completed(futures):
platform, handle, posts, err = f.result()
results.setdefault(platform, {})[handle] = posts
if err:
errors.append(f"{platform}/{handle}: {err}")
total_posts = sum(len(posts) for handles in results.values() for posts in handles.values())
log(f"✓ Posts fetched: {total_posts} total")
if since:
filter_since(results, since)
kept = sum(len(posts) for handles in results.values() for posts in handles.values())
log(f"✓ Filtered to posts on/after {since}: {kept} of {total_posts}")
_enrich_comments(results, api_key, errors)
_enrich_transcripts(results, api_key, errors)
return results, errors
def _enrich_comments(results, api_key, errors):
"""Fetch comments for the top posts per account and attach them in place."""
tasks = []
for platform, handles in results.items():
fetcher = COMMENT_FETCHERS.get(platform)
if not fetcher:
continue
for handle, posts in handles.items():
for post in _top_posts(posts, TOP_N_COMMENTS):
if post.get("url"):
tasks.append((platform, handle, post["url"], fetcher))
if not tasks:
return
log(f"⏳ Fetching comments for {len(tasks)} top post(s)...")
url_to_post = _index_by_url(results)
def _fetch(platform, handle, url, fetcher):
try:
return platform, handle, url, fetcher(url, api_key), None
except Exception as e: # noqa: BLE001
return platform, handle, url, [], str(e)
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = [pool.submit(_fetch, *t) for t in tasks]
for f in as_completed(futures):
platform, handle, url, comments, err = f.result()
if url in url_to_post and comments:
url_to_post[url]["comments"] = comments
if err:
errors.append(f"comments {platform}/{handle}: {err}")
count = sum(1 for h in results.values() for ps in h.values() for p in ps if p.get("comments"))
log(f"✓ Comments fetched for {count} post(s)")
def _enrich_transcripts(results, api_key, errors):
"""Fetch transcripts for the top video posts per account and attach them."""
tasks = []
for platform, handles in results.items():
fetcher = TRANSCRIPT_FETCHERS.get(platform)
if not fetcher:
continue
for handle, posts in handles.items():
for post in _top_posts(posts, TOP_N_TRANSCRIPTS):
if post.get("url"):
tasks.append((platform, handle, post["url"], fetcher))
if not tasks:
return
log(f"⏳ Fetching transcripts for {len(tasks)} video(s)...")
url_to_post = _index_by_url(results)
def _fetch(platform, handle, url, fetcher):
try:
return platform, handle, url, fetcher(url, api_key), None
except Exception as e: # noqa: BLE001
return platform, handle, url, None, str(e)
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = [pool.submit(_fetch, *t) for t in tasks]
for f in as_completed(futures):
platform, handle, url, transcript, err = f.result()
if url in url_to_post and transcript:
url_to_post[url]["transcript"] = transcript
if err:
errors.append(f"transcript {platform}/{handle}: {err}")
count = sum(1 for h in results.values() for ps in h.values() for p in ps if p.get("transcript"))
log(f"✓ Transcripts fetched for {count} video(s)")
def fetch_urls(urls, api_key, pillar_tokens=None):
"""Fetch individual posts by URL in parallel. Returns (results, errors).
results is a flat [post] list (not the nested profile-mode structure).
"""
pillar_tokens = pillar_tokens or set()
results = []
errors = []
tasks = []
for url in urls:
platform = detect_platform(url)
if not platform:
errors.append(f"Unsupported URL: {url}")
continue
tasks.append((url, platform))
log(f"Fetching {len(tasks)} post(s)...")
def _fetch_one(url, platform):
log(f" {platform}: {url[:80]}")
fetcher = POST_FETCHERS.get(platform)
if not fetcher:
return url, None, f"No fetcher for {platform}"
try:
post = fetcher(url, api_key)
if not post:
return url, None, f"No data returned for {url}"
post["score"] = score_engagement(post)
post["relevance"] = score_relevance(post, pillar_tokens)
post["baseline"] = None
post["outlier"] = None
return url, post, None
except Exception as e: # noqa: BLE001
return url, None, f"{url}: {e}"
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = [pool.submit(_fetch_one, url, plat) for url, plat in tasks]
for f in as_completed(futures):
url, post, err = f.result()
if post:
results.append(post)
if err:
errors.append(err)
# Enrich each post with comments + transcript (these are individual, not top-N)
for post in results:
platform = post["platform"]
url = post["url"]
comment_fetcher = COMMENT_FETCHERS.get(platform)
if comment_fetcher:
try:
post["comments"] = comment_fetcher(url, api_key)
except Exception: # noqa: BLE001
pass
transcript_fetcher = TRANSCRIPT_FETCHERS.get(platform)
if transcript_fetcher:
try:
transcript = transcript_fetcher(url, api_key)
if transcript:
post["transcript"] = transcript
post["relevance"] = score_relevance(post, pillar_tokens)
except Exception: # noqa: BLE001
pass
log(f"Done — {len(results)} post(s) fetched, {len(errors)} error(s)")
return results, errors
# Re-exported so callers can run scoring/analysis without importing analyze directly.
__all__ = [
"scrape_all", "fetch_urls", "filter_since", "analyze_results",
]
"""Fetcher registries, assembled from the per-platform modules.
Each registry maps a platform name to the relevant callable. Platforms that
lack a capability (X has no comment/transcript endpoint) are simply absent
from that registry.
"""
from . import instagram, tiktok, x, youtube
# Recent posts for a handle: fetch_profile(handle, api_key) -> [post]
PROFILE_FETCHERS = {
"instagram": instagram.fetch_profile,
"x": x.fetch_profile,
"tiktok": tiktok.fetch_profile,
"youtube": youtube.fetch_profile,
}
# A single post by URL: fetch_post(url, api_key) -> post | None
POST_FETCHERS = {
"instagram": instagram.fetch_post,
"x": x.fetch_post,
"tiktok": tiktok.fetch_post,
"youtube": youtube.fetch_post,
}
# Top comments for a post URL: fetch_comments(url, api_key) -> [comment]
COMMENT_FETCHERS = {
"instagram": instagram.fetch_comments,
"tiktok": tiktok.fetch_comments,
"youtube": youtube.fetch_comments,
# X has no comment endpoint in ScrapeCreators
}
# Spoken transcript for a post URL: fetch_transcript(url, api_key) -> str | None
TRANSCRIPT_FETCHERS = {
"instagram": instagram.fetch_transcript,
"tiktok": tiktok.fetch_transcript,
"youtube": youtube.fetch_transcript,
# X is text-only, no transcripts
}
"""Relevance scoring: token overlap of a post against the user's content pillars."""
import re
STOPWORDS = frozenset({
'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can',
'its', 'be', 'or', 'not', 'no', 'so', 'if', 'but', 'about',
'all', 'just', 'get', 'has', 'have', 'was', 'will',
})
def tokenize(text):
"""Lowercase, strip punctuation, remove stopwords and 1-char tokens."""
words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
return {w for w in words if w not in STOPWORDS and len(w) > 1}
def parse_pillars(pillars_str):
"""Parse a comma-separated pillar string into a token set."""
if not pillars_str:
return set()
tokens = set()
for pillar in pillars_str.split(","):
tokens |= tokenize(pillar.strip())
return tokens
def score_relevance(post, pillar_tokens):
"""Score how relevant a post is to the content pillars (0.0-1.0).
Blends coverage (fraction of pillar tokens present) with a precision term
that penalizes posts padded with unrelated text.
"""
if not pillar_tokens:
return 0.5 # No pillars = neutral
text = post.get("text", "") + " " + post.get("description", "") + " " + post.get("transcript", "")
post_tokens = tokenize(text)
if not post_tokens:
return 0.0
overlap = pillar_tokens & post_tokens
if not overlap:
return 0.0
coverage = len(overlap) / len(pillar_tokens)
precision = len(overlap) / min(len(post_tokens), len(pillar_tokens) + 4)
return round(min(1.0, 0.65 * coverage + 0.35 * precision), 2)
"""Per-platform weighted engagement scoring."""
def score_engagement(post):
"""Compute a weighted engagement score based on the post's platform."""
e = post.get("engagement", {})
platform = post.get("platform", "")
if platform == "x":
return (e.get("likes", 0)
+ 2 * e.get("reposts", 0)
+ 3 * e.get("replies", 0)
+ 2 * e.get("quotes", 0)
+ 4 * e.get("bookmarks", 0))
if platform == "instagram":
return (e.get("likes", 0)
+ 3 * e.get("comments", 0)
+ 0.1 * e.get("views", 0))
if platform == "tiktok":
return (e.get("likes", 0)
+ 3 * e.get("comments", 0)
+ 2 * e.get("shares", 0)
+ 2 * e.get("saves", 0)
+ 0.05 * e.get("views", 0))
if platform == "youtube":
return (e.get("views", 0) * 0.1
+ e.get("likes", 0)
+ 3 * e.get("comments", 0))
# Fallback: sum all numeric values
return sum(v for v in e.values() if isinstance(v, (int, float)))
Related skills
FAQ
Which platforms does it cover?
One ScrapeCreators API key covers X, Instagram, TikTok, and YouTube, including YouTube transcripts.
What does it output?
A single self-contained HTML page with two tabs, Posts and Ideas, saved to a dated feed under the research directory, that you can open in a browser and react to.