@houtini/seo-audit-console 0.6.0 ā 0.8.0
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/README.md +6 -3
- package/dist/core/AuditDatabase.d.ts.map +1 -1
- package/dist/core/AuditDatabase.js +6 -0
- package/dist/core/AuditDatabase.js.map +1 -1
- package/dist/core/Backlinks.d.ts +5 -1
- package/dist/core/Backlinks.d.ts.map +1 -1
- package/dist/core/Backlinks.js +31 -1
- package/dist/core/Backlinks.js.map +1 -1
- package/dist/core/DataForSeoClient.d.ts +8 -7
- package/dist/core/DataForSeoClient.d.ts.map +1 -1
- package/dist/core/DataForSeoClient.js +7 -7
- package/dist/core/DataForSeoClient.js.map +1 -1
- package/dist/core/MajesticClient.d.ts +1 -1
- package/dist/core/MajesticClient.d.ts.map +1 -1
- package/dist/core/MajesticClient.js +11 -2
- package/dist/core/MajesticClient.js.map +1 -1
- package/dist/core/dashboardData.d.ts +28 -0
- package/dist/core/dashboardData.d.ts.map +1 -1
- package/dist/core/dashboardData.js +64 -3
- package/dist/core/dashboardData.js.map +1 -1
- package/dist/core/googleNews.d.ts +26 -0
- package/dist/core/googleNews.d.ts.map +1 -0
- package/dist/core/googleNews.js +49 -0
- package/dist/core/googleNews.js.map +1 -0
- package/dist/core/webHandlers.d.ts +20 -0
- package/dist/core/webHandlers.d.ts.map +1 -0
- package/dist/core/webHandlers.js +67 -0
- package/dist/core/webHandlers.js.map +1 -0
- package/dist/core/webServer.d.ts +8 -1
- package/dist/core/webServer.d.ts.map +1 -1
- package/dist/core/webServer.js +12 -4
- package/dist/core/webServer.js.map +1 -1
- package/dist/dashboard.d.ts +3 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +58 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +100 -45
- package/dist/server.js.map +1 -1
- package/dist/src/ui/dashboard.html +83 -51
- package/package.json +108 -107
- package/server.json +85 -85
package/dist/server.js
CHANGED
|
@@ -8,7 +8,8 @@ import path from 'node:path';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { getDashboardData } from './core/dashboardData.js';
|
|
11
|
-
import { startDashboardServer, stopDashboardServer, dashboardServerUrl
|
|
11
|
+
import { startDashboardServer, stopDashboardServer, dashboardServerUrl } from './core/webServer.js';
|
|
12
|
+
import { buildWebCallHandlers } from './core/webHandlers.js';
|
|
12
13
|
import { computeSerpFootprint, persistSerpFootprint } from './core/serpFootprint.js';
|
|
13
14
|
import { computeMarketSizing, persistMarketSizing } from './core/marketSizing.js';
|
|
14
15
|
import { FirecrawlClient } from './core/FirecrawlClient.js';
|
|
@@ -22,8 +23,11 @@ import { selectReconTargets, deterministicTodos, persistReconPage, insertTodos,
|
|
|
22
23
|
function browserLink(siteUrl) {
|
|
23
24
|
const base = dashboardServerUrl();
|
|
24
25
|
if (base)
|
|
25
|
-
return `\n\
|
|
26
|
-
|
|
26
|
+
return `\n\nš Browser dashboard: ${base}/dashboard${siteUrl ? `?siteUrl=${encodeURIComponent(siteUrl)}` : ''}`;
|
|
27
|
+
// No live server: point at the dashboard surfaces that always work. get_dashboard renders the
|
|
28
|
+
// interactive dashboard in chat (works through the Docker gateway where a served port would not);
|
|
29
|
+
// serve_dashboard opens a browser tab locally; export_report writes a shareable HTML file.
|
|
30
|
+
return `\n\nš See it in the dashboard ā run get_dashboard${siteUrl ? ` for ${siteUrl}` : ''} (interactive, in chat), serve_dashboard (browser tab), or export_report (shareable HTML).`;
|
|
27
31
|
}
|
|
28
32
|
import { runAudit, runSingleCheck, listChecks } from './audit/engine.js';
|
|
29
33
|
import { buildAuditMarkdown } from './audit/report.js';
|
|
@@ -50,6 +54,7 @@ import { RankTracker } from './core/RankTracker.js';
|
|
|
50
54
|
import { Backlinks } from './core/Backlinks.js';
|
|
51
55
|
import { LinkIntersect } from './core/LinkIntersect.js';
|
|
52
56
|
import { MajesticClient } from './core/MajesticClient.js';
|
|
57
|
+
import { fetchGoogleNews } from './core/googleNews.js';
|
|
53
58
|
import { WikidataClient } from './core/WikidataClient.js';
|
|
54
59
|
import { Entities } from './core/Entities.js';
|
|
55
60
|
import { JobManager } from './core/JobManager.js';
|
|
@@ -253,17 +258,18 @@ export function createServer() {
|
|
|
253
258
|
const crawler = new Crawler(dataDir()); // no GSC credentials required
|
|
254
259
|
const dfsUser = process.env.DATAFORSEO_USERNAME;
|
|
255
260
|
const dfsPass = process.env.DATAFORSEO_PASSWORD;
|
|
256
|
-
const dfsCacheDays = Number(process.env.DATAFORSEO_CACHE_DAYS) ||
|
|
261
|
+
const dfsCacheDays = Number(process.env.DATAFORSEO_CACHE_DAYS) || 7;
|
|
257
262
|
const dfs = dfsUser && dfsPass
|
|
258
263
|
? new DataForSeoClient(dfsUser, dfsPass, path.join(dataDir(), 'dataforseo-cache.db'), dfsCacheDays)
|
|
259
264
|
: null;
|
|
260
265
|
const rankTracker = dfs ? new RankTracker(dfs, dataDir()) : null;
|
|
261
|
-
const backlinks = dfs ? new Backlinks(dfs, dataDir()) : null;
|
|
262
266
|
const linkIntersect = dfs ? new LinkIntersect(dfs, dataDir()) : null;
|
|
263
|
-
// Majestic (Trust Flow / Topical Trust Flow) ā optional link_intersect
|
|
267
|
+
// Majestic (Trust Flow / Topical Trust Flow) ā optional link_intersect + trapped-authority tier.
|
|
264
268
|
const majesticKey = process.env.MAJESTIC_API_KEY;
|
|
265
|
-
const majesticCacheDays = Number(process.env.MAJESTIC_CACHE_DAYS) ||
|
|
269
|
+
const majesticCacheDays = Number(process.env.MAJESTIC_CACHE_DAYS) || 30;
|
|
266
270
|
const majestic = majesticKey ? new MajesticClient(majesticKey, path.join(dataDir(), 'majestic-cache.db'), majesticCacheDays) : null;
|
|
271
|
+
// Backlinks after Majestic, so pull_backlinks can enrich per-URL pages with Trust Flow.
|
|
272
|
+
const backlinks = dfs ? new Backlinks(dfs, dataDir(), majestic) : null;
|
|
267
273
|
// Firecrawl (competitor-page scraping for content recon) ā optional; degrades gracefully.
|
|
268
274
|
const firecrawlKey = process.env.FIRECRAWL_API_KEY;
|
|
269
275
|
const firecrawl = firecrawlKey ? new FirecrawlClient(firecrawlKey, path.join(dataDir(), 'firecrawl-cache.db')) : null;
|
|
@@ -282,6 +288,32 @@ export function createServer() {
|
|
|
282
288
|
throw new Error('DATAFORSEO_USERNAME / DATAFORSEO_PASSWORD not set ā required for DataForSEO.');
|
|
283
289
|
return v;
|
|
284
290
|
};
|
|
291
|
+
// Which optional integrations have a key set ā drives the dashboard's key-gated tabs
|
|
292
|
+
// (Links, Content research) and their affiliate-linked upsell states. The data layer has
|
|
293
|
+
// no env access, so it's injected here onto every dashboard payload surface.
|
|
294
|
+
const apiKeysStatus = () => ({ dataforseo: !!dfs, majestic: !!majestic, firecrawl: !!firecrawl, supadata: !!supadata });
|
|
295
|
+
// The dashboard webserver's options ā one definition shared by serve_dashboard AND the
|
|
296
|
+
// auto-start at the end of refresh_property / run_audit, so a populated property always has
|
|
297
|
+
// a live browser link (browserLink() surfaces dashboardServerUrl() once this is running).
|
|
298
|
+
const buildWebOpts = (port) => ({
|
|
299
|
+
dataDir,
|
|
300
|
+
uiHtml: () => readFileSync(path.join(__dirname, 'src', 'ui', 'dashboard.html'), 'utf8'),
|
|
301
|
+
call: buildWebCallHandlers({ dataDir, dfs, apiKeys: apiKeysStatus }),
|
|
302
|
+
...(port != null ? { port } : {}),
|
|
303
|
+
});
|
|
304
|
+
// Idempotent auto-start used by refresh_property / run_audit. Best-effort: a served port that
|
|
305
|
+
// can't bind (e.g. Docker without a published port) must never fail the populate/audit ā the
|
|
306
|
+
// in-chat get_dashboard and export_report still work, and browserLink() falls back to those.
|
|
307
|
+
// Opt-out with SAC_AUTOSERVE=0 for headless/CI/sandboxed runs where binding a localhost port
|
|
308
|
+
// is unwanted (default: on, since the whole point is to hand the user a link).
|
|
309
|
+
const autoServeDashboard = async () => {
|
|
310
|
+
if (/^(0|false|no|off)$/i.test(process.env.SAC_AUTOSERVE ?? ''))
|
|
311
|
+
return;
|
|
312
|
+
try {
|
|
313
|
+
await startDashboardServer(buildWebOpts());
|
|
314
|
+
}
|
|
315
|
+
catch { /* dashboard is a convenience, not a dependency */ }
|
|
316
|
+
};
|
|
285
317
|
// āā Introspection (no data / creds required) āāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
286
318
|
server.registerTool('seo_audit_help', {
|
|
287
319
|
title: 'Help ā what this audit can do',
|
|
@@ -405,9 +437,10 @@ export function createServer() {
|
|
|
405
437
|
},
|
|
406
438
|
}, async ({ siteUrl, scope, categories, includeJudgement }) => {
|
|
407
439
|
const result = runAudit(dataDir(), siteUrl, { scope, categories, includeJudgement });
|
|
440
|
+
await autoServeDashboard(); // spin up the browser dashboard so browserLink() hands over a live URL
|
|
408
441
|
return {
|
|
409
442
|
content: [{ type: 'text', text: buildAuditMarkdown(result, siteUrl) + browserLink(siteUrl) }],
|
|
410
|
-
structuredContent: result,
|
|
443
|
+
structuredContent: { ...result, dashboardUrl: dashboardServerUrl() },
|
|
411
444
|
};
|
|
412
445
|
});
|
|
413
446
|
server.registerTool('query_audit', {
|
|
@@ -721,7 +754,14 @@ export function createServer() {
|
|
|
721
754
|
},
|
|
722
755
|
_meta: { ui: { resourceUri: SYNC_PROGRESS_URI } },
|
|
723
756
|
}, async ({ siteUrl, gsc: doGsc, crawl, inspect, ranks, segments, full, location, startDate, endDate, maxPages, inspectLimit }) => {
|
|
724
|
-
const jobId = jobs.start('refresh', (update, signal) =>
|
|
757
|
+
const jobId = jobs.start('refresh', async (update, signal) => {
|
|
758
|
+
const r = await refresh.run(siteUrl, { gsc: doGsc, crawl, inspect, ranks, segments, full, location, startDate, endDate, maxPages, inspectLimit }, update, signal);
|
|
759
|
+
// Property is now populated ā spin up the browser dashboard and hand back its URL so the
|
|
760
|
+
// finished job (polled via check_sync_status) carries a one-click link, no extra tool call.
|
|
761
|
+
await autoServeDashboard();
|
|
762
|
+
const dashboardUrl = dashboardServerUrl();
|
|
763
|
+
return dashboardUrl ? { ...r, dashboardUrl, dashboard: `${dashboardUrl}/dashboard?siteUrl=${encodeURIComponent(siteUrl)}` } : r;
|
|
764
|
+
});
|
|
725
765
|
return {
|
|
726
766
|
content: [{ type: 'text', text: `Refresh started for ${siteUrl} (job ${jobId}). Poll check_sync_status.` }],
|
|
727
767
|
structuredContent: { jobId, status: 'running', siteUrl },
|
|
@@ -865,21 +905,61 @@ export function createServer() {
|
|
|
865
905
|
};
|
|
866
906
|
});
|
|
867
907
|
server.registerTool('news_discovery', {
|
|
868
|
-
title: 'Google News
|
|
869
|
-
description: '[
|
|
908
|
+
title: 'News discovery (Google News RSS + DataForSEO)',
|
|
909
|
+
description: '[Google News is FREE (no key); DataForSEO adds a paid, richer Google News SERP | Use for: what has been PUBLISHED on a topic ā freshness, "what changed since {date}", competitor coverage] Recent news for a keyword from Google News (the free RSS search feed ā Google\'s old News API is retired) and/or the DataForSEO Google News SERP. Returns title, source, publish time, URL. Default source "both" merges + dedupes; pass source:"google" for a free keyless lookup, "dataforseo" for the paid SERP only. Default location: United States.',
|
|
870
910
|
inputSchema: {
|
|
871
911
|
keyword: z.string(),
|
|
872
912
|
location: z.union([z.string(), z.number()]).optional(),
|
|
873
913
|
languageCode: z.string().optional(),
|
|
874
914
|
depth: z.number().int().min(1).max(200).optional().describe('How many news results (default 20)'),
|
|
915
|
+
source: z.enum(['google', 'dataforseo', 'both']).optional().describe('google = free Google News RSS; dataforseo = paid Google News SERP; both (default) merges + dedupes'),
|
|
875
916
|
},
|
|
876
|
-
}, async ({ keyword, location, languageCode, depth }) => {
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
const
|
|
917
|
+
}, async ({ keyword, location, languageCode, depth, source }) => {
|
|
918
|
+
const src = source ?? 'both';
|
|
919
|
+
const n = depth ?? 20;
|
|
920
|
+
const seen = new Set();
|
|
921
|
+
const articles = [];
|
|
922
|
+
const add = (a) => { const k = String(a.url || a.title || '').toLowerCase(); if (k && !seen.has(k)) {
|
|
923
|
+
seen.add(k);
|
|
924
|
+
articles.push(a);
|
|
925
|
+
} };
|
|
926
|
+
let cost = 0, cached = true, googleCount = 0, dfsCount = 0, googleError = null;
|
|
927
|
+
// Free Google News RSS.
|
|
928
|
+
if (src === 'google' || src === 'both') {
|
|
929
|
+
try {
|
|
930
|
+
const g = await fetchGoogleNews(keyword, { limit: n });
|
|
931
|
+
googleCount = g.articles.length;
|
|
932
|
+
for (const a of g.articles)
|
|
933
|
+
add({ title: a.title, source: a.source, timestamp: a.timestamp, url: a.url, via: 'google-news' });
|
|
934
|
+
}
|
|
935
|
+
catch (e) {
|
|
936
|
+
googleError = e instanceof Error ? e.message : 'failed';
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// Paid DataForSEO Google News SERP (only when explicitly asked, or 'both' AND a key is set).
|
|
940
|
+
if (src === 'dataforseo' || (src === 'both' && !!dfs)) {
|
|
941
|
+
const r = await requireDfs(dfs).serpNews(keyword, location, languageCode, n);
|
|
942
|
+
cost += r.cost;
|
|
943
|
+
cached = cached && r.cached;
|
|
944
|
+
dfsCount = r.articles.length;
|
|
945
|
+
for (const a of r.articles)
|
|
946
|
+
add({ ...a, via: 'dataforseo' });
|
|
947
|
+
}
|
|
948
|
+
// Top sources: which publishers are covering this topic, ranked by article count ā the
|
|
949
|
+
// "who is talking about X" read. Powers "trending news + sources for X" in one call.
|
|
950
|
+
const sourceCount = new Map();
|
|
951
|
+
for (const a of articles) {
|
|
952
|
+
const s = String(a.source ?? '').trim();
|
|
953
|
+
if (s)
|
|
954
|
+
sourceCount.set(s, (sourceCount.get(s) ?? 0) + 1);
|
|
955
|
+
}
|
|
956
|
+
const topSources = [...sourceCount.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(([source, count]) => ({ source, count }));
|
|
957
|
+
const top = articles.slice(0, 15).map(a => `⢠${a.title ?? '(untitled)'}${a.source ? ` ā ${a.source}` : ''}${a.timestamp ? `, ${a.timestamp}` : ''}\n ${a.url ?? ''}`).join('\n');
|
|
958
|
+
const srcNote = [googleCount ? `${googleCount} Google News` : '', dfsCount ? `${dfsCount} DataForSEO` : ''].filter(Boolean).join(' + ') || 'no results';
|
|
959
|
+
const sourcesLine = topSources.length ? `\n\nTop sources: ${topSources.map(s => `${s.source} (${s.count})`).join(', ')}` : '';
|
|
880
960
|
return {
|
|
881
|
-
content: [{ type: 'text', text: `${
|
|
882
|
-
structuredContent: { keyword, articles
|
|
961
|
+
content: [{ type: 'text', text: `${articles.length} news results for "${keyword}" (${srcNote}${cost ? `, $${cost.toFixed(4)}` : ', free'})${googleError ? ` [Google News error: ${googleError}]` : ''}${sourcesLine}${articles.length ? `\n\n${top}` : ''}\n\nCompose with topic_trend for direction (rising/falling); schedule this call daily/hourly for a topic radar.` }],
|
|
962
|
+
structuredContent: { keyword, articles, topSources, cached, cost, sources: { googleNews: googleCount, dataforseo: dfsCount }, googleError },
|
|
883
963
|
};
|
|
884
964
|
});
|
|
885
965
|
server.registerTool('topic_trend', {
|
|
@@ -1937,7 +2017,7 @@ export function createServer() {
|
|
|
1937
2017
|
inputSchema: { siteUrl: z.string() },
|
|
1938
2018
|
_meta: { ui: { visibility: ['app'] } },
|
|
1939
2019
|
}, async ({ siteUrl }) => {
|
|
1940
|
-
const data = getDashboardData(dataDir(), siteUrl);
|
|
2020
|
+
const data = { ...getDashboardData(dataDir(), siteUrl), apiKeys: apiKeysStatus() };
|
|
1941
2021
|
return { content: [{ type: 'text', text: 'ok' }], structuredContent: data };
|
|
1942
2022
|
});
|
|
1943
2023
|
// export_report ā the dependable deliverable: a self-contained interactive dashboard
|
|
@@ -1948,7 +2028,7 @@ export function createServer() {
|
|
|
1948
2028
|
description: 'Write a self-contained, interactive dashboard HTML for a property (all data + charts inlined) to the reports folder, and return the file path. Open it in any browser or send it to a client ā no server, no MCP-App host support needed. Run refresh_property (+ run_audit for findings) first.',
|
|
1949
2029
|
inputSchema: { siteUrl: z.string(), theme: z.enum(['light', 'dark']).optional() },
|
|
1950
2030
|
}, async ({ siteUrl, theme }) => {
|
|
1951
|
-
const data = getDashboardData(dataDir(), siteUrl);
|
|
2031
|
+
const data = { ...getDashboardData(dataDir(), siteUrl), apiKeys: apiKeysStatus() };
|
|
1952
2032
|
if (data.empty) {
|
|
1953
2033
|
return { content: [{ type: 'text', text: `No synced data for ${siteUrl} ā run refresh_property first.` }], structuredContent: { error: 'empty', siteUrl } };
|
|
1954
2034
|
}
|
|
@@ -1982,32 +2062,7 @@ export function createServer() {
|
|
|
1982
2062
|
structuredContent: { stopped },
|
|
1983
2063
|
};
|
|
1984
2064
|
}
|
|
1985
|
-
const { url } = await startDashboardServer(
|
|
1986
|
-
dataDir,
|
|
1987
|
-
uiHtml: () => readFileSync(path.join(__dirname, 'src', 'ui', 'dashboard.html'), 'utf8'),
|
|
1988
|
-
call: {
|
|
1989
|
-
get_dashboard_data: async (a) => {
|
|
1990
|
-
const want = String(a.siteUrl ?? '');
|
|
1991
|
-
// Only serve properties that actually exist locally ā getDashboardData would
|
|
1992
|
-
// otherwise CREATE an empty DB file for any bogus siteUrl posted at the API.
|
|
1993
|
-
if (!listLocalProperties(dataDir()).some(p => p.siteUrl === want))
|
|
1994
|
-
throw new Error(`unknown property ${want}`);
|
|
1995
|
-
return getDashboardData(dataDir(), want);
|
|
1996
|
-
},
|
|
1997
|
-
related_terms: async (a) => {
|
|
1998
|
-
const r = await requireDfs(dfs).relatedTerms(String(a.keyword ?? ''), a.location, a.languageCode);
|
|
1999
|
-
return r;
|
|
2000
|
-
},
|
|
2001
|
-
keyword_volume: async (a) => {
|
|
2002
|
-
const r = await requireDfs(dfs).searchVolume(a.keywords ?? [], a.location, a.languageCode);
|
|
2003
|
-
const items = (r.tasks[0]?.result ?? []).map((k) => ({
|
|
2004
|
-
keyword: k.keyword, searchVolume: k.search_volume, cpc: k.cpc, competition: k.competition,
|
|
2005
|
-
}));
|
|
2006
|
-
return { keywords: items, cached: r.cached, cost: r.cost };
|
|
2007
|
-
},
|
|
2008
|
-
},
|
|
2009
|
-
...(port != null ? { port } : {}),
|
|
2010
|
-
});
|
|
2065
|
+
const { url } = await startDashboardServer(buildWebOpts(port));
|
|
2011
2066
|
const open = siteUrl ? `${url}/dashboard?siteUrl=${encodeURIComponent(siteUrl)}` : `${url}/`;
|
|
2012
2067
|
const portNote = port != null && !url.endsWith(`:${port}`)
|
|
2013
2068
|
? ` (already running on its original port ā requested port ${port} ignored; stop=true first to move it)`
|