@houtini/seo-audit-console 0.2.3 → 0.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/README.md +11 -4
- package/dist/audit/keywordList.d.ts +40 -0
- package/dist/audit/keywordList.d.ts.map +1 -0
- package/dist/audit/keywordList.js +95 -0
- package/dist/audit/keywordList.js.map +1 -0
- package/dist/audit/recon.d.ts +56 -0
- package/dist/audit/recon.d.ts.map +1 -0
- package/dist/audit/recon.js +134 -0
- package/dist/audit/recon.js.map +1 -0
- package/dist/core/AuditDatabase.d.ts.map +1 -1
- package/dist/core/AuditDatabase.js +39 -0
- package/dist/core/AuditDatabase.js.map +1 -1
- package/dist/core/FirecrawlClient.d.ts +35 -0
- package/dist/core/FirecrawlClient.d.ts.map +1 -0
- package/dist/core/FirecrawlClient.js +69 -0
- package/dist/core/FirecrawlClient.js.map +1 -0
- package/dist/core/SupadataClient.d.ts +26 -0
- package/dist/core/SupadataClient.d.ts.map +1 -0
- package/dist/core/SupadataClient.js +57 -0
- package/dist/core/SupadataClient.js.map +1 -0
- package/dist/core/dashboardData.d.ts +13 -0
- package/dist/core/dashboardData.d.ts.map +1 -1
- package/dist/core/dashboardData.js +46 -3
- package/dist/core/dashboardData.js.map +1 -1
- package/dist/core/marketSizing.d.ts +45 -0
- package/dist/core/marketSizing.d.ts.map +1 -0
- package/dist/core/marketSizing.js +104 -0
- package/dist/core/marketSizing.js.map +1 -0
- package/dist/core/reconFetch.d.ts +29 -0
- package/dist/core/reconFetch.d.ts.map +1 -0
- package/dist/core/reconFetch.js +49 -0
- package/dist/core/reconFetch.js.map +1 -0
- package/dist/core/reconResearch.d.ts +42 -0
- package/dist/core/reconResearch.d.ts.map +1 -0
- package/dist/core/reconResearch.js +101 -0
- package/dist/core/reconResearch.js.map +1 -0
- package/dist/core/serpFootprint.d.ts +42 -0
- package/dist/core/serpFootprint.d.ts.map +1 -0
- package/dist/core/serpFootprint.js +99 -0
- package/dist/core/serpFootprint.js.map +1 -0
- package/dist/core/serpRecon.d.ts +46 -0
- package/dist/core/serpRecon.d.ts.map +1 -0
- package/dist/core/serpRecon.js +71 -0
- package/dist/core/serpRecon.js.map +1 -0
- package/dist/core/webServer.d.ts +27 -0
- package/dist/core/webServer.d.ts.map +1 -0
- package/dist/core/webServer.js +156 -0
- package/dist/core/webServer.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +519 -15
- package/dist/server.js.map +1 -1
- package/dist/src/ui/dashboard.html +269 -251
- package/dist/src/ui/sync-progress.html +2 -2
- package/package.json +1 -1
- package/server.json +18 -4
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { extractPage } from './extract.js';
|
|
2
|
+
export async function fetchOwnPage(url, hostForm = 'asis', timeoutMs = 20000) {
|
|
3
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
4
|
+
const res = await fetch(url, {
|
|
5
|
+
headers: { 'user-agent': 'seo-audit-console recon (+https://github.com/houtini-ai/seo-audit)' },
|
|
6
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
7
|
+
});
|
|
8
|
+
const html = await res.text();
|
|
9
|
+
const ex = extractPage(html, url, host, { hostForm }, res.headers.get('x-robots-tag'));
|
|
10
|
+
const chunks = ex.bodyChunks ?? [];
|
|
11
|
+
const fulltextLower = chunks.map(c => `${c.heading} ${c.text}`).join(' ').toLowerCase();
|
|
12
|
+
// jsonLd is a JSON *string* (array of raw blocks) — parse ONCE, then each block (itself a
|
|
13
|
+
// JSON string or object) may carry an @graph. (Build lesson: don't iterate the raw string.)
|
|
14
|
+
const types = new Set();
|
|
15
|
+
let dateModified = null, datePublished = null;
|
|
16
|
+
try {
|
|
17
|
+
const blocks = ex.jsonLd ? JSON.parse(ex.jsonLd) : [];
|
|
18
|
+
for (const block of blocks) {
|
|
19
|
+
const obj = typeof block === 'string' ? JSON.parse(block) : block;
|
|
20
|
+
for (const node of (obj['@graph'] ?? [obj])) {
|
|
21
|
+
const t = node['@type'];
|
|
22
|
+
(Array.isArray(t) ? t : [t]).forEach((x) => x && types.add(x));
|
|
23
|
+
if (node.dateModified && !dateModified)
|
|
24
|
+
dateModified = String(node.dateModified).slice(0, 10);
|
|
25
|
+
if (node.datePublished && !datePublished)
|
|
26
|
+
datePublished = String(node.datePublished).slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch { /* malformed JSON-LD — leave types empty rather than throw */ }
|
|
31
|
+
return {
|
|
32
|
+
url,
|
|
33
|
+
status: res.status,
|
|
34
|
+
title: ex.title,
|
|
35
|
+
h1: ex.h1,
|
|
36
|
+
wordCount: ex.wordCount ?? fulltextLower.split(/\s+/).length,
|
|
37
|
+
headings: chunks.filter(c => c.heading).map(c => ({ level: c.level, heading: c.heading, chars: c.text.length })),
|
|
38
|
+
fulltextLower,
|
|
39
|
+
jsonLdTypes: [...types],
|
|
40
|
+
hasProductOrReview: types.has('Product') || types.has('Review') || types.has('AggregateRating'),
|
|
41
|
+
dateModified,
|
|
42
|
+
datePublished,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Which of these terms does the page text actually contain? Powers the coverage-gap probe. */
|
|
46
|
+
export function coverageProbe(fulltextLower, terms) {
|
|
47
|
+
return terms.map(t => ({ term: t, present: fulltextLower.includes(t.toLowerCase()) }));
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=reconFetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconFetch.js","sourceRoot":"","sources":["../../src/core/reconFetch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAqB3C,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,GAAW,EAAE,WAAoC,MAAM,EAAE,SAAS,GAAG,KAAK;IAC3G,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACzD,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;QAC3B,OAAO,EAAE,EAAE,YAAY,EAAE,oEAAoE,EAAE;QAC/F,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,SAAS,CAAC;KACvC,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,MAAM,EAAE,GAAG,WAAW,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC;IAEvF,MAAM,MAAM,GAAG,EAAE,CAAC,UAAU,IAAI,EAAE,CAAC;IACnC,MAAM,aAAa,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC;IAExF,0FAA0F;IAC1F,4FAA4F;IAC5F,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,IAAI,YAAY,GAAkB,IAAI,EAAE,aAAa,GAAkB,IAAI,CAAC;IAC5E,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACtD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,MAAM,GAAG,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YAClE,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;gBAC5C,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC;gBACxB,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;gBACvE,IAAI,IAAI,CAAC,YAAY,IAAI,CAAC,YAAY;oBAAE,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;gBAC9F,IAAI,IAAI,CAAC,aAAa,IAAI,CAAC,aAAa;oBAAE,aAAa,GAAG,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACpG,CAAC;QACH,CAAC;IACH,CAAC;IAAC,MAAM,CAAC,CAAC,6DAA6D,CAAC,CAAC;IAEzE,OAAO;QACL,GAAG;QACH,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,KAAK,EAAE,EAAE,CAAC,KAAK;QACf,EAAE,EAAE,EAAE,CAAC,EAAE;QACT,SAAS,EAAE,EAAE,CAAC,SAAS,IAAI,aAAa,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM;QAC5D,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;QAChH,aAAa;QACb,WAAW,EAAE,CAAC,GAAG,KAAK,CAAC;QACvB,kBAAkB,EAAE,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC;QAC/F,YAAY;QACZ,aAAa;KACd,CAAC;AACJ,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,aAAa,CAAC,aAAqB,EAAE,KAAe;IAClE,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AACzF,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FirecrawlClient } from './FirecrawlClient.js';
|
|
2
|
+
import type { SupadataClient } from './SupadataClient.js';
|
|
3
|
+
/** User-agent modes for the free HTTP fetch. 'browser' mimics a real visit arriving from Google
|
|
4
|
+
* (referer + cross-site fetch metadata) — this is what gets past Reddit and the mid-tier.
|
|
5
|
+
* 'googlebot' spoofs Googlebot's UA (UA-gated sites only; strict-verified Cloudflare rejects it).
|
|
6
|
+
* Cloudflare *managed-challenge* sites (e.g. PCMag) reject every HTTP-only client regardless. */
|
|
7
|
+
export type UaMode = 'browser' | 'googlebot';
|
|
8
|
+
/**
|
|
9
|
+
* Route a competitor URL to the right fetcher by who owns the source:
|
|
10
|
+
* YouTube/TikTok/X → Supadata (transcript) — Firecrawl can't read video
|
|
11
|
+
* Reddit → its own .json endpoint — Firecrawl hard-blocks Reddit
|
|
12
|
+
* everything else → Firecrawl (markdown, proxy:auto for bot-protected sites)
|
|
13
|
+
* Each returns a uniform shape so the recon packet is easy for the research session to diff.
|
|
14
|
+
*/
|
|
15
|
+
export interface CompetitorContent {
|
|
16
|
+
url: string;
|
|
17
|
+
kind: 'video' | 'reddit' | 'page';
|
|
18
|
+
title: string | null;
|
|
19
|
+
content: string;
|
|
20
|
+
cached: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Reddit serves clean JSON at url.json — title + selftext + top comments, no scraper needed.
|
|
24
|
+
* Needs the browser header profile (UA + Google referer) — a bot UA gets a 403. */
|
|
25
|
+
export declare function fetchRedditJson(url: string, maxChars?: number, ua?: UaMode): Promise<{
|
|
26
|
+
title: string | null;
|
|
27
|
+
content: string;
|
|
28
|
+
}>;
|
|
29
|
+
/** Free HTTP fallback for pages Firecrawl refuses (its blocklist covers major publishers).
|
|
30
|
+
* Fetches with a browser-like UA and reuses our own extractor for clean main-content text. */
|
|
31
|
+
export declare function fetchPlainPage(url: string, maxChars?: number, ua?: UaMode): Promise<{
|
|
32
|
+
title: string | null;
|
|
33
|
+
content: string;
|
|
34
|
+
}>;
|
|
35
|
+
export declare function fetchCompetitorContent(url: string, clients: {
|
|
36
|
+
firecrawl: FirecrawlClient | null;
|
|
37
|
+
supadata: SupadataClient | null;
|
|
38
|
+
}, opts?: {
|
|
39
|
+
maxChars?: number;
|
|
40
|
+
ua?: UaMode;
|
|
41
|
+
}): Promise<CompetitorContent>;
|
|
42
|
+
//# sourceMappingURL=reconResearch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconResearch.d.ts","sourceRoot":"","sources":["../../src/core/reconResearch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAQ1D;;;iGAGiG;AACjG,MAAM,MAAM,MAAM,GAAG,SAAS,GAAG,WAAW,CAAC;AAmB7C;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IAClC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAKD;mFACmF;AACnF,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAO,EAAE,EAAE,GAAE,MAAkB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAkB9I;AAED;8FAC8F;AAC9F,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAO,EAAE,EAAE,GAAE,MAAkB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ7I;AAED,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;IAAE,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;CAAE,EAC/E,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAC5C,OAAO,CAAC,iBAAiB,CAAC,CA2B5B"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Agent, fetch as ufetch } from 'undici';
|
|
2
|
+
import { hostOf } from './serpRecon.js';
|
|
3
|
+
import { extractPage } from './extract.js';
|
|
4
|
+
const httpAgent = new Agent({ allowH2: true, connections: 6, keepAliveTimeout: 30_000 });
|
|
5
|
+
const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
6
|
+
const GOOGLEBOT_UA = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/131.0.0.0 Safari/537.36';
|
|
7
|
+
function browserHeaders(mode) {
|
|
8
|
+
if (mode === 'googlebot') {
|
|
9
|
+
return { 'user-agent': GOOGLEBOT_UA, 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-encoding': 'gzip, deflate, br', 'from': 'googlebot(at)googlebot.com' };
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
'user-agent': CHROME_UA,
|
|
13
|
+
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
14
|
+
'accept-language': 'en-GB,en-US;q=0.9,en;q=0.8',
|
|
15
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
16
|
+
'referer': 'https://www.google.com/',
|
|
17
|
+
'sec-ch-ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
|
18
|
+
'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"',
|
|
19
|
+
'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'cross-site', 'sec-fetch-user': '?1',
|
|
20
|
+
'upgrade-insecure-requests': '1',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const isVideo = (host) => /youtube|youtu\.be|tiktok|twitter\.com|x\.com/.test(host);
|
|
24
|
+
const isReddit = (host) => /reddit\.com/.test(host);
|
|
25
|
+
/** Reddit serves clean JSON at url.json — title + selftext + top comments, no scraper needed.
|
|
26
|
+
* Needs the browser header profile (UA + Google referer) — a bot UA gets a 403. */
|
|
27
|
+
export async function fetchRedditJson(url, maxChars = 4000, ua = 'browser') {
|
|
28
|
+
const jsonUrl = url.split('?')[0].replace(/\/$/, '') + '.json';
|
|
29
|
+
const res = await ufetch(jsonUrl, { headers: browserHeaders(ua), dispatcher: httpAgent, signal: AbortSignal.timeout(20000) });
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(`Reddit .json ${res.status}`);
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
const post = data?.[0]?.data?.children?.[0]?.data ?? {};
|
|
34
|
+
const comments = data?.[1]?.data?.children ?? [];
|
|
35
|
+
const parts = [];
|
|
36
|
+
if (post.title)
|
|
37
|
+
parts.push(`# ${post.title}`);
|
|
38
|
+
if (post.selftext)
|
|
39
|
+
parts.push(post.selftext);
|
|
40
|
+
const top = comments
|
|
41
|
+
.map(c => c?.data)
|
|
42
|
+
.filter(d => d && d.body && d.body !== '[deleted]')
|
|
43
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
44
|
+
.slice(0, 12)
|
|
45
|
+
.map(d => `- (${d.score ?? 0}) ${d.body}`);
|
|
46
|
+
if (top.length)
|
|
47
|
+
parts.push('## Top comments', ...top);
|
|
48
|
+
return { title: post.title ?? null, content: parts.join('\n\n').slice(0, maxChars) };
|
|
49
|
+
}
|
|
50
|
+
/** Free HTTP fallback for pages Firecrawl refuses (its blocklist covers major publishers).
|
|
51
|
+
* Fetches with a browser-like UA and reuses our own extractor for clean main-content text. */
|
|
52
|
+
export async function fetchPlainPage(url, maxChars = 4000, ua = 'browser') {
|
|
53
|
+
const res = await ufetch(url, { headers: browserHeaders(ua), dispatcher: httpAgent, signal: AbortSignal.timeout(20000) });
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
throw new Error(`plain fetch ${res.status}`);
|
|
56
|
+
const html = await res.text();
|
|
57
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
58
|
+
const ex = extractPage(html, url, host, { hostForm: 'asis' }, null);
|
|
59
|
+
const text = (ex.bodyChunks ?? []).map(c => `${c.heading} ${c.text}`).join('\n').trim();
|
|
60
|
+
return { title: ex.title, content: text.slice(0, maxChars) };
|
|
61
|
+
}
|
|
62
|
+
export async function fetchCompetitorContent(url, clients, opts = {}) {
|
|
63
|
+
const host = hostOf(url);
|
|
64
|
+
const max = opts.maxChars ?? 4000;
|
|
65
|
+
const ua = opts.ua ?? 'browser';
|
|
66
|
+
try {
|
|
67
|
+
if (isVideo(host)) {
|
|
68
|
+
if (!clients.supadata)
|
|
69
|
+
return { url, kind: 'video', title: null, content: '', cached: false, error: 'SUPADATA_API_KEY not set — cannot transcribe video' };
|
|
70
|
+
const t = await clients.supadata.transcript(url, { maxChars: Math.max(max, 5000) });
|
|
71
|
+
return { url, kind: 'video', title: null, content: t.content, cached: t.cached };
|
|
72
|
+
}
|
|
73
|
+
if (isReddit(host)) {
|
|
74
|
+
// .json first (cleaner structured data); HTML fallback. Both need the browser profile.
|
|
75
|
+
try {
|
|
76
|
+
const r = await fetchRedditJson(url, max, ua);
|
|
77
|
+
return { url, kind: 'reddit', title: r.title, content: r.content, cached: false };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
const r = await fetchPlainPage(url, max, ua);
|
|
81
|
+
return { url, kind: 'reddit', title: r.title, content: r.content, cached: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Pages: Firecrawl first (cleaner, handles JS); fall back to the free browser-profile fetch
|
|
85
|
+
// for sites Firecrawl refuses ("we do not support this site") or when no key is set.
|
|
86
|
+
if (clients.firecrawl) {
|
|
87
|
+
try {
|
|
88
|
+
const s = await clients.firecrawl.scrape(url, { maxChars: max, proxy: 'auto' });
|
|
89
|
+
return { url, kind: 'page', title: s.title, content: s.markdown, cached: s.cached };
|
|
90
|
+
}
|
|
91
|
+
catch { /* fall through to the browser-profile fetch */ }
|
|
92
|
+
}
|
|
93
|
+
const p = await fetchPlainPage(url, max, ua);
|
|
94
|
+
return { url, kind: 'page', title: p.title, content: p.content, cached: false };
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
const kind = isVideo(host) ? 'video' : isReddit(host) ? 'reddit' : 'page';
|
|
98
|
+
return { url, kind, title: null, content: '', cached: false, error: e instanceof Error ? e.message : String(e) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=reconResearch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconResearch.js","sourceRoot":"","sources":["../../src/core/reconResearch.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
/**
|
|
3
|
+
* SERP-feature footprint - "how much of my market is being eaten by SERP features?"
|
|
4
|
+
*
|
|
5
|
+
* Built from ONE cached DataForSEO Labs ranked_keywords pull (top-down, never SERP loops):
|
|
6
|
+
* each row carries keyword_data.serp_info.serp_item_types (the features PRESENT on that
|
|
7
|
+
* keyword's SERP) plus our rank. Aggregated volume-weighted, split by whether we rank
|
|
8
|
+
* page 1 on that keyword.
|
|
9
|
+
*
|
|
10
|
+
* Honesty: feature PRESENCE is deterministic (D - it's in the Labs index). Whether the
|
|
11
|
+
* feature helps or hurts US is a judgement proxy (N) - "we rank page 1" is the nearest
|
|
12
|
+
* cheap signal to ownership; true feature ownership needs per-keyword SERP calls.
|
|
13
|
+
*/
|
|
14
|
+
export interface FootprintRow {
|
|
15
|
+
feature: string;
|
|
16
|
+
label: string;
|
|
17
|
+
keywords: number;
|
|
18
|
+
volume: number;
|
|
19
|
+
volumeSharePct: number;
|
|
20
|
+
page1Keywords: number;
|
|
21
|
+
page1Volume: number;
|
|
22
|
+
page1SharePct: number;
|
|
23
|
+
}
|
|
24
|
+
export interface SerpFootprint {
|
|
25
|
+
target: string;
|
|
26
|
+
location: string | null;
|
|
27
|
+
sampleKeywords: number;
|
|
28
|
+
sampleVolume: number;
|
|
29
|
+
features: FootprintRow[];
|
|
30
|
+
fetchedAt?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface SampleKeyword {
|
|
33
|
+
keyword: string;
|
|
34
|
+
position: number | null;
|
|
35
|
+
searchVolume: number | null;
|
|
36
|
+
serpFeatures: string[] | null;
|
|
37
|
+
}
|
|
38
|
+
export declare function computeSerpFootprint(target: string, location: string | null, sample: SampleKeyword[]): SerpFootprint;
|
|
39
|
+
export declare function ensureFootprintTable(db: Database.Database): void;
|
|
40
|
+
export declare function persistSerpFootprint(db: Database.Database, fp: SerpFootprint): void;
|
|
41
|
+
export declare function latestSerpFootprint(db: Database.Database): SerpFootprint | null;
|
|
42
|
+
//# sourceMappingURL=serpFootprint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serpFootprint.d.ts","sourceRoot":"","sources":["../../src/core/serpFootprint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAC;AAE3C;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgCD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CAC/B;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,GAAG,aAAa,CA4BpH;AAED,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,GAAG,IAAI,CAYhE;AAED,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,EAAE,EAAE,aAAa,GAAG,IAAI,CAInF;AAED,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,GAAG,aAAa,GAAG,IAAI,CAe/E"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const FEATURE_LABELS = {
|
|
2
|
+
ai_overview: 'AI Overview',
|
|
3
|
+
featured_snippet: 'Featured snippet',
|
|
4
|
+
people_also_ask: 'People also ask',
|
|
5
|
+
local_pack: 'Local pack',
|
|
6
|
+
shopping: 'Shopping',
|
|
7
|
+
popular_products: 'Popular products',
|
|
8
|
+
video: 'Video',
|
|
9
|
+
images: 'Images',
|
|
10
|
+
knowledge_graph: 'Knowledge panel',
|
|
11
|
+
top_stories: 'Top stories',
|
|
12
|
+
related_searches: 'Related searches',
|
|
13
|
+
people_also_search: 'People also search for',
|
|
14
|
+
organic: 'Organic results',
|
|
15
|
+
paid: 'Paid ads',
|
|
16
|
+
carousel: 'Carousel',
|
|
17
|
+
map: 'Map',
|
|
18
|
+
twitter: 'X (Twitter)',
|
|
19
|
+
discussions_and_forums: 'Discussions & forums',
|
|
20
|
+
short_videos: 'Short videos',
|
|
21
|
+
refine_products: 'Product refinements',
|
|
22
|
+
found_on_web: 'Found on the web',
|
|
23
|
+
perspectives: 'Perspectives',
|
|
24
|
+
product_considerations: 'Product considerations',
|
|
25
|
+
ai_mode: 'AI Mode',
|
|
26
|
+
};
|
|
27
|
+
// Ubiquitous furniture that says nothing about the market - excluded from the chart.
|
|
28
|
+
const NOISE = new Set(['organic', 'related_searches', 'people_also_search', 'paid', 'images']);
|
|
29
|
+
export function computeSerpFootprint(target, location, sample) {
|
|
30
|
+
const totVolume = sample.reduce((s, k) => s + (k.searchVolume ?? 0), 0);
|
|
31
|
+
const byFeature = new Map();
|
|
32
|
+
for (const k of sample) {
|
|
33
|
+
const vol = k.searchVolume ?? 0;
|
|
34
|
+
const page1 = k.position != null && k.position <= 10;
|
|
35
|
+
for (const f of k.serpFeatures ?? []) {
|
|
36
|
+
const b = byFeature.get(f) ?? { keywords: 0, volume: 0, p1k: 0, p1v: 0 };
|
|
37
|
+
b.keywords++;
|
|
38
|
+
b.volume += vol;
|
|
39
|
+
if (page1) {
|
|
40
|
+
b.p1k++;
|
|
41
|
+
b.p1v += vol;
|
|
42
|
+
}
|
|
43
|
+
byFeature.set(f, b);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const features = [...byFeature.entries()]
|
|
47
|
+
.filter(([f]) => !NOISE.has(f))
|
|
48
|
+
.map(([f, b]) => ({
|
|
49
|
+
feature: f,
|
|
50
|
+
label: FEATURE_LABELS[f] ?? f.replace(/_/g, ' '),
|
|
51
|
+
keywords: b.keywords,
|
|
52
|
+
volume: Math.round(b.volume),
|
|
53
|
+
volumeSharePct: totVolume ? Math.round((b.volume / totVolume) * 1000) / 10 : 0,
|
|
54
|
+
page1Keywords: b.p1k,
|
|
55
|
+
page1Volume: Math.round(b.p1v),
|
|
56
|
+
page1SharePct: b.volume ? Math.round((b.p1v / b.volume) * 1000) / 10 : 0,
|
|
57
|
+
}))
|
|
58
|
+
.sort((a, b) => b.volume - a.volume)
|
|
59
|
+
.slice(0, 12);
|
|
60
|
+
return { target, location, sampleKeywords: sample.length, sampleVolume: Math.round(totVolume), features };
|
|
61
|
+
}
|
|
62
|
+
export function ensureFootprintTable(db) {
|
|
63
|
+
db.exec(`
|
|
64
|
+
CREATE TABLE IF NOT EXISTS serp_footprint (
|
|
65
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
target TEXT NOT NULL,
|
|
67
|
+
location TEXT,
|
|
68
|
+
sample_keywords INTEGER NOT NULL,
|
|
69
|
+
sample_volume REAL NOT NULL,
|
|
70
|
+
features TEXT NOT NULL, -- JSON FootprintRow[]
|
|
71
|
+
fetched_at TEXT DEFAULT (datetime('now'))
|
|
72
|
+
);
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
export function persistSerpFootprint(db, fp) {
|
|
76
|
+
ensureFootprintTable(db);
|
|
77
|
+
db.prepare(`INSERT INTO serp_footprint (target, location, sample_keywords, sample_volume, features) VALUES (?, ?, ?, ?, ?)`)
|
|
78
|
+
.run(fp.target, fp.location, fp.sampleKeywords, fp.sampleVolume, JSON.stringify(fp.features));
|
|
79
|
+
}
|
|
80
|
+
export function latestSerpFootprint(db) {
|
|
81
|
+
ensureFootprintTable(db);
|
|
82
|
+
const row = db.prepare(`SELECT target, location, sample_keywords, sample_volume, features, fetched_at FROM serp_footprint ORDER BY id DESC LIMIT 1`).get();
|
|
83
|
+
if (!row)
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
return {
|
|
87
|
+
target: row.target,
|
|
88
|
+
location: row.location,
|
|
89
|
+
sampleKeywords: row.sample_keywords,
|
|
90
|
+
sampleVolume: row.sample_volume,
|
|
91
|
+
features: JSON.parse(row.features),
|
|
92
|
+
fetchedAt: row.fetched_at,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=serpFootprint.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serpFootprint.js","sourceRoot":"","sources":["../../src/core/serpFootprint.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DfsResponse } from './DataForSeoClient.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a DataForSEO SERP-advanced response into the fields content recon needs:
|
|
4
|
+
* where WE actually rank in organic, whether the AI Overview cites us and who it does
|
|
5
|
+
* cite, and how much of the SERP is video/product (format signals). This is the single
|
|
6
|
+
* fetch that powers the organic-rank x AIO-citation verdict matrix.
|
|
7
|
+
*/
|
|
8
|
+
export interface SerpReconResult {
|
|
9
|
+
itemTypes: string[];
|
|
10
|
+
ourOrganicRank: number | null;
|
|
11
|
+
aioPresent: boolean;
|
|
12
|
+
aioCitesUs: boolean;
|
|
13
|
+
aioAsync: boolean;
|
|
14
|
+
aioReferences: {
|
|
15
|
+
rank: number | null;
|
|
16
|
+
domain: string;
|
|
17
|
+
url: string;
|
|
18
|
+
title: string;
|
|
19
|
+
}[];
|
|
20
|
+
videoPresent: boolean;
|
|
21
|
+
organicAbove: {
|
|
22
|
+
rank: number;
|
|
23
|
+
domain: string;
|
|
24
|
+
url: string;
|
|
25
|
+
title: string;
|
|
26
|
+
}[];
|
|
27
|
+
videoItems: {
|
|
28
|
+
url: string;
|
|
29
|
+
title: string;
|
|
30
|
+
source: string;
|
|
31
|
+
}[];
|
|
32
|
+
}
|
|
33
|
+
/** Registrable-ish host: lowercase, strip scheme/path/www. Good enough to match a domain field. */
|
|
34
|
+
export declare function hostOf(s: string | undefined | null): string;
|
|
35
|
+
export declare function parseSerpForRecon(resp: DfsResponse, ownDomain: string): SerpReconResult;
|
|
36
|
+
/**
|
|
37
|
+
* The deterministic verdict from organic rank x AIO citation (Richard's rule):
|
|
38
|
+
* strong organic but NOT in the AIO = a data-accuracy/freshness problem — Google ranks
|
|
39
|
+
* us but won't quote us.
|
|
40
|
+
*/
|
|
41
|
+
export type ReconVerdict = 'defend-and-deepen' | 'accuracy-or-freshness' | 'consolidate-weak-page' | 'competitive-gap';
|
|
42
|
+
export declare function reconVerdict(r: SerpReconResult): {
|
|
43
|
+
verdict: ReconVerdict;
|
|
44
|
+
note: string;
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=serpRecon.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serpRecon.d.ts","sourceRoot":"","sources":["../../src/core/serpRecon.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE;QAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACrF,YAAY,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC7E,UAAU,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC9D;AAED,mGAAmG;AACnG,wBAAgB,MAAM,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAG3D;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,CA2CvF;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,mBAAmB,GAAG,uBAAuB,GAAG,uBAAuB,GAAG,iBAAiB,CAAC;AAEvH,wBAAgB,YAAY,CAAC,CAAC,EAAE,eAAe,GAAG;IAAE,OAAO,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAkBxF"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Registrable-ish host: lowercase, strip scheme/path/www. Good enough to match a domain field. */
|
|
2
|
+
export function hostOf(s) {
|
|
3
|
+
if (!s)
|
|
4
|
+
return '';
|
|
5
|
+
return String(s).replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/^www\./i, '').toLowerCase().trim();
|
|
6
|
+
}
|
|
7
|
+
export function parseSerpForRecon(resp, ownDomain) {
|
|
8
|
+
const own = hostOf(ownDomain);
|
|
9
|
+
const result = resp.tasks?.[0]?.result?.[0] ?? {};
|
|
10
|
+
const items = result.items ?? [];
|
|
11
|
+
const itemTypes = result.item_types ?? [...new Set(items.map(i => i.type))];
|
|
12
|
+
let ourOrganicRank = null;
|
|
13
|
+
const organic = [];
|
|
14
|
+
let aioPresent = false, aioCitesUs = false, aioAsync = false, videoPresent = false;
|
|
15
|
+
const aioReferences = [];
|
|
16
|
+
const videoItems = [];
|
|
17
|
+
for (const it of items) {
|
|
18
|
+
const t = it.type;
|
|
19
|
+
if (t === 'organic') {
|
|
20
|
+
const domain = hostOf(it.domain || it.url);
|
|
21
|
+
const rank = Number(it.rank_group) || organic.length + 1;
|
|
22
|
+
organic.push({ rank, domain, url: it.url ?? '', title: it.title ?? '' });
|
|
23
|
+
if (own && domain === own && ourOrganicRank == null)
|
|
24
|
+
ourOrganicRank = rank;
|
|
25
|
+
}
|
|
26
|
+
else if (t === 'ai_overview') {
|
|
27
|
+
aioPresent = true;
|
|
28
|
+
aioAsync = !!it.asynchronous_ai_overview;
|
|
29
|
+
const refs = it.references ?? [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
for (const ref of refs) {
|
|
32
|
+
const domain = hostOf(ref.domain || ref.url);
|
|
33
|
+
aioReferences.push({ rank: Number(ref.rank_absolute) || ++i, domain, url: ref.url ?? '', title: ref.title ?? '' });
|
|
34
|
+
if (own && domain === own)
|
|
35
|
+
aioCitesUs = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (t === 'video' || t === 'short_videos') {
|
|
39
|
+
videoPresent = true;
|
|
40
|
+
for (const v of it.items ?? []) {
|
|
41
|
+
if (v?.url)
|
|
42
|
+
videoItems.push({ url: v.url, title: v.title ?? '', source: hostOf(v.url) });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Organic ranked above us (the ones actually beating us); if we're absent, the whole top block.
|
|
47
|
+
const organicAbove = ourOrganicRank == null
|
|
48
|
+
? organic.slice(0, 8)
|
|
49
|
+
: organic.filter(o => o.rank < ourOrganicRank);
|
|
50
|
+
return { itemTypes, ourOrganicRank, aioPresent, aioCitesUs, aioAsync, aioReferences, videoPresent, organicAbove, videoItems };
|
|
51
|
+
}
|
|
52
|
+
export function reconVerdict(r) {
|
|
53
|
+
const strongOrganic = r.ourOrganicRank != null && r.ourOrganicRank <= 5;
|
|
54
|
+
const shape = r.videoPresent ? ' The SERP carries a video pack, so some click loss is format, not content.' : '';
|
|
55
|
+
if (!r.aioPresent) {
|
|
56
|
+
return strongOrganic
|
|
57
|
+
? { verdict: 'defend-and-deepen', note: `No AI Overview on this SERP; you rank organic #${r.ourOrganicRank}. Defend and deepen.${shape}` }
|
|
58
|
+
: { verdict: 'competitive-gap', note: `No AI Overview; you rank organic ${r.ourOrganicRank ?? 'outside top results'}. Close the competitive gap.${shape}` };
|
|
59
|
+
}
|
|
60
|
+
if (strongOrganic && r.aioCitesUs) {
|
|
61
|
+
return { verdict: 'defend-and-deepen', note: `You rank organic #${r.ourOrganicRank} and the AI Overview already cites you. Deepen to become the primary source.${shape}` };
|
|
62
|
+
}
|
|
63
|
+
if (strongOrganic && !r.aioCitesUs) {
|
|
64
|
+
return { verdict: 'accuracy-or-freshness', note: `You rank organic #${r.ourOrganicRank} but the AI Overview does NOT cite you — a data-accuracy or freshness signal. Make the facts current, correct and marked up so they are liftable.${shape}` };
|
|
65
|
+
}
|
|
66
|
+
if (!strongOrganic && r.aioCitesUs) {
|
|
67
|
+
return { verdict: 'consolidate-weak-page', note: `The AI Overview cites you but your organic rank is weak (${r.ourOrganicRank ?? 'absent'}) — you have a quotable nugget on an under-powered page; consolidate and strengthen it.${shape}` };
|
|
68
|
+
}
|
|
69
|
+
return { verdict: 'competitive-gap', note: `Not cited in the AI Overview and organic rank is ${r.ourOrganicRank ?? 'absent'} — full coverage gap; run the deep competitor diff.${shape}` };
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=serpRecon.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serpRecon.js","sourceRoot":"","sources":["../../src/core/serpRecon.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local dashboard webserver — the "Phase 2" delivery surface. Serves the built dashboard
|
|
3
|
+
* UI at http://127.0.0.1:PORT with live data from the property SQLite DBs, so the report
|
|
4
|
+
* lives in a real browser tab: no MCP-App host cache, no iframe sandbox, native file
|
|
5
|
+
* downloads, and a property switcher. Localhost-only by design (binds 127.0.0.1).
|
|
6
|
+
*/
|
|
7
|
+
/** Named server-side handlers the web UI may invoke via POST /api/call (allowlist). */
|
|
8
|
+
export type WebCallHandlers = Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>>;
|
|
9
|
+
export interface WebServerOptions {
|
|
10
|
+
dataDir: () => string;
|
|
11
|
+
/** Returns the built dashboard.html template (read per-request so rebuilds show without restart). */
|
|
12
|
+
uiHtml: () => string;
|
|
13
|
+
call: WebCallHandlers;
|
|
14
|
+
port?: number;
|
|
15
|
+
}
|
|
16
|
+
/** Enumerate properties that have a local DB (property_meta.site_url is the source of truth). */
|
|
17
|
+
export declare function listLocalProperties(dataDir: string): {
|
|
18
|
+
siteUrl: string;
|
|
19
|
+
file: string;
|
|
20
|
+
}[];
|
|
21
|
+
export declare function startDashboardServer(opts: WebServerOptions): Promise<{
|
|
22
|
+
url: string;
|
|
23
|
+
port: number;
|
|
24
|
+
}>;
|
|
25
|
+
export declare function stopDashboardServer(): Promise<boolean>;
|
|
26
|
+
export declare function dashboardServerUrl(): string | null;
|
|
27
|
+
//# sourceMappingURL=webServer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webServer.d.ts","sourceRoot":"","sources":["../../src/core/webServer.ts"],"names":[],"mappings":"AAKA;;;;;GAKG;AAEH,uFAAuF;AACvF,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;AAElH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,MAAM,CAAC;IACtB,qGAAqG;IACrG,MAAM,EAAE,MAAM,MAAM,CAAC;IACrB,IAAI,EAAE,eAAe,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAgBD,iGAAiG;AACjG,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAaxF;AA4BD,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA2EzG;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,OAAO,CAAC,CAM5D;AAED,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CAElD"}
|