seo-intel 1.4.0 → 1.4.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/CHANGELOG.md +26 -0
- package/cli.js +266 -111
- package/package.json +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.1 (2026-04-03)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
- **CLI JSON output** — all 11 commands now produce clean JSON with zero chalk/ANSI leakage
|
|
7
|
+
- **Brief `--format json`** — full rich data (keyword gaps, schema gaps, actions) instead of lean subset
|
|
8
|
+
- **Templates `--format json`** — suppressed chalk header and log output in JSON mode
|
|
9
|
+
- **JS-Delta `--format json`** — suppressed per-page progress chalk in JSON mode
|
|
10
|
+
|
|
11
|
+
### Agent Integration
|
|
12
|
+
- Model selection hints (`modelHint`, `modelNote`) on extract, gap-intel, blog-draft capabilities
|
|
13
|
+
- AGENT_GUIDE.md — added Model Selection Guidance table (light-local vs cloud-medium per phase)
|
|
14
|
+
- GitHub Releases now auto-created on tag push via CI
|
|
15
|
+
|
|
3
16
|
## 1.4.0 (2026-04-03)
|
|
4
17
|
|
|
5
18
|
### New Feature: Gap Intelligence
|
|
@@ -17,6 +30,19 @@
|
|
|
17
30
|
- Qwen models remain fully supported as alternatives
|
|
18
31
|
- Setup wizard, model recommendations, and VRAM tiers updated for Gemma 4
|
|
19
32
|
|
|
33
|
+
### Agent-Ready JSON Output
|
|
34
|
+
- All 11 analysis commands support `--format json` for clean, parseable output
|
|
35
|
+
- JSON output is chalk-free — no ANSI escape codes mixed into structured data
|
|
36
|
+
- Commands: shallow, decay, headings-audit, orphans, entities, schemas, friction, brief, velocity, templates, js-delta
|
|
37
|
+
|
|
38
|
+
### Programmatic API (`seo-intel/froggo`)
|
|
39
|
+
- Unified agent runner: `run(command, project, opts)` returns `{ ok, command, project, timestamp, data }`
|
|
40
|
+
- 18 capabilities with machine-readable manifest (inputs, outputs, dependencies, tier)
|
|
41
|
+
- Pipeline dependency graph for orchestration
|
|
42
|
+
- Model selection hints per capability (light-local vs cloud-medium)
|
|
43
|
+
- Deep imports: `seo-intel/aeo`, `seo-intel/crawler`, `seo-intel/db`, etc.
|
|
44
|
+
- Agent Guide (`AGENT_GUIDE.md`) with orchestration patterns and model guidance
|
|
45
|
+
|
|
20
46
|
### Server
|
|
21
47
|
- Added `gap-intel` to terminal command whitelist
|
|
22
48
|
- Forward `--vs`, `--type`, `--limit`, `--raw`, `--out` params from dashboard to CLI
|
package/cli.js
CHANGED
|
@@ -2000,11 +2000,15 @@ program
|
|
|
2000
2000
|
.option('--skip-crawl', 'Skip sample crawl (pattern analysis + GSC only)')
|
|
2001
2001
|
.option('--skip-gsc', 'Skip GSC overlay phase')
|
|
2002
2002
|
.option('--skip-competitors', 'Skip competitor sitemap census')
|
|
2003
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2003
2004
|
.action(async (project, opts) => {
|
|
2004
2005
|
if (!requirePro('templates')) return;
|
|
2005
2006
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2007
|
+
const isJson = opts.format === 'json';
|
|
2008
|
+
if (!isJson) {
|
|
2009
|
+
console.log(chalk.bold.cyan(`\n🔍 SEO Intel — Template Analysis`));
|
|
2010
|
+
console.log(chalk.dim(` Project: ${project}`));
|
|
2011
|
+
}
|
|
2008
2012
|
|
|
2009
2013
|
try {
|
|
2010
2014
|
const { runTemplatesAnalysis } = await import('./analyses/templates/index.js');
|
|
@@ -2014,9 +2018,14 @@ program
|
|
|
2014
2018
|
skipCrawl: !!opts.skipCrawl,
|
|
2015
2019
|
skipGsc: !!opts.skipGsc,
|
|
2016
2020
|
skipCompetitors: !!opts.skipCompetitors,
|
|
2017
|
-
log: (msg) => console.log(chalk.gray(msg)),
|
|
2021
|
+
log: isJson ? () => {} : (msg) => console.log(chalk.gray(msg)),
|
|
2018
2022
|
});
|
|
2019
2023
|
|
|
2024
|
+
if (isJson) {
|
|
2025
|
+
console.log(JSON.stringify({ command: 'templates', project, timestamp: new Date().toISOString(), data: report }));
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2020
2029
|
if (report.groups.length === 0) {
|
|
2021
2030
|
console.log(chalk.yellow(`\n No template patterns detected.\n`));
|
|
2022
2031
|
process.exit(0);
|
|
@@ -2183,14 +2192,13 @@ program
|
|
|
2183
2192
|
.description('Find competitor pages that are important but thin (Shallow Champion attack)')
|
|
2184
2193
|
.option('--max-words <n>', 'Max word count threshold', '700')
|
|
2185
2194
|
.option('--max-depth <n>', 'Max click depth', '2')
|
|
2195
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2186
2196
|
.action((project, opts) => {
|
|
2187
2197
|
if (!requirePro('shallow')) return;
|
|
2188
2198
|
const db = getDb();
|
|
2189
2199
|
const maxWords = parseInt(opts.maxWords);
|
|
2190
2200
|
const maxDepth = parseInt(opts.maxDepth);
|
|
2191
2201
|
|
|
2192
|
-
printAttackHeader('⚡ Shallow Champion Attack', project);
|
|
2193
|
-
|
|
2194
2202
|
const rows = db.prepare(`
|
|
2195
2203
|
SELECT p.url, p.click_depth, p.word_count, d.domain
|
|
2196
2204
|
FROM pages p
|
|
@@ -2201,6 +2209,19 @@ program
|
|
|
2201
2209
|
ORDER BY p.click_depth ASC, p.word_count ASC
|
|
2202
2210
|
`).all(project, maxDepth, maxWords).filter(r => isContentPage(r.url));
|
|
2203
2211
|
|
|
2212
|
+
const byDomain = {};
|
|
2213
|
+
for (const r of rows) {
|
|
2214
|
+
if (!byDomain[r.domain]) byDomain[r.domain] = [];
|
|
2215
|
+
byDomain[r.domain].push(r);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
if (opts.format === 'json') {
|
|
2219
|
+
console.log(JSON.stringify({ command: 'shallow', project, timestamp: new Date().toISOString(), data: { targets: rows.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })), byDomain: Object.fromEntries(Object.entries(byDomain).map(([k,v]) => [k, v.map(r => ({ url: r.url, wordCount: r.word_count, clickDepth: r.click_depth }))])), totalTargets: rows.length } }));
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
printAttackHeader('⚡ Shallow Champion Attack', project);
|
|
2224
|
+
|
|
2204
2225
|
if (!rows.length) {
|
|
2205
2226
|
console.log(chalk.yellow('No shallow champions found with current thresholds.'));
|
|
2206
2227
|
return;
|
|
@@ -2208,12 +2229,6 @@ program
|
|
|
2208
2229
|
|
|
2209
2230
|
console.log(chalk.gray(`Found ${rows.length} shallow champion targets (depth ≤${maxDepth}, words ≤${maxWords}):\n`));
|
|
2210
2231
|
|
|
2211
|
-
const byDomain = {};
|
|
2212
|
-
for (const r of rows) {
|
|
2213
|
-
if (!byDomain[r.domain]) byDomain[r.domain] = [];
|
|
2214
|
-
byDomain[r.domain].push(r);
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
2232
|
for (const [domain, pages] of Object.entries(byDomain)) {
|
|
2218
2233
|
console.log(chalk.bold.yellow(` ${domain}`));
|
|
2219
2234
|
for (const p of pages) {
|
|
@@ -2234,6 +2249,7 @@ program
|
|
|
2234
2249
|
.command('decay <project>')
|
|
2235
2250
|
.description('Find competitor pages decaying due to staleness (Content Decay Arbitrage)')
|
|
2236
2251
|
.option('--months <n>', 'Months since last update to flag as stale', '18')
|
|
2252
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2237
2253
|
.action((project, opts) => {
|
|
2238
2254
|
if (!requirePro('decay')) return;
|
|
2239
2255
|
const db = getDb();
|
|
@@ -2242,8 +2258,6 @@ program
|
|
|
2242
2258
|
cutoffDate.setMonth(cutoffDate.getMonth() - monthsAgo);
|
|
2243
2259
|
const cutoff = cutoffDate.toISOString().split('T')[0];
|
|
2244
2260
|
|
|
2245
|
-
printAttackHeader('📉 Content Decay Arbitrage', project);
|
|
2246
|
-
|
|
2247
2261
|
// Pages with known stale modified_date
|
|
2248
2262
|
const staleKnown = db.prepare(`
|
|
2249
2263
|
SELECT p.url, p.click_depth, p.word_count, p.modified_date, p.published_date, d.domain
|
|
@@ -2269,6 +2283,13 @@ program
|
|
|
2269
2283
|
LIMIT 20
|
|
2270
2284
|
`).all(project).filter(r => isContentPage(r.url));
|
|
2271
2285
|
|
|
2286
|
+
if (opts.format === 'json') {
|
|
2287
|
+
console.log(JSON.stringify({ command: 'decay', project, timestamp: new Date().toISOString(), data: { confirmedStale: staleKnown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, modifiedDate: r.modified_date, clickDepth: r.click_depth })), unknownFreshness: staleUnknown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })), monthsThreshold: monthsAgo } }));
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
printAttackHeader('📉 Content Decay Arbitrage', project);
|
|
2292
|
+
|
|
2272
2293
|
if (!staleKnown.length && !staleUnknown.length) {
|
|
2273
2294
|
console.log(chalk.yellow('No decay targets found. More crawl data or date metadata needed.'));
|
|
2274
2295
|
return;
|
|
@@ -2301,13 +2322,12 @@ program
|
|
|
2301
2322
|
.description('Pull competitor heading structures for AI gap analysis')
|
|
2302
2323
|
.option('--domain <domain>', 'Audit a specific competitor domain')
|
|
2303
2324
|
.option('--depth <n>', 'Max click depth to include', '2')
|
|
2325
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2304
2326
|
.action(async (project, opts) => {
|
|
2305
2327
|
if (!requirePro('headings-audit')) return;
|
|
2306
2328
|
const db = getDb();
|
|
2307
2329
|
const maxDepth = parseInt(opts.depth);
|
|
2308
2330
|
|
|
2309
|
-
printAttackHeader('🏗️ Heading Architecture Audit', project);
|
|
2310
|
-
|
|
2311
2331
|
const domainFilter = opts.domain ? 'AND d.domain = ?' : '';
|
|
2312
2332
|
const params = opts.domain ? [project, maxDepth, opts.domain] : [project, maxDepth];
|
|
2313
2333
|
|
|
@@ -2321,6 +2341,25 @@ program
|
|
|
2321
2341
|
ORDER BY d.domain, p.click_depth ASC, p.word_count DESC
|
|
2322
2342
|
`).all(...params).filter(r => isContentPage(r.url));
|
|
2323
2343
|
|
|
2344
|
+
const jsonResults = [];
|
|
2345
|
+
for (const page of pages.slice(0, 30)) {
|
|
2346
|
+
const headings = db.prepare(`
|
|
2347
|
+
SELECT level, text FROM headings WHERE page_id = ?
|
|
2348
|
+
ORDER BY rowid ASC
|
|
2349
|
+
`).all(page.id);
|
|
2350
|
+
|
|
2351
|
+
if (!headings.length) continue;
|
|
2352
|
+
|
|
2353
|
+
jsonResults.push({ url: page.url, domain: page.domain, wordCount: page.word_count, clickDepth: page.click_depth, headings: headings.map(h => ({ level: h.level, text: h.text })) });
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if (opts.format === 'json') {
|
|
2357
|
+
console.log(JSON.stringify({ command: 'headings-audit', project, timestamp: new Date().toISOString(), data: { pages: jsonResults, totalPages: jsonResults.length } }));
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
printAttackHeader('🏗️ Heading Architecture Audit', project);
|
|
2362
|
+
|
|
2324
2363
|
if (!pages.length) {
|
|
2325
2364
|
console.log(chalk.yellow('No pages found matching criteria.'));
|
|
2326
2365
|
return;
|
|
@@ -2363,12 +2402,11 @@ program
|
|
|
2363
2402
|
program
|
|
2364
2403
|
.command('orphans <project>')
|
|
2365
2404
|
.description('Find orphaned entities — mentioned everywhere but no dedicated page (needs Qwen extraction)')
|
|
2366
|
-
.
|
|
2405
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2406
|
+
.action((project, opts) => {
|
|
2367
2407
|
if (!requirePro('orphans')) return;
|
|
2368
2408
|
const db = getDb();
|
|
2369
2409
|
|
|
2370
|
-
printAttackHeader('👻 Orphan Entity Attack', project);
|
|
2371
|
-
|
|
2372
2410
|
// Check if we have any extraction data with primary_entities
|
|
2373
2411
|
const extractionCount = db.prepare(`
|
|
2374
2412
|
SELECT COUNT(*) as c FROM extractions e
|
|
@@ -2377,14 +2415,8 @@ program
|
|
|
2377
2415
|
WHERE d.project = ? AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]' AND e.primary_entities != ''
|
|
2378
2416
|
`).get(project);
|
|
2379
2417
|
|
|
2380
|
-
if (!extractionCount || extractionCount.c === 0) {
|
|
2381
|
-
console.log(chalk.yellow('⚠️ No entity extraction data found.'));
|
|
2382
|
-
console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
|
|
2383
|
-
return;
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
2418
|
// Get all entities from competitor pages
|
|
2387
|
-
const extractions = db.prepare(`
|
|
2419
|
+
const extractions = (!extractionCount || extractionCount.c === 0) ? [] : db.prepare(`
|
|
2388
2420
|
SELECT e.primary_entities, p.url, d.domain
|
|
2389
2421
|
FROM extractions e
|
|
2390
2422
|
JOIN pages p ON p.id = e.page_id
|
|
@@ -2406,7 +2438,7 @@ program
|
|
|
2406
2438
|
}
|
|
2407
2439
|
|
|
2408
2440
|
// Get all competitor URLs to check for dedicated pages
|
|
2409
|
-
const allUrls = db.prepare(`
|
|
2441
|
+
const allUrls = (!extractionCount || extractionCount.c === 0) ? [] : db.prepare(`
|
|
2410
2442
|
SELECT p.url FROM pages p
|
|
2411
2443
|
JOIN domains d ON d.id = p.domain_id
|
|
2412
2444
|
WHERE d.project = ? AND d.role = 'competitor'
|
|
@@ -2425,6 +2457,19 @@ program
|
|
|
2425
2457
|
|
|
2426
2458
|
orphans.sort((a, b) => b.domainCount - a.domainCount);
|
|
2427
2459
|
|
|
2460
|
+
if (opts.format === 'json') {
|
|
2461
|
+
console.log(JSON.stringify({ command: 'orphans', project, timestamp: new Date().toISOString(), data: { orphans: orphans.map(o => ({ entity: o.entity, domains: o.domains, domainCount: o.domainCount, suggestedUrl: '/solutions/' + o.entity.replace(/\s+/g, '-').toLowerCase() })), totalOrphans: orphans.length } }));
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
printAttackHeader('👻 Orphan Entity Attack', project);
|
|
2466
|
+
|
|
2467
|
+
if (!extractionCount || extractionCount.c === 0) {
|
|
2468
|
+
console.log(chalk.yellow('⚠️ No entity extraction data found.'));
|
|
2469
|
+
console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2428
2473
|
if (!orphans.length) {
|
|
2429
2474
|
if (entityMap.size === 0) {
|
|
2430
2475
|
console.log(chalk.yellow('⚠️ Entity extraction data exists but no entities were extracted.'));
|
|
@@ -2452,14 +2497,13 @@ program
|
|
|
2452
2497
|
.description('Entity coverage map — semantic gap at the entity level (concepts competitors mention, you don\'t)')
|
|
2453
2498
|
.option('--min-mentions <n>', 'Minimum competitor mentions to show', '2')
|
|
2454
2499
|
.option('--save', 'Save entity map to reports/')
|
|
2500
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2455
2501
|
.action((project, opts) => {
|
|
2456
2502
|
if (!requirePro('entities')) return;
|
|
2457
2503
|
const db = getDb();
|
|
2458
2504
|
const config = loadConfig(project);
|
|
2459
2505
|
const minMentions = parseInt(opts.minMentions) || 2;
|
|
2460
2506
|
|
|
2461
|
-
printAttackHeader('🧬 Entity Coverage Map', project);
|
|
2462
|
-
|
|
2463
2507
|
// ── Gather all entities from all domains ──
|
|
2464
2508
|
const allExtractions = db.prepare(`
|
|
2465
2509
|
SELECT e.primary_entities, d.domain, d.role, p.url
|
|
@@ -2470,12 +2514,6 @@ program
|
|
|
2470
2514
|
AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]' AND e.primary_entities != ''
|
|
2471
2515
|
`).all(project);
|
|
2472
2516
|
|
|
2473
|
-
if (!allExtractions.length) {
|
|
2474
|
-
console.log(chalk.yellow('⚠️ No entity extraction data found.'));
|
|
2475
|
-
console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
|
|
2476
|
-
return;
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
2517
|
// Build entity → { targetMentions, competitorMentions, domains, pages }
|
|
2480
2518
|
const entityMap = new Map();
|
|
2481
2519
|
|
|
@@ -2519,6 +2557,19 @@ program
|
|
|
2519
2557
|
gaps.sort((a, b) => b.compCount - a.compCount);
|
|
2520
2558
|
shared.sort((a, b) => b.compCount - a.compCount);
|
|
2521
2559
|
|
|
2560
|
+
if (opts.format === 'json') {
|
|
2561
|
+
console.log(JSON.stringify({ command: 'entities', project, timestamp: new Date().toISOString(), data: { gaps: gaps.map(g => ({ entity: g.entity, competitorCount: g.compCount, domains: g.domains })), shared: shared.map(s => ({ entity: s.entity, competitorCount: s.compCount, targetDomains: s.targetDomains, competitorDomains: s.compDomains })), unique: yourOnly.map(y => ({ entity: y.entity, targetDomains: y.targetDomains })), summary: { totalEntities: entityMap.size, gapCount: gaps.length, sharedCount: shared.length, uniqueCount: yourOnly.length } } }));
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
printAttackHeader('🧬 Entity Coverage Map', project);
|
|
2566
|
+
|
|
2567
|
+
if (!allExtractions.length) {
|
|
2568
|
+
console.log(chalk.yellow('⚠️ No entity extraction data found.'));
|
|
2569
|
+
console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2522
2573
|
let mdOutput = `# Entity Coverage Map — ${config.target.domain}\nGenerated: ${new Date().toISOString().slice(0, 10)}\n\n`;
|
|
2523
2574
|
|
|
2524
2575
|
// ── Coverage summary ──
|
|
@@ -2613,19 +2664,12 @@ program
|
|
|
2613
2664
|
.command('schemas <project>')
|
|
2614
2665
|
.description('Deep structured data competitive analysis — ratings, pricing, rich results gaps')
|
|
2615
2666
|
.option('--save', 'Save report to reports/')
|
|
2667
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2616
2668
|
.action((project, opts) => {
|
|
2617
2669
|
const db = getDb();
|
|
2618
2670
|
|
|
2619
|
-
printAttackHeader('🔬 Schema Intelligence Report', project);
|
|
2620
|
-
|
|
2621
2671
|
const rows = getSchemasByProject(db, project);
|
|
2622
2672
|
|
|
2623
|
-
if (rows.length === 0) {
|
|
2624
|
-
console.log(chalk.yellow(' No structured data found. Run a crawl first — schemas are parsed from JSON-LD during crawl.'));
|
|
2625
|
-
console.log(chalk.dim(' Tip: node cli.js crawl ' + project + '\n'));
|
|
2626
|
-
return;
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
2673
|
// Load config to identify target domain
|
|
2630
2674
|
const configPath = `./config/${project}.json`;
|
|
2631
2675
|
let targetDomain = null;
|
|
@@ -2641,10 +2685,6 @@ program
|
|
|
2641
2685
|
byDomain.get(row.domain).push(row);
|
|
2642
2686
|
}
|
|
2643
2687
|
|
|
2644
|
-
// ── Schema type coverage matrix ──
|
|
2645
|
-
console.log(chalk.bold('\n SCHEMA TYPE COVERAGE'));
|
|
2646
|
-
console.log(chalk.dim(' Which structured data types each domain uses\n'));
|
|
2647
|
-
|
|
2648
2688
|
const allTypes = [...new Set(rows.map(r => r.schema_type))].sort();
|
|
2649
2689
|
const domainList = [...byDomain.keys()].sort((a, b) => {
|
|
2650
2690
|
if (a === targetDomain) return -1;
|
|
@@ -2652,6 +2692,57 @@ program
|
|
|
2652
2692
|
return a.localeCompare(b);
|
|
2653
2693
|
});
|
|
2654
2694
|
|
|
2695
|
+
const withRatings = rows.filter(r => r.rating !== null);
|
|
2696
|
+
const withPricing = rows.filter(r => r.price !== null);
|
|
2697
|
+
|
|
2698
|
+
// ── Gap analysis — what competitors have that you don't ──
|
|
2699
|
+
const targetTypes = new Set((byDomain.get(targetDomain) || []).map(s => s.schema_type));
|
|
2700
|
+
const compTypes = new Set(rows.filter(r => r.domain !== targetDomain).map(r => r.schema_type));
|
|
2701
|
+
const schemaGaps = [...compTypes].filter(t => !targetTypes.has(t));
|
|
2702
|
+
const yourExclusives = [...targetTypes].filter(t => !compTypes.has(t));
|
|
2703
|
+
|
|
2704
|
+
// ── Actionable recommendations ──
|
|
2705
|
+
const actions = [];
|
|
2706
|
+
if (schemaGaps.length > 0) {
|
|
2707
|
+
const highValue = schemaGaps.filter(t => ['Product', 'SoftwareApplication', 'FAQPage', 'HowTo', 'Review', 'AggregateRating'].includes(t));
|
|
2708
|
+
if (highValue.length > 0) {
|
|
2709
|
+
actions.push(`Add high-value schema types: ${highValue.join(', ')}`);
|
|
2710
|
+
}
|
|
2711
|
+
const remaining = schemaGaps.filter(t => !highValue.includes(t));
|
|
2712
|
+
if (remaining.length > 0) {
|
|
2713
|
+
actions.push(`Consider adding: ${remaining.join(', ')}`);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
if (withRatings.length > 0 && !rows.some(r => r.domain === targetDomain && r.rating !== null)) {
|
|
2717
|
+
actions.push('Add aggregateRating schema for star-rich snippets (highest SERP CTR impact)');
|
|
2718
|
+
}
|
|
2719
|
+
if (withPricing.length > 0 && !rows.some(r => r.domain === targetDomain && r.price !== null)) {
|
|
2720
|
+
actions.push('Add pricing schema (Product/Offer) for price-rich results');
|
|
2721
|
+
}
|
|
2722
|
+
if (!targetTypes.has('FAQPage') && compTypes.has('FAQPage')) {
|
|
2723
|
+
actions.push('Add FAQPage schema — expands your SERP real estate with accordion snippets');
|
|
2724
|
+
}
|
|
2725
|
+
if (!targetTypes.has('BreadcrumbList') && compTypes.has('BreadcrumbList')) {
|
|
2726
|
+
actions.push('Add BreadcrumbList schema — improves SERP display and navigation signals');
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
if (opts.format === 'json') {
|
|
2730
|
+
console.log(JSON.stringify({ command: 'schemas', project, timestamp: new Date().toISOString(), data: { coverageMatrix: Object.fromEntries([...byDomain.entries()].map(([dom, schemas]) => [dom, schemas.map(s => ({ type: s.schema_type, url: s.url, name: s.name, rating: s.rating, ratingCount: s.rating_count, price: s.price, currency: s.currency }))])), gaps: schemaGaps, exclusives: yourExclusives, ratings: withRatings.map(r => ({ domain: r.domain, url: r.url, name: r.name, rating: r.rating, ratingCount: r.rating_count })), pricing: withPricing.map(r => ({ domain: r.domain, url: r.url, name: r.name, price: r.price, currency: r.currency })), actions, summary: { totalSchemas: rows.length, uniqueTypes: allTypes.length, domainsWithSchemas: byDomain.size, gapCount: schemaGaps.length } } }));
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
printAttackHeader('🔬 Schema Intelligence Report', project);
|
|
2735
|
+
|
|
2736
|
+
if (rows.length === 0) {
|
|
2737
|
+
console.log(chalk.yellow(' No structured data found. Run a crawl first — schemas are parsed from JSON-LD during crawl.'));
|
|
2738
|
+
console.log(chalk.dim(' Tip: node cli.js crawl ' + project + '\n'));
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// ── Schema type coverage matrix ──
|
|
2743
|
+
console.log(chalk.bold('\n SCHEMA TYPE COVERAGE'));
|
|
2744
|
+
console.log(chalk.dim(' Which structured data types each domain uses\n'));
|
|
2745
|
+
|
|
2655
2746
|
// Header
|
|
2656
2747
|
const typeColWidth = 22;
|
|
2657
2748
|
const domColWidth = 12;
|
|
@@ -2680,7 +2771,6 @@ program
|
|
|
2680
2771
|
}
|
|
2681
2772
|
|
|
2682
2773
|
// ── Rating intel — who has review stars? ──
|
|
2683
|
-
const withRatings = rows.filter(r => r.rating !== null);
|
|
2684
2774
|
if (withRatings.length > 0) {
|
|
2685
2775
|
console.log(chalk.bold('\n\n RATING INTELLIGENCE'));
|
|
2686
2776
|
console.log(chalk.dim(' Competitors with aggregateRating — rich star snippets in SERPs\n'));
|
|
@@ -2706,7 +2796,6 @@ program
|
|
|
2706
2796
|
}
|
|
2707
2797
|
|
|
2708
2798
|
// ── Pricing intel ──
|
|
2709
|
-
const withPricing = rows.filter(r => r.price !== null);
|
|
2710
2799
|
if (withPricing.length > 0) {
|
|
2711
2800
|
console.log(chalk.bold('\n\n PRICING SCHEMA'));
|
|
2712
2801
|
console.log(chalk.dim(' Structured pricing data (enables price rich results)\n'));
|
|
@@ -2726,12 +2815,6 @@ program
|
|
|
2726
2815
|
}
|
|
2727
2816
|
}
|
|
2728
2817
|
|
|
2729
|
-
// ── Gap analysis — what competitors have that you don't ──
|
|
2730
|
-
const targetTypes = new Set((byDomain.get(targetDomain) || []).map(s => s.schema_type));
|
|
2731
|
-
const compTypes = new Set(rows.filter(r => r.domain !== targetDomain).map(r => r.schema_type));
|
|
2732
|
-
const schemaGaps = [...compTypes].filter(t => !targetTypes.has(t));
|
|
2733
|
-
const yourExclusives = [...targetTypes].filter(t => !compTypes.has(t));
|
|
2734
|
-
|
|
2735
2818
|
if (schemaGaps.length > 0 || yourExclusives.length > 0) {
|
|
2736
2819
|
console.log(chalk.bold('\n\n COMPETITIVE GAPS'));
|
|
2737
2820
|
|
|
@@ -2752,33 +2835,8 @@ program
|
|
|
2752
2835
|
}
|
|
2753
2836
|
}
|
|
2754
2837
|
|
|
2755
|
-
// ── Actionable recommendations ──
|
|
2756
2838
|
console.log(chalk.bold('\n\n ACTIONS'));
|
|
2757
2839
|
|
|
2758
|
-
const actions = [];
|
|
2759
|
-
if (schemaGaps.length > 0) {
|
|
2760
|
-
const highValue = schemaGaps.filter(t => ['Product', 'SoftwareApplication', 'FAQPage', 'HowTo', 'Review', 'AggregateRating'].includes(t));
|
|
2761
|
-
if (highValue.length > 0) {
|
|
2762
|
-
actions.push(`Add high-value schema types: ${highValue.join(', ')}`);
|
|
2763
|
-
}
|
|
2764
|
-
const remaining = schemaGaps.filter(t => !highValue.includes(t));
|
|
2765
|
-
if (remaining.length > 0) {
|
|
2766
|
-
actions.push(`Consider adding: ${remaining.join(', ')}`);
|
|
2767
|
-
}
|
|
2768
|
-
}
|
|
2769
|
-
if (withRatings.length > 0 && !rows.some(r => r.domain === targetDomain && r.rating !== null)) {
|
|
2770
|
-
actions.push('Add aggregateRating schema for star-rich snippets (highest SERP CTR impact)');
|
|
2771
|
-
}
|
|
2772
|
-
if (withPricing.length > 0 && !rows.some(r => r.domain === targetDomain && r.price !== null)) {
|
|
2773
|
-
actions.push('Add pricing schema (Product/Offer) for price-rich results');
|
|
2774
|
-
}
|
|
2775
|
-
if (!targetTypes.has('FAQPage') && compTypes.has('FAQPage')) {
|
|
2776
|
-
actions.push('Add FAQPage schema — expands your SERP real estate with accordion snippets');
|
|
2777
|
-
}
|
|
2778
|
-
if (!targetTypes.has('BreadcrumbList') && compTypes.has('BreadcrumbList')) {
|
|
2779
|
-
actions.push('Add BreadcrumbList schema — improves SERP display and navigation signals');
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
2840
|
if (actions.length > 0) {
|
|
2783
2841
|
for (let i = 0; i < actions.length; i++) {
|
|
2784
2842
|
console.log(` ${chalk.cyan(`${i + 1}.`)} ${actions[i]}`);
|
|
@@ -2917,12 +2975,11 @@ program
|
|
|
2917
2975
|
program
|
|
2918
2976
|
.command('friction <project>')
|
|
2919
2977
|
.description('Find competitor pages with intent/CTA mismatch — high friction targets (needs Qwen extraction)')
|
|
2920
|
-
.
|
|
2978
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2979
|
+
.action((project, opts) => {
|
|
2921
2980
|
if (!requirePro('friction')) return;
|
|
2922
2981
|
const db = getDb();
|
|
2923
2982
|
|
|
2924
|
-
printAttackHeader('🎯 Intent & Friction Hijacking', project);
|
|
2925
|
-
|
|
2926
2983
|
const rows = db.prepare(`
|
|
2927
2984
|
SELECT e.search_intent, e.cta_primary, e.pricing_tier, p.url, p.word_count, d.domain
|
|
2928
2985
|
FROM extractions e
|
|
@@ -2934,12 +2991,6 @@ program
|
|
|
2934
2991
|
ORDER BY d.domain, p.click_depth ASC
|
|
2935
2992
|
`).all(project).filter(r => isContentPage(r.url));
|
|
2936
2993
|
|
|
2937
|
-
if (!rows.length) {
|
|
2938
|
-
console.log(chalk.yellow('⚠️ No intent/CTA extraction data found.'));
|
|
2939
|
-
console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
|
|
2940
|
-
return;
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
2994
|
// High friction patterns
|
|
2944
2995
|
const highFrictionCTAs = ['enterprise', 'sales', 'contact', 'book a demo', 'request', 'talk to'];
|
|
2945
2996
|
const targets = rows.filter(r => {
|
|
@@ -2950,6 +3001,19 @@ program
|
|
|
2950
3001
|
return isHighFriction && isInfoOrCommercial;
|
|
2951
3002
|
});
|
|
2952
3003
|
|
|
3004
|
+
if (opts.format === 'json') {
|
|
3005
|
+
console.log(JSON.stringify({ command: 'friction', project, timestamp: new Date().toISOString(), data: { targets: targets.map(t => ({ url: t.url, domain: t.domain, searchIntent: t.search_intent, ctaPrimary: t.cta_primary, pricingTier: t.pricing_tier, wordCount: t.word_count })), totalAnalyzed: rows.length, totalHighFriction: targets.length } }));
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
printAttackHeader('🎯 Intent & Friction Hijacking', project);
|
|
3010
|
+
|
|
3011
|
+
if (!rows.length) {
|
|
3012
|
+
console.log(chalk.yellow('⚠️ No intent/CTA extraction data found.'));
|
|
3013
|
+
console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
2953
3017
|
if (!targets.length) {
|
|
2954
3018
|
console.log(chalk.green('No high-friction mismatches found in current extraction data.'));
|
|
2955
3019
|
console.log(chalk.gray(` (${rows.length} pages analyzed)\n`));
|
|
@@ -2974,6 +3038,7 @@ program
|
|
|
2974
3038
|
.description('Weekly SEO Intel Brief — what changed, new gaps, wins, actions')
|
|
2975
3039
|
.option('--days <n>', 'Lookback window in days', '7')
|
|
2976
3040
|
.option('--save', 'Save brief to reports/')
|
|
3041
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
2977
3042
|
.action((project, opts) => {
|
|
2978
3043
|
if (!requirePro('brief')) return;
|
|
2979
3044
|
const db = getDb();
|
|
@@ -2983,6 +3048,75 @@ program
|
|
|
2983
3048
|
const cutoffISO = new Date(cutoff).toISOString().slice(0, 10);
|
|
2984
3049
|
const weekOf = new Date().toISOString().slice(0, 10);
|
|
2985
3050
|
|
|
3051
|
+
// ── JSON fast path: compute all data, no chalk ──
|
|
3052
|
+
if (opts.format === 'json') {
|
|
3053
|
+
const compDomains = config.competitors.map(c => c.domain);
|
|
3054
|
+
const targetDomainJ = config.target.domain;
|
|
3055
|
+
const competitorMoves = [];
|
|
3056
|
+
|
|
3057
|
+
// Competitor moves
|
|
3058
|
+
const targetKeywordsJ = new Set(
|
|
3059
|
+
db.prepare(`SELECT DISTINCT LOWER(k.keyword) as kw FROM keywords k JOIN pages p ON p.id = k.page_id JOIN domains d ON d.id = p.domain_id WHERE d.project = ? AND (d.role = 'target' OR d.role = 'owned')`).all(project).map(r => r.kw)
|
|
3060
|
+
);
|
|
3061
|
+
const gapKeywordsJ = new Map();
|
|
3062
|
+
const targetSchemaJ = new Set();
|
|
3063
|
+
try {
|
|
3064
|
+
const ts = db.prepare(`SELECT DISTINCT e.schema_types FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id WHERE d.project = ? AND (d.role = 'target' OR d.role = 'owned') AND e.schema_types IS NOT NULL AND e.schema_types != '[]'`).all(project);
|
|
3065
|
+
for (const row of ts) { try { for (const t of JSON.parse(row.schema_types)) targetSchemaJ.add(t); } catch {} }
|
|
3066
|
+
} catch {}
|
|
3067
|
+
const compSchemaJ = new Map();
|
|
3068
|
+
|
|
3069
|
+
for (const comp of compDomains) {
|
|
3070
|
+
const newPages = db.prepare(`SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id WHERE d.domain = ? AND d.project = ? AND p.first_seen_at > ? AND p.is_indexable = 1`).all(comp, project, cutoff).filter(r => isContentPage(r.url));
|
|
3071
|
+
const changedPages = db.prepare(`SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id WHERE d.domain = ? AND d.project = ? AND p.crawled_at > ? AND p.first_seen_at < ? AND p.is_indexable = 1`).all(comp, project, cutoff, cutoff).filter(r => isContentPage(r.url));
|
|
3072
|
+
competitorMoves.push({ domain: comp, newPages: newPages.map(p => ({ url: p.url, wordCount: p.word_count })), changedPages: changedPages.map(p => ({ url: p.url, wordCount: p.word_count })) });
|
|
3073
|
+
|
|
3074
|
+
// Keyword gaps from new pages
|
|
3075
|
+
for (const np of newPages.slice(0, 10)) {
|
|
3076
|
+
const pageRow = db.prepare('SELECT id FROM pages WHERE url = ?').get(np.url);
|
|
3077
|
+
if (!pageRow) continue;
|
|
3078
|
+
const kws = db.prepare('SELECT keyword FROM keywords WHERE page_id = ?').all(pageRow.id);
|
|
3079
|
+
for (const kw of kws) {
|
|
3080
|
+
const key = kw.keyword.toLowerCase().trim();
|
|
3081
|
+
if (key.length < 3 || targetKeywordsJ.has(key)) continue;
|
|
3082
|
+
if (!gapKeywordsJ.has(key)) gapKeywordsJ.set(key, new Set());
|
|
3083
|
+
gapKeywordsJ.get(key).add(comp);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// Schema gaps from new pages
|
|
3088
|
+
for (const np of newPages.slice(0, 10)) {
|
|
3089
|
+
const pageRow = db.prepare('SELECT id FROM pages WHERE url = ?').get(np.url);
|
|
3090
|
+
if (!pageRow) continue;
|
|
3091
|
+
const ext = db.prepare('SELECT schema_types FROM extractions WHERE page_id = ?').get(pageRow.id);
|
|
3092
|
+
if (!ext?.schema_types) continue;
|
|
3093
|
+
try {
|
|
3094
|
+
for (const st of JSON.parse(ext.schema_types)) {
|
|
3095
|
+
if (!targetSchemaJ.has(st)) {
|
|
3096
|
+
if (!compSchemaJ.has(st)) compSchemaJ.set(st, new Set());
|
|
3097
|
+
compSchemaJ.get(st).add(comp);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
} catch {}
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
const sortedGapsJ = [...gapKeywordsJ.entries()]
|
|
3105
|
+
.map(([kw, domains]) => ({ keyword: kw, domains: [...domains], count: domains.size }))
|
|
3106
|
+
.sort((a, b) => b.count - a.count).slice(0, 10);
|
|
3107
|
+
|
|
3108
|
+
const actionsJ = [];
|
|
3109
|
+
if (sortedGapsJ.length > 0) actionsJ.push(`Write content covering "${sortedGapsJ[0].keyword}" — ${sortedGapsJ[0].count} competitor(s) rank for it`);
|
|
3110
|
+
if (compSchemaJ.size > 0) { const [schema, doms] = [...compSchemaJ.entries()][0]; actionsJ.push(`Add ${schema} schema markup to relevant pages (${[...doms][0]} already has it)`); }
|
|
3111
|
+
const targetNewJ = db.prepare(`SELECT COUNT(*) as c FROM pages p JOIN domains d ON d.id = p.domain_id WHERE d.domain = ? AND d.project = ? AND p.first_seen_at > ?`).get(targetDomainJ, project, cutoff)?.c || 0;
|
|
3112
|
+
const compVelocitiesJ = competitorMoves.map(m => ({ domain: m.domain, rate: m.newPages.length })).sort((a, b) => b.rate - a.rate);
|
|
3113
|
+
if (compVelocitiesJ.length > 0 && compVelocitiesJ[0].rate > targetNewJ) actionsJ.push(`Increase publishing rate — ${compVelocitiesJ[0].domain} published ${compVelocitiesJ[0].rate} pages vs your ${targetNewJ}`);
|
|
3114
|
+
if (actionsJ.length === 0) { actionsJ.push('Re-crawl competitors to detect new content'); actionsJ.push('Review dashboard for technical SEO fixes'); }
|
|
3115
|
+
|
|
3116
|
+
console.log(JSON.stringify({ command: 'brief', project, timestamp: new Date().toISOString(), data: { competitorMoves, keywordGaps: sortedGapsJ, schemaGaps: [...compSchemaJ.entries()].map(([schema, domains]) => ({ schema, domains: [...domains] })), actions: actionsJ, period: { days, cutoff: cutoffISO, weekOf }, targetNewPages: targetNewJ } }));
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
2986
3120
|
const hr = '─'.repeat(60);
|
|
2987
3121
|
const header = `📊 Weekly SEO Intel Brief — ${config.target.domain}\n Week of ${weekOf} (last ${days} days)`;
|
|
2988
3122
|
|
|
@@ -3230,14 +3364,13 @@ program
|
|
|
3230
3364
|
.command('velocity <project>')
|
|
3231
3365
|
.description('Content velocity — how fast each domain publishes (publishing rate + new page detection)')
|
|
3232
3366
|
.option('--days <n>', 'Lookback window in days', '30')
|
|
3367
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
3233
3368
|
.action((project, opts) => {
|
|
3234
3369
|
if (!requirePro('velocity')) return;
|
|
3235
3370
|
const db = getDb();
|
|
3236
3371
|
const days = parseInt(opts.days) || 30;
|
|
3237
3372
|
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
3238
3373
|
|
|
3239
|
-
printAttackHeader('📈 Content Velocity Tracker', project);
|
|
3240
|
-
|
|
3241
3374
|
// ── 1. Pages discovered recently (first_seen_at within window) ──
|
|
3242
3375
|
const newPages = db.prepare(`
|
|
3243
3376
|
SELECT d.domain, d.role, p.url, p.first_seen_at, p.published_date, p.word_count, p.click_depth
|
|
@@ -3269,11 +3402,6 @@ program
|
|
|
3269
3402
|
GROUP BY d.domain ORDER BY d.role, d.domain
|
|
3270
3403
|
`).all(project);
|
|
3271
3404
|
|
|
3272
|
-
// ── Velocity summary per domain ──
|
|
3273
|
-
console.log(chalk.bold(' Domain Velocity Summary') + chalk.gray(` (last ${days} days)\n`));
|
|
3274
|
-
console.log(chalk.gray(' Domain Role Total New Rate/wk Published'));
|
|
3275
|
-
console.log(chalk.gray(' ' + '─'.repeat(85)));
|
|
3276
|
-
|
|
3277
3405
|
const domainNewMap = {};
|
|
3278
3406
|
for (const np of newPages) {
|
|
3279
3407
|
if (!domainNewMap[np.domain]) domainNewMap[np.domain] = [];
|
|
@@ -3293,13 +3421,27 @@ program
|
|
|
3293
3421
|
const pubCount = (domainPubMap[t.domain] || []).length;
|
|
3294
3422
|
const weeksInWindow = days / 7;
|
|
3295
3423
|
const ratePerWeek = weeksInWindow > 0 ? (Math.max(newCount, pubCount) / weeksInWindow).toFixed(1) : '—';
|
|
3296
|
-
|
|
3297
3424
|
velocities.push({ domain: t.domain, role: t.role, total: t.total_pages, newCount, pubCount, ratePerWeek: parseFloat(ratePerWeek) || 0 });
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
if (opts.format === 'json') {
|
|
3428
|
+
console.log(JSON.stringify({ command: 'velocity', project, timestamp: new Date().toISOString(), data: { velocities, recentlyPublished: publishedRecently.map(p => ({ url: p.url, domain: p.domain, role: p.role, publishedDate: p.published_date, wordCount: p.word_count })), newPages: newPages.map(p => ({ url: p.url, domain: p.domain, role: p.role, firstSeen: p.first_seen_at, wordCount: p.word_count })), period: { days, cutoff: new Date(cutoff).toISOString() } } }));
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
printAttackHeader('📈 Content Velocity Tracker', project);
|
|
3433
|
+
|
|
3434
|
+
// ── Velocity summary per domain ──
|
|
3435
|
+
console.log(chalk.bold(' Domain Velocity Summary') + chalk.gray(` (last ${days} days)\n`));
|
|
3436
|
+
console.log(chalk.gray(' Domain Role Total New Rate/wk Published'));
|
|
3437
|
+
console.log(chalk.gray(' ' + '─'.repeat(85)));
|
|
3298
3438
|
|
|
3439
|
+
for (const t of totals) {
|
|
3440
|
+
const v = velocities.find(v => v.domain === t.domain);
|
|
3299
3441
|
const roleColor = t.role === 'target' ? chalk.green : t.role === 'owned' ? chalk.blue : chalk.yellow;
|
|
3300
|
-
const rateColor =
|
|
3442
|
+
const rateColor = v.ratePerWeek > 2 ? chalk.green : v.ratePerWeek > 0 ? chalk.yellow : chalk.gray;
|
|
3301
3443
|
|
|
3302
|
-
console.log(` ${t.domain.padEnd(30)} ${roleColor(t.role.padEnd(12))} ${String(t.total_pages).padEnd(7)} ${chalk.cyan(String(newCount).padEnd(6))} ${rateColor(String(ratePerWeek + '/wk').padEnd(8))} ${String(pubCount).padEnd(6)}`);
|
|
3444
|
+
console.log(` ${t.domain.padEnd(30)} ${roleColor(t.role.padEnd(12))} ${String(t.total_pages).padEnd(7)} ${chalk.cyan(String(v.newCount).padEnd(6))} ${rateColor(String(v.ratePerWeek + '/wk').padEnd(8))} ${String(v.pubCount).padEnd(6)}`);
|
|
3303
3445
|
}
|
|
3304
3446
|
|
|
3305
3447
|
// ── Velocity leader ──
|
|
@@ -3371,15 +3513,19 @@ program
|
|
|
3371
3513
|
.option('--max-pages <n>', 'Max pages to check per domain', '10')
|
|
3372
3514
|
.option('--threshold <n>', 'Word count difference threshold to flag', '50')
|
|
3373
3515
|
.option('--save', 'Save report to reports/')
|
|
3516
|
+
.option('--format <type>', 'Output format: json or brief', 'brief')
|
|
3374
3517
|
.action(async (project, opts) => {
|
|
3375
3518
|
if (!requirePro('js-delta')) return;
|
|
3376
3519
|
const config = loadConfig(project);
|
|
3377
3520
|
const db = getDb();
|
|
3378
3521
|
const maxPerDomain = parseInt(opts.maxPages) || 10;
|
|
3379
3522
|
const threshold = parseInt(opts.threshold) || 50;
|
|
3523
|
+
const isJsonFmt = opts.format === 'json';
|
|
3380
3524
|
|
|
3381
|
-
|
|
3382
|
-
|
|
3525
|
+
if (!isJsonFmt) {
|
|
3526
|
+
printAttackHeader('🔬 JS Rendering Delta', project);
|
|
3527
|
+
console.log(chalk.gray(' Comparing raw HTML (no JS) vs Playwright render (full JS)\n'));
|
|
3528
|
+
}
|
|
3383
3529
|
|
|
3384
3530
|
// Get pages to check — focus on high-value pages (low depth, indexable)
|
|
3385
3531
|
const domainFilter = opts.domain ? 'AND d.domain = ?' : '';
|
|
@@ -3436,11 +3582,13 @@ program
|
|
|
3436
3582
|
|
|
3437
3583
|
if (!pages.length) continue;
|
|
3438
3584
|
|
|
3439
|
-
|
|
3440
|
-
|
|
3585
|
+
if (!isJsonFmt) {
|
|
3586
|
+
const roleColor = dom.role === 'target' ? chalk.green : dom.role === 'owned' ? chalk.blue : chalk.yellow;
|
|
3587
|
+
console.log(roleColor(chalk.bold(` ${dom.domain}`) + chalk.gray(` (${pages.length} pages)`)));
|
|
3588
|
+
}
|
|
3441
3589
|
|
|
3442
3590
|
for (const pg of pages) {
|
|
3443
|
-
process.stdout.write(chalk.gray(` ${pg.url.replace(/https?:\/\/[^/]+/, '').slice(0, 55).padEnd(55)} `));
|
|
3591
|
+
if (!isJsonFmt) process.stdout.write(chalk.gray(` ${pg.url.replace(/https?:\/\/[^/]+/, '').slice(0, 55).padEnd(55)} `));
|
|
3444
3592
|
|
|
3445
3593
|
try {
|
|
3446
3594
|
// 1. Raw HTML fetch (no JS)
|
|
@@ -3474,24 +3622,26 @@ program
|
|
|
3474
3622
|
};
|
|
3475
3623
|
results.push(result);
|
|
3476
3624
|
|
|
3477
|
-
if (
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3625
|
+
if (!isJsonFmt) {
|
|
3626
|
+
if (delta > threshold) {
|
|
3627
|
+
process.stdout.write(chalk.red(`⚠️ raw:${rawWords} → rendered:${renderedWords} (+${delta} words, +${pctDelta}%)\n`));
|
|
3628
|
+
} else if (delta < -threshold) {
|
|
3629
|
+
process.stdout.write(chalk.yellow(`📉 raw:${rawWords} → rendered:${renderedWords} (${delta} words)\n`));
|
|
3630
|
+
} else {
|
|
3631
|
+
process.stdout.write(chalk.green(`✓ raw:${rawWords} ≈ rendered:${renderedWords}\n`));
|
|
3632
|
+
}
|
|
3483
3633
|
}
|
|
3484
3634
|
} finally {
|
|
3485
3635
|
await page.close().catch(() => {});
|
|
3486
3636
|
}
|
|
3487
3637
|
} catch (err) {
|
|
3488
|
-
process.stdout.write(chalk.red(`✗ ${err.message.slice(0, 40)}\n`));
|
|
3638
|
+
if (!isJsonFmt) process.stdout.write(chalk.red(`✗ ${err.message.slice(0, 40)}\n`));
|
|
3489
3639
|
}
|
|
3490
3640
|
|
|
3491
3641
|
// Be respectful
|
|
3492
3642
|
await new Promise(r => setTimeout(r, 1000 + Math.random() * 1500));
|
|
3493
3643
|
}
|
|
3494
|
-
console.log('');
|
|
3644
|
+
if (!isJsonFmt) console.log('');
|
|
3495
3645
|
}
|
|
3496
3646
|
} finally {
|
|
3497
3647
|
await browser.close().catch(() => {});
|
|
@@ -3501,6 +3651,11 @@ program
|
|
|
3501
3651
|
const hiddenContent = results.filter(r => r.hidden);
|
|
3502
3652
|
const totalChecked = results.length;
|
|
3503
3653
|
|
|
3654
|
+
if (opts.format === 'json') {
|
|
3655
|
+
console.log(JSON.stringify({ command: 'js-delta', project, timestamp: new Date().toISOString(), data: { results: results.map(r => ({ url: r.url, domain: r.domain, role: r.role, rawWords: r.rawWords, renderedWords: r.renderedWords, delta: r.delta, pctDelta: r.pctDelta, hasHiddenContent: r.hidden })), summary: { totalChecked: results.length, hiddenContentPages: hiddenContent.length, threshold } } }));
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3504
3659
|
console.log(chalk.bold(` Summary: ${totalChecked} pages checked\n`));
|
|
3505
3660
|
console.log(` ${chalk.green('✓')} ${results.filter(r => !r.hidden && r.delta >= -threshold).length} pages render correctly (raw ≈ rendered)`);
|
|
3506
3661
|
console.log(` ${chalk.red('⚠️')} ${hiddenContent.length} pages with JS-hidden content (${threshold}+ words invisible to raw crawlers)`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seo-intel",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -22,6 +22,22 @@
|
|
|
22
22
|
"bin": {
|
|
23
23
|
"seo-intel": "cli.js"
|
|
24
24
|
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./cli.js",
|
|
27
|
+
"./froggo": "./froggo.js",
|
|
28
|
+
"./aeo": "./analyses/aeo/index.js",
|
|
29
|
+
"./aeo/scorer": "./analyses/aeo/scorer.js",
|
|
30
|
+
"./gap-intel": "./analyses/gap-intel/index.js",
|
|
31
|
+
"./blog-draft": "./analyses/blog-draft/index.js",
|
|
32
|
+
"./crawler": "./crawler/index.js",
|
|
33
|
+
"./db": "./db/db.js",
|
|
34
|
+
"./exports/technical": "./exports/technical.js",
|
|
35
|
+
"./exports/competitive": "./exports/competitive.js",
|
|
36
|
+
"./exports/suggestive": "./exports/suggestive.js",
|
|
37
|
+
"./exports/queries": "./exports/queries.js",
|
|
38
|
+
"./templates": "./analyses/templates/index.js",
|
|
39
|
+
"./dashboard": "./reports/generate-html.js"
|
|
40
|
+
},
|
|
25
41
|
"engines": {
|
|
26
42
|
"node": ">=22.5.0"
|
|
27
43
|
},
|