@houtini/seo-audit-console 0.6.0 → 0.7.8

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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Google News — the free, keyless source for news_discovery. Google's old News API was
3
+ * retired long ago; the live endpoint is the Google News RSS *search* feed, which returns
4
+ * recent articles for a query with title, source, link and publish time. No credentials,
5
+ * no cost — so news works even without a DataForSEO key (DataForSEO's Google News SERP
6
+ * stays the richer paid option; this is the always-available fallback / second source).
7
+ */
8
+ export interface GoogleNewsArticle {
9
+ title: string;
10
+ url: string;
11
+ source: string | null;
12
+ timestamp: string | null;
13
+ }
14
+ /**
15
+ * Fetch recent Google News results for a keyword via the RSS search feed.
16
+ * `hl` = interface language, `gl` = country; `ceid` is derived (country:lang).
17
+ */
18
+ export declare function fetchGoogleNews(keyword: string, opts?: {
19
+ hl?: string;
20
+ gl?: string;
21
+ limit?: number;
22
+ }): Promise<{
23
+ articles: GoogleNewsArticle[];
24
+ source: 'google-news-rss';
25
+ }>;
26
+ //# sourceMappingURL=googleNews.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"googleNews.d.ts","sourceRoot":"","sources":["../../src/core/googleNews.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GACtD,OAAO,CAAC;IAAE,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAAC,CAsCvE"}
@@ -0,0 +1,49 @@
1
+ import * as cheerio from 'cheerio';
2
+ /**
3
+ * Fetch recent Google News results for a keyword via the RSS search feed.
4
+ * `hl` = interface language, `gl` = country; `ceid` is derived (country:lang).
5
+ */
6
+ export async function fetchGoogleNews(keyword, opts = {}) {
7
+ const hl = opts.hl ?? 'en-US';
8
+ const gl = opts.gl ?? 'US';
9
+ const ceid = `${gl}:${hl.split('-')[0]}`;
10
+ const url = `https://news.google.com/rss/search?q=${encodeURIComponent(keyword)}&hl=${encodeURIComponent(hl)}&gl=${encodeURIComponent(gl)}&ceid=${encodeURIComponent(ceid)}`;
11
+ const res = await fetch(url, {
12
+ headers: { 'user-agent': 'Mozilla/5.0 (compatible; seo-audit-console/1.0; +https://github.com/houtini-ai/seo-audit)' },
13
+ signal: AbortSignal.timeout(15000),
14
+ });
15
+ if (!res.ok)
16
+ throw new Error(`Google News RSS ${res.status}`);
17
+ const xml = await res.text();
18
+ // RSS is well-formed XML — parse with cheerio in xmlMode (already a dependency).
19
+ const $ = cheerio.load(xml, { xmlMode: true });
20
+ const limit = Math.min(opts.limit ?? 20, 100);
21
+ const articles = [];
22
+ $('item').each((_i, el) => {
23
+ if (articles.length >= limit)
24
+ return;
25
+ const $el = $(el);
26
+ const title = $el.find('title').first().text().trim();
27
+ if (!title)
28
+ return;
29
+ // <source url="...">Publisher</source>. Google News titles are usually "Headline - Publisher";
30
+ // prefer the explicit <source> element, fall back to the trailing " - Publisher".
31
+ let source = $el.find('source').first().text().trim() || null;
32
+ let headline = title;
33
+ if (!source) {
34
+ const m = title.match(/^(.*)\s+-\s+([^-]+)$/);
35
+ if (m) {
36
+ headline = m[1].trim();
37
+ source = m[2].trim();
38
+ }
39
+ }
40
+ articles.push({
41
+ title: headline,
42
+ url: $el.find('link').first().text().trim(),
43
+ source,
44
+ timestamp: $el.find('pubDate').first().text().trim() || null,
45
+ });
46
+ });
47
+ return { articles, source: 'google-news-rss' };
48
+ }
49
+ //# sourceMappingURL=googleNews.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"googleNews.js","sourceRoot":"","sources":["../../src/core/googleNews.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA6EpE,wBAAgB,OAAO,IAAI,MAAM,CAGhC;AAkLD,wBAAgB,YAAY,IAAI;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CA8mE9E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiFpE,wBAAgB,OAAO,IAAI,MAAM,CAGhC;AAkLD,wBAAgB,YAAY,IAAI;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAiqE9E"}
package/dist/server.js CHANGED
@@ -22,8 +22,11 @@ import { selectReconTargets, deterministicTodos, persistReconPage, insertTodos,
22
22
  function browserLink(siteUrl) {
23
23
  const base = dashboardServerUrl();
24
24
  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.`;
25
+ return `\n\n📊 Browser dashboard: ${base}/dashboard${siteUrl ? `?siteUrl=${encodeURIComponent(siteUrl)}` : ''}`;
26
+ // No live server: point at the dashboard surfaces that always work. get_dashboard renders the
27
+ // interactive dashboard in chat (works through the Docker gateway where a served port would not);
28
+ // serve_dashboard opens a browser tab locally; export_report writes a shareable HTML file.
29
+ 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
30
  }
28
31
  import { runAudit, runSingleCheck, listChecks } from './audit/engine.js';
29
32
  import { buildAuditMarkdown } from './audit/report.js';
@@ -50,6 +53,7 @@ import { RankTracker } from './core/RankTracker.js';
50
53
  import { Backlinks } from './core/Backlinks.js';
51
54
  import { LinkIntersect } from './core/LinkIntersect.js';
52
55
  import { MajesticClient } from './core/MajesticClient.js';
56
+ import { fetchGoogleNews } from './core/googleNews.js';
53
57
  import { WikidataClient } from './core/WikidataClient.js';
54
58
  import { Entities } from './core/Entities.js';
55
59
  import { JobManager } from './core/JobManager.js';
@@ -253,17 +257,18 @@ export function createServer() {
253
257
  const crawler = new Crawler(dataDir()); // no GSC credentials required
254
258
  const dfsUser = process.env.DATAFORSEO_USERNAME;
255
259
  const dfsPass = process.env.DATAFORSEO_PASSWORD;
256
- const dfsCacheDays = Number(process.env.DATAFORSEO_CACHE_DAYS) || 20;
260
+ const dfsCacheDays = Number(process.env.DATAFORSEO_CACHE_DAYS) || 7;
257
261
  const dfs = dfsUser && dfsPass
258
262
  ? new DataForSeoClient(dfsUser, dfsPass, path.join(dataDir(), 'dataforseo-cache.db'), dfsCacheDays)
259
263
  : null;
260
264
  const rankTracker = dfs ? new RankTracker(dfs, dataDir()) : null;
261
- const backlinks = dfs ? new Backlinks(dfs, dataDir()) : null;
262
265
  const linkIntersect = dfs ? new LinkIntersect(dfs, dataDir()) : null;
263
- // Majestic (Trust Flow / Topical Trust Flow) — optional link_intersect enrichment tier.
266
+ // Majestic (Trust Flow / Topical Trust Flow) — optional link_intersect + trapped-authority tier.
264
267
  const majesticKey = process.env.MAJESTIC_API_KEY;
265
- const majesticCacheDays = Number(process.env.MAJESTIC_CACHE_DAYS) || 20;
268
+ const majesticCacheDays = Number(process.env.MAJESTIC_CACHE_DAYS) || 30;
266
269
  const majestic = majesticKey ? new MajesticClient(majesticKey, path.join(dataDir(), 'majestic-cache.db'), majesticCacheDays) : null;
270
+ // Backlinks after Majestic, so pull_backlinks can enrich per-URL pages with Trust Flow.
271
+ const backlinks = dfs ? new Backlinks(dfs, dataDir(), majestic) : null;
267
272
  // Firecrawl (competitor-page scraping for content recon) — optional; degrades gracefully.
268
273
  const firecrawlKey = process.env.FIRECRAWL_API_KEY;
269
274
  const firecrawl = firecrawlKey ? new FirecrawlClient(firecrawlKey, path.join(dataDir(), 'firecrawl-cache.db')) : null;
@@ -282,6 +287,10 @@ export function createServer() {
282
287
  throw new Error('DATAFORSEO_USERNAME / DATAFORSEO_PASSWORD not set — required for DataForSEO.');
283
288
  return v;
284
289
  };
290
+ // Which optional integrations have a key set — drives the dashboard's key-gated tabs
291
+ // (Links, Content research) and their affiliate-linked upsell states. The data layer has
292
+ // no env access, so it's injected here onto every dashboard payload surface.
293
+ const apiKeysStatus = () => ({ dataforseo: !!dfs, majestic: !!majestic, firecrawl: !!firecrawl, supadata: !!supadata });
285
294
  // ── Introspection (no data / creds required) ────────────────────────────
286
295
  server.registerTool('seo_audit_help', {
287
296
  title: 'Help — what this audit can do',
@@ -865,21 +874,61 @@ export function createServer() {
865
874
  };
866
875
  });
867
876
  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).',
877
+ title: 'News discovery (Google News RSS + DataForSEO)',
878
+ 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
879
  inputSchema: {
871
880
  keyword: z.string(),
872
881
  location: z.union([z.string(), z.number()]).optional(),
873
882
  languageCode: z.string().optional(),
874
883
  depth: z.number().int().min(1).max(200).optional().describe('How many news results (default 20)'),
884
+ source: z.enum(['google', 'dataforseo', 'both']).optional().describe('google = free Google News RSS; dataforseo = paid Google News SERP; both (default) merges + dedupes'),
875
885
  },
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');
886
+ }, async ({ keyword, location, languageCode, depth, source }) => {
887
+ const src = source ?? 'both';
888
+ const n = depth ?? 20;
889
+ const seen = new Set();
890
+ const articles = [];
891
+ const add = (a) => { const k = String(a.url || a.title || '').toLowerCase(); if (k && !seen.has(k)) {
892
+ seen.add(k);
893
+ articles.push(a);
894
+ } };
895
+ let cost = 0, cached = true, googleCount = 0, dfsCount = 0, googleError = null;
896
+ // Free Google News RSS.
897
+ if (src === 'google' || src === 'both') {
898
+ try {
899
+ const g = await fetchGoogleNews(keyword, { limit: n });
900
+ googleCount = g.articles.length;
901
+ for (const a of g.articles)
902
+ add({ title: a.title, source: a.source, timestamp: a.timestamp, url: a.url, via: 'google-news' });
903
+ }
904
+ catch (e) {
905
+ googleError = e instanceof Error ? e.message : 'failed';
906
+ }
907
+ }
908
+ // Paid DataForSEO Google News SERP (only when explicitly asked, or 'both' AND a key is set).
909
+ if (src === 'dataforseo' || (src === 'both' && !!dfs)) {
910
+ const r = await requireDfs(dfs).serpNews(keyword, location, languageCode, n);
911
+ cost += r.cost;
912
+ cached = cached && r.cached;
913
+ dfsCount = r.articles.length;
914
+ for (const a of r.articles)
915
+ add({ ...a, via: 'dataforseo' });
916
+ }
917
+ // Top sources: which publishers are covering this topic, ranked by article count — the
918
+ // "who is talking about X" read. Powers "trending news + sources for X" in one call.
919
+ const sourceCount = new Map();
920
+ for (const a of articles) {
921
+ const s = String(a.source ?? '').trim();
922
+ if (s)
923
+ sourceCount.set(s, (sourceCount.get(s) ?? 0) + 1);
924
+ }
925
+ const topSources = [...sourceCount.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(([source, count]) => ({ source, count }));
926
+ const top = articles.slice(0, 15).map(a => `• ${a.title ?? '(untitled)'}${a.source ? ` — ${a.source}` : ''}${a.timestamp ? `, ${a.timestamp}` : ''}\n ${a.url ?? ''}`).join('\n');
927
+ const srcNote = [googleCount ? `${googleCount} Google News` : '', dfsCount ? `${dfsCount} DataForSEO` : ''].filter(Boolean).join(' + ') || 'no results';
928
+ const sourcesLine = topSources.length ? `\n\nTop sources: ${topSources.map(s => `${s.source} (${s.count})`).join(', ')}` : '';
880
929
  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 },
930
+ 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.` }],
931
+ structuredContent: { keyword, articles, topSources, cached, cost, sources: { googleNews: googleCount, dataforseo: dfsCount }, googleError },
883
932
  };
884
933
  });
885
934
  server.registerTool('topic_trend', {
@@ -1937,7 +1986,7 @@ export function createServer() {
1937
1986
  inputSchema: { siteUrl: z.string() },
1938
1987
  _meta: { ui: { visibility: ['app'] } },
1939
1988
  }, async ({ siteUrl }) => {
1940
- const data = getDashboardData(dataDir(), siteUrl);
1989
+ const data = { ...getDashboardData(dataDir(), siteUrl), apiKeys: apiKeysStatus() };
1941
1990
  return { content: [{ type: 'text', text: 'ok' }], structuredContent: data };
1942
1991
  });
1943
1992
  // export_report — the dependable deliverable: a self-contained interactive dashboard
@@ -1948,7 +1997,7 @@ export function createServer() {
1948
1997
  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
1998
  inputSchema: { siteUrl: z.string(), theme: z.enum(['light', 'dark']).optional() },
1950
1999
  }, async ({ siteUrl, theme }) => {
1951
- const data = getDashboardData(dataDir(), siteUrl);
2000
+ const data = { ...getDashboardData(dataDir(), siteUrl), apiKeys: apiKeysStatus() };
1952
2001
  if (data.empty) {
1953
2002
  return { content: [{ type: 'text', text: `No synced data for ${siteUrl} — run refresh_property first.` }], structuredContent: { error: 'empty', siteUrl } };
1954
2003
  }
@@ -1992,12 +2041,46 @@ export function createServer() {
1992
2041
  // otherwise CREATE an empty DB file for any bogus siteUrl posted at the API.
1993
2042
  if (!listLocalProperties(dataDir()).some(p => p.siteUrl === want))
1994
2043
  throw new Error(`unknown property ${want}`);
1995
- return getDashboardData(dataDir(), want);
2044
+ return { ...getDashboardData(dataDir(), want), apiKeys: apiKeysStatus() };
1996
2045
  },
1997
2046
  related_terms: async (a) => {
1998
2047
  const r = await requireDfs(dfs).relatedTerms(String(a.keyword ?? ''), a.location, a.languageCode);
1999
2048
  return r;
2000
2049
  },
2050
+ // Content research (on-demand) — News (free Google News + paid DFS) / Videos / Trends.
2051
+ news_discovery: async (a) => {
2052
+ const kw = String(a.keyword ?? '');
2053
+ const seen = new Set();
2054
+ const articles = [];
2055
+ const add = (x) => { const k = String(x.url || x.title || '').toLowerCase(); if (k && !seen.has(k)) {
2056
+ seen.add(k);
2057
+ articles.push(x);
2058
+ } };
2059
+ try {
2060
+ const g = await fetchGoogleNews(kw, { limit: 20 });
2061
+ for (const x of g.articles)
2062
+ add({ ...x, via: 'google-news' });
2063
+ }
2064
+ catch { /* free source optional */ }
2065
+ if (dfs) {
2066
+ try {
2067
+ const r = await dfs.serpNews(kw, a.location, a.languageCode);
2068
+ for (const x of r.articles)
2069
+ add({ ...x, via: 'dataforseo' });
2070
+ }
2071
+ catch { /* paid optional */ }
2072
+ }
2073
+ return { articles };
2074
+ },
2075
+ youtube_discovery: async (a) => {
2076
+ const r = await requireDfs(dfs).serpYoutube(String(a.keyword ?? ''), a.location, a.languageCode, a.blockDepth);
2077
+ return r;
2078
+ },
2079
+ topic_trend: async (a) => {
2080
+ const kws = Array.isArray(a.keywords) ? a.keywords : [String(a.keyword ?? '')].filter(Boolean);
2081
+ const r = await requireDfs(dfs).googleTrends(kws, a.location, a.languageCode, {});
2082
+ return r;
2083
+ },
2001
2084
  keyword_volume: async (a) => {
2002
2085
  const r = await requireDfs(dfs).searchVolume(a.keywords ?? [], a.location, a.languageCode);
2003
2086
  const items = (r.tasks[0]?.result ?? []).map((k) => ({