@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.
Files changed (42) hide show
  1. package/README.md +6 -3
  2. package/dist/core/AuditDatabase.d.ts.map +1 -1
  3. package/dist/core/AuditDatabase.js +6 -0
  4. package/dist/core/AuditDatabase.js.map +1 -1
  5. package/dist/core/Backlinks.d.ts +5 -1
  6. package/dist/core/Backlinks.d.ts.map +1 -1
  7. package/dist/core/Backlinks.js +31 -1
  8. package/dist/core/Backlinks.js.map +1 -1
  9. package/dist/core/DataForSeoClient.d.ts +8 -7
  10. package/dist/core/DataForSeoClient.d.ts.map +1 -1
  11. package/dist/core/DataForSeoClient.js +7 -7
  12. package/dist/core/DataForSeoClient.js.map +1 -1
  13. package/dist/core/MajesticClient.d.ts +1 -1
  14. package/dist/core/MajesticClient.d.ts.map +1 -1
  15. package/dist/core/MajesticClient.js +11 -2
  16. package/dist/core/MajesticClient.js.map +1 -1
  17. package/dist/core/dashboardData.d.ts +28 -0
  18. package/dist/core/dashboardData.d.ts.map +1 -1
  19. package/dist/core/dashboardData.js +64 -3
  20. package/dist/core/dashboardData.js.map +1 -1
  21. package/dist/core/googleNews.d.ts +26 -0
  22. package/dist/core/googleNews.d.ts.map +1 -0
  23. package/dist/core/googleNews.js +49 -0
  24. package/dist/core/googleNews.js.map +1 -0
  25. package/dist/core/webHandlers.d.ts +20 -0
  26. package/dist/core/webHandlers.d.ts.map +1 -0
  27. package/dist/core/webHandlers.js +67 -0
  28. package/dist/core/webHandlers.js.map +1 -0
  29. package/dist/core/webServer.d.ts +8 -1
  30. package/dist/core/webServer.d.ts.map +1 -1
  31. package/dist/core/webServer.js +12 -4
  32. package/dist/core/webServer.js.map +1 -1
  33. package/dist/dashboard.d.ts +3 -0
  34. package/dist/dashboard.d.ts.map +1 -0
  35. package/dist/dashboard.js +58 -0
  36. package/dist/dashboard.js.map +1 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +100 -45
  39. package/dist/server.js.map +1 -1
  40. package/dist/src/ui/dashboard.html +83 -51
  41. package/package.json +108 -107
  42. 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, listLocalProperties } from './core/webServer.js';
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\nBrowser dashboard: ${base}/dashboard${siteUrl ? `?siteUrl=${encodeURIComponent(siteUrl)}` : ''}`;
26
- return `\n\nTip: run serve_dashboard to open the full interactive dashboard in your browser.`;
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) || 20;
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 enrichment tier.
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) || 20;
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) => refresh.run(siteUrl, { gsc: doGsc, crawl, inspect, ranks, segments, full, location, startDate, endDate, maxPages, inspectLimit }, 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 discovery (DataForSEO SERP)',
869
- description: '[Paid: SERP call PER KEYWORD, cached 20d | Use for: what has been PUBLISHED on a topic — freshness, "what changed since {date}", competitor coverage] Recent news articles ranking for a keyword (DataForSEO Google News SERP): title, source, snippet, publish timestamp and URL, in rank order (flattens both news results and top-stories). Feeds the "what\'s new / what changed" research step. SERP scope, 20-day cache. Default location: United States (2840).',
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 client = requireDfs(dfs);
878
- const r = await client.serpNews(keyword, location, languageCode, depth ?? 20);
879
- const top = r.articles.slice(0, 15).map(a => `• ${a.title ?? '(untitled)'}${a.source ? ` — ${a.source}` : ''}${a.timestamp ? `, ${a.timestamp}` : ''}\n ${a.url ?? ''}`).join('\n');
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: `${r.articles.length} news results for "${keyword}"${r.cached ? ' (cached)' : ` (live, $${r.cost.toFixed(4)})`}${r.articles.length ? `:\n${top}` : ''}` }],
882
- structuredContent: { keyword, articles: r.articles, cached: r.cached, cost: r.cost },
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)`