@aicopycoders/exodus 2026.9.1400-beta.0 → 2026.9.1400-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/docs/CLAUDE.md +1 -0
- package/assets/skills/exodus/SKILL.md +1 -0
- package/assets/skills/find-competitors/SKILL.md +3 -1
- package/assets/skills/find-competitors/scripts/adlib_sweep.py +20 -5
- package/assets/skills/find-competitors/scripts/competitor_signals.py +5 -3
- package/dist/commands/swipe.js +8 -1
- package/dist/lib/layout.js +1 -0
- package/dist/lib/scaffold.js +1 -0
- package/package.json +1 -1
- package/assets/skills/find-competitors/scripts/__pycache__/adlib_sweep.cpython-314.pyc +0 -0
- package/assets/skills/find-competitors/scripts/__pycache__/competitor_signals.cpython-314.pyc +0 -0
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -64,6 +64,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
|
|
|
64
64
|
| **Video** | `exodus-video` | Make a video ad from a Show: start a run from a script, read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, voices, keyframes, word timings, music bed) plus a manifest, cut them with the bundled first-cut script, upload the cut and approve it. Admin-only. |
|
|
65
65
|
| **Browse** | `exodus-browse` | View history and retrieve past outputs |
|
|
66
66
|
| **Drive** | `exodus-drive` | Read/write Google Docs, Sheets, and Drive files via `npx @aicopycoders/exodus drive` (uses the dashboard's Google OAuth — no local CLI) |
|
|
67
|
+
| **Swipe / Mining** | (no skill; CLI only) | The competitor watch list behind the Mining page: `npx @aicopycoders/exodus swipe brands list`, `swipe brands bulk-import <csv>` (checks each brand and starts collecting its ads), `swipe brands add "<Name>" --fb <pageId>` (adds without starting research, #1366), `swipe run` / `swipe mine`. There is no `competitors` verb. |
|
|
67
68
|
| **Ads** | `exodus-ads` | Read your own live Meta ads once the account is connected on the dashboard — `npx @aicopycoders/exodus ads list` (sort by cost per result, spend or CTR), `ads show <adId>` (copy, both windows, media links, comment counts), `npx @aicopycoders/exodus comments list` (already commenter-redacted), and `npx @aicopycoders/exodus winners definition` (what this brand means by a winner). Read-only; no Meta login or MCP needed. |
|
|
68
69
|
| **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import`. A brand whose Meta account is connected reads its ads and its winner definition straight from Exodus; only an unconnected brand needs the Meta Ads MCP. |
|
|
69
70
|
|
|
@@ -39,6 +39,7 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
|
|
|
39
39
|
| Build a bot or a workflow prompt node | `bot-builder` |
|
|
40
40
|
| "remember that", "never do that again" | `remember-that` |
|
|
41
41
|
| "handoff", "wrap up", "what did we do today" | `handoff` |
|
|
42
|
+
| Add competitors to the Mining page / watch list, see who is on it ("add these brands to mining", "put Brace Direct on the watch list") | run `npx @aicopycoders/exodus swipe brands bulk-import <csv>` (one brand: `swipe brands add "<Name>" --fb <pageId>`; `swipe brands list` to see the list). There is no `competitors` verb. |
|
|
42
43
|
| Update the CLI + skills ("update exodus", "run an exodus update") | run `npx @aicopycoders/exodus@latest update` — the ONLY command that refreshes skills; `doctor` checks health but never rewrites them |
|
|
43
44
|
|
|
44
45
|
The method skills above are Luke's thinking as skills; they run as a conversation and are listed in `CLAUDE.md` under **Method skills**. If a custom pipeline skill is installed in this folder (any additional `exodus-*` directory under `.claude/skills/`), it routes the same way — match the user's words to that skill's description.
|
|
@@ -7,7 +7,7 @@ description: Find the competitors that are actually strong (scaling and working)
|
|
|
7
7
|
|
|
8
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
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
|
|
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 the user can cut. Read the `thinking-vs-build` skill if installed.
|
|
11
11
|
|
|
12
12
|
## What "strong" means
|
|
13
13
|
|
|
@@ -128,6 +128,8 @@ Thresholds used: <…>. Verdict: FOLLOW 3+ · WATCH 2 · SKIP 0–1.
|
|
|
128
128
|
|
|
129
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
130
|
|
|
131
|
+
**Push the watch list into Exodus (the Mining page).** The CLI verb is `npx @aicopycoders/exodus swipe brands`, not `competitors`. Write the FOLLOW rows as a CSV (`name,igHandle,fbPageId,category[,website][,youtubeHandle]`; the Ad Library `view_all_page_id` is the `fbPageId`) and run `npx @aicopycoders/exodus swipe brands bulk-import <file.csv>`: it checks each brand's page on the member's own Scrape Creators key and starts collecting its ads. `swipe brands add "<Name>" --fb <pageId>` adds one brand but does not start the research yet (open issue #1366), so prefer the CSV route even for one brand. `swipe brands list` shows what is on the page.
|
|
132
|
+
|
|
131
133
|
## Hand-offs
|
|
132
134
|
|
|
133
135
|
- **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.
|
|
@@ -11,6 +11,7 @@ lanes.txt: one query per line (the outcome × mechanism × ingredient grid, see
|
|
|
11
11
|
|
|
12
12
|
Needs APIFY_API_TOKEN in .env. Cost: the apify~facebook-ads-scraper actor bills per result; 30 lanes × 150
|
|
13
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
|
+
A lane that already has a JSON file is skipped, so a rerun after failures only bills the failed lanes.
|
|
14
15
|
|
|
15
16
|
Output:
|
|
16
17
|
<outdir>/<lane>.json raw ads per lane
|
|
@@ -37,15 +38,22 @@ def run(kw, outdir, limit, country, tok):
|
|
|
37
38
|
u = ("https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=" + country +
|
|
38
39
|
"&q=" + urllib.parse.quote(kw) + "&search_type=keyword_unordered&media_type=all")
|
|
39
40
|
path = os.path.join(outdir, kw.replace(" ", "_").replace("/", "-") + ".json")
|
|
41
|
+
if os.path.exists(path):
|
|
42
|
+
print(f"{kw:<40} already swept, skipped (delete the file to redo)", flush=True)
|
|
43
|
+
return True
|
|
40
44
|
try:
|
|
41
45
|
r = requests.post(f"https://api.apify.com/v2/acts/apify~facebook-ads-scraper/run-sync-get-dataset-items?token={tok}&timeout=600",
|
|
42
46
|
json={"startUrls": [{"url": u}], "resultsLimit": limit}, timeout=(30, 660))
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
r.raise_for_status()
|
|
48
|
+
d = r.json()
|
|
49
|
+
if not isinstance(d, list):
|
|
50
|
+
raise ValueError(f"unexpected response shape: {type(d).__name__}")
|
|
45
51
|
json.dump(d, open(path, "w"))
|
|
46
52
|
print(f"{kw:<40} {r.status_code} {len(d)} ads", flush=True)
|
|
53
|
+
return True
|
|
47
54
|
except Exception as e:
|
|
48
|
-
print(kw
|
|
55
|
+
print(f"{kw:<40} FAILED {e}", flush=True)
|
|
56
|
+
return False
|
|
49
57
|
|
|
50
58
|
|
|
51
59
|
def aggregate(outdir):
|
|
@@ -75,7 +83,7 @@ def aggregate(outdir):
|
|
|
75
83
|
|
|
76
84
|
def main():
|
|
77
85
|
ap = argparse.ArgumentParser()
|
|
78
|
-
ap.add_argument("--keywords", required
|
|
86
|
+
ap.add_argument("--keywords", help="one query per line; required unless --aggregate-only")
|
|
79
87
|
ap.add_argument("--outdir", default="adlib")
|
|
80
88
|
ap.add_argument("--limit", type=int, default=150)
|
|
81
89
|
ap.add_argument("--country", default="US")
|
|
@@ -85,6 +93,7 @@ def main():
|
|
|
85
93
|
a = ap.parse_args()
|
|
86
94
|
os.makedirs(a.outdir, exist_ok=True)
|
|
87
95
|
if not a.aggregate_only:
|
|
96
|
+
if not a.keywords: sys.exit("--keywords <file> is required for a sweep")
|
|
88
97
|
tok = os.environ.get("APIFY_API_TOKEN") or sys.exit("APIFY_API_TOKEN not found in .env or environment")
|
|
89
98
|
lanes = [l.strip() for l in open(a.keywords) if l.strip() and not l.startswith("#")]
|
|
90
99
|
if not a.yes:
|
|
@@ -92,7 +101,13 @@ def main():
|
|
|
92
101
|
print(f"Outputs land in {a.outdir}/. Nothing was run. Say the plan back, get a go, then rerun with --yes.")
|
|
93
102
|
sys.exit(2)
|
|
94
103
|
with ThreadPoolExecutor(a.workers) as ex:
|
|
95
|
-
list(ex.map(lambda k: run(k, a.outdir, a.limit, a.country, tok), lanes))
|
|
104
|
+
ok = list(ex.map(lambda k: run(k, a.outdir, a.limit, a.country, tok), lanes))
|
|
105
|
+
failed = [k for k, good in zip(lanes, ok) if not good]
|
|
106
|
+
if failed:
|
|
107
|
+
print(f"\n{len(failed)} of {len(lanes)} lanes failed; not aggregating an incomplete sweep.")
|
|
108
|
+
print("Rerun the same command with --yes: finished lanes are skipped, only the failed ones are billed again.")
|
|
109
|
+
print("Failed: " + ", ".join(failed))
|
|
110
|
+
sys.exit(1)
|
|
96
111
|
aggregate(a.outdir)
|
|
97
112
|
|
|
98
113
|
|
|
@@ -64,9 +64,9 @@ def get(path, params, tries=5):
|
|
|
64
64
|
|
|
65
65
|
def search_brand(name):
|
|
66
66
|
items = get("/brand-library/search", {"keyword": name, "page_size": 8}).get("items", [])
|
|
67
|
-
|
|
68
|
-
exact = [i for i in items if i.get("name", "").lower() ==
|
|
69
|
-
close = [i for i in items if
|
|
67
|
+
want = name.lower().strip()
|
|
68
|
+
exact = [i for i in items if i.get("name", "").lower().strip() == want]
|
|
69
|
+
close = [i for i in items if want in i.get("name", "").lower()]
|
|
70
70
|
if exact or close:
|
|
71
71
|
return (exact or close)[:1]
|
|
72
72
|
# no name match: do not guess. Report what Atria returned so the user can pick.
|
|
@@ -151,6 +151,8 @@ def main():
|
|
|
151
151
|
time.sleep(1.0)
|
|
152
152
|
order = {"FOLLOW": 0, "WATCH": 1, "SKIP": 2}
|
|
153
153
|
rows.sort(key=lambda r: (order.get(r.get("verdict"), 3), -(r.get("active_total") or 0)))
|
|
154
|
+
for target in [a.out, a.json]:
|
|
155
|
+
if target and os.path.dirname(target): os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
154
156
|
with open(a.out, "w") as f:
|
|
155
157
|
f.write(f"# Competitor sheet ({dt.date.today()})\n\nVerdict: FOLLOW = 3+ strong signals · WATCH = 2 · SKIP = 0–1. Thresholds: {STRONG}\n\n")
|
|
156
158
|
f.write("| Brand | Verdict | Active | Longest (d) | Launches 30d | Reuse | Offer | Strong on | Longest-running ad |\n|---|---|---|---|---|---|---|---|---|\n")
|
package/dist/commands/swipe.js
CHANGED
|
@@ -307,7 +307,14 @@ async function runBrandsAdd(positional, flags) {
|
|
|
307
307
|
console.log(formatError(res));
|
|
308
308
|
process.exit(1);
|
|
309
309
|
}
|
|
310
|
-
|
|
310
|
+
if (res.data.researching) {
|
|
311
|
+
console.log(`Added "${name}". Finding its Ad Library page and mining its ads now.`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
console.log(`Added "${name}".`);
|
|
315
|
+
if (res.data.warning)
|
|
316
|
+
console.log(` ${res.data.warning}`);
|
|
317
|
+
}
|
|
311
318
|
}
|
|
312
319
|
async function runBrandsRemove(positional) {
|
|
313
320
|
const id = positional[0];
|
package/dist/lib/layout.js
CHANGED
|
@@ -97,6 +97,7 @@ export function ensureBrandDir(root, brand) {
|
|
|
97
97
|
const created = !fs.existsSync(path.join(dir, BRAND_MARKER_FILE));
|
|
98
98
|
fs.mkdirSync(path.join(dir, "state"), { recursive: true });
|
|
99
99
|
fs.mkdirSync(path.join(dir, "output"), { recursive: true });
|
|
100
|
+
fs.mkdirSync(path.join(dir, "inputs"), { recursive: true });
|
|
100
101
|
const marker = { slug: brand.slug, name: brand.name ?? brand.slug };
|
|
101
102
|
const markerPath = path.join(dir, BRAND_MARKER_FILE);
|
|
102
103
|
const existing = readBrandMarker(dir);
|
package/dist/lib/scaffold.js
CHANGED
|
@@ -115,6 +115,7 @@ export function ensureBaseDirs(root) {
|
|
|
115
115
|
if (!fs.existsSync(keep))
|
|
116
116
|
fs.writeFileSync(keep, "");
|
|
117
117
|
fs.mkdirSync(path.join(root, "state"), { recursive: true });
|
|
118
|
+
fs.mkdirSync(path.join(root, "inputs"), { recursive: true });
|
|
118
119
|
const exodusDir = path.join(root, ".exodus");
|
|
119
120
|
fs.mkdirSync(exodusDir, { recursive: true });
|
|
120
121
|
const statePath = path.join(exodusDir, "state.json");
|
package/package.json
CHANGED
|
Binary file
|