seo-intel 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.now();
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;
@@ -596,7 +905,7 @@ async function handleRequest(req, res) {
596
905
  const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
597
906
  'suggest-usecases', 'html', 'status', 'brief', 'keywords', 'report', 'guide',
598
907
  'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export', 'templates',
599
- 'aeo', 'blog-draft', 'gap-intel'];
908
+ 'aeo', 'blog-draft', 'gap-intel', 'watch'];
600
909
 
601
910
  if (!command || !ALLOWED.includes(command)) {
602
911
  json(res, 400, { error: `Invalid command. Allowed: ${ALLOWED.join(', ')}` });
package/setup/checks.js CHANGED
@@ -328,8 +328,15 @@ export function checkGscData(project) {
328
328
 
329
329
  if (folders.length === 0) return { hasData: false, folders: allFolders, project };
330
330
 
331
- // Check what CSV files exist in the latest folder
332
- const latest = folders.sort().pop();
331
+ // Check what CSV files exist in the most recently modified matching folder
332
+ const latest = [...folders]
333
+ .map(name => {
334
+ const folderPath = join(gscDir, name);
335
+ let mtimeMs = 0;
336
+ try { mtimeMs = statSync(folderPath).mtimeMs; } catch { /* ignore */ }
337
+ return { name, mtimeMs };
338
+ })
339
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))[0]?.name;
333
340
  const folderPath = join(gscDir, latest);
334
341
  const expectedFiles = ['Chart.csv', 'Queries.csv', 'Pages.csv', 'Countries.csv', 'Devices.csv'];
335
342
  const found = [];
@@ -177,6 +177,12 @@ export function handleSetupRequest(req, res, url) {
177
177
  return true;
178
178
  }
179
179
 
180
+ // POST /api/setup/dashboard/restart — soft restart / reload hint for dashboard UI
181
+ if (path === '/api/setup/dashboard/restart' && method === 'POST') {
182
+ handleDashboardRestart(res);
183
+ return true;
184
+ }
185
+
180
186
  // GET /api/setup/version — current version + update info
181
187
  if (path === '/api/setup/version' && method === 'GET') {
182
188
  handleVersion(req, res);
@@ -255,7 +261,17 @@ function serveWizardHtml(res) {
255
261
  function getOllamaHosts() {
256
262
  const hosts = [];
257
263
  if (process.env.OLLAMA_URL) hosts.push(process.env.OLLAMA_URL);
258
- if (process.env.OLLAMA_FALLBACK_URL) hosts.push(process.env.OLLAMA_FALLBACK_URL);
264
+ // Support comma-separated OLLAMA_HOSTS for multiple LAN addresses
265
+ if (process.env.OLLAMA_HOSTS) {
266
+ for (const h of process.env.OLLAMA_HOSTS.split(',')) {
267
+ const trimmed = h.trim();
268
+ if (trimmed && !hosts.includes(trimmed)) hosts.push(trimmed);
269
+ }
270
+ }
271
+ // Legacy single fallback
272
+ if (process.env.OLLAMA_FALLBACK_URL) {
273
+ if (!hosts.includes(process.env.OLLAMA_FALLBACK_URL)) hosts.push(process.env.OLLAMA_FALLBACK_URL);
274
+ }
259
275
  return hosts;
260
276
  }
261
277
 
@@ -394,8 +410,17 @@ async function handleEnv(req, res) {
394
410
  return;
395
411
  }
396
412
 
397
- const result = updateEnvForSetup(keys);
398
- jsonResponse(res, { success: true, path: result.path });
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 });
399
424
  } catch (err) {
400
425
  jsonResponse(res, { error: err.message }, 500);
401
426
  }
@@ -499,6 +524,15 @@ async function handleGscUpload(req, res) {
499
524
  }
500
525
  }
501
526
 
527
+ function handleDashboardRestart(res) {
528
+ jsonResponse(res, {
529
+ success: true,
530
+ restarted: true,
531
+ mode: 'soft',
532
+ message: 'Dashboard restart requested. Reload the dashboard UI to pick up latest settings.',
533
+ });
534
+ }
535
+
502
536
  // ── Version / Update Handler ──────────────────────────────────────────────
503
537
 
504
538
  async function handleVersion(req, res) {