seo-intel 1.4.2 → 1.4.4
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 +29 -0
- package/cli.js +14 -14
- package/lib/export-zip.js +102 -0
- package/package.json +1 -1
- package/reports/generate-html.js +169 -12
- package/server.js +504 -1
- package/setup/web-routes.js +11 -2
- package/setup/wizard.html +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.4 (2026-04-08)
|
|
4
|
+
|
|
5
|
+
### Export Profiles
|
|
6
|
+
- New profile-based export: Developer, Content, and AI Pipeline profiles
|
|
7
|
+
- Each profile filters to actionable data only — no raw database dumps
|
|
8
|
+
- Developer profile: technical issues, heading problems (own site only), orphan links, schema gaps
|
|
9
|
+
- Content profile: keyword gaps, long-tail opportunities, citability issues, content gaps
|
|
10
|
+
- AI Pipeline profile: structured JSON with all actionable sections for LLM consumption
|
|
11
|
+
- Heading export collapsed to per-page issue summaries (missing H1, duplicate H1, skipped levels)
|
|
12
|
+
- Empty sections automatically skipped in exports
|
|
13
|
+
- Profile picker UI in dashboard sidebar with format selector (MD, JSON, CSV, ZIP)
|
|
14
|
+
|
|
15
|
+
## 1.4.3 (2026-04-07)
|
|
16
|
+
|
|
17
|
+
### Dashboard: Export & Download
|
|
18
|
+
- Per-card download buttons (Markdown, JSON, CSV) on every dashboard card
|
|
19
|
+
- "Download All Reports (ZIP)" in export sidebar
|
|
20
|
+
- New `/api/export/download` endpoint with section filtering
|
|
21
|
+
|
|
22
|
+
### Improvements
|
|
23
|
+
- GSC data loader picks most recently modified folder (fixes stale folder selection)
|
|
24
|
+
- Report filenames use `YYYY-MM-DD` dates instead of Unix timestamps
|
|
25
|
+
- Setup wizard: multi-host Ollama support (`OLLAMA_HOSTS` env var)
|
|
26
|
+
- Skill file and Agent Guide updated with `watch`, `blog-draft`, and export features
|
|
27
|
+
|
|
28
|
+
### Cleanup
|
|
29
|
+
- Removed deprecated agentic setup banner from wizard
|
|
30
|
+
- Consolidated Agent Guide into `skill/` directory
|
|
31
|
+
|
|
3
32
|
## 1.4.2 (2026-04-05)
|
|
4
33
|
|
|
5
34
|
### 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 {
|
|
@@ -1493,6 +1525,20 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
1493
1525
|
.export-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
|
|
1494
1526
|
.export-btn i { margin-right: 5px; font-size: 0.6rem; }
|
|
1495
1527
|
.export-btn.active { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(232,213,163,0.06); }
|
|
1528
|
+
.profile-export-picker { position: relative; }
|
|
1529
|
+
.profile-export-trigger { display: flex; align-items: center; width: 100%; }
|
|
1530
|
+
.profile-export-menu {
|
|
1531
|
+
display: none;
|
|
1532
|
+
position: absolute;
|
|
1533
|
+
bottom: calc(100% + 4px);
|
|
1534
|
+
left: 0; right: 0;
|
|
1535
|
+
background: #1a1a1a;
|
|
1536
|
+
border: 1px solid var(--accent-gold);
|
|
1537
|
+
border-radius: var(--radius);
|
|
1538
|
+
padding: 10px;
|
|
1539
|
+
z-index: 50;
|
|
1540
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
1541
|
+
}
|
|
1496
1542
|
.draft-dropdown { position: relative; }
|
|
1497
1543
|
.draft-trigger { display: flex; align-items: center; width: 100%; }
|
|
1498
1544
|
.draft-menu {
|
|
@@ -1798,7 +1844,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
1798
1844
|
</div>`}
|
|
1799
1845
|
|
|
1800
1846
|
<!-- SITE WATCH -->
|
|
1801
|
-
${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
|
|
1847
|
+
${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
|
|
1802
1848
|
|
|
1803
1849
|
<!-- PAGE INVENTORY -->
|
|
1804
1850
|
<div class="card" style="margin-bottom:16px;">
|
|
@@ -2157,6 +2203,29 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2157
2203
|
</div>
|
|
2158
2204
|
<button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
|
|
2159
2205
|
</div>
|
|
2206
|
+
<div class="export-sidebar-header" style="margin-top:12px;">
|
|
2207
|
+
<i class="fa-solid fa-download"></i> Download
|
|
2208
|
+
</div>
|
|
2209
|
+
<div class="export-sidebar-btns">
|
|
2210
|
+
<div class="profile-export-picker" id="profilePicker${suffix}">
|
|
2211
|
+
<button class="export-btn profile-export-trigger"><i class="fa-solid fa-download"></i> Export Report <i class="fa-solid fa-chevron-down" style="font-size:0.55rem;margin-left:auto;opacity:0.5;"></i></button>
|
|
2212
|
+
<div class="profile-export-menu">
|
|
2213
|
+
<div class="draft-menu-section">Profile</div>
|
|
2214
|
+
<label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="dev" /> <i class="fa-solid fa-wrench"></i> Developer <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">technical fixes, schema gaps</span></label>
|
|
2215
|
+
<label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="content" checked /> <i class="fa-solid fa-pen-fancy"></i> Content <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">keyword gaps, opportunities</span></label>
|
|
2216
|
+
<label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="ai-pipeline" /> <i class="fa-solid fa-robot"></i> AI Pipeline <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">structured JSON for LLMs</span></label>
|
|
2217
|
+
<div class="draft-menu-section" style="margin-top:8px;">Format</div>
|
|
2218
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
|
2219
|
+
<label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="md" checked /> <i class="fa-solid fa-file-lines"></i> MD</label>
|
|
2220
|
+
<label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="json" /> <i class="fa-solid fa-code"></i> JSON</label>
|
|
2221
|
+
<label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="csv" /> <i class="fa-solid fa-table"></i> CSV</label>
|
|
2222
|
+
<label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="zip" /> <i class="fa-solid fa-file-zipper"></i> ZIP</label>
|
|
2223
|
+
</div>
|
|
2224
|
+
<button class="draft-generate-btn profile-download-btn" data-project="${project}" style="margin-top:10px;"><i class="fa-solid fa-download"></i> Download</button>
|
|
2225
|
+
</div>
|
|
2226
|
+
</div>
|
|
2227
|
+
<button class="export-btn download-all-btn" data-project="${project}" style="font-size:0.58rem;opacity:0.6;"><i class="fa-solid fa-file-zipper"></i> Raw Full Export (ZIP)</button>
|
|
2228
|
+
</div>
|
|
2160
2229
|
<div style="position:relative;">
|
|
2161
2230
|
<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
2231
|
<i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
|
|
@@ -2516,6 +2585,81 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2516
2585
|
});
|
|
2517
2586
|
}
|
|
2518
2587
|
|
|
2588
|
+
// ── Card export dropdowns (global — run once, capture phase) ──
|
|
2589
|
+
if (!window._cardExportBound) {
|
|
2590
|
+
window._cardExportBound = true;
|
|
2591
|
+
document.addEventListener('click', function(e) {
|
|
2592
|
+
var btn = e.target.closest('.card-export-btn');
|
|
2593
|
+
var fmtBtn = e.target.closest('[data-fmt]');
|
|
2594
|
+
if (btn) {
|
|
2595
|
+
var wrap = btn.closest('.card-export');
|
|
2596
|
+
if (wrap) {
|
|
2597
|
+
e.stopImmediatePropagation();
|
|
2598
|
+
var wasOpen = wrap.classList.contains('open');
|
|
2599
|
+
document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
|
|
2600
|
+
if (!wasOpen) wrap.classList.add('open');
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
if (fmtBtn) {
|
|
2605
|
+
var wrap2 = fmtBtn.closest('.card-export');
|
|
2606
|
+
if (wrap2) {
|
|
2607
|
+
e.stopImmediatePropagation();
|
|
2608
|
+
wrap2.classList.remove('open');
|
|
2609
|
+
var sec = wrap2.getAttribute('data-section');
|
|
2610
|
+
var proj2 = wrap2.getAttribute('data-project');
|
|
2611
|
+
var fmt = fmtBtn.getAttribute('data-fmt');
|
|
2612
|
+
if (window.location.protocol.startsWith('http')) {
|
|
2613
|
+
window.location = '/api/export/download?project=' + encodeURIComponent(proj2) + '§ion=' + encodeURIComponent(sec) + '&format=' + encodeURIComponent(fmt);
|
|
2614
|
+
}
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
var dlBtn = e.target.closest('.download-all-btn');
|
|
2619
|
+
if (dlBtn) {
|
|
2620
|
+
var proj3 = dlBtn.getAttribute('data-project');
|
|
2621
|
+
if (window.location.protocol.startsWith('http')) {
|
|
2622
|
+
window.location = '/api/export/download?project=' + encodeURIComponent(proj3) + '§ion=all&format=zip';
|
|
2623
|
+
}
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
// Profile export picker toggle
|
|
2627
|
+
var profTrigger = e.target.closest('.profile-export-trigger');
|
|
2628
|
+
if (profTrigger) {
|
|
2629
|
+
var picker = profTrigger.closest('.profile-export-picker');
|
|
2630
|
+
if (picker) {
|
|
2631
|
+
e.stopImmediatePropagation();
|
|
2632
|
+
var menu = picker.querySelector('.profile-export-menu');
|
|
2633
|
+
var wasVis = menu.style.display === 'block';
|
|
2634
|
+
document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
|
|
2635
|
+
menu.style.display = wasVis ? 'none' : 'block';
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
// Profile download button
|
|
2640
|
+
var profDl = e.target.closest('.profile-download-btn');
|
|
2641
|
+
if (profDl) {
|
|
2642
|
+
var picker2 = profDl.closest('.profile-export-picker');
|
|
2643
|
+
if (picker2) {
|
|
2644
|
+
e.stopImmediatePropagation();
|
|
2645
|
+
var projP = profDl.getAttribute('data-project');
|
|
2646
|
+
var profVal = picker2.querySelector('input[name^="exportProfile"]:checked');
|
|
2647
|
+
var fmtVal = picker2.querySelector('input[name^="exportFmt"]:checked');
|
|
2648
|
+
var prof = profVal ? profVal.value : 'content';
|
|
2649
|
+
var fmt2 = fmtVal ? fmtVal.value : 'md';
|
|
2650
|
+
picker2.querySelector('.profile-export-menu').style.display = 'none';
|
|
2651
|
+
if (window.location.protocol.startsWith('http')) {
|
|
2652
|
+
window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&profile=' + encodeURIComponent(prof) + '&format=' + encodeURIComponent(fmt2);
|
|
2653
|
+
}
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
// Outside click — close all open dropdowns
|
|
2658
|
+
document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
|
|
2659
|
+
document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
|
|
2660
|
+
}, true);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2519
2663
|
// Input enter
|
|
2520
2664
|
input.addEventListener('keydown', function(e) {
|
|
2521
2665
|
if (e.key !== 'Enter') return;
|
|
@@ -2779,7 +2923,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2779
2923
|
})() : ''}
|
|
2780
2924
|
|
|
2781
2925
|
<!-- ═══ SITE WATCH ═══ -->
|
|
2782
|
-
${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
|
|
2926
|
+
${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
|
|
2783
2927
|
|
|
2784
2928
|
<div class="section-divider">
|
|
2785
2929
|
<div class="section-divider-line right"></div>
|
|
@@ -2835,6 +2979,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2835
2979
|
|
|
2836
2980
|
<!-- ═══ HEADING DEPTH FLOW ═══ -->
|
|
2837
2981
|
<div class="card full-width" id="heading-flow">
|
|
2982
|
+
${cardExportHtml('headings', project)}
|
|
2838
2983
|
<h2><span class="icon"><i class="fa-solid fa-water"></i></span> Heading Depth Flow</h2>
|
|
2839
2984
|
<canvas id="headingFlowCanvas${suffix}" width="1100" height="320"></canvas>
|
|
2840
2985
|
</div>
|
|
@@ -2862,6 +3007,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2862
3007
|
<!-- ═══ ENTITY TOPIC MAP ═══ -->
|
|
2863
3008
|
${pro && entityTopicMap.hasData ? `
|
|
2864
3009
|
<div class="card full-width" id="entity-map">
|
|
3010
|
+
${cardExportHtml('insights', project)}
|
|
2865
3011
|
<h2><span class="icon"><i class="fa-solid fa-map"></i></span> Entity Topic Map</h2>
|
|
2866
3012
|
<div class="entity-map-grid">
|
|
2867
3013
|
${Object.entries(entityTopicMap.domainEntities).map(([domain, data]) => `
|
|
@@ -2886,6 +3032,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2886
3032
|
<!-- ═══ KEYWORD BATTLEGROUND ═══ -->
|
|
2887
3033
|
${pro ? `
|
|
2888
3034
|
<div class="card full-width" id="keyword-heatmap">
|
|
3035
|
+
${cardExportHtml('keywords', project)}
|
|
2889
3036
|
<h2><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Keyword Battleground</h2>
|
|
2890
3037
|
${keywordHeatmap.keywords.length ? `
|
|
2891
3038
|
<div class="table-wrapper">
|
|
@@ -2930,6 +3077,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2930
3077
|
|
|
2931
3078
|
<!-- ═══ TECHNICAL SEO SCORECARD ═══ -->
|
|
2932
3079
|
<div class="card full-width" id="technical-seo">
|
|
3080
|
+
${cardExportHtml('technical', project)}
|
|
2933
3081
|
<h2><span class="icon"><i class="fa-solid fa-gear"></i></span> Technical SEO Scorecard</h2>
|
|
2934
3082
|
<div class="scorecard-grid">
|
|
2935
3083
|
${technicalScores.map(ts => {
|
|
@@ -2998,6 +3146,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2998
3146
|
<!-- ═══ TECHNICAL SEO GAPS ═══ -->
|
|
2999
3147
|
${pro && latestAnalysis?.technical_gaps?.length ? `
|
|
3000
3148
|
<div class="card full-width" id="technical-gaps">
|
|
3149
|
+
${cardExportHtml('technical', project)}
|
|
3001
3150
|
<h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
|
|
3002
3151
|
<div class="analysis-table-wrap">
|
|
3003
3152
|
<table class="analysis-table">
|
|
@@ -3026,6 +3175,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3026
3175
|
<!-- ═══ QUICK WINS ═══ -->
|
|
3027
3176
|
${pro && latestAnalysis?.quick_wins?.length ? `
|
|
3028
3177
|
<div class="card" id="quick-wins">
|
|
3178
|
+
${cardExportHtml('insights', project)}
|
|
3029
3179
|
<h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
|
|
3030
3180
|
<div class="analysis-table-wrap">
|
|
3031
3181
|
<table class="analysis-table">
|
|
@@ -3048,6 +3198,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3048
3198
|
<!-- ═══ NEW PAGES TO CREATE ═══ -->
|
|
3049
3199
|
${pro && latestAnalysis?.new_pages?.length ? `
|
|
3050
3200
|
<div class="card" id="new-pages">
|
|
3201
|
+
${cardExportHtml('pages', project)}
|
|
3051
3202
|
<h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
|
|
3052
3203
|
<div class="new-pages-grid" style="grid-template-columns: 1fr;">
|
|
3053
3204
|
${(latestAnalysis.new_pages).map(np => `
|
|
@@ -3100,6 +3251,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3100
3251
|
<!-- ═══ CONTENT GAPS ═══ -->
|
|
3101
3252
|
${pro && latestAnalysis?.content_gaps?.length ? `
|
|
3102
3253
|
<div class="card full-width" id="content-gaps">
|
|
3254
|
+
${cardExportHtml('insights', project)}
|
|
3103
3255
|
<h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
|
|
3104
3256
|
<div class="insights-grid">
|
|
3105
3257
|
${(latestAnalysis.content_gaps).map(gap => `
|
|
@@ -3308,6 +3460,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3308
3460
|
<!-- ═══ SCHEMA TYPE BREAKDOWN ═══ -->
|
|
3309
3461
|
${schemaBreakdown.hasData ? `
|
|
3310
3462
|
<div class="card" id="schema-breakdown">
|
|
3463
|
+
${cardExportHtml('schemas', project)}
|
|
3311
3464
|
<h2><span class="icon"><i class="fa-solid fa-code"></i></span> Schema Markup Breakdown</h2>
|
|
3312
3465
|
<div class="table-wrapper">
|
|
3313
3466
|
<table>
|
|
@@ -3334,6 +3487,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3334
3487
|
<!-- ═══ TOP KEYWORDS ═══ -->
|
|
3335
3488
|
${pro ? `
|
|
3336
3489
|
<div class="card" id="top-keywords">
|
|
3490
|
+
${cardExportHtml('keywords', project)}
|
|
3337
3491
|
<h2><span class="icon"><i class="fa-solid fa-key"></i></span> Top Keywords (${targetDomain})</h2>
|
|
3338
3492
|
${keywords.length ? `
|
|
3339
3493
|
<div class="table-wrapper" style="max-height: 400px; overflow-y: auto;">
|
|
@@ -3369,6 +3523,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3369
3523
|
|
|
3370
3524
|
<!-- ═══ INTERNAL LINK ANALYSIS ═══ -->
|
|
3371
3525
|
<div class="card" id="internal-links">
|
|
3526
|
+
${cardExportHtml('links', project)}
|
|
3372
3527
|
<h2><span class="icon"><i class="fa-solid fa-link"></i></span> Internal Link Analysis</h2>
|
|
3373
3528
|
<div class="stat-row">
|
|
3374
3529
|
<div class="stat-box">
|
|
@@ -3407,7 +3562,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3407
3562
|
</div>` : ''}
|
|
3408
3563
|
|
|
3409
3564
|
<!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
|
|
3410
|
-
${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
|
|
3565
|
+
${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml, project) : ''}
|
|
3411
3566
|
|
|
3412
3567
|
<!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
|
|
3413
3568
|
${pro && latestAnalysis?.long_tails?.length ? `
|
|
@@ -5232,7 +5387,7 @@ function buildMultiHtmlTemplate(allProjectData) {
|
|
|
5232
5387
|
|
|
5233
5388
|
// ─── AEO Card Builder ────────────────────────────────────────────────────────
|
|
5234
5389
|
|
|
5235
|
-
function buildAeoCard(citabilityData, escapeHtml) {
|
|
5390
|
+
function buildAeoCard(citabilityData, escapeHtml, project) {
|
|
5236
5391
|
const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
|
|
5237
5392
|
const compScores = citabilityData.filter(s => s.role === 'competitor');
|
|
5238
5393
|
if (!targetScores.length) return '';
|
|
@@ -5296,6 +5451,7 @@ function buildAeoCard(citabilityData, escapeHtml) {
|
|
|
5296
5451
|
|
|
5297
5452
|
return `
|
|
5298
5453
|
<div class="card full-width" id="aeo-citability">
|
|
5454
|
+
${cardExportHtml('aeo', project)}
|
|
5299
5455
|
<h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
|
|
5300
5456
|
<div class="ki-stat-bar">
|
|
5301
5457
|
<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 +7042,7 @@ function getGscInsights(gscData, db, project) {
|
|
|
6886
7042
|
// SITE WATCH CARD
|
|
6887
7043
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
6888
7044
|
|
|
6889
|
-
function buildWatchCard(watchData, escapeHtml) {
|
|
7045
|
+
function buildWatchCard(watchData, escapeHtml, project) {
|
|
6890
7046
|
const { current, previous, events, trend } = watchData;
|
|
6891
7047
|
const score = current.health_score ?? 0;
|
|
6892
7048
|
const scoreColor = score >= 80 ? 'var(--color-success)' : score >= 60 ? 'var(--color-warning)' : 'var(--color-danger)';
|
|
@@ -6942,6 +7098,7 @@ function buildWatchCard(watchData, escapeHtml) {
|
|
|
6942
7098
|
|
|
6943
7099
|
return `
|
|
6944
7100
|
<div class="card" style="margin-bottom:16px;">
|
|
7101
|
+
${cardExportHtml('watch', project)}
|
|
6945
7102
|
<h2><span class="icon"><i class="fa-solid fa-eye"></i></span> Site Watch</h2>
|
|
6946
7103
|
|
|
6947
7104
|
<!-- 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,507 @@ 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
|
+
const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
|
|
598
|
+
|
|
599
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
600
|
+
|
|
601
|
+
const { getDb } = await import('./db/db.js');
|
|
602
|
+
const db = getDb(join(__dirname, 'seo-intel.db'));
|
|
603
|
+
const configPath = join(__dirname, 'config', `${project}.json`);
|
|
604
|
+
const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
|
|
605
|
+
|
|
606
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
607
|
+
const { createZip } = await import('./lib/export-zip.js');
|
|
608
|
+
|
|
609
|
+
const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
|
|
610
|
+
|
|
611
|
+
// ── Profile definitions: which sections + which insight types matter ──
|
|
612
|
+
const PROFILES = {
|
|
613
|
+
dev: {
|
|
614
|
+
sections: ['technical', 'schemas', 'links', 'headings', 'watch', 'insights'],
|
|
615
|
+
insightTypes: ['technical_gap', 'quick_win', 'site_watch'],
|
|
616
|
+
label: 'Developer',
|
|
617
|
+
},
|
|
618
|
+
content: {
|
|
619
|
+
sections: ['insights', 'keywords', 'aeo'],
|
|
620
|
+
insightTypes: ['keyword_gap', 'long_tail', 'content_gap', 'new_page', 'keyword_inventor', 'citability_gap', 'positioning'],
|
|
621
|
+
label: 'Content',
|
|
622
|
+
},
|
|
623
|
+
'ai-pipeline': {
|
|
624
|
+
sections: ['insights', 'aeo', 'technical', 'keywords', 'watch'],
|
|
625
|
+
insightTypes: null, // all types
|
|
626
|
+
label: 'AI Pipeline',
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
function querySection(sec) {
|
|
631
|
+
switch (sec) {
|
|
632
|
+
case 'aeo': {
|
|
633
|
+
try {
|
|
634
|
+
return db.prepare(`
|
|
635
|
+
SELECT cs.score, cs.entity_authority, cs.structured_claims, cs.answer_density,
|
|
636
|
+
cs.qa_proximity, cs.freshness, cs.schema_coverage, cs.tier, cs.ai_intents,
|
|
637
|
+
p.url, p.title, p.word_count, d.domain, d.role
|
|
638
|
+
FROM citability_scores cs
|
|
639
|
+
JOIN pages p ON p.id = cs.page_id
|
|
640
|
+
JOIN domains d ON d.id = p.domain_id
|
|
641
|
+
WHERE d.project = ?
|
|
642
|
+
ORDER BY d.role ASC, cs.score ASC
|
|
643
|
+
`).all(project);
|
|
644
|
+
} catch { return []; }
|
|
645
|
+
}
|
|
646
|
+
case 'insights': {
|
|
647
|
+
try {
|
|
648
|
+
const rows = db.prepare(
|
|
649
|
+
`SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
|
|
650
|
+
).all(project);
|
|
651
|
+
return rows.map(r => {
|
|
652
|
+
try { return { ...JSON.parse(r.data), _type: r.type, _id: r.id, _first_seen: r.first_seen, _last_seen: r.last_seen }; }
|
|
653
|
+
catch { return { _type: r.type, _id: r.id, raw: r.data }; }
|
|
654
|
+
});
|
|
655
|
+
} catch { return []; }
|
|
656
|
+
}
|
|
657
|
+
case 'technical': {
|
|
658
|
+
try {
|
|
659
|
+
return db.prepare(`
|
|
660
|
+
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
661
|
+
t.has_canonical, t.has_og_tags, t.has_schema, t.has_robots, t.is_mobile_ok,
|
|
662
|
+
d.domain, d.role
|
|
663
|
+
FROM pages p
|
|
664
|
+
JOIN domains d ON d.id = p.domain_id
|
|
665
|
+
LEFT JOIN technical t ON t.page_id = p.id
|
|
666
|
+
WHERE d.project = ?
|
|
667
|
+
ORDER BY d.domain, p.url
|
|
668
|
+
`).all(project);
|
|
669
|
+
} catch { return []; }
|
|
670
|
+
}
|
|
671
|
+
case 'keywords': {
|
|
672
|
+
try {
|
|
673
|
+
return db.prepare(`
|
|
674
|
+
SELECT k.keyword, d.domain, d.role, k.location, COUNT(*) as freq
|
|
675
|
+
FROM keywords k
|
|
676
|
+
JOIN pages p ON p.id = k.page_id
|
|
677
|
+
JOIN domains d ON d.id = p.domain_id
|
|
678
|
+
WHERE d.project = ?
|
|
679
|
+
GROUP BY k.keyword, d.domain
|
|
680
|
+
ORDER BY freq DESC
|
|
681
|
+
`).all(project);
|
|
682
|
+
} catch { return []; }
|
|
683
|
+
}
|
|
684
|
+
case 'pages': {
|
|
685
|
+
try {
|
|
686
|
+
return db.prepare(`
|
|
687
|
+
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
688
|
+
p.title, p.meta_desc, p.published_date, p.modified_date,
|
|
689
|
+
p.crawled_at, p.first_seen_at, d.domain, d.role
|
|
690
|
+
FROM pages p
|
|
691
|
+
JOIN domains d ON d.id = p.domain_id
|
|
692
|
+
WHERE d.project = ?
|
|
693
|
+
ORDER BY d.domain, p.url
|
|
694
|
+
`).all(project);
|
|
695
|
+
} catch { return []; }
|
|
696
|
+
}
|
|
697
|
+
case 'watch': {
|
|
698
|
+
try {
|
|
699
|
+
const snap = db.prepare('SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1').get(project);
|
|
700
|
+
if (!snap) return [];
|
|
701
|
+
const events = db.prepare('SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY severity, event_type').all(snap.id);
|
|
702
|
+
const pages = db.prepare('SELECT * FROM watch_page_states WHERE snapshot_id = ?').all(snap.id);
|
|
703
|
+
return { snapshot: snap, events, pages };
|
|
704
|
+
} catch { return []; }
|
|
705
|
+
}
|
|
706
|
+
case 'schemas': {
|
|
707
|
+
try {
|
|
708
|
+
return db.prepare(`
|
|
709
|
+
SELECT d.domain, d.role, p.url, ps.schema_type, ps.name, ps.description,
|
|
710
|
+
ps.rating, ps.rating_count, ps.price, ps.currency, ps.author,
|
|
711
|
+
ps.date_published, ps.date_modified
|
|
712
|
+
FROM page_schemas ps
|
|
713
|
+
JOIN pages p ON p.id = ps.page_id
|
|
714
|
+
JOIN domains d ON d.id = p.domain_id
|
|
715
|
+
WHERE d.project = ?
|
|
716
|
+
ORDER BY d.domain, ps.schema_type
|
|
717
|
+
`).all(project);
|
|
718
|
+
} catch { return []; }
|
|
719
|
+
}
|
|
720
|
+
case 'headings': {
|
|
721
|
+
try {
|
|
722
|
+
return db.prepare(`
|
|
723
|
+
SELECT d.domain, d.role, p.url, h.level, h.text
|
|
724
|
+
FROM headings h
|
|
725
|
+
JOIN pages p ON p.id = h.page_id
|
|
726
|
+
JOIN domains d ON d.id = p.domain_id
|
|
727
|
+
WHERE d.project = ?
|
|
728
|
+
ORDER BY d.domain, p.url, h.level
|
|
729
|
+
`).all(project);
|
|
730
|
+
} catch { return []; }
|
|
731
|
+
}
|
|
732
|
+
case 'links': {
|
|
733
|
+
try {
|
|
734
|
+
return db.prepare(`
|
|
735
|
+
SELECT l.source_page_id, l.target_url, l.anchor_text, l.is_internal,
|
|
736
|
+
p.url as source_url, d.domain, d.role
|
|
737
|
+
FROM links l
|
|
738
|
+
JOIN pages p ON p.id = l.source_page_id
|
|
739
|
+
JOIN domains d ON d.id = p.domain_id
|
|
740
|
+
WHERE d.project = ?
|
|
741
|
+
ORDER BY d.domain, p.url
|
|
742
|
+
`).all(project);
|
|
743
|
+
} catch { return []; }
|
|
744
|
+
}
|
|
745
|
+
default: return [];
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Profile-aware filtering: strip raw dumps, keep actionable items ──
|
|
750
|
+
function filterForProfile(sec, data, prof) {
|
|
751
|
+
if (!prof || !data) return data;
|
|
752
|
+
const p = PROFILES[prof];
|
|
753
|
+
if (!p) return data;
|
|
754
|
+
|
|
755
|
+
switch (sec) {
|
|
756
|
+
case 'insights': {
|
|
757
|
+
if (!Array.isArray(data)) return data;
|
|
758
|
+
return p.insightTypes ? data.filter(r => p.insightTypes.includes(r._type)) : data;
|
|
759
|
+
}
|
|
760
|
+
case 'technical': {
|
|
761
|
+
if (!Array.isArray(data) || prof === 'ai-pipeline') return data;
|
|
762
|
+
// Dev profile: only pages with issues
|
|
763
|
+
return data.filter(r =>
|
|
764
|
+
r.status_code >= 400 || !r.has_canonical || !r.has_og_tags ||
|
|
765
|
+
!r.has_schema || !r.has_robots || !r.is_mobile_ok ||
|
|
766
|
+
(r.load_ms && r.load_ms > 3000) || (r.word_count != null && r.word_count < 100)
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
case 'headings': {
|
|
770
|
+
if (!Array.isArray(data)) return data;
|
|
771
|
+
// Own site only — group by page, return per-page issue summary
|
|
772
|
+
const ownOnly = data.filter(r => r.role === 'target' || r.role === 'owned');
|
|
773
|
+
const byPage = {};
|
|
774
|
+
for (const r of ownOnly) (byPage[r.url] ||= []).push(r);
|
|
775
|
+
const issues = [];
|
|
776
|
+
for (const [url, headings] of Object.entries(byPage)) {
|
|
777
|
+
const h1s = headings.filter(h => h.level === 1);
|
|
778
|
+
const levels = headings.map(h => h.level);
|
|
779
|
+
const problems = [];
|
|
780
|
+
if (h1s.length === 0) problems.push('missing H1');
|
|
781
|
+
else if (h1s.length > 1) problems.push(`${h1s.length}× H1`);
|
|
782
|
+
// Check for skipped levels (e.g. H1→H3 skips H2)
|
|
783
|
+
const unique = [...new Set(levels)].sort((a, b) => a - b);
|
|
784
|
+
for (let i = 1; i < unique.length; i++) {
|
|
785
|
+
if (unique[i] - unique[i - 1] > 1) {
|
|
786
|
+
problems.push(`skips H${unique[i - 1]}→H${unique[i]}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (problems.length) {
|
|
790
|
+
const sequence = levels.map(l => `H${l}`).join(' → ');
|
|
791
|
+
issues.push({ url, domain: headings[0].domain, issues: problems.join(', '), sequence, heading_count: headings.length });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return issues;
|
|
795
|
+
}
|
|
796
|
+
case 'links': {
|
|
797
|
+
if (!Array.isArray(data)) return data;
|
|
798
|
+
// Only orphan pages (pages that are never a target) and broken anchors
|
|
799
|
+
const targetUrls = new Set(data.filter(l => l.is_internal).map(l => l.target_url));
|
|
800
|
+
const sourceUrls = new Set(data.map(l => l.source_url));
|
|
801
|
+
// Pages that link out but are never linked TO = orphan
|
|
802
|
+
const orphans = new Set([...sourceUrls].filter(u => !targetUrls.has(u)));
|
|
803
|
+
return data.filter(r => orphans.has(r.source_url) || !r.anchor_text);
|
|
804
|
+
}
|
|
805
|
+
case 'schemas': {
|
|
806
|
+
if (!Array.isArray(data) || prof !== 'dev') return data;
|
|
807
|
+
// Dev: pages missing schema are more useful — but we only have pages WITH schema here
|
|
808
|
+
// So return all (schema gaps come from technical section's has_schema=false)
|
|
809
|
+
return data;
|
|
810
|
+
}
|
|
811
|
+
case 'aeo': {
|
|
812
|
+
if (!Array.isArray(data)) return data;
|
|
813
|
+
if (prof === 'content') {
|
|
814
|
+
// Content: only low-scoring pages (needs improvement)
|
|
815
|
+
return data.filter(r => r.score < 60);
|
|
816
|
+
}
|
|
817
|
+
return data;
|
|
818
|
+
}
|
|
819
|
+
case 'keywords': {
|
|
820
|
+
if (!Array.isArray(data)) return data;
|
|
821
|
+
if (prof === 'content') {
|
|
822
|
+
// Content: only competitor-dominated keywords (role != target/owned)
|
|
823
|
+
const byKw = {};
|
|
824
|
+
for (const r of data) { (byKw[r.keyword] ||= []).push(r); }
|
|
825
|
+
const gapKws = new Set();
|
|
826
|
+
for (const [kw, rows] of Object.entries(byKw)) {
|
|
827
|
+
const hasTarget = rows.some(r => r.role === 'target' || r.role === 'owned');
|
|
828
|
+
const hasCompetitor = rows.some(r => r.role === 'competitor');
|
|
829
|
+
if (!hasTarget && hasCompetitor) gapKws.add(kw);
|
|
830
|
+
}
|
|
831
|
+
return data.filter(r => gapKws.has(r.keyword));
|
|
832
|
+
}
|
|
833
|
+
return data;
|
|
834
|
+
}
|
|
835
|
+
case 'watch': {
|
|
836
|
+
// Keep only errors + warnings, drop notices
|
|
837
|
+
if (data && data.events) {
|
|
838
|
+
return { ...data, events: data.events.filter(e => e.severity === 'error' || e.severity === 'warning') };
|
|
839
|
+
}
|
|
840
|
+
return data;
|
|
841
|
+
}
|
|
842
|
+
default: return data;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function toCSV(rows) {
|
|
847
|
+
if (!rows || (Array.isArray(rows) && !rows.length)) return '';
|
|
848
|
+
const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
|
|
849
|
+
if (!arr.length) return '';
|
|
850
|
+
const keys = Object.keys(arr[0]);
|
|
851
|
+
const escape = (v) => {
|
|
852
|
+
if (v == null) return '';
|
|
853
|
+
const s = String(v);
|
|
854
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
855
|
+
};
|
|
856
|
+
return [keys.join(','), ...arr.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function toMarkdown(sec, data, proj) {
|
|
860
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
861
|
+
const header = `# SEO Intel — ${sec.charAt(0).toUpperCase() + sec.slice(1)} Export\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
|
|
862
|
+
if (!data || (Array.isArray(data) && !data.length)) return header + '_No data available._\n';
|
|
863
|
+
|
|
864
|
+
switch (sec) {
|
|
865
|
+
case 'aeo': {
|
|
866
|
+
const targetRows = data.filter(r => r.role === 'target' || r.role === 'owned');
|
|
867
|
+
const avg = targetRows.length ? Math.round(targetRows.reduce((a, r) => a + r.score, 0) / targetRows.length) : 0;
|
|
868
|
+
let md = header + `## Summary\n\n- Pages scored: ${data.length}\n- Target average: ${avg}/100\n\n`;
|
|
869
|
+
md += '## Page Scores\n\n| Score | Tier | URL | Title | Weakest Signals |\n|-------|------|-----|-------|-----------------|\n';
|
|
870
|
+
for (const r of data) {
|
|
871
|
+
const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
|
|
872
|
+
const weakest = signals.sort((a, b) => (r[a] || 0) - (r[b] || 0)).slice(0, 2).map(s => s.replace(/_/g, ' ')).join(', ');
|
|
873
|
+
md += `| ${r.score} | ${r.tier} | ${r.url} | ${(r.title || '').slice(0, 50)} | ${weakest} |\n`;
|
|
874
|
+
}
|
|
875
|
+
return md;
|
|
876
|
+
}
|
|
877
|
+
case 'insights': {
|
|
878
|
+
let md = header + `## Active Insights (${data.length})\n\n`;
|
|
879
|
+
const grouped = {};
|
|
880
|
+
for (const r of data) { (grouped[r._type] ||= []).push(r); }
|
|
881
|
+
for (const [type, items] of Object.entries(grouped)) {
|
|
882
|
+
md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
|
|
883
|
+
for (const item of items) {
|
|
884
|
+
const desc = item.phrase || item.keyword || item.title || item.page || item.message || JSON.stringify(item).slice(0, 120);
|
|
885
|
+
md += `- ${desc}\n`;
|
|
886
|
+
}
|
|
887
|
+
md += '\n';
|
|
888
|
+
}
|
|
889
|
+
return md;
|
|
890
|
+
}
|
|
891
|
+
case 'technical': {
|
|
892
|
+
let md = header + '## Technical Audit\n\n| URL | Status | Words | Load ms | Canonical | OG | Schema | Robots | Mobile |\n|-----|--------|-------|---------|-----------|-----|--------|--------|--------|\n';
|
|
893
|
+
for (const r of data) {
|
|
894
|
+
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`;
|
|
895
|
+
}
|
|
896
|
+
return md;
|
|
897
|
+
}
|
|
898
|
+
case 'keywords': {
|
|
899
|
+
let md = header + '## Keyword Matrix\n\n| Keyword | Domain | Role | Location | Frequency |\n|---------|--------|------|----------|-----------|\n';
|
|
900
|
+
for (const r of data.slice(0, 500)) {
|
|
901
|
+
md += `| ${r.keyword} | ${r.domain} | ${r.role} | ${r.location || ''} | ${r.freq} |\n`;
|
|
902
|
+
}
|
|
903
|
+
if (data.length > 500) md += `\n_...and ${data.length - 500} more rows._\n`;
|
|
904
|
+
return md;
|
|
905
|
+
}
|
|
906
|
+
case 'pages': {
|
|
907
|
+
let md = header + '## Crawled Pages\n\n| URL | Status | Words | Title | Domain | Role |\n|-----|--------|-------|-------|--------|------|\n';
|
|
908
|
+
for (const r of data) {
|
|
909
|
+
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${(r.title || '').slice(0, 50)} | ${r.domain} | ${r.role} |\n`;
|
|
910
|
+
}
|
|
911
|
+
return md;
|
|
912
|
+
}
|
|
913
|
+
case 'watch': {
|
|
914
|
+
const snap = data.snapshot || {};
|
|
915
|
+
const events = data.events || [];
|
|
916
|
+
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`;
|
|
917
|
+
if (events.length) {
|
|
918
|
+
md += '## Events\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n';
|
|
919
|
+
for (const e of events) {
|
|
920
|
+
md += `| ${e.event_type} | ${e.severity} | ${e.url} | ${(e.details || '').slice(0, 80)} |\n`;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return md;
|
|
924
|
+
}
|
|
925
|
+
case 'schemas': {
|
|
926
|
+
let md = header + '## Schema Markup\n\n| Domain | URL | Type | Name | Rating | Price |\n|--------|-----|------|------|--------|-------|\n';
|
|
927
|
+
for (const r of data) {
|
|
928
|
+
md += `| ${r.domain} | ${r.url} | ${r.schema_type} | ${(r.name || '').slice(0, 40)} | ${r.rating || ''} | ${r.price ? r.currency + r.price : ''} |\n`;
|
|
929
|
+
}
|
|
930
|
+
return md;
|
|
931
|
+
}
|
|
932
|
+
case 'headings': {
|
|
933
|
+
let md = header + '## Heading Structure\n\n| Domain | URL | Level | Text |\n|--------|-----|-------|------|\n';
|
|
934
|
+
for (const r of data.slice(0, 1000)) {
|
|
935
|
+
md += `| ${r.domain} | ${r.url} | H${r.level} | ${(r.text || '').slice(0, 80)} |\n`;
|
|
936
|
+
}
|
|
937
|
+
if (data.length > 1000) md += `\n_...and ${data.length - 1000} more rows._\n`;
|
|
938
|
+
return md;
|
|
939
|
+
}
|
|
940
|
+
case 'links': {
|
|
941
|
+
let md = header + '## Internal Links\n\n| Source | Target | Anchor |\n|--------|--------|--------|\n';
|
|
942
|
+
for (const r of data.filter(l => l.is_internal).slice(0, 1000)) {
|
|
943
|
+
md += `| ${r.source_url} | ${r.target_url} | ${(r.anchor_text || '').slice(0, 50)} |\n`;
|
|
944
|
+
}
|
|
945
|
+
if (data.length > 1000) md += `\n_...and more rows._\n`;
|
|
946
|
+
return md;
|
|
947
|
+
}
|
|
948
|
+
default: {
|
|
949
|
+
return header + '```json\n' + JSON.stringify(data, null, 2).slice(0, 10000) + '\n```\n';
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ── Resolve sections: profile overrides section=all ──
|
|
955
|
+
const validProfiles = Object.keys(PROFILES);
|
|
956
|
+
if (profile && !validProfiles.includes(profile)) {
|
|
957
|
+
json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const resolvedSections = profile
|
|
961
|
+
? PROFILES[profile].sections
|
|
962
|
+
: (section === 'all' ? SECTIONS : [section]);
|
|
963
|
+
|
|
964
|
+
if (!profile && section !== 'all' && !SECTIONS.includes(section)) {
|
|
965
|
+
json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Helper: query + filter for profile
|
|
970
|
+
function getData(sec) {
|
|
971
|
+
const raw = querySection(sec);
|
|
972
|
+
return profile ? filterForProfile(sec, raw, profile) : raw;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function isEmpty(data) {
|
|
976
|
+
if (!data) return true;
|
|
977
|
+
if (Array.isArray(data)) return data.length === 0;
|
|
978
|
+
if (data.events) return data.events.length === 0;
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const profileTag = profile ? `-${profile}` : '';
|
|
983
|
+
const profileLabel = profile ? PROFILES[profile].label : '';
|
|
984
|
+
|
|
985
|
+
if (format === 'zip') {
|
|
986
|
+
const entries = [];
|
|
987
|
+
for (const sec of resolvedSections) {
|
|
988
|
+
const data = getData(sec);
|
|
989
|
+
if (isEmpty(data)) continue; // skip empty sections
|
|
990
|
+
const baseName = `${project}${profileTag}-${sec}-${dateStr}`;
|
|
991
|
+
entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
|
|
992
|
+
entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
|
|
993
|
+
const csv = toCSV(data);
|
|
994
|
+
if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
|
|
995
|
+
}
|
|
996
|
+
if (!entries.length) {
|
|
997
|
+
json(res, 200, { message: 'No actionable data to export.' });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const zipBuf = createZip(entries);
|
|
1001
|
+
const zipName = profile
|
|
1002
|
+
? `${project}-${profile}-export-${dateStr}.zip`
|
|
1003
|
+
: (section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`);
|
|
1004
|
+
res.writeHead(200, {
|
|
1005
|
+
'Content-Type': 'application/zip',
|
|
1006
|
+
'Content-Disposition': `attachment; filename="${zipName}"`,
|
|
1007
|
+
'Content-Length': zipBuf.length,
|
|
1008
|
+
});
|
|
1009
|
+
res.end(zipBuf);
|
|
1010
|
+
} else if (format === 'json') {
|
|
1011
|
+
if (profile) {
|
|
1012
|
+
// Profile JSON: merged object with all profile sections
|
|
1013
|
+
const result = { profile: profileLabel, project, date: dateStr, sections: {} };
|
|
1014
|
+
for (const sec of resolvedSections) {
|
|
1015
|
+
const data = getData(sec);
|
|
1016
|
+
if (!isEmpty(data)) result.sections[sec] = data;
|
|
1017
|
+
}
|
|
1018
|
+
const fileName = `${project}-${profile}-${dateStr}.json`;
|
|
1019
|
+
res.writeHead(200, {
|
|
1020
|
+
'Content-Type': 'application/json',
|
|
1021
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1022
|
+
});
|
|
1023
|
+
res.end(JSON.stringify(result, null, 2));
|
|
1024
|
+
} else {
|
|
1025
|
+
const data = getData(resolvedSections[0]);
|
|
1026
|
+
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.json`;
|
|
1027
|
+
res.writeHead(200, {
|
|
1028
|
+
'Content-Type': 'application/json',
|
|
1029
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1030
|
+
});
|
|
1031
|
+
res.end(JSON.stringify(data, null, 2));
|
|
1032
|
+
}
|
|
1033
|
+
} else if (format === 'csv') {
|
|
1034
|
+
if (profile) {
|
|
1035
|
+
// Profile CSV: concatenate sections with headers
|
|
1036
|
+
let csv = '';
|
|
1037
|
+
for (const sec of resolvedSections) {
|
|
1038
|
+
const data = getData(sec);
|
|
1039
|
+
const secCsv = toCSV(data);
|
|
1040
|
+
if (secCsv) csv += `# ${sec}\n${secCsv}\n\n`;
|
|
1041
|
+
}
|
|
1042
|
+
const fileName = `${project}-${profile}-${dateStr}.csv`;
|
|
1043
|
+
res.writeHead(200, {
|
|
1044
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
1045
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1046
|
+
});
|
|
1047
|
+
res.end(csv || 'No actionable data.');
|
|
1048
|
+
} else {
|
|
1049
|
+
const data = getData(resolvedSections[0]);
|
|
1050
|
+
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.csv`;
|
|
1051
|
+
res.writeHead(200, {
|
|
1052
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
1053
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1054
|
+
});
|
|
1055
|
+
res.end(toCSV(data));
|
|
1056
|
+
}
|
|
1057
|
+
} else if (format === 'md') {
|
|
1058
|
+
if (profile) {
|
|
1059
|
+
// Profile Markdown: combined report
|
|
1060
|
+
let md = `# SEO Intel — ${profileLabel} Report\n\n- Project: ${project}\n- Date: ${dateStr}\n- Profile: ${profileLabel}\n\n`;
|
|
1061
|
+
for (const sec of resolvedSections) {
|
|
1062
|
+
const data = getData(sec);
|
|
1063
|
+
if (isEmpty(data)) continue;
|
|
1064
|
+
md += toMarkdown(sec, data, project).replace(/^# .+\n\n- Project:.+\n- Date:.+\n\n/, ''); // strip per-section header
|
|
1065
|
+
}
|
|
1066
|
+
if (md.split('\n').length < 8) md += '_No actionable data found. Run crawl + extract + analyze first._\n';
|
|
1067
|
+
const fileName = `${project}-${profile}-${dateStr}.md`;
|
|
1068
|
+
res.writeHead(200, {
|
|
1069
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
1070
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1071
|
+
});
|
|
1072
|
+
res.end(md);
|
|
1073
|
+
} else {
|
|
1074
|
+
const data = getData(resolvedSections[0]);
|
|
1075
|
+
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.md`;
|
|
1076
|
+
res.writeHead(200, {
|
|
1077
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
1078
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1079
|
+
});
|
|
1080
|
+
res.end(toMarkdown(resolvedSections[0], data, project));
|
|
1081
|
+
}
|
|
1082
|
+
} else {
|
|
1083
|
+
json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
|
|
1084
|
+
}
|
|
1085
|
+
} catch (e) {
|
|
1086
|
+
console.error('[export/download]', e);
|
|
1087
|
+
json(res, 500, { error: e.message });
|
|
1088
|
+
}
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
589
1092
|
// ─── API: SSE Terminal — stream command output ───
|
|
590
1093
|
if (req.method === 'GET' && path === '/api/terminal') {
|
|
591
1094
|
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');
|