seo-intel 1.5.38 → 1.5.40
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 +32 -0
- package/cli.js +35 -0
- package/lib/cron.js +108 -0
- package/package.json +1 -1
- package/reports/generate-html.js +139 -7
- package/setup/web-routes.js +39 -0
- package/setup/wizard.html +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.40 (2026-05-28)
|
|
4
|
+
|
|
5
|
+
### Setup wizard — daily notifications now self-install
|
|
6
|
+
The notify loop is now opt-in via a single click. Setup-wizard's Done step gains a small "Daily problem notifications" card that toggles a managed `crontab` line via `lib/cron.js`. No more manual `crontab -e`.
|
|
7
|
+
|
|
8
|
+
- **New CLI:** `seo-intel install-cron [--schedule "0 9 * * *"] [--open] [--remove]` — install / replace / remove a managed cron line. Idempotent. macOS + Linux. Windows returns a clear "use Task Scheduler" message.
|
|
9
|
+
- **New library:** `lib/cron.js` exports `installNotifyCron`, `removeNotifyCron`, `getNotifyCronStatus`. Uses a `# managed-by-seo-intel` marker comment so we only touch our own entries — the user's other cron jobs are never modified.
|
|
10
|
+
- **Setup wizard card** (Step 6, Done): live status pill, one-click Enable/Disable button, default schedule 9am daily, intel-blue accent matching the visual brief tokens. Polls `/api/setup/cron` on step entry; toggle hits `/api/setup/cron POST { action: 'install'|'remove' }`.
|
|
11
|
+
- Cron line writes the absolute `process.execPath` so the scheduled job works regardless of the user's PATH inside cron's minimal environment (a classic foot-gun avoided).
|
|
12
|
+
|
|
13
|
+
Full lifecycle verified: status read → install (`0 9 * * *` line written, crontab confirms) → status reports installed → remove → crontab clean → status reports clean. Smoke 10/10.
|
|
14
|
+
|
|
15
|
+
## 1.5.39 (2026-05-27)
|
|
16
|
+
|
|
17
|
+
### Dashboard — Problems card as the landing surface (Ahrefs-style "what's broken")
|
|
18
|
+
The biggest UX shift since MCP shipped. Opening the dashboard now greets the user with a unified Problems card at the very top of every project panel — same data backing the `list_problems` MCP tool, finally surfaced for humans too.
|
|
19
|
+
|
|
20
|
+
- **New `buildProblemsCard()`** renders Ahrefs-style: big counters (Critical / Warn / Info) using the v1.5.33 visual-brief `.vb-score-big` numerals, top 12 issues table with severity dots, category, fix-difficulty stars (1–5), and an expandable "Fix" disclosure per row showing the agent-friendly `fix_template`.
|
|
21
|
+
- Single source of truth: same `getProblems()` library function that powers `list_problems` MCP tool. Dashboard and AI agents see identical data; closing one closes both.
|
|
22
|
+
- "Showing top 12 of 190 — query the rest via MCP: `list_problems("carbium", limit=190)`" — makes the agent escape hatch visible from the dashboard itself.
|
|
23
|
+
- Empty state: "all clear" message when no problems pending.
|
|
24
|
+
|
|
25
|
+
### Dashboard — AI Citability card polished to brief spec
|
|
26
|
+
- Inline colors (`#4ade80`, `#facc15`, `#ff8c00`, `#ef4444`) swapped to brief signal tokens (`var(--signal-good)`, `var(--signal-warn)`, `var(--signal-bad)`). One color system, no drift.
|
|
27
|
+
- Score gradient aligned with `lib/problems.js` severity buckets: ≥60 good, 35–59 warn, <35 bad.
|
|
28
|
+
- New `.vb-pill` header chip with the "weakest signal" caption ("weakest: answer density") so the user sees the headline takeaway at a glance.
|
|
29
|
+
- Existing signal bars + page-score table preserved — minimal disruption, maximum polish.
|
|
30
|
+
|
|
31
|
+
**Verified live against carbium / risunouto / ukkometa:** 36 severity dots rendered across three Problems cards, 3 MCP-hint references, citability cards on each pro panel, no existing functionality broken. Smoke 10/10. HTML size unchanged (2.4MB).
|
|
32
|
+
|
|
33
|
+
Next: setup-wizard cron-entry installer (v1.5.40), then per-page polish for Site Watch timeline / Competitive Radar / Action Export modal.
|
|
34
|
+
|
|
3
35
|
## 1.5.38 (2026-05-23)
|
|
4
36
|
|
|
5
37
|
### Fix — LM Studio model count was always 0 (wrong endpoint + wrong parser)
|
package/cli.js
CHANGED
|
@@ -1511,6 +1511,41 @@ program
|
|
|
1511
1511
|
if (fired === 0) console.log(chalk.dim(' ✓ No projects need attention.'));
|
|
1512
1512
|
});
|
|
1513
1513
|
|
|
1514
|
+
// ── INSTALL-CRON (one-shot cron entry for daily notify) ────────────────────
|
|
1515
|
+
program
|
|
1516
|
+
.command('install-cron')
|
|
1517
|
+
.description('Install / remove the daily seo-intel notify cron entry (macOS + Linux)')
|
|
1518
|
+
.option('--remove', 'Remove the managed cron entry instead of installing')
|
|
1519
|
+
.option('--schedule <cron>', 'Cron schedule, 5 fields (default: 9am daily)', '0 9 * * *')
|
|
1520
|
+
.option('--open', 'Pass --open to the scheduled notify command (dashboard opens when fired)')
|
|
1521
|
+
.action(async (opts) => {
|
|
1522
|
+
const { installNotifyCron, removeNotifyCron, getNotifyCronStatus, DEFAULT_SCHEDULE } = await import('./lib/cron.js');
|
|
1523
|
+
if (opts.remove) {
|
|
1524
|
+
const r = removeNotifyCron();
|
|
1525
|
+
if (!r.ok) { console.error(chalk.red(`Failed: ${r.error}`)); process.exit(1); }
|
|
1526
|
+
console.log(chalk.green(r.removed ? ' ✓ Removed managed cron entry.' : ' (Nothing to remove.)'));
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const status = getNotifyCronStatus();
|
|
1530
|
+
if (status.platform === 'win32') {
|
|
1531
|
+
console.log(chalk.yellow(' ⚠ Windows: use Task Scheduler manually.'));
|
|
1532
|
+
console.log(chalk.dim(' Daily task command:'));
|
|
1533
|
+
console.log(chalk.dim(` ${process.execPath} ${join(__dirname, 'cli.js')} notify`));
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (status.installed) {
|
|
1537
|
+
console.log(chalk.dim(` Existing entry detected (schedule: ${status.schedule}). Replacing…`));
|
|
1538
|
+
}
|
|
1539
|
+
const result = installNotifyCron({ schedule: opts.schedule, openOnFire: !!opts.open });
|
|
1540
|
+
if (!result.ok) { console.error(chalk.red(`Failed: ${result.error}`)); process.exit(1); }
|
|
1541
|
+
console.log(chalk.green(` ✓ Daily notification scheduled (cron: ${result.schedule})`));
|
|
1542
|
+
console.log(chalk.dim(` Cron line: ${result.line}`));
|
|
1543
|
+
console.log(chalk.dim(` Remove with: seo-intel install-cron --remove`));
|
|
1544
|
+
if (process.platform === 'darwin') {
|
|
1545
|
+
console.log(chalk.dim(` Note: macOS may prompt to authorise cron access on first fire — approve in System Settings if so.`));
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1514
1549
|
// ── STATUS ─────────────────────────────────────────────────────────────────
|
|
1515
1550
|
program
|
|
1516
1551
|
.command('status')
|
package/lib/cron.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cron.js — Install / remove the daily `seo-intel notify` cron entry.
|
|
3
|
+
*
|
|
4
|
+
* The "user forgets to check SEO" defense from v1.5.34's delivery brainstorm.
|
|
5
|
+
* Adds a single managed crontab line tagged with a marker comment so we can
|
|
6
|
+
* find and replace/remove our own entry without touching the user's other
|
|
7
|
+
* cron jobs.
|
|
8
|
+
*
|
|
9
|
+
* macOS + Linux: uses crontab(1). On macOS the first install will prompt the
|
|
10
|
+
* user to approve calendar/automation access via the system permission dialog
|
|
11
|
+
* — that's normal, nothing we can do about it.
|
|
12
|
+
*
|
|
13
|
+
* Windows: returns ok:false with a hint pointing at Task Scheduler. Out of
|
|
14
|
+
* scope for v1.5.40.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'child_process';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { dirname, join } from 'path';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const ROOT = join(__dirname, '..');
|
|
23
|
+
const NODE_BIN = process.execPath;
|
|
24
|
+
const MARKER = '# managed-by-seo-intel';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_SCHEDULE = '0 9 * * *'; // 9am every day
|
|
27
|
+
|
|
28
|
+
function readCrontab() {
|
|
29
|
+
const r = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
30
|
+
if (r.status === 0) return r.stdout || '';
|
|
31
|
+
// status !== 0 typically means "no crontab for user yet" — return empty
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeCrontab(content) {
|
|
36
|
+
const text = (content || '').replace(/\n*$/, '\n'); // ensure single trailing newline
|
|
37
|
+
const r = spawnSync('crontab', ['-'], { input: text, encoding: 'utf8' });
|
|
38
|
+
if (r.status !== 0) {
|
|
39
|
+
throw new Error(`crontab write failed: ${r.stderr || 'unknown error'}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isWindows() { return process.platform === 'win32'; }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {{ installed: boolean, line: string|null, schedule: string|null, platform: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function getNotifyCronStatus() {
|
|
49
|
+
if (isWindows()) return { installed: false, line: null, schedule: null, platform: 'win32' };
|
|
50
|
+
const lines = readCrontab().split('\n').filter(l => l.includes(MARKER));
|
|
51
|
+
if (!lines.length) return { installed: false, line: null, schedule: null, platform: process.platform };
|
|
52
|
+
const line = lines[0];
|
|
53
|
+
// Schedule is the first 5 space-separated fields
|
|
54
|
+
const parts = line.trim().split(/\s+/);
|
|
55
|
+
const schedule = parts.slice(0, 5).join(' ');
|
|
56
|
+
return { installed: true, line, schedule, platform: process.platform };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Install (or replace) the managed cron line.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [opts]
|
|
63
|
+
* @param {string} [opts.schedule] Cron schedule, default DEFAULT_SCHEDULE (9am daily)
|
|
64
|
+
* @param {boolean} [opts.openOnFire] Append `--open` flag so the dashboard opens when fired
|
|
65
|
+
* @returns {{ ok: boolean, line?: string, schedule?: string, error?: string, hint?: string }}
|
|
66
|
+
*/
|
|
67
|
+
export function installNotifyCron({ schedule = DEFAULT_SCHEDULE, openOnFire = false } = {}) {
|
|
68
|
+
if (isWindows()) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: 'Windows not supported — use Task Scheduler manually',
|
|
72
|
+
hint: `Create a daily task running: ${NODE_BIN} "${join(ROOT, 'cli.js')}" notify`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Sanity-check schedule (5 fields, no shell metachars)
|
|
76
|
+
if (!/^[-*\/0-9, ]+$/.test(schedule) || schedule.trim().split(/\s+/).length !== 5) {
|
|
77
|
+
return { ok: false, error: `Invalid cron schedule "${schedule}". Expected 5 fields, e.g. "0 9 * * *".` };
|
|
78
|
+
}
|
|
79
|
+
const cmd = `cd ${ROOT} && ${NODE_BIN} cli.js notify${openOnFire ? ' --open' : ''}`;
|
|
80
|
+
const newLine = `${schedule} ${cmd} ${MARKER}`;
|
|
81
|
+
const current = readCrontab();
|
|
82
|
+
const kept = current.split('\n').filter(l => l && !l.includes(MARKER));
|
|
83
|
+
kept.push(newLine);
|
|
84
|
+
try {
|
|
85
|
+
writeCrontab(kept.join('\n'));
|
|
86
|
+
return { ok: true, line: newLine, schedule };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { ok: false, error: e.message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Remove the managed cron line (if any). Idempotent.
|
|
94
|
+
* @returns {{ ok: boolean, removed: boolean, error?: string }}
|
|
95
|
+
*/
|
|
96
|
+
export function removeNotifyCron() {
|
|
97
|
+
if (isWindows()) return { ok: true, removed: false }; // nothing to remove
|
|
98
|
+
const current = readCrontab();
|
|
99
|
+
const before = current.split('\n').filter(Boolean).length;
|
|
100
|
+
const kept = current.split('\n').filter(l => l && !l.includes(MARKER));
|
|
101
|
+
if (kept.length === before) return { ok: true, removed: false };
|
|
102
|
+
try {
|
|
103
|
+
writeCrontab(kept.join('\n'));
|
|
104
|
+
return { ok: true, removed: true };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, removed: false, error: e.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/package.json
CHANGED
package/reports/generate-html.js
CHANGED
|
@@ -21,6 +21,7 @@ import { isPro } from '../lib/license.js';
|
|
|
21
21
|
import { getActiveInsights } from '../db/db.js';
|
|
22
22
|
import { getCitabilityScores } from '../analyses/aeo/index.js';
|
|
23
23
|
import { getWatchData } from '../analyses/watch/index.js';
|
|
24
|
+
import { getProblems, getProblemCounts } from '../lib/problems.js';
|
|
24
25
|
|
|
25
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
|
|
@@ -127,6 +128,14 @@ export function gatherProjectData(db, project, config) {
|
|
|
127
128
|
let citabilityData = null;
|
|
128
129
|
try { citabilityData = getCitabilityScores(db, project); } catch { /* table may not exist yet */ }
|
|
129
130
|
|
|
131
|
+
// Problems (v1.5.39) — unified Ahrefs-style "what's broken" feed
|
|
132
|
+
let problems = [];
|
|
133
|
+
let problemCounts = null;
|
|
134
|
+
try {
|
|
135
|
+
problems = getProblems(db, project, { includePaid: isPro(), limit: 200 });
|
|
136
|
+
problemCounts = getProblemCounts(db, project, { includePaid: isPro() });
|
|
137
|
+
} catch { /* fresh DB / migration not run yet — silent */ }
|
|
138
|
+
|
|
130
139
|
// Site Watch data
|
|
131
140
|
let watchData = null;
|
|
132
141
|
try { watchData = getWatchData(db, project); } catch { /* tables may not exist yet */ }
|
|
@@ -155,6 +164,7 @@ export function gatherProjectData(db, project, config) {
|
|
|
155
164
|
gravityMap, contentTerrain, keywordVenn, performanceBubbles,
|
|
156
165
|
headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
|
|
157
166
|
keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData, watchData,
|
|
167
|
+
problems, problemCounts,
|
|
158
168
|
};
|
|
159
169
|
|
|
160
170
|
// Rollback the owned→target merge so the actual DB is unchanged
|
|
@@ -227,6 +237,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
227
237
|
gravityMap, contentTerrain, keywordVenn, performanceBubbles,
|
|
228
238
|
headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
|
|
229
239
|
keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData, watchData,
|
|
240
|
+
problems = [], problemCounts = null,
|
|
230
241
|
} = data;
|
|
231
242
|
|
|
232
243
|
const totalPages = domains.reduce((sum, d) => sum + d.page_count, 0);
|
|
@@ -2979,6 +2990,9 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2979
2990
|
|
|
2980
2991
|
<div class="dashboard">
|
|
2981
2992
|
|
|
2993
|
+
<!-- ═══ PROBLEMS (v1.5.39 — Ahrefs-style landing card) ═══ -->
|
|
2994
|
+
${buildProblemsCard(problems, problemCounts, escapeHtml, project)}
|
|
2995
|
+
|
|
2982
2996
|
<!-- ═══ GSC PERFORMANCE TREND ═══ -->
|
|
2983
2997
|
${gscData ? (() => {
|
|
2984
2998
|
const s = gscData.summary;
|
|
@@ -5849,6 +5863,115 @@ function buildMultiHtmlTemplate(allProjectData) {
|
|
|
5849
5863
|
|
|
5850
5864
|
// ─── AEO Card Builder ────────────────────────────────────────────────────────
|
|
5851
5865
|
|
|
5866
|
+
// ─── Problems card (v1.5.39) — Ahrefs-style unified "what's broken" feed ──
|
|
5867
|
+
// Uses lib/problems.js getProblems() — single source of truth shared with MCP.
|
|
5868
|
+
function buildProblemsCard(problems, counts, escapeHtml, project) {
|
|
5869
|
+
if (!counts || counts.total === 0) {
|
|
5870
|
+
return `
|
|
5871
|
+
<div class="card full-width vb-card" id="problems-card" style="margin-bottom: 24px;">
|
|
5872
|
+
<div style="display:flex; align-items:center; gap:14px; margin-bottom:14px;">
|
|
5873
|
+
<span class="vb-pill">Problems</span>
|
|
5874
|
+
<span class="vb-label-caps">all clear</span>
|
|
5875
|
+
</div>
|
|
5876
|
+
<div style="color: var(--text-muted); font-size: 0.85rem;">
|
|
5877
|
+
<i class="fa-solid fa-check" style="color: var(--signal-good); margin-right: 6px;"></i>
|
|
5878
|
+
No pending issues detected for this project. Run a fresh crawl to refresh detection.
|
|
5879
|
+
</div>
|
|
5880
|
+
</div>`;
|
|
5881
|
+
}
|
|
5882
|
+
|
|
5883
|
+
const sev = (s) => s === 'critical' ? 'crit' : s === 'warn' ? 'warn' : 'info';
|
|
5884
|
+
const sevColor = (s) => s === 'critical' ? 'var(--signal-bad)' : s === 'warn' ? 'var(--signal-warn)' : 'var(--signal-good)';
|
|
5885
|
+
const sevLabel = (s) => s.toUpperCase();
|
|
5886
|
+
const diffStars = (n) => '●'.repeat(Math.max(1, Math.min(5, n))) + '○'.repeat(5 - Math.max(1, Math.min(5, n)));
|
|
5887
|
+
|
|
5888
|
+
const top = problems.slice(0, 12);
|
|
5889
|
+
const remaining = problems.length - top.length;
|
|
5890
|
+
|
|
5891
|
+
const rows = top.map(p => {
|
|
5892
|
+
const sevClass = sev(p.severity);
|
|
5893
|
+
const sevCol = sevColor(p.severity);
|
|
5894
|
+
const fix = (p.fix_template || '').slice(0, 200);
|
|
5895
|
+
const urls = (p.affected_urls || []).slice(0, 3).map(u => {
|
|
5896
|
+
try { return new URL(u).pathname || u; } catch { return u.slice(0, 50); }
|
|
5897
|
+
});
|
|
5898
|
+
return `
|
|
5899
|
+
<tr data-problem-id="${escapeHtml(p.id)}">
|
|
5900
|
+
<td style="vertical-align:top; padding-top: 14px;">
|
|
5901
|
+
<span class="vb-severity-dot ${sevClass}"></span>
|
|
5902
|
+
</td>
|
|
5903
|
+
<td style="vertical-align:top;">
|
|
5904
|
+
<div style="font-family: var(--font-display); font-weight: 700; font-size: 0.92rem; color: var(--text-primary); line-height: 1.3;">${escapeHtml(p.title)}</div>
|
|
5905
|
+
<div style="font-size: 0.72rem; color: var(--text-muted); margin-top: 4px; line-height: 1.5;">${escapeHtml(p.description)}</div>
|
|
5906
|
+
${urls.length ? `<div class="vb-num-tabular" style="font-size: 0.68rem; color: var(--text-subtle); margin-top: 4px;">${urls.map(u => `<code style="background:transparent;">${escapeHtml(u)}</code>`).join(' · ')}</div>` : ''}
|
|
5907
|
+
</td>
|
|
5908
|
+
<td style="vertical-align:top; padding-top: 14px;">
|
|
5909
|
+
<span class="vb-label-caps" style="color: ${sevCol};">${sevLabel(p.severity)}</span>
|
|
5910
|
+
<div style="font-size: 0.65rem; color: var(--text-muted); margin-top: 2px;">${escapeHtml(p.category)}</div>
|
|
5911
|
+
</td>
|
|
5912
|
+
<td style="vertical-align:top; padding-top: 14px;" title="Fix difficulty: ${p.fix_difficulty}/5">
|
|
5913
|
+
<span style="color: var(--intel-blue); font-size: 0.7rem; letter-spacing: 1px;">${diffStars(p.fix_difficulty)}</span>
|
|
5914
|
+
</td>
|
|
5915
|
+
<td style="vertical-align:top; padding-top: 12px;">
|
|
5916
|
+
<details style="font-size: 0.7rem;">
|
|
5917
|
+
<summary style="cursor:pointer; color: var(--intel-blue); font-weight: 600; user-select: none;">Fix</summary>
|
|
5918
|
+
<div style="margin-top: 8px; padding: 10px; background: var(--surface-off); border-left: 2px solid var(--intel-blue); color: var(--text-secondary); line-height: 1.5; font-size: 0.72rem;">${escapeHtml(fix)}${(p.fix_template || '').length > 200 ? '…' : ''}</div>
|
|
5919
|
+
</details>
|
|
5920
|
+
</td>
|
|
5921
|
+
</tr>`;
|
|
5922
|
+
}).join('');
|
|
5923
|
+
|
|
5924
|
+
return `
|
|
5925
|
+
<div class="card full-width vb-card" id="problems-card" style="margin-bottom: 24px;">
|
|
5926
|
+
<div style="display:flex; align-items:center; gap:14px; margin-bottom: 18px; flex-wrap: wrap;">
|
|
5927
|
+
<span class="vb-pill">Problems</span>
|
|
5928
|
+
<span style="font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; color: var(--text-primary); letter-spacing: -0.02em;">${counts.total} issue${counts.total === 1 ? '' : 's'} pending</span>
|
|
5929
|
+
<span class="vb-label-caps" style="margin-left:auto; color: var(--text-subtle);">ahrefs-style site health</span>
|
|
5930
|
+
</div>
|
|
5931
|
+
|
|
5932
|
+
<div style="display:flex; gap: 32px; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--surface-border); flex-wrap: wrap;">
|
|
5933
|
+
<div>
|
|
5934
|
+
<div class="vb-score-big ${counts.critical > 0 ? 'bad' : 'good'}">${counts.critical}</div>
|
|
5935
|
+
<div class="vb-label-caps" style="margin-top: 6px;">critical</div>
|
|
5936
|
+
</div>
|
|
5937
|
+
<div>
|
|
5938
|
+
<div class="vb-score-big ${counts.warn > 9 ? 'warn' : 'good'}">${counts.warn}</div>
|
|
5939
|
+
<div class="vb-label-caps" style="margin-top: 6px;">warn</div>
|
|
5940
|
+
</div>
|
|
5941
|
+
<div>
|
|
5942
|
+
<div class="vb-score-big good">${counts.info}</div>
|
|
5943
|
+
<div class="vb-label-caps" style="margin-top: 6px;">info</div>
|
|
5944
|
+
</div>
|
|
5945
|
+
<div style="margin-left:auto; max-width: 420px; align-self: center;">
|
|
5946
|
+
<div style="font-size: 0.78rem; color: var(--text-secondary); line-height: 1.6;">
|
|
5947
|
+
Each problem ships with a <strong style="color: var(--intel-blue);">fix template</strong> and <strong style="color: var(--intel-blue);">verification</strong> step — copy the Fix into an AI agent (via MCP <code style="color: var(--text-primary); background: var(--surface-off); padding: 1px 5px; border-radius: 3px; font-size: 0.7rem;">list_problems</code>) or apply manually.
|
|
5948
|
+
</div>
|
|
5949
|
+
</div>
|
|
5950
|
+
</div>
|
|
5951
|
+
|
|
5952
|
+
<table class="analysis-table" style="margin: 0;">
|
|
5953
|
+
<thead>
|
|
5954
|
+
<tr>
|
|
5955
|
+
<th style="width: 28px;"></th>
|
|
5956
|
+
<th>Issue</th>
|
|
5957
|
+
<th style="width: 90px;">Severity</th>
|
|
5958
|
+
<th style="width: 90px;">Difficulty</th>
|
|
5959
|
+
<th style="width: 100px;">Action</th>
|
|
5960
|
+
</tr>
|
|
5961
|
+
</thead>
|
|
5962
|
+
<tbody>${rows}</tbody>
|
|
5963
|
+
</table>
|
|
5964
|
+
|
|
5965
|
+
${remaining > 0 ? `
|
|
5966
|
+
<div style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--surface-border); text-align: center;">
|
|
5967
|
+
<span style="color: var(--text-muted); font-size: 0.78rem;">
|
|
5968
|
+
Showing top ${top.length} of ${counts.total} — query the rest via MCP:
|
|
5969
|
+
<code style="color: var(--intel-blue); background: var(--surface-off); padding: 2px 6px; border-radius: 3px; margin-left: 4px;">list_problems("${escapeHtml(project)}", limit=${counts.total})</code>
|
|
5970
|
+
</span>
|
|
5971
|
+
</div>` : ''}
|
|
5972
|
+
</div>`;
|
|
5973
|
+
}
|
|
5974
|
+
|
|
5852
5975
|
function buildAeoCard(citabilityData, escapeHtml, project) {
|
|
5853
5976
|
const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
|
|
5854
5977
|
const compScores = citabilityData.filter(s => s.role === 'competitor');
|
|
@@ -5867,7 +5990,8 @@ function buildAeoCard(citabilityData, escapeHtml, project) {
|
|
|
5867
5990
|
avg: Math.round(targetScores.reduce((a, s) => a + (s[sig] || 0), 0) / targetScores.length),
|
|
5868
5991
|
}));
|
|
5869
5992
|
|
|
5870
|
-
|
|
5993
|
+
// Brief gradient: 0–34 bad, 35–59 warn, 60+ good (matches lib/problems.js severity)
|
|
5994
|
+
const scoreColor = (s) => s >= 60 ? 'var(--signal-good)' : s >= 35 ? 'var(--signal-warn)' : 'var(--signal-bad)';
|
|
5871
5995
|
|
|
5872
5996
|
// Page rows (worst first, limit 25)
|
|
5873
5997
|
const pageRows = targetScores
|
|
@@ -5908,20 +6032,28 @@ function buildAeoCard(citabilityData, escapeHtml, project) {
|
|
|
5908
6032
|
compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgComp)}">${avgComp}</span><span class="ki-stat-label">Competitor Avg</span></div>`;
|
|
5909
6033
|
}
|
|
5910
6034
|
if (delta !== null) {
|
|
5911
|
-
compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${delta >= 0 ? '
|
|
6035
|
+
compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${delta >= 0 ? 'var(--signal-good)' : 'var(--signal-bad)'}">${delta > 0 ? '+' : ''}${delta}</span><span class="ki-stat-label">Delta</span></div>`;
|
|
5912
6036
|
}
|
|
5913
6037
|
|
|
6038
|
+
// Visual-brief pill header: pick the worst signal for the "weakest area" caption
|
|
6039
|
+
const weakestSignal = [...signalAvgs].sort((a, b) => a.avg - b.avg)[0];
|
|
6040
|
+
|
|
5914
6041
|
return `
|
|
5915
6042
|
<div class="card full-width" id="aeo-citability">
|
|
5916
6043
|
${cardExportHtml('aeo', project)}
|
|
5917
|
-
<
|
|
6044
|
+
<div style="display:flex; align-items:center; gap: 14px; margin-bottom: 18px; flex-wrap: wrap;">
|
|
6045
|
+
<span class="vb-pill">AI Citability</span>
|
|
6046
|
+
<span style="font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; color: var(--text-primary); letter-spacing: -0.02em;">${targetScores.length} pages scored</span>
|
|
6047
|
+
${weakestSignal ? `<span class="vb-label-caps" style="margin-left:auto; color: var(--text-subtle);">weakest: ${weakestSignal.label}</span>` : ''}
|
|
6048
|
+
</div>
|
|
6049
|
+
<h2 style="display:none;"><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
|
|
5918
6050
|
<div class="ki-stat-bar">
|
|
5919
6051
|
<div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
|
|
5920
6052
|
${compStatHtml}
|
|
5921
|
-
<div class="ki-stat"><span class="ki-stat-number" style="color
|
|
5922
|
-
<div class="ki-stat"><span class="ki-stat-number" style="color
|
|
5923
|
-
<div class="ki-stat"><span class="ki-stat-number" style="color
|
|
5924
|
-
<div class="ki-stat"><span class="ki-stat-number" style="color
|
|
6053
|
+
<div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-good)">${tierCounts.excellent}</span><span class="ki-stat-label">Excellent</span></div>
|
|
6054
|
+
<div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-warn)">${tierCounts.good}</span><span class="ki-stat-label">Good</span></div>
|
|
6055
|
+
<div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-bad);opacity:0.85">${tierCounts.needs_work}</span><span class="ki-stat-label">Needs Work</span></div>
|
|
6056
|
+
<div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-bad)">${tierCounts.poor}</span><span class="ki-stat-label">Poor</span></div>
|
|
5925
6057
|
</div>
|
|
5926
6058
|
|
|
5927
6059
|
<div style="display:flex;gap:2rem;margin:1.5rem 0;flex-wrap:wrap;">
|
package/setup/web-routes.js
CHANGED
|
@@ -128,6 +128,17 @@ export function handleSetupRequest(req, res, url) {
|
|
|
128
128
|
return true;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// GET /api/setup/cron — read notify-cron install state
|
|
132
|
+
if (path === '/api/setup/cron' && method === 'GET') {
|
|
133
|
+
handleCronStatus(req, res);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
// POST /api/setup/cron — install / remove
|
|
137
|
+
if (path === '/api/setup/cron' && method === 'POST') {
|
|
138
|
+
handleCronAction(req, res);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
// POST /api/setup/save-env — save a key to .env
|
|
132
143
|
if (path === '/api/setup/save-env' && method === 'POST') {
|
|
133
144
|
handleSaveEnv(req, res);
|
|
@@ -341,6 +352,34 @@ async function handlePingOllama(req, res) {
|
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
354
|
|
|
355
|
+
async function handleCronStatus(req, res) {
|
|
356
|
+
try {
|
|
357
|
+
const { getNotifyCronStatus } = await import('../lib/cron.js');
|
|
358
|
+
jsonResponse(res, getNotifyCronStatus());
|
|
359
|
+
} catch (err) {
|
|
360
|
+
jsonResponse(res, { error: err.message }, 500);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function handleCronAction(req, res) {
|
|
365
|
+
try {
|
|
366
|
+
const body = await readBody(req);
|
|
367
|
+
const { action, schedule, openOnFire } = body || {};
|
|
368
|
+
const { installNotifyCron, removeNotifyCron, getNotifyCronStatus } = await import('../lib/cron.js');
|
|
369
|
+
if (action === 'install') {
|
|
370
|
+
const result = installNotifyCron({ schedule, openOnFire: !!openOnFire });
|
|
371
|
+
jsonResponse(res, { ...result, status: getNotifyCronStatus() });
|
|
372
|
+
} else if (action === 'remove') {
|
|
373
|
+
const result = removeNotifyCron();
|
|
374
|
+
jsonResponse(res, { ...result, status: getNotifyCronStatus() });
|
|
375
|
+
} else {
|
|
376
|
+
jsonResponse(res, { error: 'action must be "install" or "remove"' }, 400);
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
jsonResponse(res, { error: err.message }, 500);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
344
383
|
async function handleSaveEnv(req, res) {
|
|
345
384
|
try {
|
|
346
385
|
const body = await readBody(req);
|
package/setup/wizard.html
CHANGED
|
@@ -1862,6 +1862,21 @@ input::placeholder {
|
|
|
1862
1862
|
<!-- Populated by JS -->
|
|
1863
1863
|
</table>
|
|
1864
1864
|
|
|
1865
|
+
<!-- ── Daily notification scheduler (v1.5.40) ────────────────────────── -->
|
|
1866
|
+
<div id="notifyCronCard" style="margin-top:20px; padding:14px 16px; background:var(--bg-elevated); border:1px solid rgba(59,130,246,0.18); border-radius:var(--radius); display:flex; gap:14px; align-items:center; flex-wrap:wrap;">
|
|
1867
|
+
<i class="fa-solid fa-bell" style="font-size:1rem; color:#3b82f6;"></i>
|
|
1868
|
+
<div style="flex:1; min-width:200px;">
|
|
1869
|
+
<div style="font-size:0.8rem; font-weight:600; color:var(--text-primary); margin-bottom:2px;">Daily problem notifications</div>
|
|
1870
|
+
<div id="notifyCronHint" style="font-size:0.66rem; color:var(--text-muted); line-height:1.4;">
|
|
1871
|
+
Fires a native macOS / Linux notification each morning for projects with pending criticals or warns.
|
|
1872
|
+
</div>
|
|
1873
|
+
</div>
|
|
1874
|
+
<div id="notifyCronStatus" style="font-size:0.66rem; color:var(--text-muted);">…</div>
|
|
1875
|
+
<button id="notifyCronToggleBtn" class="btn btn-sm" onclick="toggleNotifyCron()" style="padding:5px 12px; font-size:0.7rem; min-width:90px;">
|
|
1876
|
+
<i class="fa-solid fa-spinner fa-spin"></i>
|
|
1877
|
+
</button>
|
|
1878
|
+
</div>
|
|
1879
|
+
|
|
1865
1880
|
<h3 style="font-family:var(--font-display); font-size:0.85rem; color:var(--text-primary); margin: 20px 0 12px; font-weight:600;">
|
|
1866
1881
|
<i class="fa-solid fa-rocket" style="color:var(--accent-gold); margin-right:6px;"></i> What's Next
|
|
1867
1882
|
</h3>
|
|
@@ -3409,6 +3424,64 @@ input::placeholder {
|
|
|
3409
3424
|
}
|
|
3410
3425
|
|
|
3411
3426
|
goToStep(6);
|
|
3427
|
+
loadNotifyCronStatus();
|
|
3428
|
+
};
|
|
3429
|
+
|
|
3430
|
+
// ── Daily notification cron (v1.5.40) ──────────────────────────────────
|
|
3431
|
+
let _cronState = null;
|
|
3432
|
+
async function loadNotifyCronStatus() {
|
|
3433
|
+
const statusEl = document.getElementById('notifyCronStatus');
|
|
3434
|
+
const btn = document.getElementById('notifyCronToggleBtn');
|
|
3435
|
+
const hint = document.getElementById('notifyCronHint');
|
|
3436
|
+
if (!statusEl || !btn) return;
|
|
3437
|
+
try {
|
|
3438
|
+
const s = await API.get('/api/setup/cron');
|
|
3439
|
+
_cronState = s;
|
|
3440
|
+
if (s.platform === 'win32') {
|
|
3441
|
+
statusEl.textContent = 'Windows: use Task Scheduler';
|
|
3442
|
+
btn.disabled = true;
|
|
3443
|
+
btn.innerHTML = 'N/A';
|
|
3444
|
+
hint.textContent = 'Cron auto-install is macOS / Linux only — set up a daily Task Scheduler entry manually.';
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
if (s.installed) {
|
|
3448
|
+
statusEl.innerHTML = `<span style="color:var(--color-success);"><i class="fa-solid fa-check"></i> Active · ${s.schedule}</span>`;
|
|
3449
|
+
btn.innerHTML = '<i class="fa-solid fa-bell-slash"></i> Disable';
|
|
3450
|
+
btn.style.borderColor = 'rgba(244,123,93,0.4)';
|
|
3451
|
+
btn.style.color = '#f47b5d';
|
|
3452
|
+
} else {
|
|
3453
|
+
statusEl.innerHTML = `<span style="color:var(--text-muted);">Not scheduled</span>`;
|
|
3454
|
+
btn.innerHTML = '<i class="fa-solid fa-bell"></i> Enable';
|
|
3455
|
+
btn.style.borderColor = 'rgba(59,130,246,0.4)';
|
|
3456
|
+
btn.style.color = '#3b82f6';
|
|
3457
|
+
}
|
|
3458
|
+
btn.disabled = false;
|
|
3459
|
+
} catch (err) {
|
|
3460
|
+
statusEl.textContent = 'Status check failed';
|
|
3461
|
+
btn.disabled = true;
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
window.toggleNotifyCron = async function() {
|
|
3466
|
+
const btn = document.getElementById('notifyCronToggleBtn');
|
|
3467
|
+
if (!btn || btn.disabled) return;
|
|
3468
|
+
btn.disabled = true;
|
|
3469
|
+
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
|
3470
|
+
const action = _cronState?.installed ? 'remove' : 'install';
|
|
3471
|
+
try {
|
|
3472
|
+
const r = await API.post('/api/setup/cron', { action, schedule: '0 9 * * *' });
|
|
3473
|
+
if (r.ok === false && r.error) {
|
|
3474
|
+
document.getElementById('notifyCronStatus').textContent = 'Error: ' + r.error;
|
|
3475
|
+
btn.disabled = false;
|
|
3476
|
+
btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Retry';
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
await loadNotifyCronStatus();
|
|
3480
|
+
} catch (err) {
|
|
3481
|
+
document.getElementById('notifyCronStatus').textContent = 'Error: ' + err.message;
|
|
3482
|
+
btn.disabled = false;
|
|
3483
|
+
btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Retry';
|
|
3484
|
+
}
|
|
3412
3485
|
};
|
|
3413
3486
|
|
|
3414
3487
|
window.copyCli = function() {
|