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/CHANGELOG.md +30 -0
- package/analyses/watch/diff.js +158 -0
- package/analyses/watch/health.js +78 -0
- package/analyses/watch/index.js +215 -0
- package/cli.js +155 -14
- package/db/db.js +73 -0
- package/lib/export-zip.js +102 -0
- package/package.json +1 -1
- package/reports/generate-html.js +253 -11
- package/reports/gsc-loader.js +14 -4
- package/server.js +311 -2
- package/setup/checks.js +9 -2
- package/setup/web-routes.js +37 -3
- package/setup/wizard.html +484 -323
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|