seo-intel 1.4.1 → 1.4.3

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/cli.js CHANGED
@@ -660,6 +660,25 @@ program
660
660
  console.log(chalk.dim(` ⚠ Dashboard refresh skipped: ${dashErr.message}`));
661
661
  }
662
662
 
663
+ // Auto-run site watch after crawl
664
+ try {
665
+ const { runWatch } = await import('./analyses/watch/index.js');
666
+ const watchResult = runWatch(db, project, { log: () => {} });
667
+ if (watchResult.snapshot) {
668
+ if (watchResult.isBaseline) {
669
+ console.log(chalk.dim(` šŸ‘ Site Watch: baseline snapshot saved (health: ${watchResult.healthScore}/100)`));
670
+ } else if (watchResult.events.length) {
671
+ const t = watchResult.trend;
672
+ const trendStr = t > 0 ? `ā–² +${t}` : t < 0 ? `ā–¼ ${t}` : '';
673
+ console.log(chalk.dim(` šŸ‘ Site Watch: ${watchResult.events.length} changes detected (health: ${watchResult.healthScore}/100${trendStr ? ' ' + trendStr : ''})`));
674
+ } else {
675
+ console.log(chalk.dim(` šŸ‘ Site Watch: no changes (health: ${watchResult.healthScore}/100)`));
676
+ }
677
+ }
678
+ } catch (e) {
679
+ console.log(chalk.dim(` ⚠ Site Watch skipped: ${e.message}`));
680
+ }
681
+
663
682
  if (opts.extract === false && totalExtracted === 0) {
664
683
  console.log(chalk.bold.green(`\nāœ… Crawl complete (${elapsed}s) — raw data collected.`));
665
684
  console.log(chalk.white(' Next steps:'));
@@ -723,9 +742,9 @@ program
723
742
  console.log(chalk.yellow('Sending to Gemini...\n'));
724
743
 
725
744
  // Save prompt for debugging (markdown for Obsidian/agent compatibility)
726
- const promptTs = Date.now();
745
+ const promptTs = new Date().toISOString().slice(0, 10);
727
746
  const promptPath = join(__dirname, `reports/${project}-prompt-${promptTs}.md`);
728
- const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date(promptTs).toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
747
+ const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date().toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
729
748
  writeFileSync(promptPath, promptFrontmatter + prompt, 'utf8');
730
749
  console.log(chalk.gray(`Prompt saved: ${promptPath}`));
731
750
 
@@ -745,13 +764,13 @@ program
745
764
  analysis = JSON.parse(jsonMatch[0]);
746
765
  } catch {
747
766
  console.error(chalk.red('Could not parse JSON from response. Saving raw output.'));
748
- const rawPath = join(__dirname, `reports/${project}-raw-${Date.now()}.md`);
767
+ const rawPath = join(__dirname, `reports/${project}-raw-${new Date().toISOString().slice(0, 10)}.md`);
749
768
  writeFileSync(rawPath, result, 'utf8');
750
769
  process.exit(1);
751
770
  }
752
771
 
753
772
  // Save structured analysis to file
754
- const outPath = join(__dirname, `reports/${project}-analysis-${Date.now()}.json`);
773
+ const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
755
774
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
756
775
 
757
776
  // Save to DB (so HTML dashboard picks it up)
@@ -911,7 +930,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
911
930
  data = JSON.parse(jsonMatch[0]);
912
931
  } catch {
913
932
  console.error(chalk.red('Could not parse JSON from Gemini response.'));
914
- const rawPath = join(__dirname, `reports/${project}-keywords-raw-${Date.now()}.md`);
933
+ const rawPath = join(__dirname, `reports/${project}-keywords-raw-${new Date().toISOString().slice(0, 10)}.md`);
915
934
  writeFileSync(rawPath, result, 'utf8');
916
935
  console.error(chalk.gray(`Raw output saved: ${rawPath}`));
917
936
  process.exit(1);
@@ -972,7 +991,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
972
991
  }
973
992
 
974
993
  if (opts.save) {
975
- const outPath = join(__dirname, `reports/${project}-keywords-${Date.now()}.json`);
994
+ const outPath = join(__dirname, `reports/${project}-keywords-${new Date().toISOString().slice(0, 10)}.json`);
976
995
  writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf8');
977
996
  console.log(chalk.bold.green(`āœ… Report saved: ${outPath}\n`));
978
997
 
@@ -1740,7 +1759,7 @@ async function runAnalysis(project, db) {
1740
1759
  try {
1741
1760
  const jsonMatch = result.match(/\{[\s\S]*\}/);
1742
1761
  const analysis = JSON.parse(jsonMatch[0]);
1743
- const outPath = join(__dirname, `reports/${project}-analysis-${Date.now()}.json`);
1762
+ const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
1744
1763
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
1745
1764
 
1746
1765
  // Save to DB
@@ -2391,7 +2410,7 @@ program
2391
2410
  report += `> Analyze this heading structure from ${page.domain}. What H2/H3 sub-topics are logically missing? What would a user expect to find that isn't covered? Be specific.\n\n---\n\n`;
2392
2411
  }
2393
2412
 
2394
- const outPath = join(__dirname, `reports/${project}-headings-audit-${Date.now()}.md`);
2413
+ const outPath = join(__dirname, `reports/${project}-headings-audit-${new Date().toISOString().slice(0, 10)}.md`);
2395
2414
  writeFileSync(outPath, report, 'utf8');
2396
2415
 
2397
2416
  console.log(chalk.bold.green(`\nāœ… Full audit saved: ${outPath}`));
@@ -2653,7 +2672,7 @@ program
2653
2672
 
2654
2673
  // ── Save ──
2655
2674
  if (opts.save) {
2656
- const outPath = join(__dirname, `reports/${project}-entities-${Date.now()}.md`);
2675
+ const outPath = join(__dirname, `reports/${project}-entities-${new Date().toISOString().slice(0, 10)}.md`);
2657
2676
  writeFileSync(outPath, mdOutput, 'utf8');
2658
2677
  console.log(chalk.bold.green(` āœ… Entity map saved: ${outPath}\n`));
2659
2678
  }
@@ -3350,7 +3369,7 @@ program
3350
3369
 
3351
3370
  // ── Save ──
3352
3371
  if (opts.save) {
3353
- const outPath = join(__dirname, `reports/${project}-brief-${Date.now()}.md`);
3372
+ const outPath = join(__dirname, `reports/${project}-brief-${new Date().toISOString().slice(0, 10)}.md`);
3354
3373
  writeFileSync(outPath, mdOutput, 'utf8');
3355
3374
  console.log(chalk.bold.green(` āœ… Brief saved: ${outPath}\n`));
3356
3375
  }
@@ -3694,7 +3713,7 @@ program
3694
3713
  }
3695
3714
 
3696
3715
  if (opts.save) {
3697
- const outPath = join(__dirname, `reports/${project}-js-delta-${Date.now()}.md`);
3716
+ const outPath = join(__dirname, `reports/${project}-js-delta-${new Date().toISOString().slice(0, 10)}.md`);
3698
3717
  writeFileSync(outPath, mdOutput, 'utf8');
3699
3718
  console.log(chalk.bold.green(` āœ… Report saved: ${outPath}\n`));
3700
3719
  }
@@ -3840,7 +3859,7 @@ program
3840
3859
  }
3841
3860
 
3842
3861
  // Output
3843
- const timestamp = Date.now();
3862
+ const timestamp = new Date().toISOString().slice(0, 10);
3844
3863
  const defaultPath = join(__dirname, `reports/${project}-export-${timestamp}.${format}`);
3845
3864
  const outPath = opts.output || defaultPath;
3846
3865
 
@@ -4218,12 +4237,134 @@ program
4218
4237
  for (const p of worst) {
4219
4238
  md += `- **${p.url}** — ${p.score}/100 (${p.tier})\n`;
4220
4239
  }
4221
- const outPath = join(__dirname, `reports/${project}-aeo-${Date.now()}.md`);
4240
+ const outPath = join(__dirname, `reports/${project}-aeo-${new Date().toISOString().slice(0, 10)}.md`);
4222
4241
  writeFileSync(outPath, md, 'utf8');
4223
4242
  console.log(chalk.bold.green(` āœ… Report saved: ${outPath}\n`));
4224
4243
  }
4225
4244
  });
4226
4245
 
4246
+ // ── SITE WATCH ──────────────────────────────────────────────────────────
4247
+ program
4248
+ .command('watch <project>')
4249
+ .description('Site health monitor — detect changes between crawl runs')
4250
+ .option('--format <type>', 'Output format: brief or json', 'brief')
4251
+ .action(async (project, opts) => {
4252
+ const db = getDb();
4253
+ const config = loadConfig(project);
4254
+ if (!config) return;
4255
+ const isJson = opts.format === 'json';
4256
+
4257
+ if (!isJson) {
4258
+ printAttackHeader('Site Watch', project);
4259
+ }
4260
+
4261
+ const { runWatch } = await import('./analyses/watch/index.js');
4262
+
4263
+ const result = runWatch(db, project, {
4264
+ log: isJson ? () => {} : (msg) => console.log(chalk.gray(msg)),
4265
+ });
4266
+
4267
+ if (!result.snapshot) {
4268
+ if (isJson) console.log(JSON.stringify({ ok: false, error: 'No crawled pages found' }));
4269
+ return;
4270
+ }
4271
+
4272
+ if (isJson) {
4273
+ console.log(JSON.stringify({
4274
+ snapshot: result.snapshot,
4275
+ events: result.events,
4276
+ healthScore: result.healthScore,
4277
+ previousHealthScore: result.previousHealthScore,
4278
+ trend: result.trend,
4279
+ isBaseline: result.isBaseline,
4280
+ }, null, 2));
4281
+ } else {
4282
+ // ── Health Score ──
4283
+ const score = result.healthScore;
4284
+ const scoreFmt = score >= 80 ? chalk.bold.green(score + '/100')
4285
+ : score >= 60 ? chalk.bold.yellow(score + '/100')
4286
+ : chalk.bold.red(score + '/100');
4287
+
4288
+ console.log('');
4289
+ console.log(` Health Score: ${scoreFmt}`);
4290
+
4291
+ if (result.previousHealthScore !== null) {
4292
+ const t = result.trend;
4293
+ const trendStr = t > 0 ? chalk.green(`ā–² +${t}`)
4294
+ : t < 0 ? chalk.red(`ā–¼ ${t}`)
4295
+ : chalk.gray('— unchanged');
4296
+ console.log(` ${trendStr} (was ${result.previousHealthScore}/100)`);
4297
+ }
4298
+ console.log('');
4299
+
4300
+ if (result.isBaseline) {
4301
+ console.log(chalk.bold.green(` āœ… Baseline captured — ${result.snapshot.total_pages} pages`));
4302
+ console.log(chalk.gray(' Run another crawl to see changes.\n'));
4303
+ } else {
4304
+ // ── Severity summary ──
4305
+ const { errors_count: e, warnings_count: w, notices_count: n } = result.snapshot;
4306
+ const prev = result.previousHealthScore !== null ? {
4307
+ e: 0, w: 0, n: 0, // We'll compute deltas from events
4308
+ } : null;
4309
+
4310
+ console.log(` ${chalk.red('ā—')} Critical: ${e}`);
4311
+ console.log(` ${chalk.yellow('ā—')} Warning: ${w}`);
4312
+ console.log(` ${chalk.gray('ā—')} Notice: ${n}`);
4313
+ console.log('');
4314
+
4315
+ // ── Event list ──
4316
+ if (result.events.length) {
4317
+ console.log(chalk.bold(' What\'s New:'));
4318
+ console.log('');
4319
+
4320
+ const shown = result.events.slice(0, 25);
4321
+ for (const ev of shown) {
4322
+ const icon = ev.severity === 'critical' ? chalk.red('ā—')
4323
+ : ev.severity === 'warning' ? chalk.yellow('ā–²')
4324
+ : chalk.gray('ā—‹');
4325
+ const sev = ev.severity.toUpperCase().padEnd(8);
4326
+ const path = ev.url.replace(/https?:\/\/[^/]+/, '') || '/';
4327
+
4328
+ let desc = '';
4329
+ switch (ev.event_type) {
4330
+ case 'page_added': desc = `${path} — new page`; break;
4331
+ case 'page_removed': desc = `${path} — removed`; break;
4332
+ case 'new_error': desc = `${path} → ${ev.new_value} (was ${ev.old_value})`; break;
4333
+ case 'status_changed': desc = `${path} status ${ev.old_value} → ${ev.new_value}`; break;
4334
+ case 'title_changed': desc = `${path} title: "${(ev.old_value || '').slice(0, 30)}" → "${(ev.new_value || '').slice(0, 30)}"`; break;
4335
+ case 'h1_changed': desc = `${path} H1 changed`; break;
4336
+ case 'meta_desc_changed': desc = `${path} meta description changed`; break;
4337
+ case 'word_count_changed': desc = `${path} word count ${ev.old_value} → ${ev.new_value}`; break;
4338
+ case 'indexability_changed': desc = `${path} became ${ev.new_value}`; break;
4339
+ case 'content_changed': desc = `${path} content updated`; break;
4340
+ default: desc = `${path} — ${ev.event_type.replace(/_/g, ' ')}`;
4341
+ }
4342
+
4343
+ console.log(` ${icon} ${chalk.dim(sev)} ${desc}`);
4344
+ }
4345
+
4346
+ if (result.events.length > 25) {
4347
+ console.log(chalk.gray(` ... and ${result.events.length - 25} more`));
4348
+ }
4349
+ console.log('');
4350
+ } else {
4351
+ console.log(chalk.green(' āœ… No changes detected since last crawl.\n'));
4352
+ }
4353
+ }
4354
+ }
4355
+
4356
+ // ── Regenerate dashboard ──
4357
+ if (!isJson) {
4358
+ try {
4359
+ const configs = loadAllConfigs();
4360
+ generateMultiDashboard(db, configs);
4361
+ console.log(chalk.green(' āœ… Dashboard updated with Site Watch card\n'));
4362
+ } catch (e) {
4363
+ console.log(chalk.gray(` (Dashboard not updated: ${e.message})\n`));
4364
+ }
4365
+ }
4366
+ });
4367
+
4227
4368
  // ── GAP INTEL ────────────────────────────────────────────────────────────
4228
4369
 
4229
4370
  program
@@ -4380,7 +4521,7 @@ program
4380
4521
  const slug = opts.topic
4381
4522
  ? opts.topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
4382
4523
  : 'auto';
4383
- const filename = `${project}-blog-draft-${slug}-${Date.now()}.md`;
4524
+ const filename = `${project}-blog-draft-${slug}-${new Date().toISOString().slice(0, 10)}.md`;
4384
4525
  const outPath = join(__dirname, 'reports', filename);
4385
4526
  writeFileSync(outPath, draft, 'utf8');
4386
4527
  console.log(chalk.bold.green(` āœ… Draft saved: ${outPath}`));
package/db/db.js CHANGED
@@ -29,6 +29,51 @@ export function getDb(dbPath = './seo-intel.db') {
29
29
  // Backfill first_seen_at from crawled_at for existing rows
30
30
  _db.exec('UPDATE pages SET first_seen_at = crawled_at WHERE first_seen_at IS NULL');
31
31
 
32
+ // Site Watch tables
33
+ try {
34
+ _db.exec(`
35
+ CREATE TABLE IF NOT EXISTS watch_snapshots (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ project TEXT NOT NULL,
38
+ created_at INTEGER NOT NULL,
39
+ total_pages INTEGER NOT NULL DEFAULT 0,
40
+ health_score INTEGER,
41
+ errors_count INTEGER NOT NULL DEFAULT 0,
42
+ warnings_count INTEGER NOT NULL DEFAULT 0,
43
+ notices_count INTEGER NOT NULL DEFAULT 0
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_watch_snapshots_project ON watch_snapshots(project, created_at DESC);
46
+
47
+ CREATE TABLE IF NOT EXISTS watch_page_states (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ snapshot_id INTEGER NOT NULL REFERENCES watch_snapshots(id) ON DELETE CASCADE,
50
+ url TEXT NOT NULL,
51
+ status_code INTEGER,
52
+ title TEXT,
53
+ h1 TEXT,
54
+ meta_desc TEXT,
55
+ word_count INTEGER,
56
+ is_indexable INTEGER DEFAULT 1,
57
+ content_hash TEXT,
58
+ UNIQUE(snapshot_id, url)
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_watch_page_states_snapshot ON watch_page_states(snapshot_id);
61
+
62
+ CREATE TABLE IF NOT EXISTS watch_events (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ snapshot_id INTEGER NOT NULL REFERENCES watch_snapshots(id) ON DELETE CASCADE,
65
+ event_type TEXT NOT NULL,
66
+ severity TEXT NOT NULL,
67
+ url TEXT NOT NULL,
68
+ old_value TEXT,
69
+ new_value TEXT,
70
+ details TEXT
71
+ );
72
+ CREATE INDEX IF NOT EXISTS idx_watch_events_snapshot ON watch_events(snapshot_id);
73
+ CREATE INDEX IF NOT EXISTS idx_watch_events_type ON watch_events(event_type);
74
+ `);
75
+ } catch { /* tables already exist */ }
76
+
32
77
  // Migrate existing analyses → insights (one-time)
33
78
  _migrateAnalysesToInsights(_db);
34
79
 
@@ -49,6 +94,7 @@ function _insightFingerprint(type, item) {
49
94
  case 'positioning': raw = 'positioning'; break;
50
95
  case 'keyword_inventor': raw = item.phrase || ''; break;
51
96
  case 'citability_gap': raw = item.url || ''; break;
97
+ case 'site_watch': raw = `${item.url || ''}::${item.event_type || ''}`; break;
52
98
  default: raw = JSON.stringify(item);
53
99
  }
54
100
  return raw.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
@@ -201,6 +247,7 @@ export function getActiveInsights(db, project) {
201
247
  technical_gaps: grouped.technical_gap || [],
202
248
  positioning: grouped.positioning?.[0] || null,
203
249
  keyword_inventor: grouped.keyword_inventor || [],
250
+ site_watch: grouped.site_watch || [],
204
251
  generated_at: rows.length ? Math.max(...rows.map(r => r.last_seen)) : null,
205
252
  };
206
253
  }
@@ -548,3 +595,29 @@ export function getHeadingStructure(db, project) {
548
595
  ORDER BY d.domain, h.level
549
596
  `).all(project);
550
597
  }
598
+
599
+ // ── Site Watch ────────────────────────────────────────────────────────────
600
+
601
+ export function getLatestWatchSnapshot(db, project) {
602
+ return db.prepare(
603
+ 'SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1'
604
+ ).get(project) || null;
605
+ }
606
+
607
+ export function getWatchPageStates(db, snapshotId) {
608
+ return db.prepare(
609
+ 'SELECT * FROM watch_page_states WHERE snapshot_id = ?'
610
+ ).all(snapshotId);
611
+ }
612
+
613
+ export function getWatchEvents(db, snapshotId) {
614
+ return db.prepare(
615
+ 'SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY CASE severity WHEN \'critical\' THEN 0 WHEN \'warning\' THEN 1 ELSE 2 END, event_type'
616
+ ).all(snapshotId);
617
+ }
618
+
619
+ export function getWatchHistory(db, project, limit = 10) {
620
+ return db.prepare(
621
+ 'SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT ?'
622
+ ).all(project, limit);
623
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Minimal ZIP archive builder — zero dependencies.
3
+ * Creates valid ZIP files (uncompressed / STORE method) from an array of entries.
4
+ * Sufficient for text-based exports (MD, JSON, CSV) where deflate adds little value.
5
+ *
6
+ * Usage:
7
+ * import { createZip } from './lib/export-zip.js';
8
+ * const buf = createZip([{ name: 'report.md', content: '# Hello' }]);
9
+ * res.end(buf);
10
+ */
11
+
12
+ /**
13
+ * @param {{ name: string, content: string | Buffer }[]} entries
14
+ * @returns {Buffer} Valid ZIP archive
15
+ */
16
+ export function createZip(entries) {
17
+ const localHeaders = [];
18
+ const centralHeaders = [];
19
+ let offset = 0;
20
+
21
+ for (const entry of entries) {
22
+ const nameBytes = Buffer.from(entry.name, 'utf8');
23
+ const data = typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content;
24
+ const crc = crc32(data);
25
+
26
+ // Local file header (30 + nameLen + dataLen)
27
+ const local = Buffer.alloc(30 + nameBytes.length);
28
+ local.writeUInt32LE(0x04034b50, 0); // signature
29
+ local.writeUInt16LE(20, 4); // version needed (2.0)
30
+ local.writeUInt16LE(0, 6); // flags
31
+ local.writeUInt16LE(0, 8); // compression: STORE
32
+ local.writeUInt16LE(0, 10); // mod time
33
+ local.writeUInt16LE(0, 12); // mod date
34
+ local.writeUInt32LE(crc, 14); // crc-32
35
+ local.writeUInt32LE(data.length, 18); // compressed size
36
+ local.writeUInt32LE(data.length, 22); // uncompressed size
37
+ local.writeUInt16LE(nameBytes.length, 26); // filename length
38
+ local.writeUInt16LE(0, 28); // extra field length
39
+ nameBytes.copy(local, 30);
40
+
41
+ localHeaders.push(Buffer.concat([local, data]));
42
+
43
+ // Central directory header (46 + nameLen)
44
+ const central = Buffer.alloc(46 + nameBytes.length);
45
+ central.writeUInt32LE(0x02014b50, 0); // signature
46
+ central.writeUInt16LE(20, 4); // version made by
47
+ central.writeUInt16LE(20, 6); // version needed
48
+ central.writeUInt16LE(0, 8); // flags
49
+ central.writeUInt16LE(0, 10); // compression: STORE
50
+ central.writeUInt16LE(0, 12); // mod time
51
+ central.writeUInt16LE(0, 14); // mod date
52
+ central.writeUInt32LE(crc, 16); // crc-32
53
+ central.writeUInt32LE(data.length, 20); // compressed size
54
+ central.writeUInt32LE(data.length, 24); // uncompressed size
55
+ central.writeUInt16LE(nameBytes.length, 28); // filename length
56
+ central.writeUInt16LE(0, 30); // extra field length
57
+ central.writeUInt16LE(0, 32); // comment length
58
+ central.writeUInt16LE(0, 34); // disk number start
59
+ central.writeUInt16LE(0, 36); // internal attrs
60
+ central.writeUInt32LE(0, 38); // external attrs
61
+ central.writeUInt32LE(offset, 42); // local header offset
62
+ nameBytes.copy(central, 46);
63
+
64
+ centralHeaders.push(central);
65
+ offset += local.length + data.length;
66
+ }
67
+
68
+ const centralDir = Buffer.concat(centralHeaders);
69
+ const centralDirOffset = offset;
70
+
71
+ // End of central directory record (22 bytes)
72
+ const eocd = Buffer.alloc(22);
73
+ eocd.writeUInt32LE(0x06054b50, 0); // signature
74
+ eocd.writeUInt16LE(0, 4); // disk number
75
+ eocd.writeUInt16LE(0, 6); // disk with central dir
76
+ eocd.writeUInt16LE(entries.length, 8); // entries on this disk
77
+ eocd.writeUInt16LE(entries.length, 10); // total entries
78
+ eocd.writeUInt32LE(centralDir.length, 12); // central dir size
79
+ eocd.writeUInt32LE(centralDirOffset, 16); // central dir offset
80
+ eocd.writeUInt16LE(0, 20); // comment length
81
+
82
+ return Buffer.concat([...localHeaders, centralDir, eocd]);
83
+ }
84
+
85
+ // ─── CRC-32 (IEEE 802.3) ────────────────────────────────────────────────────
86
+
87
+ const CRC_TABLE = new Uint32Array(256);
88
+ for (let n = 0; n < 256; n++) {
89
+ let c = n;
90
+ for (let k = 0; k < 8; k++) {
91
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
92
+ }
93
+ CRC_TABLE[n] = c;
94
+ }
95
+
96
+ function crc32(buf) {
97
+ let crc = 0xFFFFFFFF;
98
+ for (let i = 0; i < buf.length; i++) {
99
+ crc = CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
100
+ }
101
+ return (crc ^ 0xFFFFFFFF) >>> 0;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",