seo_intel.js
// Stage 0 — SEO intel. For each brief's primary target keyword:
// SerpApi (localized to Chicago, IL) -> top-5 organic -> Puppeteer scrape
// (Bright Data fallback) -> on-page analysis. Writes per-brief intel bundles.
//
// Usage:
// node seo_intel.js --slug <slug> # one brief (dry-run)
// node seo_intel.js --limit 1 # first N briefs
// node seo_intel.js # all 15
const fs = require("fs");
const path = require("path");
const { parseBriefs } = require("./lib/briefs");
const { serpSearch, organicUrls, LOCATION, sleep } = require("./lib/serp");
const { scrape } = require("./lib/scrape");
const { analyze } = require("./lib/onpage");
const OUT_DIR = path.join(__dirname, "seo");
const LOG_FILE = path.join(__dirname, "seo_run.log");
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}`;
console.log(line);
fs.appendFileSync(LOG_FILE, line + "\n");
}
function arg(name) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : null;
}
async function processBrief(brief) {
const dir = path.join(OUT_DIR, brief.slug);
fs.mkdirSync(dir, { recursive: true });
const query = brief.targetKeyword || brief.queries[0];
log(`[${brief.slug}] query="${query}" (location: ${LOCATION})`);
let serpJson;
try {
serpJson = await serpSearch(query);
} catch (e) {
log(`[${brief.slug}] SERP FAILED: ${e.message}`);
return { slug: brief.slug, ok: false, error: `serp: ${e.message}` };
}
fs.writeFileSync(
path.join(dir, "serp.json"),
JSON.stringify(serpJson, null, 2),
);
const organics = organicUrls(serpJson, 5);
log(`[${brief.slug}] top organic: ${organics.length} urls`);
const competitors = [];
for (let i = 0; i < organics.length; i++) {
const { url } = organics[i];
const n = i + 1;
const res = await scrape(url);
if (!res.ok) {
log(`[${brief.slug}] #${n} ${url} -> SKIP (${res.error})`);
competitors.push({ rank: n, url, ok: false, error: res.error, via: res.via });
continue;
}
const intel = analyze(res.html, { url, queries: brief.queries });
fs.writeFileSync(
path.join(dir, `competitor_${n}.md`),
`# ${intel.titleTag || url}\n\n> source: ${url} (via ${res.via})\n\n${intel.markdown}\n`,
);
// Don't store the giant markdown twice inside intel.json.
const { markdown, ...intelMeta } = intel;
competitors.push({
rank: n,
url,
ok: true,
via: res.via,
serpTitle: organics[i].title,
...intelMeta,
});
log(
`[${brief.slug}] #${n} ${url} -> OK (${res.via}, ${intel.wordCount}w, title="${(intel.titleTag || "").slice(0, 60)}")`,
);
await sleep(800);
}
const bundle = {
slug: brief.slug,
workingTitle: brief.workingTitle,
targetKeyword: query,
queries: brief.queries,
location: LOCATION,
generated_at: new Date().toISOString(),
competitors,
};
fs.writeFileSync(path.join(dir, "intel.json"), JSON.stringify(bundle, null, 2));
const okCount = competitors.filter((c) => c.ok).length;
return { slug: brief.slug, ok: true, scraped: okCount, total: organics.length };
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const { briefs } = parseBriefs();
let targets = briefs;
const slug = arg("slug");
const limit = arg("limit");
if (slug) targets = briefs.filter((b) => b.slug === slug);
else if (limit) targets = briefs.slice(0, Number(limit));
if (!targets.length) {
log(`No briefs matched (slug=${slug}). Available: ${briefs.map((b) => b.slug).join(", ")}`);
return;
}
log(`=== Stage 0 SEO intel: ${targets.length} brief(s) ===`);
const results = [];
for (const b of targets) {
results.push(await processBrief(b));
}
fs.writeFileSync(
path.join(OUT_DIR, "_stats.json"),
JSON.stringify(
{ generated_at: new Date().toISOString(), location: LOCATION, results },
null,
2,
),
);
const ok = results.filter((r) => r.ok).length;
const scraped = results.reduce((s, r) => s + (r.scraped || 0), 0);
log(`=== done: ${ok}/${results.length} briefs, ${scraped} competitor pages scraped ===`);
}
main().catch((e) => {
log(`FATAL: ${e.stack || e}`);
process.exit(1);
});