seo-intel 1.4.2 → 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 +17 -0
- package/cli.js +14 -14
- package/lib/export-zip.js +102 -0
- package/package.json +1 -1
- package/reports/generate-html.js +106 -12
- package/server.js +310 -1
- package/setup/web-routes.js +11 -2
- package/setup/wizard.html +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.3 (2026-04-07)
|
|
4
|
+
|
|
5
|
+
### Dashboard: Export & Download
|
|
6
|
+
- Per-card download buttons (Markdown, JSON, CSV) on every dashboard card
|
|
7
|
+
- "Download All Reports (ZIP)" in export sidebar
|
|
8
|
+
- New `/api/export/download` endpoint with section filtering
|
|
9
|
+
|
|
10
|
+
### Improvements
|
|
11
|
+
- GSC data loader picks most recently modified folder (fixes stale folder selection)
|
|
12
|
+
- Report filenames use `YYYY-MM-DD` dates instead of Unix timestamps
|
|
13
|
+
- Setup wizard: multi-host Ollama support (`OLLAMA_HOSTS` env var)
|
|
14
|
+
- Skill file and Agent Guide updated with `watch`, `blog-draft`, and export features
|
|
15
|
+
|
|
16
|
+
### Cleanup
|
|
17
|
+
- Removed deprecated agentic setup banner from wizard
|
|
18
|
+
- Consolidated Agent Guide into `skill/` directory
|
|
19
|
+
|
|
3
20
|
## 1.4.2 (2026-04-05)
|
|
4
21
|
|
|
5
22
|
### New Feature: Site Watch
|
package/cli.js
CHANGED
|
@@ -742,9 +742,9 @@ program
|
|
|
742
742
|
console.log(chalk.yellow('Sending to Gemini...\n'));
|
|
743
743
|
|
|
744
744
|
// Save prompt for debugging (markdown for Obsidian/agent compatibility)
|
|
745
|
-
const promptTs = Date.
|
|
745
|
+
const promptTs = new Date().toISOString().slice(0, 10);
|
|
746
746
|
const promptPath = join(__dirname, `reports/${project}-prompt-${promptTs}.md`);
|
|
747
|
-
const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date(
|
|
747
|
+
const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date().toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
|
|
748
748
|
writeFileSync(promptPath, promptFrontmatter + prompt, 'utf8');
|
|
749
749
|
console.log(chalk.gray(`Prompt saved: ${promptPath}`));
|
|
750
750
|
|
|
@@ -764,13 +764,13 @@ program
|
|
|
764
764
|
analysis = JSON.parse(jsonMatch[0]);
|
|
765
765
|
} catch {
|
|
766
766
|
console.error(chalk.red('Could not parse JSON from response. Saving raw output.'));
|
|
767
|
-
const rawPath = join(__dirname, `reports/${project}-raw-${Date.
|
|
767
|
+
const rawPath = join(__dirname, `reports/${project}-raw-${new Date().toISOString().slice(0, 10)}.md`);
|
|
768
768
|
writeFileSync(rawPath, result, 'utf8');
|
|
769
769
|
process.exit(1);
|
|
770
770
|
}
|
|
771
771
|
|
|
772
772
|
// Save structured analysis to file
|
|
773
|
-
const outPath = join(__dirname, `reports/${project}-analysis-${Date.
|
|
773
|
+
const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
|
|
774
774
|
writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
|
|
775
775
|
|
|
776
776
|
// Save to DB (so HTML dashboard picks it up)
|
|
@@ -930,7 +930,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
|
|
|
930
930
|
data = JSON.parse(jsonMatch[0]);
|
|
931
931
|
} catch {
|
|
932
932
|
console.error(chalk.red('Could not parse JSON from Gemini response.'));
|
|
933
|
-
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`);
|
|
934
934
|
writeFileSync(rawPath, result, 'utf8');
|
|
935
935
|
console.error(chalk.gray(`Raw output saved: ${rawPath}`));
|
|
936
936
|
process.exit(1);
|
|
@@ -991,7 +991,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
|
|
|
991
991
|
}
|
|
992
992
|
|
|
993
993
|
if (opts.save) {
|
|
994
|
-
const outPath = join(__dirname, `reports/${project}-keywords-${Date.
|
|
994
|
+
const outPath = join(__dirname, `reports/${project}-keywords-${new Date().toISOString().slice(0, 10)}.json`);
|
|
995
995
|
writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf8');
|
|
996
996
|
console.log(chalk.bold.green(`✅ Report saved: ${outPath}\n`));
|
|
997
997
|
|
|
@@ -1759,7 +1759,7 @@ async function runAnalysis(project, db) {
|
|
|
1759
1759
|
try {
|
|
1760
1760
|
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
|
1761
1761
|
const analysis = JSON.parse(jsonMatch[0]);
|
|
1762
|
-
const outPath = join(__dirname, `reports/${project}-analysis-${Date.
|
|
1762
|
+
const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
|
|
1763
1763
|
writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
|
|
1764
1764
|
|
|
1765
1765
|
// Save to DB
|
|
@@ -2410,7 +2410,7 @@ program
|
|
|
2410
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`;
|
|
2411
2411
|
}
|
|
2412
2412
|
|
|
2413
|
-
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`);
|
|
2414
2414
|
writeFileSync(outPath, report, 'utf8');
|
|
2415
2415
|
|
|
2416
2416
|
console.log(chalk.bold.green(`\n✅ Full audit saved: ${outPath}`));
|
|
@@ -2672,7 +2672,7 @@ program
|
|
|
2672
2672
|
|
|
2673
2673
|
// ── Save ──
|
|
2674
2674
|
if (opts.save) {
|
|
2675
|
-
const outPath = join(__dirname, `reports/${project}-entities-${Date.
|
|
2675
|
+
const outPath = join(__dirname, `reports/${project}-entities-${new Date().toISOString().slice(0, 10)}.md`);
|
|
2676
2676
|
writeFileSync(outPath, mdOutput, 'utf8');
|
|
2677
2677
|
console.log(chalk.bold.green(` ✅ Entity map saved: ${outPath}\n`));
|
|
2678
2678
|
}
|
|
@@ -3369,7 +3369,7 @@ program
|
|
|
3369
3369
|
|
|
3370
3370
|
// ── Save ──
|
|
3371
3371
|
if (opts.save) {
|
|
3372
|
-
const outPath = join(__dirname, `reports/${project}-brief-${Date.
|
|
3372
|
+
const outPath = join(__dirname, `reports/${project}-brief-${new Date().toISOString().slice(0, 10)}.md`);
|
|
3373
3373
|
writeFileSync(outPath, mdOutput, 'utf8');
|
|
3374
3374
|
console.log(chalk.bold.green(` ✅ Brief saved: ${outPath}\n`));
|
|
3375
3375
|
}
|
|
@@ -3713,7 +3713,7 @@ program
|
|
|
3713
3713
|
}
|
|
3714
3714
|
|
|
3715
3715
|
if (opts.save) {
|
|
3716
|
-
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`);
|
|
3717
3717
|
writeFileSync(outPath, mdOutput, 'utf8');
|
|
3718
3718
|
console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
|
|
3719
3719
|
}
|
|
@@ -3859,7 +3859,7 @@ program
|
|
|
3859
3859
|
}
|
|
3860
3860
|
|
|
3861
3861
|
// Output
|
|
3862
|
-
const timestamp = Date.
|
|
3862
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
3863
3863
|
const defaultPath = join(__dirname, `reports/${project}-export-${timestamp}.${format}`);
|
|
3864
3864
|
const outPath = opts.output || defaultPath;
|
|
3865
3865
|
|
|
@@ -4237,7 +4237,7 @@ program
|
|
|
4237
4237
|
for (const p of worst) {
|
|
4238
4238
|
md += `- **${p.url}** — ${p.score}/100 (${p.tier})\n`;
|
|
4239
4239
|
}
|
|
4240
|
-
const outPath = join(__dirname, `reports/${project}-aeo-${Date.
|
|
4240
|
+
const outPath = join(__dirname, `reports/${project}-aeo-${new Date().toISOString().slice(0, 10)}.md`);
|
|
4241
4241
|
writeFileSync(outPath, md, 'utf8');
|
|
4242
4242
|
console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
|
|
4243
4243
|
}
|
|
@@ -4521,7 +4521,7 @@ program
|
|
|
4521
4521
|
const slug = opts.topic
|
|
4522
4522
|
? opts.topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
|
|
4523
4523
|
: 'auto';
|
|
4524
|
-
const filename = `${project}-blog-draft-${slug}-${Date.
|
|
4524
|
+
const filename = `${project}-blog-draft-${slug}-${new Date().toISOString().slice(0, 10)}.md`;
|
|
4525
4525
|
const outPath = join(__dirname, 'reports', filename);
|
|
4526
4526
|
writeFileSync(outPath, draft, 'utf8');
|
|
4527
4527
|
console.log(chalk.bold.green(` ✅ Draft saved: ${outPath}`));
|
|
@@ -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
package/reports/generate-html.js
CHANGED
|
@@ -24,13 +24,19 @@ import { getWatchData } from '../analyses/watch/index.js';
|
|
|
24
24
|
|
|
25
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
/** Build per-card export dropdown HTML */
|
|
28
|
+
function cardExportHtml(section, project) {
|
|
29
|
+
return `<div class="card-export" data-project="${project}" data-section="${section}">
|
|
30
|
+
<button class="card-export-btn" title="Export"><i class="fa-solid fa-download"></i></button>
|
|
31
|
+
<div class="card-export-dropdown">
|
|
32
|
+
<button data-fmt="md"><i class="fa-solid fa-file-lines"></i> Markdown</button>
|
|
33
|
+
<button data-fmt="json"><i class="fa-solid fa-code"></i> JSON</button>
|
|
34
|
+
<button data-fmt="csv"><i class="fa-solid fa-table"></i> CSV</button>
|
|
35
|
+
<button data-fmt="zip"><i class="fa-solid fa-file-zipper"></i> ZIP (all)</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
41
|
* Gather all dashboard data for a single project
|
|
36
42
|
*/
|
|
@@ -419,6 +425,32 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
419
425
|
.card.full-width {
|
|
420
426
|
grid-column: 1 / -1;
|
|
421
427
|
}
|
|
428
|
+
.card { position: relative; }
|
|
429
|
+
.card-export {
|
|
430
|
+
position: absolute; top: 10px; right: 10px; z-index: 5;
|
|
431
|
+
}
|
|
432
|
+
.card-export-btn {
|
|
433
|
+
background: var(--bg-elevated); border: 1px solid var(--border-subtle);
|
|
434
|
+
color: var(--text-muted); cursor: pointer; border-radius: var(--radius);
|
|
435
|
+
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
|
436
|
+
font-size: 0.7rem; transition: all .15s;
|
|
437
|
+
}
|
|
438
|
+
.card-export-btn:hover { color: var(--accent-gold); border-color: var(--accent-gold); }
|
|
439
|
+
.card-export-dropdown {
|
|
440
|
+
display: none; position: absolute; right: 0; top: 32px;
|
|
441
|
+
background: var(--bg-card); border: 1px solid var(--border-card);
|
|
442
|
+
border-radius: var(--radius); min-width: 150px; padding: 4px 0;
|
|
443
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
|
444
|
+
}
|
|
445
|
+
.card-export.open .card-export-dropdown { display: block; }
|
|
446
|
+
.card-export-dropdown button {
|
|
447
|
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
448
|
+
background: none; border: none; color: var(--text-secondary);
|
|
449
|
+
padding: 7px 14px; font-size: 0.72rem; cursor: pointer; text-align: left;
|
|
450
|
+
font-family: var(--font-body);
|
|
451
|
+
}
|
|
452
|
+
.card-export-dropdown button:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
|
453
|
+
.card-export-dropdown button i { width: 14px; text-align: center; color: var(--text-muted); font-size: 0.65rem; }
|
|
422
454
|
|
|
423
455
|
/* ─── Extraction Status Bar ─────────────────────────────────────────── */
|
|
424
456
|
.extraction-status {
|
|
@@ -1798,7 +1830,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
1798
1830
|
</div>`}
|
|
1799
1831
|
|
|
1800
1832
|
<!-- SITE WATCH -->
|
|
1801
|
-
${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
|
|
1833
|
+
${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
|
|
1802
1834
|
|
|
1803
1835
|
<!-- PAGE INVENTORY -->
|
|
1804
1836
|
<div class="card" style="margin-bottom:16px;">
|
|
@@ -2157,6 +2189,12 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2157
2189
|
</div>
|
|
2158
2190
|
<button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
|
|
2159
2191
|
</div>
|
|
2192
|
+
<div class="export-sidebar-header" style="margin-top:12px;">
|
|
2193
|
+
<i class="fa-solid fa-download"></i> Download
|
|
2194
|
+
</div>
|
|
2195
|
+
<div class="export-sidebar-btns">
|
|
2196
|
+
<button class="export-btn download-all-btn" data-project="${project}"><i class="fa-solid fa-file-zipper"></i> Download All Reports (ZIP)</button>
|
|
2197
|
+
</div>
|
|
2160
2198
|
<div style="position:relative;">
|
|
2161
2199
|
<div id="exportSaveStatus${suffix}" style="display:none;padding:4px 10px;font-size:.6rem;color:var(--color-success);background:rgba(80,200,120,0.06);border-bottom:1px solid rgba(80,200,120,0.15);font-family:'SF Mono',monospace;">
|
|
2162
2200
|
<i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
|
|
@@ -2516,6 +2554,49 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2516
2554
|
});
|
|
2517
2555
|
}
|
|
2518
2556
|
|
|
2557
|
+
// ── Card export dropdowns (global — run once, capture phase) ──
|
|
2558
|
+
if (!window._cardExportBound) {
|
|
2559
|
+
window._cardExportBound = true;
|
|
2560
|
+
document.addEventListener('click', function(e) {
|
|
2561
|
+
var btn = e.target.closest('.card-export-btn');
|
|
2562
|
+
var fmtBtn = e.target.closest('[data-fmt]');
|
|
2563
|
+
if (btn) {
|
|
2564
|
+
var wrap = btn.closest('.card-export');
|
|
2565
|
+
if (wrap) {
|
|
2566
|
+
e.stopImmediatePropagation();
|
|
2567
|
+
var wasOpen = wrap.classList.contains('open');
|
|
2568
|
+
document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
|
|
2569
|
+
if (!wasOpen) wrap.classList.add('open');
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (fmtBtn) {
|
|
2574
|
+
var wrap2 = fmtBtn.closest('.card-export');
|
|
2575
|
+
if (wrap2) {
|
|
2576
|
+
e.stopImmediatePropagation();
|
|
2577
|
+
wrap2.classList.remove('open');
|
|
2578
|
+
var sec = wrap2.getAttribute('data-section');
|
|
2579
|
+
var proj2 = wrap2.getAttribute('data-project');
|
|
2580
|
+
var fmt = fmtBtn.getAttribute('data-fmt');
|
|
2581
|
+
if (window.location.protocol.startsWith('http')) {
|
|
2582
|
+
window.location = '/api/export/download?project=' + encodeURIComponent(proj2) + '§ion=' + encodeURIComponent(sec) + '&format=' + encodeURIComponent(fmt);
|
|
2583
|
+
}
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
var dlBtn = e.target.closest('.download-all-btn');
|
|
2588
|
+
if (dlBtn) {
|
|
2589
|
+
var proj3 = dlBtn.getAttribute('data-project');
|
|
2590
|
+
if (window.location.protocol.startsWith('http')) {
|
|
2591
|
+
window.location = '/api/export/download?project=' + encodeURIComponent(proj3) + '§ion=all&format=zip';
|
|
2592
|
+
}
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
// Outside click — close all open dropdowns
|
|
2596
|
+
document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
|
|
2597
|
+
}, true);
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2519
2600
|
// Input enter
|
|
2520
2601
|
input.addEventListener('keydown', function(e) {
|
|
2521
2602
|
if (e.key !== 'Enter') return;
|
|
@@ -2779,7 +2860,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2779
2860
|
})() : ''}
|
|
2780
2861
|
|
|
2781
2862
|
<!-- ═══ SITE WATCH ═══ -->
|
|
2782
|
-
${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
|
|
2863
|
+
${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
|
|
2783
2864
|
|
|
2784
2865
|
<div class="section-divider">
|
|
2785
2866
|
<div class="section-divider-line right"></div>
|
|
@@ -2835,6 +2916,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2835
2916
|
|
|
2836
2917
|
<!-- ═══ HEADING DEPTH FLOW ═══ -->
|
|
2837
2918
|
<div class="card full-width" id="heading-flow">
|
|
2919
|
+
${cardExportHtml('headings', project)}
|
|
2838
2920
|
<h2><span class="icon"><i class="fa-solid fa-water"></i></span> Heading Depth Flow</h2>
|
|
2839
2921
|
<canvas id="headingFlowCanvas${suffix}" width="1100" height="320"></canvas>
|
|
2840
2922
|
</div>
|
|
@@ -2862,6 +2944,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2862
2944
|
<!-- ═══ ENTITY TOPIC MAP ═══ -->
|
|
2863
2945
|
${pro && entityTopicMap.hasData ? `
|
|
2864
2946
|
<div class="card full-width" id="entity-map">
|
|
2947
|
+
${cardExportHtml('insights', project)}
|
|
2865
2948
|
<h2><span class="icon"><i class="fa-solid fa-map"></i></span> Entity Topic Map</h2>
|
|
2866
2949
|
<div class="entity-map-grid">
|
|
2867
2950
|
${Object.entries(entityTopicMap.domainEntities).map(([domain, data]) => `
|
|
@@ -2886,6 +2969,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2886
2969
|
<!-- ═══ KEYWORD BATTLEGROUND ═══ -->
|
|
2887
2970
|
${pro ? `
|
|
2888
2971
|
<div class="card full-width" id="keyword-heatmap">
|
|
2972
|
+
${cardExportHtml('keywords', project)}
|
|
2889
2973
|
<h2><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Keyword Battleground</h2>
|
|
2890
2974
|
${keywordHeatmap.keywords.length ? `
|
|
2891
2975
|
<div class="table-wrapper">
|
|
@@ -2930,6 +3014,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2930
3014
|
|
|
2931
3015
|
<!-- ═══ TECHNICAL SEO SCORECARD ═══ -->
|
|
2932
3016
|
<div class="card full-width" id="technical-seo">
|
|
3017
|
+
${cardExportHtml('technical', project)}
|
|
2933
3018
|
<h2><span class="icon"><i class="fa-solid fa-gear"></i></span> Technical SEO Scorecard</h2>
|
|
2934
3019
|
<div class="scorecard-grid">
|
|
2935
3020
|
${technicalScores.map(ts => {
|
|
@@ -2998,6 +3083,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2998
3083
|
<!-- ═══ TECHNICAL SEO GAPS ═══ -->
|
|
2999
3084
|
${pro && latestAnalysis?.technical_gaps?.length ? `
|
|
3000
3085
|
<div class="card full-width" id="technical-gaps">
|
|
3086
|
+
${cardExportHtml('technical', project)}
|
|
3001
3087
|
<h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
|
|
3002
3088
|
<div class="analysis-table-wrap">
|
|
3003
3089
|
<table class="analysis-table">
|
|
@@ -3026,6 +3112,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3026
3112
|
<!-- ═══ QUICK WINS ═══ -->
|
|
3027
3113
|
${pro && latestAnalysis?.quick_wins?.length ? `
|
|
3028
3114
|
<div class="card" id="quick-wins">
|
|
3115
|
+
${cardExportHtml('insights', project)}
|
|
3029
3116
|
<h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
|
|
3030
3117
|
<div class="analysis-table-wrap">
|
|
3031
3118
|
<table class="analysis-table">
|
|
@@ -3048,6 +3135,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3048
3135
|
<!-- ═══ NEW PAGES TO CREATE ═══ -->
|
|
3049
3136
|
${pro && latestAnalysis?.new_pages?.length ? `
|
|
3050
3137
|
<div class="card" id="new-pages">
|
|
3138
|
+
${cardExportHtml('pages', project)}
|
|
3051
3139
|
<h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
|
|
3052
3140
|
<div class="new-pages-grid" style="grid-template-columns: 1fr;">
|
|
3053
3141
|
${(latestAnalysis.new_pages).map(np => `
|
|
@@ -3100,6 +3188,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3100
3188
|
<!-- ═══ CONTENT GAPS ═══ -->
|
|
3101
3189
|
${pro && latestAnalysis?.content_gaps?.length ? `
|
|
3102
3190
|
<div class="card full-width" id="content-gaps">
|
|
3191
|
+
${cardExportHtml('insights', project)}
|
|
3103
3192
|
<h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
|
|
3104
3193
|
<div class="insights-grid">
|
|
3105
3194
|
${(latestAnalysis.content_gaps).map(gap => `
|
|
@@ -3308,6 +3397,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3308
3397
|
<!-- ═══ SCHEMA TYPE BREAKDOWN ═══ -->
|
|
3309
3398
|
${schemaBreakdown.hasData ? `
|
|
3310
3399
|
<div class="card" id="schema-breakdown">
|
|
3400
|
+
${cardExportHtml('schemas', project)}
|
|
3311
3401
|
<h2><span class="icon"><i class="fa-solid fa-code"></i></span> Schema Markup Breakdown</h2>
|
|
3312
3402
|
<div class="table-wrapper">
|
|
3313
3403
|
<table>
|
|
@@ -3334,6 +3424,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3334
3424
|
<!-- ═══ TOP KEYWORDS ═══ -->
|
|
3335
3425
|
${pro ? `
|
|
3336
3426
|
<div class="card" id="top-keywords">
|
|
3427
|
+
${cardExportHtml('keywords', project)}
|
|
3337
3428
|
<h2><span class="icon"><i class="fa-solid fa-key"></i></span> Top Keywords (${targetDomain})</h2>
|
|
3338
3429
|
${keywords.length ? `
|
|
3339
3430
|
<div class="table-wrapper" style="max-height: 400px; overflow-y: auto;">
|
|
@@ -3369,6 +3460,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3369
3460
|
|
|
3370
3461
|
<!-- ═══ INTERNAL LINK ANALYSIS ═══ -->
|
|
3371
3462
|
<div class="card" id="internal-links">
|
|
3463
|
+
${cardExportHtml('links', project)}
|
|
3372
3464
|
<h2><span class="icon"><i class="fa-solid fa-link"></i></span> Internal Link Analysis</h2>
|
|
3373
3465
|
<div class="stat-row">
|
|
3374
3466
|
<div class="stat-box">
|
|
@@ -3407,7 +3499,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3407
3499
|
</div>` : ''}
|
|
3408
3500
|
|
|
3409
3501
|
<!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
|
|
3410
|
-
${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
|
|
3502
|
+
${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml, project) : ''}
|
|
3411
3503
|
|
|
3412
3504
|
<!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
|
|
3413
3505
|
${pro && latestAnalysis?.long_tails?.length ? `
|
|
@@ -5232,7 +5324,7 @@ function buildMultiHtmlTemplate(allProjectData) {
|
|
|
5232
5324
|
|
|
5233
5325
|
// ─── AEO Card Builder ────────────────────────────────────────────────────────
|
|
5234
5326
|
|
|
5235
|
-
function buildAeoCard(citabilityData, escapeHtml) {
|
|
5327
|
+
function buildAeoCard(citabilityData, escapeHtml, project) {
|
|
5236
5328
|
const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
|
|
5237
5329
|
const compScores = citabilityData.filter(s => s.role === 'competitor');
|
|
5238
5330
|
if (!targetScores.length) return '';
|
|
@@ -5296,6 +5388,7 @@ function buildAeoCard(citabilityData, escapeHtml) {
|
|
|
5296
5388
|
|
|
5297
5389
|
return `
|
|
5298
5390
|
<div class="card full-width" id="aeo-citability">
|
|
5391
|
+
${cardExportHtml('aeo', project)}
|
|
5299
5392
|
<h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
|
|
5300
5393
|
<div class="ki-stat-bar">
|
|
5301
5394
|
<div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
|
|
@@ -6886,7 +6979,7 @@ function getGscInsights(gscData, db, project) {
|
|
|
6886
6979
|
// SITE WATCH CARD
|
|
6887
6980
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
6888
6981
|
|
|
6889
|
-
function buildWatchCard(watchData, escapeHtml) {
|
|
6982
|
+
function buildWatchCard(watchData, escapeHtml, project) {
|
|
6890
6983
|
const { current, previous, events, trend } = watchData;
|
|
6891
6984
|
const score = current.health_score ?? 0;
|
|
6892
6985
|
const scoreColor = score >= 80 ? 'var(--color-success)' : score >= 60 ? 'var(--color-warning)' : 'var(--color-danger)';
|
|
@@ -6942,6 +7035,7 @@ function buildWatchCard(watchData, escapeHtml) {
|
|
|
6942
7035
|
|
|
6943
7036
|
return `
|
|
6944
7037
|
<div class="card" style="margin-bottom:16px;">
|
|
7038
|
+
${cardExportHtml('watch', project)}
|
|
6945
7039
|
<h2><span class="icon"><i class="fa-solid fa-eye"></i></span> Site Watch</h2>
|
|
6946
7040
|
|
|
6947
7041
|
<!-- Health Score + Summary -->
|
package/server.js
CHANGED
|
@@ -96,6 +96,8 @@ const MIME = {
|
|
|
96
96
|
'.png': 'image/png',
|
|
97
97
|
'.svg': 'image/svg+xml',
|
|
98
98
|
'.md': 'text/markdown; charset=utf-8',
|
|
99
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
100
|
+
'.zip': 'application/zip',
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
// ── Read progress with PID liveness check (mirrors cli.js) ──
|
|
@@ -449,7 +451,7 @@ async function handleRequest(req, res) {
|
|
|
449
451
|
|
|
450
452
|
try {
|
|
451
453
|
const data = JSON.parse(rawJson);
|
|
452
|
-
const stamp = Date.
|
|
454
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
453
455
|
const baseName = `${project}-actions-${stamp}`;
|
|
454
456
|
writeFileSync(join(REPORTS_DIR, `${baseName}.json`), JSON.stringify(data, null, 2), 'utf8');
|
|
455
457
|
writeFileSync(join(REPORTS_DIR, `${baseName}.md`), buildActionsMarkdown(data), 'utf8');
|
|
@@ -586,6 +588,313 @@ async function handleRequest(req, res) {
|
|
|
586
588
|
return;
|
|
587
589
|
}
|
|
588
590
|
|
|
591
|
+
// ─── API: Universal Export Download ───
|
|
592
|
+
if (req.method === 'GET' && path === '/api/export/download') {
|
|
593
|
+
try {
|
|
594
|
+
const project = url.searchParams.get('project');
|
|
595
|
+
const section = url.searchParams.get('section') || 'all';
|
|
596
|
+
const format = url.searchParams.get('format') || 'json';
|
|
597
|
+
|
|
598
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
599
|
+
|
|
600
|
+
const { getDb } = await import('./db/db.js');
|
|
601
|
+
const db = getDb(join(__dirname, 'seo-intel.db'));
|
|
602
|
+
const configPath = join(__dirname, 'config', `${project}.json`);
|
|
603
|
+
const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
|
|
604
|
+
|
|
605
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
606
|
+
const { createZip } = await import('./lib/export-zip.js');
|
|
607
|
+
|
|
608
|
+
const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
|
|
609
|
+
|
|
610
|
+
function querySection(sec) {
|
|
611
|
+
switch (sec) {
|
|
612
|
+
case 'aeo': {
|
|
613
|
+
try {
|
|
614
|
+
return db.prepare(`
|
|
615
|
+
SELECT cs.score, cs.entity_authority, cs.structured_claims, cs.answer_density,
|
|
616
|
+
cs.qa_proximity, cs.freshness, cs.schema_coverage, cs.tier, cs.ai_intents,
|
|
617
|
+
p.url, p.title, p.word_count, d.domain, d.role
|
|
618
|
+
FROM citability_scores cs
|
|
619
|
+
JOIN pages p ON p.id = cs.page_id
|
|
620
|
+
JOIN domains d ON d.id = p.domain_id
|
|
621
|
+
WHERE d.project = ?
|
|
622
|
+
ORDER BY d.role ASC, cs.score ASC
|
|
623
|
+
`).all(project);
|
|
624
|
+
} catch { return []; }
|
|
625
|
+
}
|
|
626
|
+
case 'insights': {
|
|
627
|
+
try {
|
|
628
|
+
const rows = db.prepare(
|
|
629
|
+
`SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
|
|
630
|
+
).all(project);
|
|
631
|
+
return rows.map(r => {
|
|
632
|
+
try { return { ...JSON.parse(r.data), _type: r.type, _id: r.id, _first_seen: r.first_seen, _last_seen: r.last_seen }; }
|
|
633
|
+
catch { return { _type: r.type, _id: r.id, raw: r.data }; }
|
|
634
|
+
});
|
|
635
|
+
} catch { return []; }
|
|
636
|
+
}
|
|
637
|
+
case 'technical': {
|
|
638
|
+
try {
|
|
639
|
+
return db.prepare(`
|
|
640
|
+
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
641
|
+
t.has_canonical, t.has_og_tags, t.has_schema, t.has_robots, t.is_mobile_ok,
|
|
642
|
+
d.domain, d.role
|
|
643
|
+
FROM pages p
|
|
644
|
+
JOIN domains d ON d.id = p.domain_id
|
|
645
|
+
LEFT JOIN technical t ON t.page_id = p.id
|
|
646
|
+
WHERE d.project = ?
|
|
647
|
+
ORDER BY d.domain, p.url
|
|
648
|
+
`).all(project);
|
|
649
|
+
} catch { return []; }
|
|
650
|
+
}
|
|
651
|
+
case 'keywords': {
|
|
652
|
+
try {
|
|
653
|
+
return db.prepare(`
|
|
654
|
+
SELECT k.keyword, d.domain, d.role, k.location, COUNT(*) as freq
|
|
655
|
+
FROM keywords k
|
|
656
|
+
JOIN pages p ON p.id = k.page_id
|
|
657
|
+
JOIN domains d ON d.id = p.domain_id
|
|
658
|
+
WHERE d.project = ?
|
|
659
|
+
GROUP BY k.keyword, d.domain
|
|
660
|
+
ORDER BY freq DESC
|
|
661
|
+
`).all(project);
|
|
662
|
+
} catch { return []; }
|
|
663
|
+
}
|
|
664
|
+
case 'pages': {
|
|
665
|
+
try {
|
|
666
|
+
return db.prepare(`
|
|
667
|
+
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
668
|
+
p.title, p.meta_desc, p.published_date, p.modified_date,
|
|
669
|
+
p.crawled_at, p.first_seen_at, d.domain, d.role
|
|
670
|
+
FROM pages p
|
|
671
|
+
JOIN domains d ON d.id = p.domain_id
|
|
672
|
+
WHERE d.project = ?
|
|
673
|
+
ORDER BY d.domain, p.url
|
|
674
|
+
`).all(project);
|
|
675
|
+
} catch { return []; }
|
|
676
|
+
}
|
|
677
|
+
case 'watch': {
|
|
678
|
+
try {
|
|
679
|
+
const snap = db.prepare('SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1').get(project);
|
|
680
|
+
if (!snap) return [];
|
|
681
|
+
const events = db.prepare('SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY severity, event_type').all(snap.id);
|
|
682
|
+
const pages = db.prepare('SELECT * FROM watch_page_states WHERE snapshot_id = ?').all(snap.id);
|
|
683
|
+
return { snapshot: snap, events, pages };
|
|
684
|
+
} catch { return []; }
|
|
685
|
+
}
|
|
686
|
+
case 'schemas': {
|
|
687
|
+
try {
|
|
688
|
+
return db.prepare(`
|
|
689
|
+
SELECT d.domain, d.role, p.url, ps.schema_type, ps.name, ps.description,
|
|
690
|
+
ps.rating, ps.rating_count, ps.price, ps.currency, ps.author,
|
|
691
|
+
ps.date_published, ps.date_modified
|
|
692
|
+
FROM page_schemas ps
|
|
693
|
+
JOIN pages p ON p.id = ps.page_id
|
|
694
|
+
JOIN domains d ON d.id = p.domain_id
|
|
695
|
+
WHERE d.project = ?
|
|
696
|
+
ORDER BY d.domain, ps.schema_type
|
|
697
|
+
`).all(project);
|
|
698
|
+
} catch { return []; }
|
|
699
|
+
}
|
|
700
|
+
case 'headings': {
|
|
701
|
+
try {
|
|
702
|
+
return db.prepare(`
|
|
703
|
+
SELECT d.domain, d.role, p.url, h.level, h.text
|
|
704
|
+
FROM headings h
|
|
705
|
+
JOIN pages p ON p.id = h.page_id
|
|
706
|
+
JOIN domains d ON d.id = p.domain_id
|
|
707
|
+
WHERE d.project = ?
|
|
708
|
+
ORDER BY d.domain, p.url, h.level
|
|
709
|
+
`).all(project);
|
|
710
|
+
} catch { return []; }
|
|
711
|
+
}
|
|
712
|
+
case 'links': {
|
|
713
|
+
try {
|
|
714
|
+
return db.prepare(`
|
|
715
|
+
SELECT l.source_page_id, l.target_url, l.anchor_text, l.is_internal,
|
|
716
|
+
p.url as source_url, d.domain, d.role
|
|
717
|
+
FROM links l
|
|
718
|
+
JOIN pages p ON p.id = l.source_page_id
|
|
719
|
+
JOIN domains d ON d.id = p.domain_id
|
|
720
|
+
WHERE d.project = ?
|
|
721
|
+
ORDER BY d.domain, p.url
|
|
722
|
+
`).all(project);
|
|
723
|
+
} catch { return []; }
|
|
724
|
+
}
|
|
725
|
+
default: return [];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function toCSV(rows) {
|
|
730
|
+
if (!rows || (Array.isArray(rows) && !rows.length)) return '';
|
|
731
|
+
const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
|
|
732
|
+
if (!arr.length) return '';
|
|
733
|
+
const keys = Object.keys(arr[0]);
|
|
734
|
+
const escape = (v) => {
|
|
735
|
+
if (v == null) return '';
|
|
736
|
+
const s = String(v);
|
|
737
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
738
|
+
};
|
|
739
|
+
return [keys.join(','), ...arr.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function toMarkdown(sec, data, proj) {
|
|
743
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
744
|
+
const header = `# SEO Intel — ${sec.charAt(0).toUpperCase() + sec.slice(1)} Export\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
|
|
745
|
+
if (!data || (Array.isArray(data) && !data.length)) return header + '_No data available._\n';
|
|
746
|
+
|
|
747
|
+
switch (sec) {
|
|
748
|
+
case 'aeo': {
|
|
749
|
+
const targetRows = data.filter(r => r.role === 'target' || r.role === 'owned');
|
|
750
|
+
const avg = targetRows.length ? Math.round(targetRows.reduce((a, r) => a + r.score, 0) / targetRows.length) : 0;
|
|
751
|
+
let md = header + `## Summary\n\n- Pages scored: ${data.length}\n- Target average: ${avg}/100\n\n`;
|
|
752
|
+
md += '## Page Scores\n\n| Score | Tier | URL | Title | Weakest Signals |\n|-------|------|-----|-------|-----------------|\n';
|
|
753
|
+
for (const r of data) {
|
|
754
|
+
const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
|
|
755
|
+
const weakest = signals.sort((a, b) => (r[a] || 0) - (r[b] || 0)).slice(0, 2).map(s => s.replace(/_/g, ' ')).join(', ');
|
|
756
|
+
md += `| ${r.score} | ${r.tier} | ${r.url} | ${(r.title || '').slice(0, 50)} | ${weakest} |\n`;
|
|
757
|
+
}
|
|
758
|
+
return md;
|
|
759
|
+
}
|
|
760
|
+
case 'insights': {
|
|
761
|
+
let md = header + `## Active Insights (${data.length})\n\n`;
|
|
762
|
+
const grouped = {};
|
|
763
|
+
for (const r of data) { (grouped[r._type] ||= []).push(r); }
|
|
764
|
+
for (const [type, items] of Object.entries(grouped)) {
|
|
765
|
+
md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
|
|
766
|
+
for (const item of items) {
|
|
767
|
+
const desc = item.phrase || item.keyword || item.title || item.page || item.message || JSON.stringify(item).slice(0, 120);
|
|
768
|
+
md += `- ${desc}\n`;
|
|
769
|
+
}
|
|
770
|
+
md += '\n';
|
|
771
|
+
}
|
|
772
|
+
return md;
|
|
773
|
+
}
|
|
774
|
+
case 'technical': {
|
|
775
|
+
let md = header + '## Technical Audit\n\n| URL | Status | Words | Load ms | Canonical | OG | Schema | Robots | Mobile |\n|-----|--------|-------|---------|-----------|-----|--------|--------|--------|\n';
|
|
776
|
+
for (const r of data) {
|
|
777
|
+
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${r.load_ms || 0} | ${r.has_canonical ? 'Y' : 'N'} | ${r.has_og_tags ? 'Y' : 'N'} | ${r.has_schema ? 'Y' : 'N'} | ${r.has_robots ? 'Y' : 'N'} | ${r.is_mobile_ok ? 'Y' : 'N'} |\n`;
|
|
778
|
+
}
|
|
779
|
+
return md;
|
|
780
|
+
}
|
|
781
|
+
case 'keywords': {
|
|
782
|
+
let md = header + '## Keyword Matrix\n\n| Keyword | Domain | Role | Location | Frequency |\n|---------|--------|------|----------|-----------|\n';
|
|
783
|
+
for (const r of data.slice(0, 500)) {
|
|
784
|
+
md += `| ${r.keyword} | ${r.domain} | ${r.role} | ${r.location || ''} | ${r.freq} |\n`;
|
|
785
|
+
}
|
|
786
|
+
if (data.length > 500) md += `\n_...and ${data.length - 500} more rows._\n`;
|
|
787
|
+
return md;
|
|
788
|
+
}
|
|
789
|
+
case 'pages': {
|
|
790
|
+
let md = header + '## Crawled Pages\n\n| URL | Status | Words | Title | Domain | Role |\n|-----|--------|-------|-------|--------|------|\n';
|
|
791
|
+
for (const r of data) {
|
|
792
|
+
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${(r.title || '').slice(0, 50)} | ${r.domain} | ${r.role} |\n`;
|
|
793
|
+
}
|
|
794
|
+
return md;
|
|
795
|
+
}
|
|
796
|
+
case 'watch': {
|
|
797
|
+
const snap = data.snapshot || {};
|
|
798
|
+
const events = data.events || [];
|
|
799
|
+
let md = header + `## Site Watch Snapshot\n\n- Health score: ${snap.health_score ?? 'N/A'}\n- Pages: ${snap.total_pages || 0}\n- Errors: ${snap.errors_count || 0} | Warnings: ${snap.warnings_count || 0} | Notices: ${snap.notices_count || 0}\n\n`;
|
|
800
|
+
if (events.length) {
|
|
801
|
+
md += '## Events\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n';
|
|
802
|
+
for (const e of events) {
|
|
803
|
+
md += `| ${e.event_type} | ${e.severity} | ${e.url} | ${(e.details || '').slice(0, 80)} |\n`;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return md;
|
|
807
|
+
}
|
|
808
|
+
case 'schemas': {
|
|
809
|
+
let md = header + '## Schema Markup\n\n| Domain | URL | Type | Name | Rating | Price |\n|--------|-----|------|------|--------|-------|\n';
|
|
810
|
+
for (const r of data) {
|
|
811
|
+
md += `| ${r.domain} | ${r.url} | ${r.schema_type} | ${(r.name || '').slice(0, 40)} | ${r.rating || ''} | ${r.price ? r.currency + r.price : ''} |\n`;
|
|
812
|
+
}
|
|
813
|
+
return md;
|
|
814
|
+
}
|
|
815
|
+
case 'headings': {
|
|
816
|
+
let md = header + '## Heading Structure\n\n| Domain | URL | Level | Text |\n|--------|-----|-------|------|\n';
|
|
817
|
+
for (const r of data.slice(0, 1000)) {
|
|
818
|
+
md += `| ${r.domain} | ${r.url} | H${r.level} | ${(r.text || '').slice(0, 80)} |\n`;
|
|
819
|
+
}
|
|
820
|
+
if (data.length > 1000) md += `\n_...and ${data.length - 1000} more rows._\n`;
|
|
821
|
+
return md;
|
|
822
|
+
}
|
|
823
|
+
case 'links': {
|
|
824
|
+
let md = header + '## Internal Links\n\n| Source | Target | Anchor |\n|--------|--------|--------|\n';
|
|
825
|
+
for (const r of data.filter(l => l.is_internal).slice(0, 1000)) {
|
|
826
|
+
md += `| ${r.source_url} | ${r.target_url} | ${(r.anchor_text || '').slice(0, 50)} |\n`;
|
|
827
|
+
}
|
|
828
|
+
if (data.length > 1000) md += `\n_...and more rows._\n`;
|
|
829
|
+
return md;
|
|
830
|
+
}
|
|
831
|
+
default: {
|
|
832
|
+
return header + '```json\n' + JSON.stringify(data, null, 2).slice(0, 10000) + '\n```\n';
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Build response based on section + format
|
|
838
|
+
const sections = section === 'all' ? SECTIONS : [section];
|
|
839
|
+
if (section !== 'all' && !SECTIONS.includes(section)) {
|
|
840
|
+
json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (format === 'zip') {
|
|
845
|
+
// ZIP: bundle all requested sections in all formats
|
|
846
|
+
const entries = [];
|
|
847
|
+
for (const sec of sections) {
|
|
848
|
+
const data = querySection(sec);
|
|
849
|
+
const baseName = `${project}-${sec}-${dateStr}`;
|
|
850
|
+
entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
|
|
851
|
+
entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
|
|
852
|
+
const csv = toCSV(data);
|
|
853
|
+
if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
|
|
854
|
+
}
|
|
855
|
+
const zipBuf = createZip(entries);
|
|
856
|
+
const zipName = section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`;
|
|
857
|
+
res.writeHead(200, {
|
|
858
|
+
'Content-Type': 'application/zip',
|
|
859
|
+
'Content-Disposition': `attachment; filename="${zipName}"`,
|
|
860
|
+
'Content-Length': zipBuf.length,
|
|
861
|
+
});
|
|
862
|
+
res.end(zipBuf);
|
|
863
|
+
} else if (format === 'json') {
|
|
864
|
+
const data = querySection(sections[0]);
|
|
865
|
+
const fileName = `${project}-${sections[0]}-${dateStr}.json`;
|
|
866
|
+
const content = JSON.stringify(data, null, 2);
|
|
867
|
+
res.writeHead(200, {
|
|
868
|
+
'Content-Type': 'application/json',
|
|
869
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
870
|
+
});
|
|
871
|
+
res.end(content);
|
|
872
|
+
} else if (format === 'csv') {
|
|
873
|
+
const data = querySection(sections[0]);
|
|
874
|
+
const fileName = `${project}-${sections[0]}-${dateStr}.csv`;
|
|
875
|
+
res.writeHead(200, {
|
|
876
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
877
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
878
|
+
});
|
|
879
|
+
res.end(toCSV(data));
|
|
880
|
+
} else if (format === 'md') {
|
|
881
|
+
const data = querySection(sections[0]);
|
|
882
|
+
const fileName = `${project}-${sections[0]}-${dateStr}.md`;
|
|
883
|
+
res.writeHead(200, {
|
|
884
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
885
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
886
|
+
});
|
|
887
|
+
res.end(toMarkdown(sections[0], data, project));
|
|
888
|
+
} else {
|
|
889
|
+
json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
|
|
890
|
+
}
|
|
891
|
+
} catch (e) {
|
|
892
|
+
console.error('[export/download]', e);
|
|
893
|
+
json(res, 500, { error: e.message });
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
589
898
|
// ─── API: SSE Terminal — stream command output ───
|
|
590
899
|
if (req.method === 'GET' && path === '/api/terminal') {
|
|
591
900
|
const params = url.searchParams;
|
package/setup/web-routes.js
CHANGED
|
@@ -410,8 +410,17 @@ async function handleEnv(req, res) {
|
|
|
410
410
|
return;
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
-
|
|
414
|
-
|
|
413
|
+
// saveModelsModule sends raw env var names (OLLAMA_MODEL, ANALYSIS_PROVIDER, etc.)
|
|
414
|
+
// while updateEnvForSetup expects camelCase. Write raw env vars directly.
|
|
415
|
+
for (const [key, value] of Object.entries(keys)) {
|
|
416
|
+
if (/^[A-Z_]+$/.test(key) && value) {
|
|
417
|
+
writeEnvKey(key, String(value));
|
|
418
|
+
process.env[key] = String(value);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const envPath = join(ROOT, '.env');
|
|
423
|
+
jsonResponse(res, { success: true, path: envPath });
|
|
415
424
|
} catch (err) {
|
|
416
425
|
jsonResponse(res, { error: err.message }, 500);
|
|
417
426
|
}
|
package/setup/wizard.html
CHANGED
|
@@ -3152,6 +3152,10 @@ input::placeholder {
|
|
|
3152
3152
|
if (state.selectedAnalysisProvider && state.selectedAnalysisModel) {
|
|
3153
3153
|
payload.ANALYSIS_PROVIDER = state.selectedAnalysisProvider;
|
|
3154
3154
|
payload.ANALYSIS_MODEL = state.selectedAnalysisModel;
|
|
3155
|
+
} else if (state.selectedAnalysis) {
|
|
3156
|
+
// Local Ollama analysis model — provider/model not set separately
|
|
3157
|
+
payload.ANALYSIS_PROVIDER = 'ollama';
|
|
3158
|
+
payload.ANALYSIS_MODEL = state.selectedAnalysis;
|
|
3155
3159
|
}
|
|
3156
3160
|
|
|
3157
3161
|
const cloudInput = document.getElementById('cloudApiKeyInput');
|