/aeo-daily v0.6 — Local Daily AEO Content Engine
Ships 1 LinkedIn post (voice-clean) + 8-slide carousel + comment-bait reply pack + GHL CSV row every day.
Source repo: <repo>
WEDGE (locked 2026-06-08): ONE thing this engine sells — "Get Cited": make SMBs the answer ChatGPT / Perplexity / Gemini gives. Every BUSINESS post points at that. TRAVEL voice is NOT a second business — it is the proof-of-life layer of the same wedge (solo operator running an AI agency from a local cafe). Default --voice business. Travel runs 3x/week max on personal IG/TikTok only, never competing for the business CTA, with at most ONE subtle bridge line/week ("built this from a local cafe").
FUNNEL (locked 2026-06-08): content is not the product, leads are. Every business post carries a funnel block {cta_keyword, lead_magnet_ref, booking_link}. Flow: reel/post -> comment keyword (e.g. CITED) -> DM auto-reply -> free AEO Visibility Snapshot (scripts/snapshot_gen.py) -> book call. Snapshot = lead magnet + demo + close in one. CTA bank: funnel/cta-bank.json. DM script: funnel/dm-flow.md. Full spec: funnel/README.md.
CHANGELOG
- v0.6.0 (2026-06-08): WEDGE + FUNNEL + engine truth-up (4-agent fanout). (1) Wedge locked = "Get Cited" AEO-for-SMBs; travel demoted to 3x/wk proof-of-life layer (see WEDGE callout above). (2) Funnel built —
scripts/snapshot_gen.py (AEO Visibility Snapshot lead magnet, client-presentable cream/purple HTML), funnel/cta-bank.json (10 one-word keyword CTAs, zero "DM me"), funnel/dm-flow.md, funnel/README.md. copy.json now carries a funnel block. (3) Missing scripts now REAL — the v0.5.0 vaporware is built: config/scroll-hooks.json (150 hooks / 15 triggers, ___ blanks, _meta.angle_to_category), scripts/suggest_hooks.py, scripts/export_text.py, scripts/export_variants.py, scripts/build_master_html.py. (4) lint_voice.py hardened (v1.2.0) — now enforces ai_tell_structures (negation-parallelism / rule-of-three / balanced-pair-warn / framework-promise), scans ALL business channels (was linkedin.post only), added U+2018 + funnel.cta_keyword rule (fail under --strict, warn without), UTF-8 stdout fix. (5) Engagement loop live (true v0.6) — analytics/weekly.csv + scripts/weight_hooks.py -> config/hook-weights.json; suggest_hooks.py reorders an angle's categories by learned cta_trigger weight. (6) client-wins.json refreshed (+5 real wins through 06-07). Canonical psych-category vocab = the 15 hyphen-named categories in scroll-hooks.json (weekly.csv must use them).
- v0.5.0 (2026-06-07): HOOK LIBRARY wired in. New
config/scroll-hooks.json = 150 scroll-stopper hooks across 15 psychology triggers (curiosity-gap, loss-aversion, contrarian, identity-callout, transformation, secret, authority, urgency, shock, question, specificity, story-loop, problem-agitate, confession, benefit), each with ___ blank for any topic. New scripts/suggest_hooks.py maps weekday angle → best psych categories and prints N filled hooks (deterministic, --offset to rotate). New MANDATORY PHASE 2.5 — engine ALWAYS surfaces 8 matched hooks before writing copy; LI hook + video 0-4s beat must be built from them. Browsable viewer HOOK-LIBRARY.html (live topic-fill + filter + search + copy).
- v0.4.2 (2026-05-20): TRAVEL SOFT-DISABLED by default + MASTER.html + image v2.
build_pack_v2.py new --voice business|travel|all flag (default business). Travel platforms skipped from render loop unless explicitly requested. Travel code path preserved — flip to --voice all to resume both tracks. run-aeo-daily.bat now passes --voice business for the 07:00 local-time auto-build.
scripts/build_master_html.py new — fans every channel pack into ONE MASTER.html (iframes + sticky TOC + angle header + GHL CSV download block). Auto-invoked by build_pack_v2.py right after INDEX.html. Voice filter respected → travel iframes simply don't exist when skipped.
INDEX.html template no longer renders empty "Travel Voice" section when 0 travel cards present.
process_image.py v2 — face-detect now runs on ANY filename NOT starting DJI_ (was: only IMG_*, missed all SCENE-* portraits). Multi-cascade union (frontal_default + frontal_alt2 + profileface with horizontal flip). CLAHE histogram equalize before detect → recovers faces in shaded/backlit outdoor frames. Headroom guard in _crop_to shifts crop DOWN when face would clip top edge (kills "forehead at hairline" bug). Per-ratio focal-Y lift — 1×1 → 0.40, 4×5 → 0.38, 9×16 → 0.32 (face lands in upper third for reels). --debug flag for verbose face-detect logging.
- v0.4.1 (2026-05-19 PM): Round-3 production patch. (a)
build_ghl_csvs.py is now schema-aware: tries nested copy[track][slug] first, falls back to legacy flat keys → 4 travel CSVs (ig_travel_1/2, tt_travel_1/2) no longer SKIPPED on extended schema. New --copy flag accepts override path. Image refs sourced from _picks_log.json (Track C source-of-truth) with avatar-dir glob fallback. (b) config/hook-bank.json::image_picker_hints now holds real filename PATTERNS (glob) + literal stems pulled from rotation pool, never invented filenames. (c) build_pack_v2._resolve_image_hint supports glob matching (DJI_*, SCENE-2026-*-street-*) in addition to exact/prefix. (d) copy.image_picks MUST reference real stems or glob patterns — see PHASE 3 note below.
- v0.4.0 (2026-05-19): FORKED VOICE schema. copy.json now contains shared{} + business{linkedin,fbpage,ig_pro,yt,tt_pro} + travel{fb_profile,ig_travel_1,ig_travel_2,tt_travel_1,tt_travel_2,pinterest} = 11 per-platform variants written in one pass. New
travel_rotation 7-day hook bank added to config/hook-bank.json (cafe-discovery / visa-life / expat-mistake / scooter-day / food-find / builder-life / week-recap-travel). lint_voice.py --voice business|travel|both enforces both tracks independently. Travel voice hard-blocks AEO/agency/client/SkynetLabs/schema/n8n/MRR/Loom/Fiverr terms + DM-me CTAs. Image picker hint per voice — business uses IMG_ portraits, travel uses DJI__ + scene_* scenes. Schema doc at config/copy-schema.json.
- v0.3.0 (2026-05-18): brand palette swap (drop pure-black → cream base + brand purple #6b3fa0 + cyan #2dc6d6 + coral #ff6b5b + gold detail), 2→4 photo picks (portrait priority — IMG** over DJI**), photo prominence (hook bg duotone, context top strip, data 2-photo grid, CTA gold-bordered portrait), per-role slide styling (hook/context/data/confession/fix/cta classes).
- v0.2.0 (2026-05-18): 5→8 slide carousel (research: 60% dwell gap), voice-lint script (60+ AI tells blocked), client-wins + local-anchor + banned-phrases configs, hook variants A/B per day, hashtag tail required, comment-bait reply pack (first 60 min), founder-voice + local authenticity layer, arrow glyph fix (→ not ▸).
- v0.1.0 (2026-05-18): initial scaffold + 5-slide pipeline.
VOICE FORK — v0.4 (READ FIRST)
The engine now writes 11 per-platform variants in 2 voices in a single pass.
BUSINESS voice (5 channels: linkedin, fb_page, ig_pro, yt, tt_pro)
- Voice spine = locked LinkedIn template (contrarian hook → reframe → arrow lines → vulnerability beat → CTA).
- Anchor: local-life PUNCH (one specific detail dropped mid-post).
- Client win mandatory (
shared.client_win_ref from config/client-wins.json).
- Hashtags: business pool (
#aeo #linkedinmarketing #agencyowner #automation #brandtracking #claudecode #n8n #b2b).
- Hook bank:
config/hook-bank.json → rotation[<weekday>].
- Image picker: IMG_* portraits (face-on-camera).
- Lint:
python scripts/lint_voice.py --date <DATE> --voice business --strict.
TRAVEL voice (6 channels: fb_profile, ig_travel_1, ig_travel_2, tt_travel_1, tt_travel_2, pinterest)
- NO AEO, NO agency, NO client pitches, NO SkynetLabs, NO schema/citation/n8n/MRR/B2B talk.
- Self-deprecating, observational. Local daily-life (cafes, scooters, beach, expat scene, food, local-currency prices).
- Anchor: local-life DRIFT (one detail per post, plus 1 named place + 1 real currency amount).
- Story anchor mandatory (
travel.fb_profile.story_anchor) = small embarrassing/funny moment.
- Hashtags: travel pool (
#travel #digitalnomad #nomadlife #expatlife #cafehopping #scooterlife #slowtravel). Blocklist: any business tag.
- Hook bank:
config/hook-bank.json → travel_rotation[<weekday>].
- Image picker: DJI** + scene**.jpg + IMG_4*** scenes (NOT portraits).
- Lint:
python scripts/lint_voice.py --date <DATE> --voice travel --strict.
- Banned CTA patterns: "DM me", "I'll reply with X", "follow for more", "save this".
Travel rotation (locked v0.4)
| Day |
Angle |
Sample hook |
| Mon |
cafe-discovery |
"New cafe in town. Flat white was cheap. Wifi held for 2 hours." |
| Tue |
visa-life |
"Visa run on Tuesday. Agent quoted the 60-day rate. Standard." |
| Wed |
expat-mistake |
"Rented a scooter without checking the brakes. Found out at the first downhill." |
| Thu |
scooter-day |
"Cross-town on the scooter today. 90 minutes if you skip the toll." |
| Fri |
food-find |
"Local plate for a few bucks at a spot behind the apartment. Extra sauce added." |
| Sat |
builder-life |
"Saturday looks like: wifi-cafe morning, beach walk noon, local dinner." |
| Sun |
week-recap-travel |
"Week recap: 1 visa run, 2 new cafes, 1 wipeout learning to surf." |
Lint both tracks in one call
python scripts/lint_voice.py --date <DATE> --voice both --strict
VOICE — LOCKED (the author's reference posts, do not flatten)
Reference POST 1 — "Freelancer → Operator" reframe (his top-engaging)
Stop calling yourself a freelancer.
Words shape ceilings.
Freelancer says: "I trade hours for cash."
Operator says: "I run systems that deliver outcomes."
The market pays operators 10x more. Same skills. Different label. Different price.
→ Stop saying "rate" → start saying "engagement"
→ Stop saying "available hours" → start saying "client capacity"
→ Stop sending invoices → start sending retainer agreements
→ Stop chasing leads → start filtering inbound
What's on your LinkedIn headline today? Calling myself out first — mine literally said "freelancer" 12 months ago.
Reference POST 2 — "Claude Routines killed my n8n flows"
I spent 2 years building automations on n8n.
Last week, Claude Routines replaced three of them in under 10 minutes.
No nodes. No webhooks. No 2 AM error emails.
Just a text box and a schedule.
Here's one I'm running daily at 9 AM:
→ Pulls last 24-48 hrs of news across AI automation, n8n, Claude API, Next.js, Meta Ads
→ Filters for what's actually relevant to my agency and content pipeline
→ Writes 30-45 sec vertical video script — hook, body, CTA — ready to film
I wake up. Script is waiting. I shoot. I post.
A genuine ask to the builders here: What's the most creative Routine you've deployed?
Voice spine (every post)
- Contrarian hook — 1 line, ≤210 chars, ≤10 words ideal (hits 2.3x engagement per 2026 LI data).
- Reframe — 2-3 short lines, fragments OK.
- Arrow lines (
→) — 3-5 items (keep glyph per voice memory — do NOT use ▸ ▪ • or em-dash).
- Personal vulnerability beat — admit specific past mistake, dated ("12 months ago" / "last quarter" / "the dental call last week").
- CTA question + compile-promise — "drop X below, I'll reply with Y."
- Hashtag tail — 5-8 tags (
#aeo #linkedinmarketing etc).
Anti-AI authenticity layer (NEW v0.2 — required)
- 1 anchor per post — a named local cafe, a ride-hail detail, a local-currency amount, a power-cut, a surf/commute time, or another concrete detail from your city.
- 1 real client win referenced — slug from
config/client-wins.json (dental-flagship / real-estate-voice / saas-email / healthcare-workflow / security-av / travel-client-site).
- Allowed local code-switch sparingly: 1-2 colloquial words from your own dialect (max 1 per post, italicized).
- Mid-post drift allowed — "Anyway." / "Scratch that, actually —" / trail-off ellipsis (
... straight, not …).
BANNED PATTERNS (lint_voice.py hard-fails)
Full list: config/banned-phrases.json. Highlights:
Phrases: delve, moreover, navigate, leverage, robust, seamless, holistic, transformative, groundbreaking, pivotal, evolving landscape, in essence, at its core, the real question is, embark on, foster, harness, "let that sink in," "read that again."
Openers BANNED: "Unpopular opinion:" / "Hot take:" / "Plot twist:" / "Real talk:"
Closers BANNED: "What's your take?" / "Agree?" / "Save this post" / "Bookmark this"
Structures BANNED:
- Negation parallelism — "It's not X, it's Y"
- Forced rule-of-three — "innovation, inspiration, insight"
- Balanced sentence pairs — "Talent gets you in. Discipline keeps you there."
- Unnamed framework promise — "Here's the 3-step framework" (OK if named after real client)
Encoding BANNED: em-dash (—), en-dash (–), curly quotes (" " '), unicode ellipsis (…). Use straight ASCII.
Travel cliches BANNED: paradise, island vibes, manifesting, sunset views, nomad lifestyle.
EXECUTION FLOW (one session, ~6 min)
PHASE 1 — Fetch + dedup feeds (30s)
cd "<repo>" && \
python scripts/fetch_feeds.py --date $(date +%Y-%m-%d) --top 30
20 RSS, 48h SQLite dedup, writes outputs/<DATE>/today_items.json.
PHASE 2 — Pick today's angle (5s)
python scripts/pick_angle.py --date $(date +%Y-%m-%d)
Weekday → angle.json w/ 2 hook variants + top 5 candidates.
PHASE 2.5 — Surface scroll-stopper hooks (MANDATORY, 3s)
Always show the user matched psychology hooks BEFORE writing copy. Pull from the 150-hook library, filled with today's topic noun.
python scripts/suggest_hooks.py --angle <angle_slug> --topic "<topic noun>" --n 8
<angle_slug> = angle.json::angle_slug. The script maps angle → best psych categories via config/scroll-hooks.json::_meta.angle_to_category.
<topic noun> = the concrete subject of today's post (the niche/service/pain, e.g. "editing video in the terminal", "AEO for clinics").
- Print the 8 hooks to the user. The LinkedIn hook + the video 0-4s beat MUST be built from one of these (paraphrase tightly to keep voice — never paste verbatim).
- Full library viewer for browsing/filtering:
HOOK-LIBRARY.html (live topic-fill box + search + copy). Dump all 150 grouped: python scripts/suggest_hooks.py --all --topic "<noun>".
- Hook rule: first line ONLY. Pay off the open loop within the next 2 lines or it trains distrust. ASCII only (no em-dash / curly quotes).
PHASE 3 — Claude writes copy.json (~5 min, v0.4 FORKED schema)
Read: angle.json, config/client-wins.json, config/local-anchors.json, config/banned-phrases.json, config/hook-bank.json (read BOTH rotation for business AND travel_rotation for travel), config/copy-schema.json.
Pick once per day:
- 1 source from candidates (most agency-relevant) → business block only.
- 1 business hook variant from
hook-bank.rotation[<weekday>] (or tight paraphrase).
- 1 travel hook variant from
hook-bank.travel_rotation[<weekday>].
- 1 client_win_slug (
shared.client_win_ref).
- 1 local anchor (
shared.local_anchor) — STYLED DIFFERENTLY in each track (business = punch / travel = drift).
- 1 vulnerability_beat (
shared.vulnerability_beat) — used by business only.
- 1 story_anchor (
travel.fb_profile.story_anchor) — used by travel only.
Write outputs/<DATE>/copy.json with the forked schema:
{
"date": "2026-05-19",
"angle_slug": "prompt-exposure",
"weekday": "tuesday",
"source_url": "https://...",
"source_title": "...",
"hook_variant_chosen": 2,
"shared": {
"news_anchor": "...",
"client_win_ref": "dental-flagship",
"local_anchor": "Coffee at the corner cafe, a few bucks.",
"vulnerability_beat": "I used to think AEO was schema + FAQ. Cost me 2 closes in Q1."
},
"business": {
"linkedin": {"post": "<900-1200ch w/ 8-role spine>", "slides": [...8 slides...], "hashtags": [...5-8...], "comment_bait": [...3...]},
"fb_page": {"cards": [...3...], "post": "<200-800ch>", "hashtags": [...3-5...]},
"ig_pro": {"slides": [...8...], "caption": "...", "hashtags": [...5-8...], "reel_script": "30-45s"},
"yt": {"thumb": {"headline":"...","sub":"..."}, "shorts_script": "45-60s", "title": "<=70ch", "desc": "..."},
"tt_pro": {"cover": {"headline":"...","sub":"..."}, "script": "30-45s", "caption": "...", "hashtags": [...3-5...]}
},
"travel": {
"angle_slug": "visa-life",
"hook_variant_chosen": 1,
"story_anchor": "Stood in the immigration line 90 min. Agent took 20 USD over WA.",
"fb_profile": {"cards": [...3...], "post": "<150-700ch>", "story_anchor": "..."},
"ig_travel_1": {"slides": [...5...], "caption": "...", "hashtags": [...5-8...], "reel_script": "15-30s"},
"ig_travel_2": {"slides": [...5...], "caption": "...", "hashtags": [...5-8...]},
"tt_travel_1": {"script": "...", "caption": "...", "hashtags": [...3-5...]},
"tt_travel_2": {"script": "...", "caption": "...", "hashtags": [...3-5...]},
"pinterest": {"pin": {"headline":"...","overlay":"...","size":"1000x1500"}, "desc": "...", "board": "Travel", "hashtags": [...8-12...]}
}
}
Full schema spec: config/copy-schema.json.
image_picks block — MUST use real filenames OR glob patterns (v0.4.1)
Authoring image_picks is the #1 cause of "PENDING_IMAGES" CSV rows. The hint MUST be one of:
A literal stem present in the rotation pool — verify before writing:
python "<your-images>/_pick-next.py" --count 400 | grep -E "^DJI_|^SCENE-|^IMG_"
Examples that resolve today: IMG_4105, SCENE-2026-05-07-garden-variant, DJI_20260518_0023.
A glob pattern with * or ? — build_pack_v2._resolve_image_hint runs fnmatch against every candidate stem (case-insensitive). The FIRST match wins, which means the rotation-log order decides which actual file you get.
- Travel scenes:
DJI_*, SCENE-2026-*-street-*, SCENE-2026-*-garden-*.
- Business portraits:
IMG_*, SCENE-*-rooftop-portrait-*, SCENE-*-veranda-*.
NEVER invent filenames like scene_street_01.jpg or scene_beach_low_tide unless they actually exist on disk. They will not resolve, drop to fallback (whatever-track-appropriate is available), and you lose authorial control over which image pairs with which slide.
Pattern cheat-sheet lives in config/hook-bank.json::image_picker_hints. Add new globs there as new naming families enter the pool. NEVER edit process_image.py for this — the resolver is in build_pack_v2.py.
PHASE 3.5 — Voice lint, BOTH tracks (3s, HARD GATE)
python scripts/lint_voice.py --date $(date +%Y-%m-%d) --voice both --strict
Business fails on ANY: banned phrase, encoding tell, hook>210 chars, hashtag count off, missing vulnerability beat, missing anchor, missing client_win_ref, wrong slide count/roles.
Travel fails on ANY: banned business term (aeo/agency/client/SkynetLabs/n8n/MRR/Loom/Fiverr/schema/citation), blocked travel hashtag, missing local-currency anchor, missing named place, missing story_anchor, business-CTA pattern ("DM me" / "I'll reply with"), encoding tell.
If lint fails → fix copy.json + re-run BEFORE Phase 4.
PHASE 4 — Export TEXT-ONLY pack (5s) — DEFAULT since 2026-05-31
Preferred: 3 variants per platform (story / contrarian / confession-style angles for A/B). Author outputs/<DATE>/variants.json (see shape in scripts/export_variants.py header — platforms[].variants[] each = {style, angle, body, hashtags}), then:
python scripts/export_variants.py --date $(date +%Y-%m-%d)
Emits VARIANTS.html (bright theme, 3 labelled copy-boxes per platform + Copy button) + VARIANTS.txt. 11 platforms x 3 = 33 ready posts. Pick one variant per platform, attach own image, post.
Voice rules for variants (research-backed, 2026): open IN-SCENE (a moment, not a thesis) · uneven rhythm (short. fragment. one longer line) · KILL rule-of-three / "it's not X it's Y" / inflated words / em-dashes · end on a LOW-LOAD CTA that drives comments (binary "chase or wait?", "tell me I'm wrong", fill-in-blank, oddly-specific question) NOT "what's your take?" · LI 900-1500 chars, IG hook <125 chars, FB/TT short.
Video = ONE shared, SHOOT-READY script, NOT per-platform. video_scripts.{business,travel}[] each = {style, angle, shots:[{t, line, angle, delivery, broll}], caption, hashtags}. The shots array is a director's breakdown: each beat tells you the exact LINE to say, the CAMERA angle/framing, the DELIVERY cue, and the ON-SCREEN/B-roll text. Rotate camera angles across beats (front-on tight -> push-in/lean -> 3/4 angle -> back to front-on for CTA) so the video isn't static. One shoot -> post to YouTube Short + TikTok + IG Reel + FB Reel. Do NOT write separate YT/TikTok/IG/FB scripts.
- business video scripts MUST be driven by the LATEST industry/service news — pull a real, dated item from PHASE 1's
today_items.json (AI agents / automation / AEO / the niches we sell), attribute the source claim (no fabricated stats), then tie it to a SMB service angle + comment CTA. One real news anchor per variant.
- travel video scripts stay personal lifestyle (local daily-life), NOT industry news.
Single-copy fallback (1 per platform, from copy.json):
python scripts/export_text.py --date $(date +%Y-%m-%d)
- Reads
copy.json, emits COPY.html + COPY.txt.
- NO images, NO
_avatars, NO picker, NO html2canvas. You select + push your own images.
Image carousels are DEPRECATED (the old build_pack_v2.py render still works if ever needed, but the renderer had a silent blank-text bug; text export is the supported path). To resume rendered images: python scripts/build_pack_v2.py --date <DATE>.
PHASE 5 — (optional) GHL CSV row (5s)
python scripts/append_ghl.py --date $(date +%Y-%m-%d) --slot 10:00
6-col canonical CSV → tomorrow 10:00 local time. imageUrls left blank; paste manually-chosen image URLs before import. Skip entirely if posting by hand.
PHASE 6 — Tell the user what to do
Print:
OPEN: outputs/<DATE>/COPY.html
1. 📋 Each platform = a Copy button. Click → paste into the native composer.
2. 🖼 Pick + attach your own image(s) per post.
3. 📋 LinkedIn: fire the 3 comment-baits 1 per 15-20 min in first 60 min.
4. Done.
TOOL-STACK WARNING (research-backed)
- NEVER post via Taplio — 30% reach penalty + 55% engagement penalty. LinkedIn algo flags AI-template patterns from Taplio.
- Safe: native LI scheduler, Buffer, GHL scheduler.
- Analytics-only (zero algo risk): AuthoredUp (hook preview), Shield Analytics (retro).
- AVOID: Hypefury for LI (it's Twitter-first, weaker LI signal).
OUTPUT CONVENTION
outputs/YYYY-MM-DD/
├── today_items.json # 30 deduped RSS items
├── angle.json # angle + 2 hook variants + 5 candidates
├── copy.json # Claude-written, voice-linted
├── index.html # 8-slide carousel + post + reply pack
├── _avatars/ # 2 picked photos
├── _picked_images.json # rotation audit
└── csv/ghl-linkedin.csv # tomorrow 10:00 local time
RULES (REPRIORITIZED v0.2)
- Voice spine = locked (reference POST 1 + POST 2). Don't flatten.
- Anchor required — 1 real local detail per post.
- Client win required — slug from
client-wins.json. No invented stats.
- Vulnerability beat required — dated, specific, uncomfortable.
- Hook ≤210 chars — LI "See more" truncate point.
- Hashtags 5-8 — no more, no less.
- Comment-bait pack — 3 pre-staged replies. Fire 1 per 15-20 min first hour.
- lint_voice.py --strict must pass before render.
- Rotation log sacred — always
--mark photos.
- No Taplio. Ever.
ANTI-PATTERNS
- Don't paste hook from
hook_variants verbatim — paraphrase tightly to keep voice unique.
- Don't reuse a client win slug 2 days in a row.
- Don't drop the
→ arrow glyph for ▸ • or em-dash — voice memory locked.
- Don't fill
vulnerability_beat with generic "I used to think AI was overhyped" — needs date, person, currency, or specific fail.
- Don't omit the comment-bait pack — that's where 70% of reach lives.
TROUBLESHOOTING
| Symptom |
Fix |
feedparser missing |
pip install -r requirements.txt |
| 0 items after dedup |
rm cache/seen.sqlite |
| Image picker fails |
check _USAGE_LOG.json exists in |
| html2canvas blank |
hard-refresh; check _avatars/ populated |
| lint_voice.py FAILS |
read each FAIL line; fix copy.json; re-run |
| Hook too long |
use only first variant from angle.json, strip trailing clause |
| Anchor missing |
inject 1 line from local-anchors.json into Slide 2 + LI post body |
ROADMAP
- v0.3 — wire
/reel-studio mograph mode for daily 30s video off same angle.
- v0.4 — auto-push PNGs to GitHub
carousels-png/<DATE>/ for ManyChat asset library.
- v0.5 — Task Scheduler invocation:
claude -p /aeo-daily daily 7am local time.
- v0.6 — DONE (2026-06-08): engagement-loop live via
weight_hooks.py + hook-weights.json overlay read by suggest_hooks.py.
- v0.7 — auto-generate 2 hook variants via Claude inside Phase 3 (not pre-baked).
- v0.8 — wire
build_master_html.py auto-invoke into build_pack_v2.py; add --voice flag to build_pack_v2.py (still missing — renderer always emits all channels).
- v0.9 — live-query the AEO Visibility Snapshot (auto-run ChatGPT/Perplexity/Gemini for the prospect instead of manual fill).
Last updated: 2026-06-08 (v0.6.0 — WEDGE "Get Cited" + FUNNEL (snapshot lead magnet, cta-bank, dm-flow) + 6 missing scripts built + lint v1.2.0 hardened across all channels + engagement hook-weighting loop live + client-wins refreshed)
1---2name: aeo-daily3description: Local AEO daily content engine — FORKED VOICE + voice filter (v0.4.2). One command → today's pack across 5 BUSINESS channels by default (was 11). Travel pack on-demand via --voice flag. BUSINESS voice (5 channels, default): LI personal, FB page, IG pro, YT pro, TikTok pro. Carousel + reels + comment-bait. TRAVEL voice (6 channels, on-demand): FB profile, IG travel x2, TikTok travel x2, Pinterest. Daily-life narrative, no agency talk. Pipeline writes single MASTER.html (all channels embedded as iframes + sticky TOC) at outputs/<DATE>/MASTER.html. All saved to <repo>\outputs\YYYY-MM-DD\copy.json (forked schema, see config/copy-schema.json). Legacy v0.3 still supported: --legacy flag falls back to single LI post + 8-slide carousel. Voice = the author's locked LinkedIn template (contrarian hook → reframe → → arrow lines → vulnerability beat → CTA + 5-8 hashtags). Authenticity layer required: 1 local anchor per post (named cafe / local currency / scooter / your local spot). 1 real client win referenced (a SaaS cli4license: MIT5---67# /aeo-daily v0.6 — Local Daily AEO Content Engine89Ships **1 LinkedIn post (voice-clean) + 8-slide carousel + comment-bait reply pack + GHL CSV row** every day.1011Source repo: `<repo>`1213> **WEDGE (locked 2026-06-08):** ONE thing this engine sells — **"Get Cited": make SMBs the answer ChatGPT / Perplexity / Gemini gives.** Every BUSINESS post points at that. TRAVEL voice is NOT a second business — it is the **proof-of-life layer** of the same wedge (solo operator running an AI agency from a local cafe). Default `--voice business`. Travel runs 3x/week max on personal IG/TikTok only, never competing for the business CTA, with at most ONE subtle bridge line/week ("built this from a local cafe").14>15> **FUNNEL (locked 2026-06-08):** content is not the product, leads are. Every business post carries a `funnel` block `{cta_keyword, lead_magnet_ref, booking_link}`. Flow: reel/post -> comment keyword (e.g. CITED) -> DM auto-reply -> free **AEO Visibility Snapshot** (`scripts/snapshot_gen.py`) -> book call. Snapshot = lead magnet + demo + close in one. CTA bank: `funnel/cta-bank.json`. DM script: `funnel/dm-flow.md`. Full spec: `funnel/README.md`.1617---1819## CHANGELOG2021- **v0.6.0 (2026-06-08):** WEDGE + FUNNEL + engine truth-up (4-agent fanout). (1) **Wedge locked** = "Get Cited" AEO-for-SMBs; travel demoted to 3x/wk proof-of-life layer (see WEDGE callout above). (2) **Funnel built** — `scripts/snapshot_gen.py` (AEO Visibility Snapshot lead magnet, client-presentable cream/purple HTML), `funnel/cta-bank.json` (10 one-word keyword CTAs, zero "DM me"), `funnel/dm-flow.md`, `funnel/README.md`. `copy.json` now carries a `funnel` block. (3) **Missing scripts now REAL** — the v0.5.0 vaporware is built: `config/scroll-hooks.json` (150 hooks / 15 triggers, `___` blanks, `_meta.angle_to_category`), `scripts/suggest_hooks.py`, `scripts/export_text.py`, `scripts/export_variants.py`, `scripts/build_master_html.py`. (4) **lint_voice.py hardened (v1.2.0)** — now enforces `ai_tell_structures` (negation-parallelism / rule-of-three / balanced-pair-warn / framework-promise), scans ALL business channels (was linkedin.post only), added U+2018 + funnel.cta_keyword rule (fail under --strict, warn without), UTF-8 stdout fix. (5) **Engagement loop live (true v0.6)** — `analytics/weekly.csv` + `scripts/weight_hooks.py` -> `config/hook-weights.json`; `suggest_hooks.py` reorders an angle's categories by learned cta_trigger weight. (6) **client-wins.json refreshed** (+5 real wins through 06-07). Canonical psych-category vocab = the 15 hyphen-named categories in scroll-hooks.json (weekly.csv must use them).22- **v0.5.0 (2026-06-07):** HOOK LIBRARY wired in. New `config/scroll-hooks.json` = 150 scroll-stopper hooks across 15 psychology triggers (curiosity-gap, loss-aversion, contrarian, identity-callout, transformation, secret, authority, urgency, shock, question, specificity, story-loop, problem-agitate, confession, benefit), each with `___` blank for any topic. New `scripts/suggest_hooks.py` maps weekday angle → best psych categories and prints N filled hooks (deterministic, `--offset` to rotate). New MANDATORY PHASE 2.5 — engine ALWAYS surfaces 8 matched hooks before writing copy; LI hook + video 0-4s beat must be built from them. Browsable viewer `HOOK-LIBRARY.html` (live topic-fill + filter + search + copy).23- **v0.4.2 (2026-05-20):** TRAVEL SOFT-DISABLED by default + MASTER.html + image v2.24 - `build_pack_v2.py` new `--voice business|travel|all` flag (default `business`). Travel platforms skipped from render loop unless explicitly requested. Travel code path preserved — flip to `--voice all` to resume both tracks. `run-aeo-daily.bat` now passes `--voice business` for the 07:00 local-time auto-build.25 - `scripts/build_master_html.py` new — fans every channel pack into ONE `MASTER.html` (iframes + sticky TOC + angle header + GHL CSV download block). Auto-invoked by `build_pack_v2.py` right after `INDEX.html`. Voice filter respected → travel iframes simply don't exist when skipped.26 - `INDEX.html` template no longer renders empty "Travel Voice" section when 0 travel cards present.27 - `process_image.py` v2 — face-detect now runs on ANY filename NOT starting `DJI_` (was: only `IMG_*`, missed all `SCENE-*` portraits). Multi-cascade union (`frontal_default` + `frontal_alt2` + `profileface` with horizontal flip). CLAHE histogram equalize before detect → recovers faces in shaded/backlit outdoor frames. Headroom guard in `_crop_to` shifts crop DOWN when face would clip top edge (kills "forehead at hairline" bug). Per-ratio focal-Y lift — 1×1 → 0.40, 4×5 → 0.38, 9×16 → 0.32 (face lands in upper third for reels). `--debug` flag for verbose face-detect logging.28- **v0.4.1 (2026-05-19 PM):** Round-3 production patch. (a) `build_ghl_csvs.py` is now schema-aware: tries nested `copy[track][slug]` first, falls back to legacy flat keys → 4 travel CSVs (`ig_travel_1/2`, `tt_travel_1/2`) no longer SKIPPED on extended schema. New `--copy` flag accepts override path. Image refs sourced from `_picks_log.json` (Track C source-of-truth) with avatar-dir glob fallback. (b) `config/hook-bank.json::image_picker_hints` now holds real filename PATTERNS (glob) + literal stems pulled from rotation pool, never invented filenames. (c) `build_pack_v2._resolve_image_hint` supports glob matching (`DJI_*`, `SCENE-2026-*-street-*`) in addition to exact/prefix. (d) `copy.image_picks` MUST reference real stems or glob patterns — see PHASE 3 note below.29- **v0.4.0 (2026-05-19):** FORKED VOICE schema. copy.json now contains shared{} + business{linkedin,fb*page,ig_pro,yt,tt_pro} + travel{fb_profile,ig_travel_1,ig_travel_2,tt_travel_1,tt_travel_2,pinterest} = 11 per-platform variants written in one pass. New `travel_rotation` 7-day hook bank added to `config/hook-bank.json` (cafe-discovery / visa-life / expat-mistake / scooter-day / food-find / builder-life / week-recap-travel). `lint_voice.py --voice business|travel|both` enforces both tracks independently. Travel voice hard-blocks AEO/agency/client/SkynetLabs/schema/n8n/MRR/Loom/Fiverr terms + DM-me CTAs. Image picker hint per voice — business uses IMG*\_ portraits, travel uses DJI\_\_ + scene\_\* scenes. Schema doc at `config/copy-schema.json`.30- **v0.3.0 (2026-05-18):** brand palette swap (drop pure-black → cream base + brand purple #6b3fa0 + cyan #2dc6d6 + coral #ff6b5b + gold detail), 2→4 photo picks (portrait priority — IMG*\* over DJI*\*), photo prominence (hook bg duotone, context top strip, data 2-photo grid, CTA gold-bordered portrait), per-role slide styling (hook/context/data/confession/fix/cta classes).31- **v0.2.0 (2026-05-18):** 5→8 slide carousel (research: 60% dwell gap), voice-lint script (60+ AI tells blocked), client-wins + local-anchor + banned-phrases configs, hook variants A/B per day, hashtag tail required, comment-bait reply pack (first 60 min), founder-voice + local authenticity layer, arrow glyph fix (→ not ▸).32- **v0.1.0 (2026-05-18):** initial scaffold + 5-slide pipeline.3334---3536## VOICE FORK — v0.4 (READ FIRST)3738The engine now writes 11 per-platform variants in 2 voices in a single pass.3940### BUSINESS voice (5 channels: linkedin, fb_page, ig_pro, yt, tt_pro)4142- Voice spine = locked LinkedIn template (contrarian hook → reframe → arrow lines → vulnerability beat → CTA).43- Anchor: local-life PUNCH (one specific detail dropped mid-post).44- Client win mandatory (`shared.client_win_ref` from `config/client-wins.json`).45- Hashtags: business pool (`#aeo #linkedinmarketing #agencyowner #automation #brandtracking #claudecode #n8n #b2b`).46- Hook bank: `config/hook-bank.json` → `rotation[<weekday>]`.47- Image picker: IMG\_\* portraits (face-on-camera).48- Lint: `python scripts/lint_voice.py --date <DATE> --voice business --strict`.4950### TRAVEL voice (6 channels: fb_profile, ig_travel_1, ig_travel_2, tt_travel_1, tt_travel_2, pinterest)5152- NO AEO, NO agency, NO client pitches, NO SkynetLabs, NO schema/citation/n8n/MRR/B2B talk.53- Self-deprecating, observational. Local daily-life (cafes, scooters, beach, expat scene, food, local-currency prices).54- Anchor: local-life DRIFT (one detail per post, plus 1 named place + 1 real currency amount).55- Story anchor mandatory (`travel.fb_profile.story_anchor`) = small embarrassing/funny moment.56- Hashtags: travel pool (`#travel #digitalnomad #nomadlife #expatlife #cafehopping #scooterlife #slowtravel`). Blocklist: any business tag.57- Hook bank: `config/hook-bank.json` → `travel_rotation[<weekday>]`.58- Image picker: DJI*\* + scene*\*.jpg + IMG_4\*\*\* scenes (NOT portraits).59- Lint: `python scripts/lint_voice.py --date <DATE> --voice travel --strict`.60- Banned CTA patterns: "DM me", "I'll reply with X", "follow for more", "save this".6162### Travel rotation (locked v0.4)6364| Day | Angle | Sample hook |65| --- | ----------------- | -------------------------------------------------------------------------------- |66| Mon | cafe-discovery | "New cafe in town. Flat white was cheap. Wifi held for 2 hours." |67| Tue | visa-life | "Visa run on Tuesday. Agent quoted the 60-day rate. Standard." |68| Wed | expat-mistake | "Rented a scooter without checking the brakes. Found out at the first downhill." |69| Thu | scooter-day | "Cross-town on the scooter today. 90 minutes if you skip the toll." |70| Fri | food-find | "Local plate for a few bucks at a spot behind the apartment. Extra sauce added." |71| Sat | builder-life | "Saturday looks like: wifi-cafe morning, beach walk noon, local dinner." |72| Sun | week-recap-travel | "Week recap: 1 visa run, 2 new cafes, 1 wipeout learning to surf." |7374### Lint both tracks in one call7576```bash77python scripts/lint_voice.py --date <DATE> --voice both --strict78```7980---8182## VOICE — LOCKED (the author's reference posts, do not flatten)8384### Reference POST 1 — "Freelancer → Operator" reframe (his top-engaging)8586> Stop calling yourself a freelancer.87> Words shape ceilings.88> Freelancer says: "I trade hours for cash."89> Operator says: "I run systems that deliver outcomes."90> The market pays operators 10x more. Same skills. Different label. Different price.91> → Stop saying "rate" → start saying "engagement"92> → Stop saying "available hours" → start saying "client capacity"93> → Stop sending invoices → start sending retainer agreements94> → Stop chasing leads → start filtering inbound95> What's on your LinkedIn headline today? Calling myself out first — mine literally said "freelancer" 12 months ago.9697### Reference POST 2 — "Claude Routines killed my n8n flows"9899> I spent 2 years building automations on n8n.100> Last week, Claude Routines replaced three of them in under 10 minutes.101> No nodes. No webhooks. No 2 AM error emails.102> Just a text box and a schedule.103> Here's one I'm running daily at 9 AM:104> → Pulls last 24-48 hrs of news across AI automation, n8n, Claude API, Next.js, Meta Ads105> → Filters for what's actually relevant to my agency and content pipeline106> → Writes 30-45 sec vertical video script — hook, body, CTA — ready to film107> I wake up. Script is waiting. I shoot. I post.108> A genuine ask to the builders here: What's the most creative Routine you've deployed?109110### Voice spine (every post)1111121. **Contrarian hook** — 1 line, ≤210 chars, ≤10 words ideal (hits 2.3x engagement per 2026 LI data).1132. **Reframe** — 2-3 short lines, fragments OK.1143. **Arrow lines** (`→`) — 3-5 items (keep glyph per voice memory — do NOT use ▸ ▪ • or em-dash).1154. **Personal vulnerability beat** — admit specific past mistake, dated ("12 months ago" / "last quarter" / "the dental call last week").1165. **CTA question + compile-promise** — "drop X below, I'll reply with Y."1176. **Hashtag tail** — 5-8 tags (`#aeo #linkedinmarketing` etc).118119### Anti-AI authenticity layer (NEW v0.2 — required)120121- 1 **anchor** per post — a named local cafe, a ride-hail detail, a local-currency amount, a power-cut, a surf/commute time, or another concrete detail from your city.122- 1 **real client win** referenced — slug from `config/client-wins.json` (dental-flagship / real-estate-voice / saas-email / healthcare-workflow / security-av / travel-client-site).123- Allowed local code-switch sparingly: 1-2 colloquial words from your own dialect (max 1 per post, italicized).124- Mid-post drift allowed — "Anyway." / "Scratch that, actually —" / trail-off ellipsis (`...` straight, not `…`).125126---127128## BANNED PATTERNS (lint_voice.py hard-fails)129130Full list: `config/banned-phrases.json`. Highlights:131132**Phrases:** delve, moreover, navigate, leverage, robust, seamless, holistic, transformative, groundbreaking, pivotal, evolving landscape, in essence, at its core, the real question is, embark on, foster, harness, "let that sink in," "read that again."133134**Openers BANNED:** "Unpopular opinion:" / "Hot take:" / "Plot twist:" / "Real talk:"135136**Closers BANNED:** "What's your take?" / "Agree?" / "Save this post" / "Bookmark this"137138**Structures BANNED:**139140- Negation parallelism — "It's not X, it's Y"141- Forced rule-of-three — "innovation, inspiration, insight"142- Balanced sentence pairs — "Talent gets you in. Discipline keeps you there."143- Unnamed framework promise — "Here's the 3-step framework" (OK if named after real client)144145**Encoding BANNED:** em-dash (—), en-dash (–), curly quotes (" " '), unicode ellipsis (…). Use straight ASCII.146147**Travel cliches BANNED:** paradise, island vibes, manifesting, sunset views, nomad lifestyle.148149---150151## EXECUTION FLOW (one session, ~6 min)152153### PHASE 1 — Fetch + dedup feeds (30s)154155```bash156cd "<repo>" && \157python scripts/fetch_feeds.py --date $(date +%Y-%m-%d) --top 30158```15916020 RSS, 48h SQLite dedup, writes `outputs/<DATE>/today_items.json`.161162### PHASE 2 — Pick today's angle (5s)163164```bash165python scripts/pick_angle.py --date $(date +%Y-%m-%d)166```167168Weekday → `angle.json` w/ 2 hook variants + top 5 candidates.169170### PHASE 2.5 — Surface scroll-stopper hooks (MANDATORY, 3s)171172Always show the user matched psychology hooks BEFORE writing copy. Pull from the 150-hook library, filled with today's topic noun.173174```bash175python scripts/suggest_hooks.py --angle <angle_slug> --topic "<topic noun>" --n 8176```177178- `<angle_slug>` = `angle.json::angle_slug`. The script maps angle → best psych categories via `config/scroll-hooks.json::_meta.angle_to_category`.179- `<topic noun>` = the concrete subject of today's post (the niche/service/pain, e.g. "editing video in the terminal", "AEO for clinics").180- Print the 8 hooks to the user. The LinkedIn hook + the video 0-4s beat MUST be built from one of these (paraphrase tightly to keep voice — never paste verbatim).181- Full library viewer for browsing/filtering: `HOOK-LIBRARY.html` (live topic-fill box + search + copy). Dump all 150 grouped: `python scripts/suggest_hooks.py --all --topic "<noun>"`.182- Hook rule: first line ONLY. Pay off the open loop within the next 2 lines or it trains distrust. ASCII only (no em-dash / curly quotes).183184### PHASE 3 — Claude writes copy.json (~5 min, v0.4 FORKED schema)185186Read: `angle.json`, `config/client-wins.json`, `config/local-anchors.json`, `config/banned-phrases.json`, `config/hook-bank.json` (read BOTH `rotation` for business AND `travel_rotation` for travel), `config/copy-schema.json`.187188Pick **once per day**:189190- 1 source from candidates (most agency-relevant) → business block only.191- 1 business hook variant from `hook-bank.rotation[<weekday>]` (or tight paraphrase).192- 1 travel hook variant from `hook-bank.travel_rotation[<weekday>]`.193- 1 client_win_slug (`shared.client_win_ref`).194- 1 local anchor (`shared.local_anchor`) — STYLED DIFFERENTLY in each track (business = punch / travel = drift).195- 1 vulnerability_beat (`shared.vulnerability_beat`) — used by business only.196- 1 story_anchor (`travel.fb_profile.story_anchor`) — used by travel only.197198Write `outputs/<DATE>/copy.json` with the forked schema:199200```json201{202 "date": "2026-05-19",203 "angle_slug": "prompt-exposure",204 "weekday": "tuesday",205 "source_url": "https://...",206 "source_title": "...",207 "hook_variant_chosen": 2,208 "shared": {209 "news_anchor": "...",210 "client_win_ref": "dental-flagship",211 "local_anchor": "Coffee at the corner cafe, a few bucks.",212 "vulnerability_beat": "I used to think AEO was schema + FAQ. Cost me 2 closes in Q1."213 },214 "business": {215 "linkedin": {"post": "<900-1200ch w/ 8-role spine>", "slides": [...8 slides...], "hashtags": [...5-8...], "comment_bait": [...3...]},216 "fb_page": {"cards": [...3...], "post": "<200-800ch>", "hashtags": [...3-5...]},217 "ig_pro": {"slides": [...8...], "caption": "...", "hashtags": [...5-8...], "reel_script": "30-45s"},218 "yt": {"thumb": {"headline":"...","sub":"..."}, "shorts_script": "45-60s", "title": "<=70ch", "desc": "..."},219 "tt_pro": {"cover": {"headline":"...","sub":"..."}, "script": "30-45s", "caption": "...", "hashtags": [...3-5...]}220 },221 "travel": {222 "angle_slug": "visa-life",223 "hook_variant_chosen": 1,224 "story_anchor": "Stood in the immigration line 90 min. Agent took 20 USD over WA.",225 "fb_profile": {"cards": [...3...], "post": "<150-700ch>", "story_anchor": "..."},226 "ig_travel_1": {"slides": [...5...], "caption": "...", "hashtags": [...5-8...], "reel_script": "15-30s"},227 "ig_travel_2": {"slides": [...5...], "caption": "...", "hashtags": [...5-8...]},228 "tt_travel_1": {"script": "...", "caption": "...", "hashtags": [...3-5...]},229 "tt_travel_2": {"script": "...", "caption": "...", "hashtags": [...3-5...]},230 "pinterest": {"pin": {"headline":"...","overlay":"...","size":"1000x1500"}, "desc": "...", "board": "Travel", "hashtags": [...8-12...]}231 }232}233```234235Full schema spec: `config/copy-schema.json`.236237### image_picks block — MUST use real filenames OR glob patterns (v0.4.1)238239Authoring `image_picks` is the #1 cause of "PENDING_IMAGES" CSV rows. The hint **MUST** be one of:2402411. **A literal stem present in the rotation pool** — verify before writing:242243 ```bash244 python "<your-images>/_pick-next.py" --count 400 | grep -E "^DJI_|^SCENE-|^IMG_"245 ```246247 Examples that resolve today: `IMG_4105`, `SCENE-2026-05-07-garden-variant`, `DJI_20260518_0023`.2482492. **A glob pattern with `*` or `?`** — `build_pack_v2._resolve_image_hint` runs `fnmatch` against every candidate stem (case-insensitive). The FIRST match wins, which means the rotation-log order decides which actual file you get.250 - Travel scenes: `DJI_*`, `SCENE-2026-*-street-*`, `SCENE-2026-*-garden-*`.251 - Business portraits: `IMG_*`, `SCENE-*-rooftop-portrait-*`, `SCENE-*-veranda-*`.2522533. **NEVER** invent filenames like `scene_street_01.jpg` or `scene_beach_low_tide` unless they actually exist on disk. They will not resolve, drop to fallback (whatever-track-appropriate is available), and you lose authorial control over which image pairs with which slide.254255Pattern cheat-sheet lives in `config/hook-bank.json::image_picker_hints`. Add new globs there as new naming families enter the pool. NEVER edit `process_image.py` for this — the resolver is in `build_pack_v2.py`.256257### PHASE 3.5 — Voice lint, BOTH tracks (3s, HARD GATE)258259```bash260python scripts/lint_voice.py --date $(date +%Y-%m-%d) --voice both --strict261```262263Business fails on ANY: banned phrase, encoding tell, hook>210 chars, hashtag count off, missing vulnerability beat, missing anchor, missing client_win_ref, wrong slide count/roles.264265Travel fails on ANY: banned business term (aeo/agency/client/SkynetLabs/n8n/MRR/Loom/Fiverr/schema/citation), blocked travel hashtag, missing local-currency anchor, missing named place, missing story_anchor, business-CTA pattern ("DM me" / "I'll reply with"), encoding tell.266267**If lint fails → fix copy.json + re-run BEFORE Phase 4.**268269### PHASE 4 — Export TEXT-ONLY pack (5s) — DEFAULT since 2026-05-31270271**Preferred: 3 variants per platform** (story / contrarian / confession-style angles for A/B). Author `outputs/<DATE>/variants.json` (see shape in `scripts/export_variants.py` header — `platforms[].variants[]` each = {style, angle, body, hashtags}), then:272273```bash274python scripts/export_variants.py --date $(date +%Y-%m-%d)275```276277Emits `VARIANTS.html` (bright theme, 3 labelled copy-boxes per platform + Copy button) + `VARIANTS.txt`. 11 platforms x 3 = 33 ready posts. Pick one variant per platform, attach own image, post.278279Voice rules for variants (research-backed, 2026): open IN-SCENE (a moment, not a thesis) · uneven rhythm (short. fragment. one longer line) · KILL rule-of-three / "it's not X it's Y" / inflated words / em-dashes · end on a LOW-LOAD CTA that drives comments (binary "chase or wait?", "tell me I'm wrong", fill-in-blank, oddly-specific question) NOT "what's your take?" · LI 900-1500 chars, IG hook <125 chars, FB/TT short.280281**Video = ONE shared, SHOOT-READY script, NOT per-platform.** `video_scripts.{business,travel}[]` each = {style, angle, shots:[{t, line, angle, delivery, broll}], caption, hashtags}. The `shots` array is a director's breakdown: each beat tells you the exact LINE to say, the CAMERA angle/framing, the DELIVERY cue, and the ON-SCREEN/B-roll text. Rotate camera angles across beats (front-on tight -> push-in/lean -> 3/4 angle -> back to front-on for CTA) so the video isn't static. One shoot -> post to YouTube Short + TikTok + IG Reel + FB Reel. Do NOT write separate YT/TikTok/IG/FB scripts.282283- **business video scripts MUST be driven by the LATEST industry/service news** — pull a real, dated item from PHASE 1's `today_items.json` (AI agents / automation / AEO / the niches we sell), attribute the source claim (no fabricated stats), then tie it to a SMB service angle + comment CTA. One real news anchor per variant.284- travel video scripts stay personal lifestyle (local daily-life), NOT industry news.285286**Single-copy fallback** (1 per platform, from `copy.json`):287288```bash289python scripts/export_text.py --date $(date +%Y-%m-%d)290```291292- Reads `copy.json`, emits `COPY.html` + `COPY.txt`.293- NO images, NO `_avatars`, NO picker, NO html2canvas. You select + push your own images.294295> Image carousels are DEPRECATED (the old `build_pack_v2.py` render still works if ever needed, but the renderer had a silent blank-text bug; text export is the supported path). To resume rendered images: `python scripts/build_pack_v2.py --date <DATE>`.296297### PHASE 5 — (optional) GHL CSV row (5s)298299```bash300python scripts/append_ghl.py --date $(date +%Y-%m-%d) --slot 10:00301```3023036-col canonical CSV → tomorrow 10:00 local time. `imageUrls` left blank; paste manually-chosen image URLs before import. Skip entirely if posting by hand.304305### PHASE 6 — Tell the user what to do306307Print:308309```310OPEN: outputs/<DATE>/COPY.html3113121. 📋 Each platform = a Copy button. Click → paste into the native composer.3132. 🖼 Pick + attach your own image(s) per post.3143. 📋 LinkedIn: fire the 3 comment-baits 1 per 15-20 min in first 60 min.3154. Done.316```317318---319320## TOOL-STACK WARNING (research-backed)321322- **NEVER post via Taplio** — 30% reach penalty + 55% engagement penalty. LinkedIn algo flags AI-template patterns from Taplio.323- **Safe:** native LI scheduler, Buffer, GHL scheduler.324- **Analytics-only (zero algo risk):** AuthoredUp (hook preview), Shield Analytics (retro).325- **AVOID:** Hypefury for LI (it's Twitter-first, weaker LI signal).326327---328329## OUTPUT CONVENTION330331```332outputs/YYYY-MM-DD/333├── today_items.json # 30 deduped RSS items334├── angle.json # angle + 2 hook variants + 5 candidates335├── copy.json # Claude-written, voice-linted336├── index.html # 8-slide carousel + post + reply pack337├── _avatars/ # 2 picked photos338├── _picked_images.json # rotation audit339└── csv/ghl-linkedin.csv # tomorrow 10:00 local time340```341342---343344## RULES (REPRIORITIZED v0.2)3453461. **Voice spine = locked** (reference POST 1 + POST 2). Don't flatten.3472. **Anchor required** — 1 real local detail per post.3483. **Client win required** — slug from `client-wins.json`. No invented stats.3494. **Vulnerability beat required** — dated, specific, uncomfortable.3505. **Hook ≤210 chars** — LI "See more" truncate point.3516. **Hashtags 5-8** — no more, no less.3527. **Comment-bait pack** — 3 pre-staged replies. Fire 1 per 15-20 min first hour.3538. **lint_voice.py --strict** must pass before render.3549. **Rotation log sacred** — always `--mark` photos.35510. **No Taplio.** Ever.356357---358359## ANTI-PATTERNS360361- Don't paste hook from `hook_variants` verbatim — paraphrase tightly to keep voice unique.362- Don't reuse a client win slug 2 days in a row.363- Don't drop the `→` arrow glyph for `▸` `•` or em-dash — voice memory locked.364- Don't fill `vulnerability_beat` with generic "I used to think AI was overhyped" — needs date, person, currency, or specific fail.365- Don't omit the comment-bait pack — that's where 70% of reach lives.366367---368369## TROUBLESHOOTING370371| Symptom | Fix |372| -------------------- | ------------------------------------------------------------------- |373| `feedparser` missing | `pip install -r requirements.txt` |374| 0 items after dedup | `rm cache/seen.sqlite` |375| Image picker fails | check `_USAGE_LOG.json` exists in <your-images> |376| html2canvas blank | hard-refresh; check `_avatars/` populated |377| lint_voice.py FAILS | read each FAIL line; fix copy.json; re-run |378| Hook too long | use only first variant from `angle.json`, strip trailing clause |379| Anchor missing | inject 1 line from `local-anchors.json` into Slide 2 + LI post body |380381---382383## ROADMAP384385- **v0.3** — wire `/reel-studio` mograph mode for daily 30s video off same angle.386- **v0.4** — auto-push PNGs to GitHub `carousels-png/<DATE>/` for ManyChat asset library.387- **v0.5** — Task Scheduler invocation: `claude -p /aeo-daily` daily 7am local time.388- **v0.6** — DONE (2026-06-08): engagement-loop live via `weight_hooks.py` + `hook-weights.json` overlay read by `suggest_hooks.py`.389- **v0.7** — auto-generate 2 hook variants via Claude inside Phase 3 (not pre-baked).390- **v0.8** — wire `build_master_html.py` auto-invoke into `build_pack_v2.py`; add `--voice` flag to `build_pack_v2.py` (still missing — renderer always emits all channels).391- **v0.9** — live-query the AEO Visibility Snapshot (auto-run ChatGPT/Perplexity/Gemini for the prospect instead of manual fill).392393---394395Last updated: 2026-06-08 (v0.6.0 — WEDGE "Get Cited" + FUNNEL (snapshot lead magnet, cta-bank, dm-flow) + 6 missing scripts built + lint v1.2.0 hardened across all channels + engagement hook-weighting loop live + client-wins refreshed)