@aicopycoders/exodus 2026.9.1100-beta.2 → 2026.9.1400-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/assets/docs/CLAUDE.md +22 -1
  2. package/assets/docs/PIPELINES.md +20 -0
  3. package/assets/skills/anatomy-of-ads/SKILL.md +138 -0
  4. package/assets/skills/anatomy-of-ads/references/beat-map.md +69 -0
  5. package/assets/skills/anatomy-of-ads/references/concept-angle.md +50 -0
  6. package/assets/skills/anatomy-of-ads/references/hook-and-structure.md +69 -0
  7. package/assets/skills/bot-builder/SKILL.md +147 -0
  8. package/assets/skills/bot-builder/references/exodus-prompt-node.md +113 -0
  9. package/assets/skills/bot-builder/references/prompt-blocks.md +169 -0
  10. package/assets/skills/copy-instincts/SKILL.md +154 -0
  11. package/assets/skills/copy-instincts/references/instincts-catalog.md +131 -0
  12. package/assets/skills/copy-instincts/references/locked-examples.md +104 -0
  13. package/assets/skills/copy-instincts/references/worked-runs.md +148 -0
  14. package/assets/skills/exodus/SKILL.md +10 -1
  15. package/assets/skills/exodus-video/SKILL.md +12 -4
  16. package/assets/skills/exodus-video/scripts/first-cut.mjs +129 -32
  17. package/assets/skills/find-competitors/SKILL.md +148 -0
  18. package/assets/skills/find-competitors/references/query-grid.md +78 -0
  19. package/assets/skills/find-competitors/scripts/__pycache__/adlib_sweep.cpython-314.pyc +0 -0
  20. package/assets/skills/find-competitors/scripts/__pycache__/competitor_signals.cpython-314.pyc +0 -0
  21. package/assets/skills/find-competitors/scripts/adlib_sweep.py +100 -0
  22. package/assets/skills/find-competitors/scripts/competitor_signals.py +168 -0
  23. package/assets/skills/handoff/SKILL.md +81 -0
  24. package/assets/skills/mechanism/SKILL.md +130 -0
  25. package/assets/skills/mechanism/references/bot-prompts.md +94 -0
  26. package/assets/skills/mechanism/references/competitor-mechanisms.md +39 -0
  27. package/assets/skills/odf/SKILL.md +167 -0
  28. package/assets/skills/patterns-and-gaps/SKILL.md +120 -0
  29. package/assets/skills/positioning-spiral/SKILL.md +166 -0
  30. package/assets/skills/remember-that/SKILL.md +56 -0
  31. package/assets/skills/segment-grid/README.md +45 -0
  32. package/assets/skills/segment-grid/SKILL.md +84 -0
  33. package/assets/skills/thinking-vs-build/SKILL.md +120 -0
  34. package/assets/templates/STANDARDS.md +49 -0
  35. package/dist/commands/init.d.ts +1 -0
  36. package/dist/commands/init.js +10 -5
  37. package/dist/commands/update.js +1 -1
  38. package/dist/commands/video.d.ts +6 -0
  39. package/dist/commands/video.js +30 -1
  40. package/dist/lib/assets.d.ts +1 -0
  41. package/dist/lib/assets.js +3 -0
  42. package/dist/lib/scaffold.d.ts +3 -0
  43. package/dist/lib/scaffold.js +18 -1
  44. package/package.json +1 -1
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: find-competitors
3
+ description: Find the competitors that are actually strong (scaling and working) in any niche, on Meta first and Amazon second, and turn them into a ranked competitor sheet and a watch list. Use it whenever someone asks "who are the real competitors", "who's scaling in this niche", "find brands that are working", "is anyone doing cortisol / sleep / X at scale", "check these brands", "who else sells to these people", "build me a watch list", "who should we spy on", or hands over a list of brands with notes and wants it verified and extended. Three entry points: BRIEF (cold start, no list), HEAD LIST (the user arrives with brands grouped by mechanism and a read on each), and GRID CELL (an existing segment grid with empty cells that need a competitor column). Carries the tool quirks (Atria, Meta Ad Library via Apify, Amazon) and two scripts so nobody rediscovers them. It ends at the ranked sheet and the watch list; ripping ads is the swipe process and patterns and gaps are their own skills.
4
+ ---
5
+
6
+ # Find competitors
7
+
8
+ A competitor is worth following when they are scaling and working, not when they are in the same category. The test is measurable and the search is by mechanism. Everything else here exists to make the first hour not depend on knowing the tools.
9
+
10
+ Think mode until the plan-back. The sweep and the signal check are build bursts: say the plan and the cost back in two lines, get the go, run them comprehensively, write files as they land, report spend. Then back to a short list he can cut. Read the `thinking-vs-build` skill if installed.
11
+
12
+ ## What "strong" means
13
+
14
+ Six signals, each cheap to check. The first five come from Atria's brand feed and the script computes them; the sixth comes from Amazon.
15
+
16
+ | Signal | What it proves | Strong when |
17
+ |---|---|---|
18
+ | Active ads now | Breadth of spend | 50+ |
19
+ | Longest-running active ad, days | A proven angle | 90+ |
20
+ | New ads in the last 30 days | Momentum | 10+ |
21
+ | Creative reuse (re-launched ads) | Winners they trust | 5+ in the top-impression sample |
22
+ | Offer aggression | A funnel that converts cold | 3+ ads with intro-offer words; persona pages and quiz funnels count double |
23
+ | Amazon review count and velocity | Demand off-platform | Top ten in two or more searches, or 1,000+ reviews and growing |
24
+
25
+ **Follow** at three or more strong. **Watch** at two. **Skip** at one or none. Spend estimates from Atria or Motion are a tiebreak only; they are rough. Adjust the thresholds to the category (a niche with three advertisers is not a niche with three hundred), and say so on the sheet.
26
+
27
+ One qualifier that is not a number: are they selling a mechanism (a UMP) or a category? Mechanism brands are the ones worth following. Group the sheet by UMP.
28
+
29
+ ## The doors, ranked by yield
30
+
31
+ 1. **The user's head list**, with their read on each brand. Highest signal, fastest. Keep their notes verbatim as the brief.
32
+ 2. **Keyword lanes on the Meta Ad Library**, 25 to 40 queries from the grid in `references/query-grid.md`, aggregated by advertiser. Run through Apify with `scripts/adlib_sweep.py`.
33
+ 3. **Amazon category and ingredient shortlists.** Brand names fall out of the top tens; review volume is a signal on its own.
34
+ 4. **Neighbours of a known strong brand.** Same Atria lane, named in comments and Reddit threads, the domains behind advertorial pages, the brands that share a spokesperson type.
35
+ 5. **Motion and Foreplay library pages** for the lanes, when the user has access.
36
+ 6. **A shadow Instagram account** saturated in the market and scanned for outliers. Slowest, best for zeitgeist. The Scout library in Exodus (`exodus-hooks`) already holds this when the brand is set up there.
37
+
38
+ ## Entry point 1: BRIEF (cold start)
39
+
40
+ The strategist has a product and knows nothing about the market.
41
+
42
+ 1. **Load and say back.** Product, outcome sold today, star ingredient, the segment grid if one exists, focus order if agreed. One paragraph.
43
+ 2. **Build the query grid.** Outcome × mechanism × ingredient words, 25 to 40 lanes, written to a text file. Show the lanes as a list the user can cut before spending.
44
+ 3. **Plan-back.** "Sweep N lanes at 150 results each through Apify, about $X, then score the top 30 advertisers on Atria, a few minutes. Go?"
45
+ 4. **Sweep.** `adlib_sweep.py` writes one JSON per lane and `advertisers.md`, ranked by lanes hit and ads seen. Read the aggregate the way the reference describes (mechanism brands, single-angle brands, advertorial fronts, retailers).
46
+ 5. **Score.** `competitor_signals.py` on the top 30 advertisers, plus any brand the Amazon door added. Writes the sheet.
47
+ 6. **Fill the hand columns** for every FOLLOW: UMP, offer, persona pages, why it matters, what to steal. Two lines each, from the longest-running ads the script printed.
48
+ 7. **Hand back** the sheet in think-mode form: top ten as a list, verdicts, one line each. The user cuts. Then the watch list.
49
+
50
+ Stop rule: 20 to 30 scored brands. More does not change the top ten.
51
+
52
+ ## Entry point 2: HEAD LIST (the user arrives with brands and a read)
53
+
54
+ The list is grouped by UMP and each brand has a note ("spends 2M a month", "mostly gut, but they call out weed smokers", "not sure how well it performs").
55
+
56
+ 1. **Keep the notes verbatim** as the brief. They say what to look for, what to discount, and which segments to keep in mind.
57
+ 2. **Score every brand** with the script. No sweep needed yet.
58
+ 3. **Compare the numbers to the read.** Where the signals disagree with the note ("thought they hit cortisol harder" but the longest-running ads are gut), say so in one line per brand. That is the whole value of this pass.
59
+ 4. **Find the blind spots.** Two or three neighbours per UMP group: the same Atria lane, brands named in the comments under the listed brands' ads, the domains behind any advertorial page in the list. Score them.
60
+ 5. **Segments seen.** Any segment a listed brand calls out (a demographic, a state, an occasion) goes on the running segments-seen list for the `odf` skill.
61
+ 6. **Hand back:** the list confirmed and extended, ranked by UMP group, then straight into the rip. Do not rebuild what the user already knows.
62
+
63
+ ## Entry point 3: GRID CELL (an account going horizontal)
64
+
65
+ A brand with winners wants new segments and needs to know who already sells to those people.
66
+
67
+ 1. **Take the grid** and the empty cells from the `odf` skill.
68
+ 2. **Search per cell, by the facet's self-ID words**, not the outcome word. "On HRT and still wake at 3am" finds different advertisers than "sleep supplement". Two to four lanes per cell.
69
+ 3. **Score** whoever appears in two or more of a cell's lanes.
70
+ 4. **Write a competitor column** onto the gap ranking: who is in the cell, how strong, on which UMP, and whether the cell is empty because nobody has found it or because someone tried and left (an inactive brand with a long past run is a warning).
71
+ 5. **Hand back** to the gaps ranking. This entry does not produce a general sheet.
72
+
73
+ ## The tools and their quirks
74
+
75
+ Carry these into every run. They were learned the hard way. Both tools are the member's own paid accounts: get the keys from Atria and Apify, add them to `.env` by hand (`keys pull` leaves lines it does not own alone), and expect the scripts to stop with a clear message when a key is missing.
76
+
77
+ **Atria** (`https://api.tryatria.com/open/v1`, header `X-API-Key`, key in `.env` as `ATRIA_API_KEY`).
78
+ - Brand search: `/brand-library/search?keyword=` (the parameter is `keyword`, not `q`). Do not take the first result blindly; match the name or report the candidates.
79
+ - Brand ads: `/brand-library/{id}/ads?status=active&page_size=50&order=` with `order` in `most_impressions`, `newest`, `oldest`, `most_active`, `best_match`. Fields include `days_running`, `start_date` (ISO with offset), `creative_duplicates`, `impression_rank`, `impression_trend`, `media_format`, `video_duration`, `link_url`.
80
+ - `/ad-library/search` ignores every filter and returns the same feed. Never use it for discovery.
81
+ - Single ad: `/ad-library/{mID}` gives body, title, link, video URLs on the Atria CDN, days running. The public ad page hides the transcript behind login, so ripping needs local transcription.
82
+ - Rate limit: about ten quick calls returns 429. Pause 20 seconds. The script does this.
83
+ - DCO ads store their videos under `cards`, not `videos`.
84
+
85
+ **Meta Ad Library via Apify** (`apify~facebook-ads-scraper`, run-sync-get-dataset-items, token in `.env` as `APIFY_API_TOKEN`).
86
+ - Keyword URL: `search_type=keyword_unordered&active_status=active&country=US&q=<phrase>`. Exact phrase in body: `search_type=keyword_exact_phrase`.
87
+ - Page URL: `search_type=page&view_all_page_id=<id>` sorted by total impressions is the watch-list link shape.
88
+ - Fields: `pageName`, `pageId`, `startDate`, `isActive`, `collationCount`, `snapshot.body`, `snapshot.title`, `snapshot.linkUrl`, `snapshot.videos[]`. Aggregate by `pageName`; the domain in `linkUrl` is the real brand behind advertorial pages.
89
+ - Bills per result. 30 lanes at 150 is a few dollars. Plan-back first.
90
+
91
+ **Amazon.** Search in the in-app browser, two result pages per query, pull ASIN, title, rating, review count, price, sponsored flag with a page script. Free and fast. Rank by relevance × review count. See the reference for the door.
92
+
93
+ **Transcripts** for the rip that follows: ElevenLabs Scribe (`cloud_storage_url` = the video URL) or local whisper-cpp. Standardise on one per project so bank rows are comparable.
94
+
95
+ ## Scripts
96
+
97
+ Run from the workspace root so `.env` is found. Write outputs into the brand's `inputs/research-raw/competitors/` folder.
98
+
99
+ ```
100
+ python3 .claude/skills/find-competitors/scripts/adlib_sweep.py --keywords lanes.txt --outdir <brand>/inputs/research-raw/competitors/adlib --limit 150
101
+ python3 .claude/skills/find-competitors/scripts/competitor_signals.py --file <brand>/inputs/research-raw/competitors/adlib/advertisers.txt --out <brand>/inputs/research-raw/competitors/competitor-sheet.md --json signals.json
102
+ ```
103
+
104
+ `competitor_signals.py --names "Brand A" "Brand B"` scores a head list directly. Edit `STRONG` at the top of the script to change the thresholds for a category. Both scripts print progress per lane or brand so a cut connection loses only what was in flight.
105
+
106
+ ## The sheet
107
+
108
+ ```
109
+ # <Brand> — Competitor sheet (v<n>, <date>)
110
+ Thresholds used: <…>. Verdict: FOLLOW 3+ · WATCH 2 · SKIP 0–1.
111
+
112
+ ## By UMP
113
+ ### <UMP, e.g. cortisol>
114
+ | Brand | Verdict | Active | Longest (d) | Launches 30d | Reuse | Offer | Amazon reviews | Offer & funnel | Why it matters | What to steal |
115
+
116
+ ### <UMP 2>
117
+ …
118
+
119
+ ## Segments seen
120
+ <a> · <b> · <c>
121
+
122
+ ## Watch list (FOLLOW rows)
123
+ | Brand | Ad Library page link | Atria id | Domain |
124
+
125
+ ## Tool feedback from this run
126
+ - …
127
+ ```
128
+
129
+ The sheet lives in the folder. The FOLLOW rows become the watch list: an Ad Library page link per brand (the shape above) saved as a links file the rip can consume, and the brands added to Atria's followed list by hand in the app. When the brand is set up in Exodus, the Scout feed covers the Instagram side.
130
+
131
+ ## Hand-offs
132
+
133
+ - **Rip:** the FOLLOW rows and their longest-running ads go to the swipe process (transcript, headline, body, rank, on-screen text) and into the ad bank next to the brand's own ads.
134
+ - **Patterns:** what the FOLLOW set has in common is the `patterns-and-gaps` skill's job.
135
+ - **Gaps:** the empty cells and the competitor column go to `patterns-and-gaps` and the `odf` grid.
136
+ - **Positioning:** the `positioning-spiral` calls this at its research station.
137
+
138
+ ## Worked example (fictional)
139
+
140
+ Evenkeel Reset Gummies, an evening saffron and magnesium gummy. Entry point BRIEF. Query grid of 28 lanes (see the reference). Sweep returned 214 advertisers; 31 appeared in three or more lanes. Signal check on those 31 plus four brands from the Amazon top tens. Result: nine FOLLOW, eight WATCH, the rest SKIP. The nine grouped into three UMPs: cortisol (four brands, all symptom-stacking in the hook, one at 289 days on a doctor-skit), perimenopause (three, all persona-page funnels), and sleep-onset (two, both melatonin-adjacent, both fatiguing). What it changed upstream: nobody strong owns "stay asleep" as a UMP; the cortisol brands own belly and face, not the night. That is a lane, and it went back to the positioning spiral as the mechanism station's next question.
141
+
142
+ ## What this skill is not
143
+
144
+ It does not rip ads, find the patterns across them, or rank the gaps. It does not decide the mechanism. It finds who is strong, proves it with numbers, and puts them where the rest of the process can reach them.
145
+
146
+ ## Standards
147
+
148
+ Before producing anything, read `STANDARDS.md` at the workspace root (and the section for this output type). Grade the draft against it before showing it; rewrite failures first. When the user reacts with a judgment, a banking phrase, or the same note twice, hand the reaction to `remember-that`. At the end of the session, `handoff` scrapes the chat and proposes entries.
@@ -0,0 +1,78 @@
1
+ # The query grid
2
+
3
+ Competitors are found by mechanism, not by category. "Stress relief supplement" is the worst query in any grid because it returns the category's grey middle. The grid crosses three word lists, and the strong brands show up in several cells at once.
4
+
5
+ ## Build the three lists
6
+
7
+ Take them from the segment grid if one exists (the `odf` skill), otherwise from the brief and ten minutes with the reviews.
8
+
9
+ 1. **Outcome words**, in the market's own language, one per sub-outcome that matters. Not the label ("sleep"), the phrase a buyer types ("wake up at 3am", "can't stay asleep", "puffy face", "rage at my kids").
10
+ 2. **Mechanism words**, the beliefs the market already holds about the cause: cortisol, hormones, gut, nervous system, inflammation, blood sugar, estrogen, testosterone, thyroid. These are Belief facets, and they are also the UMPs competitors sell.
11
+ 3. **Ingredient words**, the star and its likely partners, plus the two or three ingredients the category's big brands lead with.
12
+
13
+ ## Cross them
14
+
15
+ Not every combination. Use these shapes:
16
+
17
+ - outcome alone (the plain phrase): `wake up at 3am`
18
+ - mechanism + outcome: `cortisol belly`, `cortisol 3am`
19
+ - mechanism + product word: `cortisol supplement`, `cortisol gummies`
20
+ - ingredient alone: `saffron supplement`, `magnesium glycinate sleep`
21
+ - ingredient + outcome: `ashwagandha sleep`
22
+ - the demographic word when it is a market on its own: `perimenopause supplement`, `menopause belly`
23
+
24
+ Aim for 25 to 40 lanes. Fewer than 20 misses brands; more than 50 returns the same advertisers again.
25
+
26
+ ## Worked grid (fictional evening gummy: saffron, magnesium glycinate, rhodiola)
27
+
28
+ ```
29
+ # outcome phrases
30
+ wake up at 3am
31
+ can't stay asleep
32
+ racing mind at night
33
+ puffy face morning
34
+ rage at my kids
35
+ stress eating at night
36
+ no energy after 40
37
+ # mechanism + outcome
38
+ cortisol belly
39
+ cortisol 3am
40
+ cortisol face
41
+ high cortisol symptoms
42
+ hormone balance sleep
43
+ nervous system regulation
44
+ # mechanism + product
45
+ cortisol supplement
46
+ cortisol gummies
47
+ cortisol reset
48
+ # ingredients
49
+ saffron supplement
50
+ saffron mood
51
+ magnesium glycinate sleep
52
+ rhodiola energy
53
+ ashwagandha sleep
54
+ ashwagandha cortisol
55
+ l-theanine
56
+ # demographic markets
57
+ perimenopause supplement
58
+ menopause belly
59
+ menopause mood swings
60
+ sleep supplement women over 40
61
+ ```
62
+
63
+ ## Reading the aggregate
64
+
65
+ `adlib_sweep.py` writes `advertisers.md` ranked by how many lanes each advertiser appeared in. Read it like this:
66
+
67
+ - **Many lanes, many ads:** a mechanism brand. Score it first.
68
+ - **One lane, many ads:** a single-angle brand or a lane that is really a different market (a menopause brand in the "menopause belly" lane). Score it if the lane is in your focus order.
69
+ - **Many lanes, few ads:** an affiliate or advertorial page pushing several products. Check the link domains column; the domains are the real brands.
70
+ - **Pages with community-style names** ("The Blood Sugar Journal", "Cholesterol Relief Community") are advertorial fronts. Follow the domain, not the page.
71
+
72
+ Drop retailers, marketplaces, and apps unless the brief says otherwise.
73
+
74
+ ## The Amazon door
75
+
76
+ Amazon is a demand signal off Meta and a source of brand names the ad sweeps miss. For each outcome and ingredient word, search Amazon, take two result pages, and pull ASIN, title, rating, review count, price, and the sponsored flag with a page script in the in-app browser. Dedupe sponsored against organic. Rank by relevance times review count. Brand names in the top ten of two or more searches go into the signal check. Review count and velocity become the sixth column on the sheet.
77
+
78
+ Apify has Amazon search-result actors if the browser route is slow, but they bill per result and the browser route was free and fast for 12 categories.
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ adlib_sweep.py — sweep the Meta Ad Library by keyword lanes via Apify and aggregate advertisers.
4
+
5
+ Usage:
6
+ python3 adlib_sweep.py --keywords lanes.txt --outdir adlib --limit 150 --country US # prints the plan, spends nothing
7
+ python3 adlib_sweep.py --keywords lanes.txt --outdir adlib --limit 150 --country US --yes # runs the sweep
8
+ python3 adlib_sweep.py --keywords lanes.txt --outdir adlib --aggregate-only
9
+
10
+ lanes.txt: one query per line (the outcome × mechanism × ingredient grid, see references/query-grid.md).
11
+
12
+ Needs APIFY_API_TOKEN in .env. Cost: the apify~facebook-ads-scraper actor bills per result; 30 lanes × 150
13
+ results is a few dollars. Without --yes the script prints the plan and stops; say it back, get a go, rerun with --yes.
14
+
15
+ Output:
16
+ <outdir>/<lane>.json raw ads per lane
17
+ <outdir>/advertisers.md advertisers ranked by how many lanes they appear in and ad count,
18
+ with the lanes each one showed up in. This is the candidate list.
19
+ """
20
+ import argparse, json, os, sys, urllib.parse, glob, collections
21
+ from concurrent.futures import ThreadPoolExecutor
22
+
23
+ try:
24
+ import requests
25
+ from dotenv import load_dotenv
26
+ except ImportError:
27
+ sys.exit("pip3 install requests python-dotenv")
28
+
29
+ here = os.path.abspath(os.getcwd())
30
+ for _ in range(7):
31
+ env = os.path.join(here, ".env")
32
+ if os.path.exists(env): load_dotenv(env); break
33
+ here = os.path.dirname(here)
34
+
35
+
36
+ def run(kw, outdir, limit, country, tok):
37
+ u = ("https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=" + country +
38
+ "&q=" + urllib.parse.quote(kw) + "&search_type=keyword_unordered&media_type=all")
39
+ path = os.path.join(outdir, kw.replace(" ", "_").replace("/", "-") + ".json")
40
+ try:
41
+ r = requests.post(f"https://api.apify.com/v2/acts/apify~facebook-ads-scraper/run-sync-get-dataset-items?token={tok}&timeout=600",
42
+ json={"startUrls": [{"url": u}], "resultsLimit": limit}, timeout=(30, 660))
43
+ d = r.json() if r.status_code == 200 else []
44
+ if not isinstance(d, list): d = []
45
+ json.dump(d, open(path, "w"))
46
+ print(f"{kw:<40} {r.status_code} {len(d)} ads", flush=True)
47
+ except Exception as e:
48
+ print(kw, "ERR", e, flush=True)
49
+
50
+
51
+ def aggregate(outdir):
52
+ by_page = collections.defaultdict(lambda: dict(lanes=set(), ads=0, page_id=None, longest_active=0, links=set()))
53
+ for f in glob.glob(os.path.join(outdir, "*.json")):
54
+ lane = os.path.basename(f)[:-5].replace("_", " ")
55
+ try: ads = json.load(open(f))
56
+ except Exception: continue
57
+ if not isinstance(ads, list): continue
58
+ for a in ads:
59
+ p = a.get("pageName") or (a.get("snapshot") or {}).get("pageName")
60
+ if not p: continue
61
+ e = by_page[p]; e["lanes"].add(lane); e["ads"] += 1; e["page_id"] = e["page_id"] or a.get("pageId")
62
+ lu = (a.get("snapshot") or {}).get("linkUrl")
63
+ if lu: e["links"].add(urllib.parse.urlparse(lu).netloc)
64
+ rows = sorted(by_page.items(), key=lambda kv: (-len(kv[1]["lanes"]), -kv[1]["ads"]))
65
+ with open(os.path.join(outdir, "advertisers.md"), "w") as f:
66
+ f.write("# Advertisers found across lanes\n\nRanked by number of lanes, then ads seen. Feed the top rows to competitor_signals.py.\n\n")
67
+ f.write("| Advertiser | Lanes | Ads seen | Domains | Lanes hit | Ad Library page |\n|---|---|---|---|---|---|\n")
68
+ for p, e in rows:
69
+ link = f"https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=ALL&view_all_page_id={e['page_id']}&search_type=page&sort_data[mode]=total_impressions&sort_data[direction]=desc" if e["page_id"] else ""
70
+ f.write(f"| {p} | {len(e['lanes'])} | {e['ads']} | {', '.join(sorted(e['links']))[:80]} | {', '.join(sorted(e['lanes']))[:120]} | {link} |\n")
71
+ with open(os.path.join(outdir, "advertisers.txt"), "w") as f:
72
+ for p, e in rows: f.write(p + "\n")
73
+ print("wrote", os.path.join(outdir, "advertisers.md"), f"({len(rows)} advertisers)")
74
+
75
+
76
+ def main():
77
+ ap = argparse.ArgumentParser()
78
+ ap.add_argument("--keywords", required=True)
79
+ ap.add_argument("--outdir", default="adlib")
80
+ ap.add_argument("--limit", type=int, default=150)
81
+ ap.add_argument("--country", default="US")
82
+ ap.add_argument("--workers", type=int, default=5)
83
+ ap.add_argument("--aggregate-only", action="store_true")
84
+ ap.add_argument("--yes", action="store_true", help="actually run the paid sweep")
85
+ a = ap.parse_args()
86
+ os.makedirs(a.outdir, exist_ok=True)
87
+ if not a.aggregate_only:
88
+ tok = os.environ.get("APIFY_API_TOKEN") or sys.exit("APIFY_API_TOKEN not found in .env or environment")
89
+ lanes = [l.strip() for l in open(a.keywords) if l.strip() and not l.startswith("#")]
90
+ if not a.yes:
91
+ print(f"Plan: {len(lanes)} lanes x up to {a.limit} results = up to {len(lanes) * a.limit} billed results on apify~facebook-ads-scraper (country {a.country}).")
92
+ print(f"Outputs land in {a.outdir}/. Nothing was run. Say the plan back, get a go, then rerun with --yes.")
93
+ sys.exit(2)
94
+ with ThreadPoolExecutor(a.workers) as ex:
95
+ list(ex.map(lambda k: run(k, a.outdir, a.limit, a.country, tok), lanes))
96
+ aggregate(a.outdir)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ competitor_signals.py — score a list of brands on the six strength signals using the Atria API.
4
+
5
+ Usage:
6
+ python3 competitor_signals.py --names "Brand A" "Brand B" --out sheet.md
7
+ python3 competitor_signals.py --file brands.txt --out sheet.md --json signals.json
8
+
9
+ brands.txt: one brand name per line (blank lines and # comments ignored).
10
+
11
+ Needs ATRIA_API_KEY in the workspace .env (or the environment). Rate limit is tight:
12
+ ~10 calls in quick succession returns 429, so the script pauses and retries.
13
+
14
+ Signals per brand (all from Atria's brand feed, no spend estimates). Three feed orders are used:
15
+ most_impressions (top ads, offer words, reuse), newest (launches in 30 days), oldest (longest-running).
16
+ active_total active ads right now
17
+ longest_days days_running of the longest active ad
18
+ launches_30d active ads with start_date in the last 30 days
19
+ reuse active ads Atria marks as creative duplicates (re-launched winners), in the top-impression sample
20
+ offer intro-offer words seen in active ad bodies (%, off, free, trial, quiz)
21
+ verdict FOLLOW (3+ strong) · WATCH (2) · SKIP (0–1)
22
+
23
+ Thresholds (edit STRONG below to fit the category):
24
+ active_total >= 50 · longest_days >= 90 · launches_30d >= 10 · reuse >= 5 · offer >= 3
25
+ Cost: about 6 to 8 Atria calls per brand. 30 brands is a few minutes with the rate-limit pauses.
26
+ """
27
+ import argparse, json, os, re, sys, time, datetime as dt
28
+ from collections import Counter
29
+
30
+ try:
31
+ import requests
32
+ except ImportError:
33
+ sys.exit("pip3 install requests python-dotenv (use --break-system-packages on Homebrew python)")
34
+
35
+ try:
36
+ from dotenv import load_dotenv
37
+ here = os.path.abspath(os.getcwd())
38
+ for _ in range(7):
39
+ env = os.path.join(here, ".env")
40
+ if os.path.exists(env):
41
+ load_dotenv(env); break
42
+ here = os.path.dirname(here)
43
+ except ImportError:
44
+ pass
45
+
46
+ KEY = os.environ.get("ATRIA_API_KEY")
47
+ if not KEY:
48
+ sys.exit("ATRIA_API_KEY not found in .env or environment")
49
+ H = {"X-API-Key": KEY}
50
+ B = "https://api.tryatria.com/open/v1"
51
+ STRONG = dict(active_total=50, longest_days=90, launches_30d=10, reuse=5, offer=3)
52
+ OFFER_RX = re.compile(r"(\d{2}% ?off|% off|free (shipping|trial|gift)|buy \d get|bogo|quiz|first[- ]time|intro(ductory)? (offer|price)|money[- ]back|save \$?\d)", re.I)
53
+
54
+
55
+ def get(path, params, tries=5):
56
+ for i in range(tries):
57
+ r = requests.get(B + path, headers=H, params=params, timeout=60)
58
+ if r.status_code == 429:
59
+ time.sleep(20); continue
60
+ r.raise_for_status()
61
+ return r.json().get("data") or {}
62
+ raise RuntimeError(f"429 persisted on {path}")
63
+
64
+
65
+ def search_brand(name):
66
+ items = get("/brand-library/search", {"keyword": name, "page_size": 8}).get("items", [])
67
+ first = name.lower().split()[0]
68
+ exact = [i for i in items if i.get("name", "").lower() == name.lower()]
69
+ close = [i for i in items if first in i.get("name", "").lower()]
70
+ if exact or close:
71
+ return (exact or close)[:1]
72
+ # no name match: do not guess. Report what Atria returned so the user can pick.
73
+ return [dict(_candidates=[i.get("name") for i in items[:6]])]
74
+
75
+
76
+ def brand_ads(bid, status="active", pages=2, order="most_impressions", stop=None):
77
+ out, cur, total = [], None, None
78
+ for _ in range(pages):
79
+ p = {"status": status, "page_size": 50, "order": order}
80
+ if cur: p["cursor"] = cur
81
+ d = get(f"/brand-library/{bid}/ads", p)
82
+ items = d.get("items", []); out += items; cur = d.get("cursor"); total = d.get("total", total)
83
+ time.sleep(0.8)
84
+ if not cur or len(items) < 50: break
85
+ if stop and items and stop(items[-1]): break
86
+ return out, total
87
+
88
+
89
+ def parse_date(s):
90
+ if not s: return None
91
+ try: return dt.datetime.fromisoformat(str(s).replace("Z", "+00:00")).replace(tzinfo=None)
92
+ except Exception: pass
93
+ try: return dt.datetime.utcfromtimestamp(int(s))
94
+ except Exception: return None
95
+
96
+
97
+ def age_days(a, now):
98
+ d = parse_date(a.get("start_date"))
99
+ return (now - d).days if d else 10**6
100
+
101
+
102
+ def score(name):
103
+ hits = search_brand(name)
104
+ if not hits or "_candidates" in hits[0]:
105
+ cands = hits[0]["_candidates"] if hits else []
106
+ return dict(name=name, found=False, error=("no name match; Atria offered: " + ", ".join(cands)) if cands else "no results")
107
+ b = hits[0]; bid = b["id"]
108
+ now = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)
109
+ act, total = brand_ads(bid, "active", 1, "most_impressions")
110
+ newest, _ = brand_ads(bid, "active", 4, "newest", stop=lambda a: age_days(a, now) > 30)
111
+ oldest, _ = brand_ads(bid, "active", 1, "oldest")
112
+ longest = max([a.get("days_running") or 0 for a in (oldest[:5] + act)] or [0])
113
+ launches = sum(1 for a in newest if age_days(a, now) <= 30)
114
+ if len(newest) >= 200 and launches >= 200: launches = f"{launches}+"
115
+ reuse = sum(1 for a in act if (a.get("creative_duplicates") or 0) > 0)
116
+ offer = sum(1 for a in act if OFFER_RX.search((a.get("body") or "") + " " + (a.get("title") or "")))
117
+ fmts = Counter(a.get("media_format") for a in act)
118
+ top = sorted(oldest[:5] + act, key=lambda a: -(a.get("days_running") or 0))[:3]
119
+ sig = dict(active_total=total if total is not None else len(act), longest_days=longest,
120
+ launches_30d=launches, reuse=reuse, offer=offer)
121
+ def num(x):
122
+ try: return int(str(x).rstrip("+"))
123
+ except Exception: return 0
124
+ strong = [k for k, v in STRONG.items() if num(sig[k]) >= v]
125
+ verdict = "FOLLOW" if len(strong) >= 3 else "WATCH" if len(strong) == 2 else "SKIP"
126
+ return dict(name=b.get("name", name), atria_id=bid, found=True, lifetime_ads=b.get("ad_num"),
127
+ website=b.get("website"), industries=b.get("industries"), **sig,
128
+ strong=strong, verdict=verdict, formats=dict(fmts),
129
+ longest_ads=[dict(days=a.get("days_running"), title=(a.get("title") or "")[:90],
130
+ hook=(a.get("body") or "")[:160].replace("\n", " "), link=a.get("link_url")) for a in top])
131
+
132
+
133
+ def main():
134
+ ap = argparse.ArgumentParser()
135
+ ap.add_argument("--names", nargs="*", default=[])
136
+ ap.add_argument("--file")
137
+ ap.add_argument("--out", default="competitor-sheet.md")
138
+ ap.add_argument("--json")
139
+ a = ap.parse_args()
140
+ names = list(a.names)
141
+ if a.file:
142
+ names += [l.strip() for l in open(a.file) if l.strip() and not l.startswith("#")]
143
+ if not names: sys.exit("no brand names given")
144
+ rows = []
145
+ for n in names:
146
+ try:
147
+ r = score(n); rows.append(r)
148
+ print(f"{r.get('name', n):<34} {r.get('verdict', 'NOT FOUND'):<9} active={r.get('active_total')} longest={r.get('longest_days')}d launches30={r.get('launches_30d')} reuse={r.get('reuse')} offer={r.get('offer')}", flush=True)
149
+ except Exception as e:
150
+ rows.append(dict(name=n, found=False, error=str(e))); print(n, "ERR", e, flush=True)
151
+ time.sleep(1.0)
152
+ order = {"FOLLOW": 0, "WATCH": 1, "SKIP": 2}
153
+ rows.sort(key=lambda r: (order.get(r.get("verdict"), 3), -(r.get("active_total") or 0)))
154
+ with open(a.out, "w") as f:
155
+ f.write(f"# Competitor sheet ({dt.date.today()})\n\nVerdict: FOLLOW = 3+ strong signals · WATCH = 2 · SKIP = 0–1. Thresholds: {STRONG}\n\n")
156
+ f.write("| Brand | Verdict | Active | Longest (d) | Launches 30d | Reuse | Offer | Strong on | Longest-running ad |\n|---|---|---|---|---|---|---|---|---|\n")
157
+ for r in rows:
158
+ if not r.get("found"):
159
+ f.write(f"| {r['name']} | NOT FOUND | | | | | | | {r.get('error', '')} |\n"); continue
160
+ la = r["longest_ads"][0] if r["longest_ads"] else {}
161
+ f.write(f"| {r['name']} | {r['verdict']} | {r['active_total']} | {r['longest_days']} | {r['launches_30d']} | {r['reuse']} | {r['offer']} | {', '.join(r['strong'])} | {la.get('days', '')}d: {la.get('hook', '')[:100]} |\n")
162
+ f.write("\n## Fill in by hand\n\nFor every FOLLOW: UMP (the mechanism they sell) · offer · persona pages · why it matters · what to steal · Amazon review count.\n")
163
+ if a.json: json.dump(rows, open(a.json, "w"), indent=1)
164
+ print("wrote", a.out)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: handoff
3
+ description: End a working session properly, and periodically read the sessions back. Fires on "handoff", "wrap up", "scrape this chat", "save this session", "add this to my workflow", "note your place and close", "what did we do today", or at the natural end of a session. It (1) scrapes the chat into a dated session file with verbatim asks, outputs and where they live, corrections, and the steering behind each ask; (2) proposes STANDARDS.md entries from what it saw the user say, edit, and pick, and waits for a yes; (3) writes a resume prompt so the next session starts warm; (4) updates the one-page distilled workflow. In review mode ("handoff review", "read my last ten sessions", "how do I actually work") it reads the session files and standards and writes an end-to-end read of the user's workflow with patterns, disagreements between their own versions, and suggestions. The session files are the most valuable asset in the folder: the before-and-after record of how the user thinks. Never edit one after it is written.
4
+ ---
5
+
6
+ # Handoff
7
+
8
+ The chat is where feedback actually lives: what was said, what was edited, what was picked. Most of it evaporates when the window closes. This skill catches it, once, at the end.
9
+
10
+ Read `STANDARDS.md` first ("How I give feedback" decides how much this skill proposes). Sessions live in `workflow/` at the workspace root: `workflow/sessions/` for the dated session files, `workflow/workflow.md` for the one-page distilled workflow, `workflow/reviews/` for review reads. Create the folder on first use.
11
+
12
+ ## Mode 1: session (default)
13
+
14
+ Start with one question and nothing else: "Pull this session into your workflow?" No means write nothing. Yes means all four steps, then a short reply.
15
+
16
+ ### Step 1. Scrape the chat
17
+
18
+ Write `workflow/sessions/YYYY-MM-DD_<brand>-<topic>.md` (date is the session start). Split the chat into phases by intent (setup, research, planning, build, review, meta). Per phase:
19
+
20
+ 1. **Ask (verbatim).** The user's exact words in a blockquote, typos included. Several quotes if the ask evolved. Never paraphrase here.
21
+ 2. **What happened.** Two to four sentences.
22
+ 3. **Outputs.** Paths, artifact links, Drive links, Exodus surfaces. Exact.
23
+ 4. **Steering.** Why they asked it, what it reveals about how they work, the rule or preference it implies. One paragraph.
24
+ 5. **Corrections.** Anything redirected, quoted verbatim, with the lesson.
25
+ 6. **Tool feedback.** Gaps hit in Exodus or any tool.
26
+
27
+ At the end of the file: **Where everything lives** (a table), **Principles extracted** (numbered, from this session's quotes only), **What to make better next time** (concrete, from the corrections).
28
+
29
+ Rules: quotes exact; include the meta-asks (honesty checks, "can you see the context"); include reversals; note when another person or session edited files; never save secrets (write "keys pasted in chat" and nothing else); if context was summarised, say so and mark unquotable phases "reconstructed". Once written, the file is read-only to the system.
30
+
31
+ ### Step 2. Propose standards
32
+
33
+ Read the session back for three kinds of signal and turn each into a proposed entry in the shape STANDARDS.md uses (one dated line, the quote underneath, the section):
34
+
35
+ - **Said.** Anything with always, never, from now on, I hate, I love, do it like this. Also any note given twice across this and earlier sessions (check the Log).
36
+ - **Edited.** Any place the user changed a draft: what was there, what they made it, the principle in one line. These are before-and-after pairs; record them as pairs.
37
+ - **Picked.** Any place the user chose from a set (three hooks out of twenty, one option of four): what they kept, what they rejected, the pattern in one line.
38
+
39
+ Present them as a numbered list, at most ten, strongest first. The user answers by number. Write only the approved ones, exactly as `remember-that` would (section, log line, bank promote for approved winning lines, offer for anything that touches a workflow or primer). If "How I give feedback" says no proposals, skip this step and say so.
40
+
41
+ ### Step 3. Write the resume prompt
42
+
43
+ Append to the session file a section **Resume** with: what is done, what is missing, what is next (in that order, the way "note your place" works), the files to read first, and one paragraph the user can paste into a new session to start warm. Say the paragraph back in the reply so it is one copy away.
44
+
45
+ ### Step 4. Update the distilled workflow
46
+
47
+ Open `workflow/workflow.md` (create it if it does not exist). Add or adjust only what this session changed. Keep it one page. Do not rewrite it.
48
+
49
+ ### The reply
50
+
51
+ Short: the session file path, the two or three strongest steering signals in one line each, the proposals awaiting a yes (or that they were written), the resume paragraph, and any change to `workflow.md`. No essay.
52
+
53
+ ## Mode 2: review
54
+
55
+ Fires on "handoff review", "read my sessions", "how do I actually work", or when the session count since the last review passes the number in "How I give feedback" (default ten). Reads every session file, `workflow.md`, and `STANDARDS.md`, and writes `workflow/reviews/YYYY-MM-DD_review.md`:
56
+
57
+ 1. **The shape of it.** The user's workflow as a numbered sequence of stages, drawn from what they actually did across sessions, not what any skill says they should do.
58
+ 2. **The rules underneath it.** What drives the work: the modes they switch between, what they read raw before trusting, how they brief (by example, by list), the taxonomy they use, what they check every time.
59
+ 3. **Where their own versions disagree.** Places the sessions contradict each other or the standards, one line each, with the decision it implies.
60
+ 4. **Where it stands.** What is done, unfinished, blocked, and what has moved on disk since the last scrape.
61
+ 5. **Suggestions.** Three to five, each with the evidence rung it rests on (own sessions, standards, logic marked HYPOTHESIS). Things to make into rules, tools, or skills; things to retire; things the user keeps doing by hand that should be a bot (hand to `bot-builder`).
62
+ 6. **Standards proposals** from the review, same numbered shape as Mode 1, waiting for a yes.
63
+
64
+ Then say the three biggest findings in the reply and stop. The user decides what to act on.
65
+
66
+ ## Mode 3: resume
67
+
68
+ At the start of a session, check the newest file in `workflow/sessions/`. If it has a Resume section, offer it: "Last session ended at: <done / missing / next>. Continue from there?" Yes means read the files it names and pick up at "next". Do not re-derive what was already decided.
69
+
70
+ ## Rules
71
+
72
+ - Session files are never edited after writing. A correction to a scrape is a new note in the next session.
73
+ - Quotes are the asset. Steering is your read of them, labelled as such.
74
+ - Plain language throughout. Collaborators will read these. No jargon that needs explaining. Do not call anyone a junior.
75
+ - Keep the where-it-lives table current; the scrape is mechanical when the index was kept during the session.
76
+ - Never propose the same standard twice. Check the Log.
77
+ - The user is the only source of rules. Proposals wait.
78
+
79
+ ## Changelog
80
+
81
+ - 2026-09-14 · v1. Absorbs the earlier workflow-scrape skill; adds proposals from said, edited, picked; adds review and resume modes.