seo-intel 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/analyses/watch/diff.js +158 -0
- package/analyses/watch/health.js +78 -0
- package/analyses/watch/index.js +215 -0
- package/cli.js +155 -14
- package/db/db.js +73 -0
- package/lib/export-zip.js +102 -0
- package/package.json +1 -1
- package/reports/generate-html.js +253 -11
- package/reports/gsc-loader.js +14 -4
- package/server.js +311 -2
- package/setup/checks.js +9 -2
- package/setup/web-routes.js +37 -3
- package/setup/wizard.html +484 -323
package/reports/generate-html.js
CHANGED
|
@@ -20,16 +20,23 @@ import { loadGscData } from './gsc-loader.js';
|
|
|
20
20
|
import { isPro } from '../lib/license.js';
|
|
21
21
|
import { getActiveInsights } from '../db/db.js';
|
|
22
22
|
import { getCitabilityScores } from '../analyses/aeo/index.js';
|
|
23
|
+
import { getWatchData } from '../analyses/watch/index.js';
|
|
23
24
|
|
|
24
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
|
|
33
40
|
/**
|
|
34
41
|
* Gather all dashboard data for a single project
|
|
35
42
|
*/
|
|
@@ -108,6 +115,10 @@ function gatherProjectData(db, project, config) {
|
|
|
108
115
|
let citabilityData = null;
|
|
109
116
|
try { citabilityData = getCitabilityScores(db, project); } catch { /* table may not exist yet */ }
|
|
110
117
|
|
|
118
|
+
// Site Watch data
|
|
119
|
+
let watchData = null;
|
|
120
|
+
try { watchData = getWatchData(db, project); } catch { /* tables may not exist yet */ }
|
|
121
|
+
|
|
111
122
|
// Extraction status
|
|
112
123
|
const extractionStatus = getExtractionStatus(db, project, config);
|
|
113
124
|
|
|
@@ -131,7 +142,7 @@ function gatherProjectData(db, project, config) {
|
|
|
131
142
|
ctaLandscape, entityTopicMap, schemaBreakdown,
|
|
132
143
|
gravityMap, contentTerrain, keywordVenn, performanceBubbles,
|
|
133
144
|
headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
|
|
134
|
-
keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData,
|
|
145
|
+
keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData, watchData,
|
|
135
146
|
};
|
|
136
147
|
|
|
137
148
|
// Rollback the owned→target merge so the actual DB is unchanged
|
|
@@ -194,7 +205,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
194
205
|
ctaLandscape, entityTopicMap, schemaBreakdown,
|
|
195
206
|
gravityMap, contentTerrain, keywordVenn, performanceBubbles,
|
|
196
207
|
headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
|
|
197
|
-
keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData,
|
|
208
|
+
keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData, watchData,
|
|
198
209
|
} = data;
|
|
199
210
|
|
|
200
211
|
const totalPages = domains.reduce((sum, d) => sum + d.page_count, 0);
|
|
@@ -414,6 +425,32 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
414
425
|
.card.full-width {
|
|
415
426
|
grid-column: 1 / -1;
|
|
416
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; }
|
|
417
454
|
|
|
418
455
|
/* ─── Extraction Status Bar ─────────────────────────────────────────── */
|
|
419
456
|
.extraction-status {
|
|
@@ -1792,6 +1829,9 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
1792
1829
|
Crawled <strong>${totalPages}</strong> pages — no major structural issues detected.
|
|
1793
1830
|
</div>`}
|
|
1794
1831
|
|
|
1832
|
+
<!-- SITE WATCH -->
|
|
1833
|
+
${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
|
|
1834
|
+
|
|
1795
1835
|
<!-- PAGE INVENTORY -->
|
|
1796
1836
|
<div class="card" style="margin-bottom:16px;">
|
|
1797
1837
|
<h2><span class="icon"><i class="fa-solid fa-table-list"></i></span> Page Inventory — ${targetDomain}</h2>
|
|
@@ -2149,6 +2189,12 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2149
2189
|
</div>
|
|
2150
2190
|
<button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
|
|
2151
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>
|
|
2152
2198
|
<div style="position:relative;">
|
|
2153
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;">
|
|
2154
2200
|
<i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
|
|
@@ -2508,6 +2554,49 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2508
2554
|
});
|
|
2509
2555
|
}
|
|
2510
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
|
+
|
|
2511
2600
|
// Input enter
|
|
2512
2601
|
input.addEventListener('keydown', function(e) {
|
|
2513
2602
|
if (e.key !== 'Enter') return;
|
|
@@ -2770,6 +2859,9 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2770
2859
|
</div>`;
|
|
2771
2860
|
})() : ''}
|
|
2772
2861
|
|
|
2862
|
+
<!-- ═══ SITE WATCH ═══ -->
|
|
2863
|
+
${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
|
|
2864
|
+
|
|
2773
2865
|
<div class="section-divider">
|
|
2774
2866
|
<div class="section-divider-line right"></div>
|
|
2775
2867
|
<span class="section-divider-label"><i class="fa-solid fa-radar"></i> Competitive Landscape</span>
|
|
@@ -2824,6 +2916,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2824
2916
|
|
|
2825
2917
|
<!-- ═══ HEADING DEPTH FLOW ═══ -->
|
|
2826
2918
|
<div class="card full-width" id="heading-flow">
|
|
2919
|
+
${cardExportHtml('headings', project)}
|
|
2827
2920
|
<h2><span class="icon"><i class="fa-solid fa-water"></i></span> Heading Depth Flow</h2>
|
|
2828
2921
|
<canvas id="headingFlowCanvas${suffix}" width="1100" height="320"></canvas>
|
|
2829
2922
|
</div>
|
|
@@ -2851,6 +2944,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2851
2944
|
<!-- ═══ ENTITY TOPIC MAP ═══ -->
|
|
2852
2945
|
${pro && entityTopicMap.hasData ? `
|
|
2853
2946
|
<div class="card full-width" id="entity-map">
|
|
2947
|
+
${cardExportHtml('insights', project)}
|
|
2854
2948
|
<h2><span class="icon"><i class="fa-solid fa-map"></i></span> Entity Topic Map</h2>
|
|
2855
2949
|
<div class="entity-map-grid">
|
|
2856
2950
|
${Object.entries(entityTopicMap.domainEntities).map(([domain, data]) => `
|
|
@@ -2875,6 +2969,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2875
2969
|
<!-- ═══ KEYWORD BATTLEGROUND ═══ -->
|
|
2876
2970
|
${pro ? `
|
|
2877
2971
|
<div class="card full-width" id="keyword-heatmap">
|
|
2972
|
+
${cardExportHtml('keywords', project)}
|
|
2878
2973
|
<h2><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Keyword Battleground</h2>
|
|
2879
2974
|
${keywordHeatmap.keywords.length ? `
|
|
2880
2975
|
<div class="table-wrapper">
|
|
@@ -2919,6 +3014,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2919
3014
|
|
|
2920
3015
|
<!-- ═══ TECHNICAL SEO SCORECARD ═══ -->
|
|
2921
3016
|
<div class="card full-width" id="technical-seo">
|
|
3017
|
+
${cardExportHtml('technical', project)}
|
|
2922
3018
|
<h2><span class="icon"><i class="fa-solid fa-gear"></i></span> Technical SEO Scorecard</h2>
|
|
2923
3019
|
<div class="scorecard-grid">
|
|
2924
3020
|
${technicalScores.map(ts => {
|
|
@@ -2987,6 +3083,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2987
3083
|
<!-- ═══ TECHNICAL SEO GAPS ═══ -->
|
|
2988
3084
|
${pro && latestAnalysis?.technical_gaps?.length ? `
|
|
2989
3085
|
<div class="card full-width" id="technical-gaps">
|
|
3086
|
+
${cardExportHtml('technical', project)}
|
|
2990
3087
|
<h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
|
|
2991
3088
|
<div class="analysis-table-wrap">
|
|
2992
3089
|
<table class="analysis-table">
|
|
@@ -3015,6 +3112,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3015
3112
|
<!-- ═══ QUICK WINS ═══ -->
|
|
3016
3113
|
${pro && latestAnalysis?.quick_wins?.length ? `
|
|
3017
3114
|
<div class="card" id="quick-wins">
|
|
3115
|
+
${cardExportHtml('insights', project)}
|
|
3018
3116
|
<h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
|
|
3019
3117
|
<div class="analysis-table-wrap">
|
|
3020
3118
|
<table class="analysis-table">
|
|
@@ -3037,6 +3135,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3037
3135
|
<!-- ═══ NEW PAGES TO CREATE ═══ -->
|
|
3038
3136
|
${pro && latestAnalysis?.new_pages?.length ? `
|
|
3039
3137
|
<div class="card" id="new-pages">
|
|
3138
|
+
${cardExportHtml('pages', project)}
|
|
3040
3139
|
<h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
|
|
3041
3140
|
<div class="new-pages-grid" style="grid-template-columns: 1fr;">
|
|
3042
3141
|
${(latestAnalysis.new_pages).map(np => `
|
|
@@ -3089,6 +3188,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3089
3188
|
<!-- ═══ CONTENT GAPS ═══ -->
|
|
3090
3189
|
${pro && latestAnalysis?.content_gaps?.length ? `
|
|
3091
3190
|
<div class="card full-width" id="content-gaps">
|
|
3191
|
+
${cardExportHtml('insights', project)}
|
|
3092
3192
|
<h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
|
|
3093
3193
|
<div class="insights-grid">
|
|
3094
3194
|
${(latestAnalysis.content_gaps).map(gap => `
|
|
@@ -3297,6 +3397,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3297
3397
|
<!-- ═══ SCHEMA TYPE BREAKDOWN ═══ -->
|
|
3298
3398
|
${schemaBreakdown.hasData ? `
|
|
3299
3399
|
<div class="card" id="schema-breakdown">
|
|
3400
|
+
${cardExportHtml('schemas', project)}
|
|
3300
3401
|
<h2><span class="icon"><i class="fa-solid fa-code"></i></span> Schema Markup Breakdown</h2>
|
|
3301
3402
|
<div class="table-wrapper">
|
|
3302
3403
|
<table>
|
|
@@ -3323,6 +3424,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3323
3424
|
<!-- ═══ TOP KEYWORDS ═══ -->
|
|
3324
3425
|
${pro ? `
|
|
3325
3426
|
<div class="card" id="top-keywords">
|
|
3427
|
+
${cardExportHtml('keywords', project)}
|
|
3326
3428
|
<h2><span class="icon"><i class="fa-solid fa-key"></i></span> Top Keywords (${targetDomain})</h2>
|
|
3327
3429
|
${keywords.length ? `
|
|
3328
3430
|
<div class="table-wrapper" style="max-height: 400px; overflow-y: auto;">
|
|
@@ -3358,6 +3460,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3358
3460
|
|
|
3359
3461
|
<!-- ═══ INTERNAL LINK ANALYSIS ═══ -->
|
|
3360
3462
|
<div class="card" id="internal-links">
|
|
3463
|
+
${cardExportHtml('links', project)}
|
|
3361
3464
|
<h2><span class="icon"><i class="fa-solid fa-link"></i></span> Internal Link Analysis</h2>
|
|
3362
3465
|
<div class="stat-row">
|
|
3363
3466
|
<div class="stat-box">
|
|
@@ -3396,7 +3499,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3396
3499
|
</div>` : ''}
|
|
3397
3500
|
|
|
3398
3501
|
<!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
|
|
3399
|
-
${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
|
|
3502
|
+
${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml, project) : ''}
|
|
3400
3503
|
|
|
3401
3504
|
<!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
|
|
3402
3505
|
${pro && latestAnalysis?.long_tails?.length ? `
|
|
@@ -5221,7 +5324,7 @@ function buildMultiHtmlTemplate(allProjectData) {
|
|
|
5221
5324
|
|
|
5222
5325
|
// ─── AEO Card Builder ────────────────────────────────────────────────────────
|
|
5223
5326
|
|
|
5224
|
-
function buildAeoCard(citabilityData, escapeHtml) {
|
|
5327
|
+
function buildAeoCard(citabilityData, escapeHtml, project) {
|
|
5225
5328
|
const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
|
|
5226
5329
|
const compScores = citabilityData.filter(s => s.role === 'competitor');
|
|
5227
5330
|
if (!targetScores.length) return '';
|
|
@@ -5285,6 +5388,7 @@ function buildAeoCard(citabilityData, escapeHtml) {
|
|
|
5285
5388
|
|
|
5286
5389
|
return `
|
|
5287
5390
|
<div class="card full-width" id="aeo-citability">
|
|
5391
|
+
${cardExportHtml('aeo', project)}
|
|
5288
5392
|
<h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
|
|
5289
5393
|
<div class="ki-stat-bar">
|
|
5290
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>
|
|
@@ -6870,3 +6974,141 @@ function getGscInsights(gscData, db, project) {
|
|
|
6870
6974
|
|
|
6871
6975
|
return insights.length ? insights : null;
|
|
6872
6976
|
}
|
|
6977
|
+
|
|
6978
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6979
|
+
// SITE WATCH CARD
|
|
6980
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6981
|
+
|
|
6982
|
+
function buildWatchCard(watchData, escapeHtml, project) {
|
|
6983
|
+
const { current, previous, events, trend } = watchData;
|
|
6984
|
+
const score = current.health_score ?? 0;
|
|
6985
|
+
const scoreColor = score >= 80 ? 'var(--color-success)' : score >= 60 ? 'var(--color-warning)' : 'var(--color-danger)';
|
|
6986
|
+
const trendArrow = trend > 0 ? `<span style="color:var(--color-success);">▲ +${trend}</span>`
|
|
6987
|
+
: trend < 0 ? `<span style="color:var(--color-danger);">▼ ${trend}</span>`
|
|
6988
|
+
: previous ? '<span style="color:var(--text-muted);">— unchanged</span>' : '';
|
|
6989
|
+
const prevText = previous ? ` <span style="color:var(--text-muted);font-size:0.7rem;">(was ${previous.health_score}/100)</span>` : '';
|
|
6990
|
+
|
|
6991
|
+
const timestamp = new Date(current.created_at).toLocaleDateString('en-US', {
|
|
6992
|
+
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
6993
|
+
});
|
|
6994
|
+
|
|
6995
|
+
// Severity counts with deltas
|
|
6996
|
+
const eCurr = current.errors_count || 0;
|
|
6997
|
+
const wCurr = current.warnings_count || 0;
|
|
6998
|
+
const nCurr = current.notices_count || 0;
|
|
6999
|
+
const ePrev = previous?.errors_count || 0;
|
|
7000
|
+
const wPrev = previous?.warnings_count || 0;
|
|
7001
|
+
const nPrev = previous?.notices_count || 0;
|
|
7002
|
+
|
|
7003
|
+
function delta(curr, prev) {
|
|
7004
|
+
if (!previous) return '';
|
|
7005
|
+
const d = curr - prev;
|
|
7006
|
+
if (d > 0) return `<span style="color:var(--color-danger);font-size:0.65rem;"> +${d}</span>`;
|
|
7007
|
+
if (d < 0) return `<span style="color:var(--color-success);font-size:0.65rem;"> ${d}</span>`;
|
|
7008
|
+
return '';
|
|
7009
|
+
}
|
|
7010
|
+
|
|
7011
|
+
// Event type labels
|
|
7012
|
+
const eventLabel = (type) => {
|
|
7013
|
+
const labels = {
|
|
7014
|
+
page_added: 'New page',
|
|
7015
|
+
page_removed: 'Page removed',
|
|
7016
|
+
new_error: 'New error',
|
|
7017
|
+
status_changed: 'Status changed',
|
|
7018
|
+
title_changed: 'Title changed',
|
|
7019
|
+
h1_changed: 'H1 changed',
|
|
7020
|
+
meta_desc_changed: 'Meta description changed',
|
|
7021
|
+
word_count_changed: 'Word count changed',
|
|
7022
|
+
indexability_changed: 'Indexability changed',
|
|
7023
|
+
content_changed: 'Content updated',
|
|
7024
|
+
};
|
|
7025
|
+
return labels[type] || type.replace(/_/g, ' ');
|
|
7026
|
+
};
|
|
7027
|
+
|
|
7028
|
+
const severityIcon = (s) => {
|
|
7029
|
+
if (s === 'critical') return '<span style="color:var(--color-danger);">●</span>';
|
|
7030
|
+
if (s === 'warning') return '<span style="color:var(--color-warning);">▲</span>';
|
|
7031
|
+
return '<span style="color:var(--text-muted);">○</span>';
|
|
7032
|
+
};
|
|
7033
|
+
|
|
7034
|
+
const shownEvents = events.slice(0, 20);
|
|
7035
|
+
|
|
7036
|
+
return `
|
|
7037
|
+
<div class="card" style="margin-bottom:16px;">
|
|
7038
|
+
${cardExportHtml('watch', project)}
|
|
7039
|
+
<h2><span class="icon"><i class="fa-solid fa-eye"></i></span> Site Watch</h2>
|
|
7040
|
+
|
|
7041
|
+
<!-- Health Score + Summary -->
|
|
7042
|
+
<div style="display:flex;align-items:center;gap:24px;margin-bottom:16px;">
|
|
7043
|
+
<div style="text-align:center;">
|
|
7044
|
+
<div style="font-size:2rem;font-weight:700;color:${scoreColor};line-height:1;">${score}</div>
|
|
7045
|
+
<div style="font-size:0.65rem;color:var(--text-muted);">Health Score</div>
|
|
7046
|
+
</div>
|
|
7047
|
+
<div style="font-size:0.75rem;">
|
|
7048
|
+
<div>${trendArrow}${prevText}</div>
|
|
7049
|
+
<div style="color:var(--text-muted);font-size:0.65rem;margin-top:4px;">${current.total_pages} pages · ${timestamp}</div>
|
|
7050
|
+
</div>
|
|
7051
|
+
<div style="display:flex;gap:16px;margin-left:auto;font-size:0.75rem;">
|
|
7052
|
+
<div style="text-align:center;">
|
|
7053
|
+
<div style="font-size:1.1rem;font-weight:600;color:var(--color-danger);">${eCurr}</div>
|
|
7054
|
+
<div style="font-size:0.6rem;color:var(--text-muted);">Critical${delta(eCurr, ePrev)}</div>
|
|
7055
|
+
</div>
|
|
7056
|
+
<div style="text-align:center;">
|
|
7057
|
+
<div style="font-size:1.1rem;font-weight:600;color:var(--color-warning);">${wCurr}</div>
|
|
7058
|
+
<div style="font-size:0.6rem;color:var(--text-muted);">Warning${delta(wCurr, wPrev)}</div>
|
|
7059
|
+
</div>
|
|
7060
|
+
<div style="text-align:center;">
|
|
7061
|
+
<div style="font-size:1.1rem;font-weight:600;color:var(--text-secondary);">${nCurr}</div>
|
|
7062
|
+
<div style="font-size:0.6rem;color:var(--text-muted);">Notice${delta(nCurr, nPrev)}</div>
|
|
7063
|
+
</div>
|
|
7064
|
+
</div>
|
|
7065
|
+
</div>
|
|
7066
|
+
|
|
7067
|
+
${shownEvents.length ? `
|
|
7068
|
+
<!-- What's New -->
|
|
7069
|
+
<div style="border-top:1px solid var(--border-card);padding-top:12px;">
|
|
7070
|
+
<div style="font-size:0.72rem;font-weight:600;color:var(--text-primary);margin-bottom:8px;">What's New</div>
|
|
7071
|
+
<div class="table-wrapper" style="max-height:320px;overflow-y:auto;">
|
|
7072
|
+
<table>
|
|
7073
|
+
<thead>
|
|
7074
|
+
<tr>
|
|
7075
|
+
<th style="width:30px;"></th>
|
|
7076
|
+
<th>Type</th>
|
|
7077
|
+
<th>URL</th>
|
|
7078
|
+
<th>Change</th>
|
|
7079
|
+
</tr>
|
|
7080
|
+
</thead>
|
|
7081
|
+
<tbody>
|
|
7082
|
+
${shownEvents.map(ev => {
|
|
7083
|
+
const path = ev.url.replace(/^https?:\/\/[^/]+/, '') || '/';
|
|
7084
|
+
let changeText = '';
|
|
7085
|
+
if (ev.old_value && ev.new_value) {
|
|
7086
|
+
changeText = `${escapeHtml((ev.old_value || '').slice(0, 40))} → ${escapeHtml((ev.new_value || '').slice(0, 40))}`;
|
|
7087
|
+
} else if (ev.new_value) {
|
|
7088
|
+
changeText = escapeHtml((ev.new_value || '').slice(0, 60));
|
|
7089
|
+
} else if (ev.old_value) {
|
|
7090
|
+
changeText = `was: ${escapeHtml((ev.old_value || '').slice(0, 60))}`;
|
|
7091
|
+
}
|
|
7092
|
+
return `<tr>
|
|
7093
|
+
<td style="text-align:center;">${severityIcon(ev.severity)}</td>
|
|
7094
|
+
<td style="font-size:0.7rem;white-space:nowrap;">${eventLabel(ev.event_type)}</td>
|
|
7095
|
+
<td style="font-size:0.7rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(ev.url)}">${escapeHtml(path)}</td>
|
|
7096
|
+
<td style="font-size:0.68rem;color:var(--text-secondary);max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${changeText}</td>
|
|
7097
|
+
</tr>`;
|
|
7098
|
+
}).join('')}
|
|
7099
|
+
</tbody>
|
|
7100
|
+
</table>
|
|
7101
|
+
</div>
|
|
7102
|
+
${events.length > 20 ? `<div style="font-size:0.65rem;color:var(--text-muted);margin-top:4px;">Showing 20 of ${events.length} changes. Run <code>seo-intel watch</code> for full report.</div>` : ''}
|
|
7103
|
+
</div>
|
|
7104
|
+
` : previous ? `
|
|
7105
|
+
<div style="border-top:1px solid var(--border-card);padding-top:12px;font-size:0.75rem;color:var(--color-success);">
|
|
7106
|
+
<i class="fa-solid fa-check-circle" style="margin-right:6px;"></i> No changes detected since last crawl.
|
|
7107
|
+
</div>
|
|
7108
|
+
` : `
|
|
7109
|
+
<div style="border-top:1px solid var(--border-card);padding-top:12px;font-size:0.75rem;color:var(--text-muted);">
|
|
7110
|
+
<i class="fa-solid fa-info-circle" style="margin-right:6px;"></i> Baseline captured. Run another crawl to see changes.
|
|
7111
|
+
</div>
|
|
7112
|
+
`}
|
|
7113
|
+
</div>`;
|
|
7114
|
+
}
|
package/reports/gsc-loader.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Google Search Console CSV data loader
|
|
3
3
|
* Reads GSC export folders from seo-intel/gsc/<project>*/
|
|
4
|
-
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
|
|
5
5
|
import { join, dirname } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
|
|
@@ -87,8 +87,18 @@ export function loadGscData(project) {
|
|
|
87
87
|
);
|
|
88
88
|
if (!folders.length) return null;
|
|
89
89
|
|
|
90
|
-
// Use most
|
|
91
|
-
|
|
90
|
+
// Use most recently modified matching folder.
|
|
91
|
+
// This avoids stale picks like "carbium-2" winning over a freshly uploaded "carbium".
|
|
92
|
+
const selectedFolder = [...folders]
|
|
93
|
+
.map(name => {
|
|
94
|
+
const path = join(GSC_DIR, name);
|
|
95
|
+
let mtimeMs = 0;
|
|
96
|
+
try { mtimeMs = statSync(path).mtimeMs; } catch { /* ignore */ }
|
|
97
|
+
return { name, path, mtimeMs };
|
|
98
|
+
})
|
|
99
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))[0];
|
|
100
|
+
|
|
101
|
+
const folder = selectedFolder.path;
|
|
92
102
|
|
|
93
103
|
function loadCSV(filename) {
|
|
94
104
|
const filepath = join(folder, filename);
|
|
@@ -185,6 +195,6 @@ export function loadGscData(project) {
|
|
|
185
195
|
countries,
|
|
186
196
|
devices,
|
|
187
197
|
summary: { totalClicks, totalImpressions, avgPosition, avgCtr, dateRange },
|
|
188
|
-
folder:
|
|
198
|
+
folder: selectedFolder.name,
|
|
189
199
|
};
|
|
190
200
|
}
|