mcp-scraper 0.7.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/dist/bin/api-server.cjs +1541 -1481
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +1 -1
- package/dist/bin/mcp-scraper-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-install.cjs +1 -1
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +1 -1
- package/dist/bin/mcp-stdio-server.cjs +1 -1
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +3 -3
- package/dist/chunk-3MKSXDJ7.js +7 -0
- package/dist/chunk-3MKSXDJ7.js.map +1 -0
- package/dist/{chunk-YGRZU7IR.js → chunk-ITJC3NN4.js} +4 -3
- package/dist/{chunk-GWRIO6JT.js → chunk-R7EETU7Z.js} +134 -134
- package/dist/chunk-R7EETU7Z.js.map +1 -0
- package/dist/{extract-bundle-COS56ZDO.js → extract-bundle-U4D5LW5W.js} +4 -2
- package/dist/extract-bundle-U4D5LW5W.js.map +1 -0
- package/dist/{server-JNY4XPZE.js → server-WBDVVHR6.js} +73 -23
- package/dist/server-WBDVVHR6.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-EGWJ74EX.js +0 -7
- package/dist/chunk-EGWJ74EX.js.map +0 -1
- package/dist/chunk-GWRIO6JT.js.map +0 -1
- package/dist/extract-bundle-COS56ZDO.js.map +0 -1
- package/dist/server-JNY4XPZE.js.map +0 -1
- /package/dist/{chunk-YGRZU7IR.js.map → chunk-ITJC3NN4.js.map} +0 -0
package/dist/bin/api-server.cjs
CHANGED
|
@@ -6717,7 +6717,8 @@ async function spiderSite(opts) {
|
|
|
6717
6717
|
totalFound: results.length,
|
|
6718
6718
|
durationMs: Date.now() - startMs,
|
|
6719
6719
|
truncated: queue.length > 0 || results.length >= maxUrls,
|
|
6720
|
-
browserRetries
|
|
6720
|
+
browserRetries,
|
|
6721
|
+
sitemapUrls: sitemapUrls.slice(0, maxUrls)
|
|
6721
6722
|
};
|
|
6722
6723
|
}
|
|
6723
6724
|
var SKIP_EXTENSIONS, SKIP_PATH_SEGMENTS, SKIP_PATH_PREFIXES, SKIP_QUERY_PARAMS, KERNEL_RETRY_LIMIT, UA;
|
|
@@ -6944,682 +6945,1158 @@ var init_rotating_proxy_crawl = __esm({
|
|
|
6944
6945
|
}
|
|
6945
6946
|
});
|
|
6946
6947
|
|
|
6947
|
-
// src/api/
|
|
6948
|
-
function
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
return
|
|
6952
|
-
}
|
|
6953
|
-
function emptyPageData(url, status, via) {
|
|
6954
|
-
return {
|
|
6955
|
-
url,
|
|
6956
|
-
status,
|
|
6957
|
-
via,
|
|
6958
|
-
contentType: null,
|
|
6959
|
-
responseTimeMs: null,
|
|
6960
|
-
sizeBytes: null,
|
|
6961
|
-
redirectUrl: null,
|
|
6962
|
-
lastModified: null,
|
|
6963
|
-
xRobotsTag: null,
|
|
6964
|
-
title: null,
|
|
6965
|
-
titleLength: null,
|
|
6966
|
-
titlePixels: null,
|
|
6967
|
-
metaDescription: null,
|
|
6968
|
-
metaDescLength: null,
|
|
6969
|
-
metaKeywords: null,
|
|
6970
|
-
h1: null,
|
|
6971
|
-
h1_2: null,
|
|
6972
|
-
h2Count: 0,
|
|
6973
|
-
headings: [],
|
|
6974
|
-
wordCount: 0,
|
|
6975
|
-
textRatio: 0,
|
|
6976
|
-
contentHash: "",
|
|
6977
|
-
metaRobots: null,
|
|
6978
|
-
relNext: null,
|
|
6979
|
-
relPrev: null,
|
|
6980
|
-
hreflang: [],
|
|
6981
|
-
og: null,
|
|
6982
|
-
twitter: null,
|
|
6983
|
-
ampHref: null,
|
|
6984
|
-
imageCount: 0,
|
|
6985
|
-
imagesMissingAlt: 0,
|
|
6986
|
-
imageLinks: [],
|
|
6987
|
-
indexable: status === 200,
|
|
6988
|
-
indexabilityReason: status === 200 ? null : "non-200",
|
|
6989
|
-
schemaTypes: [],
|
|
6990
|
-
canonicalUrl: null,
|
|
6991
|
-
internalLinks: 0,
|
|
6992
|
-
externalLinks: 0,
|
|
6993
|
-
outlinks: [],
|
|
6994
|
-
bodyMarkdown: "",
|
|
6995
|
-
schema: []
|
|
6996
|
-
};
|
|
6997
|
-
}
|
|
6998
|
-
function makeSeedSpider(startUrl, urls) {
|
|
6999
|
-
return {
|
|
7000
|
-
startUrl,
|
|
7001
|
-
urls: urls.map((url) => ({ url, status: 200, linkedFrom: "seed", via: "fetch" })),
|
|
7002
|
-
totalFound: urls.length,
|
|
7003
|
-
durationMs: 0,
|
|
7004
|
-
truncated: false,
|
|
7005
|
-
browserRetries: 0
|
|
7006
|
-
};
|
|
6948
|
+
// src/api/image-audit.ts
|
|
6949
|
+
function formatBytes(n) {
|
|
6950
|
+
if (n == null) return null;
|
|
6951
|
+
if (n < 1024) return `${n} B`;
|
|
6952
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
6953
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
7007
6954
|
}
|
|
7008
|
-
function
|
|
7009
|
-
const
|
|
7010
|
-
const
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? null;
|
|
7018
|
-
const metaDescription = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i)?.[1]?.trim() ?? null;
|
|
7019
|
-
const canonicalUrl = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<link[^>]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i)?.[1]?.trim() ?? null;
|
|
7020
|
-
const headings = [];
|
|
7021
|
-
for (const m of html.matchAll(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi)) {
|
|
7022
|
-
const text = m[2].replace(/<[^>]+>/g, "").trim();
|
|
7023
|
-
if (text) headings.push({ level: Number(m[1]), text: text.slice(0, 200) });
|
|
7024
|
-
}
|
|
7025
|
-
const h1 = headings.find((h) => h.level === 1)?.text ?? null;
|
|
7026
|
-
const bodyText = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
7027
|
-
const wordCount2 = bodyText.split(" ").filter((w) => w.length > 2).length;
|
|
7028
|
-
const schemaTypes = [];
|
|
7029
|
-
const schema = [];
|
|
7030
|
-
for (const m of html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
7031
|
-
try {
|
|
7032
|
-
const parsed = JSON.parse(m[1]);
|
|
7033
|
-
if (Array.isArray(parsed)) schema.push(...parsed);
|
|
7034
|
-
else schema.push(parsed);
|
|
7035
|
-
const collect = (obj) => {
|
|
7036
|
-
if (!obj || typeof obj !== "object") return;
|
|
7037
|
-
const o = obj;
|
|
7038
|
-
if (typeof o["@type"] === "string") schemaTypes.push(o["@type"]);
|
|
7039
|
-
else if (Array.isArray(o["@type"])) schemaTypes.push(...o["@type"].filter((t) => typeof t === "string"));
|
|
7040
|
-
if (Array.isArray(o["@graph"])) o["@graph"].forEach(collect);
|
|
7041
|
-
};
|
|
7042
|
-
collect(parsed);
|
|
7043
|
-
} catch {
|
|
7044
|
-
}
|
|
6955
|
+
function collectUrls(pages) {
|
|
6956
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
6957
|
+
for (const p of pages) for (const raw of p.imageLinks || []) {
|
|
6958
|
+
const u = decodeEntities(raw);
|
|
6959
|
+
if (!/^https?:\/\//i.test(u)) continue;
|
|
6960
|
+
if (/[{}]|%7[bd]/i.test(u)) continue;
|
|
6961
|
+
const k = dedupKey(u);
|
|
6962
|
+
const prev = byKey.get(k);
|
|
6963
|
+
if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
|
|
7045
6964
|
}
|
|
7046
|
-
|
|
7047
|
-
|
|
6965
|
+
return [...byKey.values()];
|
|
6966
|
+
}
|
|
6967
|
+
async function sizeAndType(url, timeoutMs) {
|
|
6968
|
+
const ctrl = new AbortController();
|
|
6969
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
6970
|
+
const read = (res) => ({
|
|
6971
|
+
len: res.headers.get("content-length"),
|
|
6972
|
+
cr: res.headers.get("content-range"),
|
|
6973
|
+
ct: res.headers.get("content-type"),
|
|
6974
|
+
status: res.status
|
|
6975
|
+
});
|
|
7048
6976
|
try {
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
}
|
|
7053
|
-
const outlinks = [];
|
|
7054
|
-
for (const m of html.matchAll(/<a\b([^>]*?)href\s*=\s*["']([^"'#][^"']*)["']([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
|
6977
|
+
let bytes = null;
|
|
6978
|
+
let ct = null;
|
|
6979
|
+
let status = null;
|
|
7055
6980
|
try {
|
|
7056
|
-
const
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
const anchor = m[4].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
7061
|
-
outlinks.push({ href: abs.href, anchor, rel, internal: abs.origin === origin });
|
|
6981
|
+
const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
|
|
6982
|
+
status = h.status;
|
|
6983
|
+
ct = h.ct;
|
|
6984
|
+
if (h.len) bytes = Number(h.len);
|
|
7062
6985
|
} catch {
|
|
7063
6986
|
}
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
const h2Count = headings.filter((h) => h.level === 2).length;
|
|
7074
|
-
const textRatio = Number((bodyText.length / Math.max(1, html.length)).toFixed(3));
|
|
7075
|
-
const contentHash = (0, import_node_crypto3.createHash)("sha1").update(bodyText).digest("hex");
|
|
7076
|
-
const metaRobots = html.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim().toLowerCase() ?? null;
|
|
7077
|
-
const relNext = html.match(/<link[^>]+rel=["']next["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7078
|
-
const relPrev = html.match(/<link[^>]+rel=["']prev["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7079
|
-
const hreflang = [];
|
|
7080
|
-
for (const m of html.matchAll(/<link[^>]+rel=["']alternate["'][^>]*>/gi)) {
|
|
7081
|
-
const lang = m[0].match(/hreflang=["']([^"']*)["']/i)?.[1];
|
|
7082
|
-
const href = m[0].match(/href=["']([^"']*)["']/i)?.[1];
|
|
7083
|
-
if (lang && href) hreflang.push({ lang, href });
|
|
7084
|
-
}
|
|
7085
|
-
const ogTag = (p) => html.match(new RegExp(`<meta[^>]+property=["']og:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
|
|
7086
|
-
const og = ogTag("title") || ogTag("description") || ogTag("image") || ogTag("type") ? { title: ogTag("title"), description: ogTag("description"), image: ogTag("image"), type: ogTag("type") } : null;
|
|
7087
|
-
const twTag = (p) => html.match(new RegExp(`<meta[^>]+name=["']twitter:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
|
|
7088
|
-
const twitter = twTag("card") || twTag("title") || twTag("description") ? { card: twTag("card"), title: twTag("title"), description: twTag("description") } : null;
|
|
7089
|
-
const ampHref = html.match(/<link[^>]+rel=["']amphtml["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7090
|
-
let imageCount = 0;
|
|
7091
|
-
let imagesMissingAlt = 0;
|
|
7092
|
-
const imageLinkSet = /* @__PURE__ */ new Set();
|
|
7093
|
-
for (const m of html.matchAll(/<img\b([^>]*)>/gi)) {
|
|
7094
|
-
imageCount++;
|
|
7095
|
-
const alt = m[1].match(/\balt\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
7096
|
-
if (!alt || !alt.trim()) imagesMissingAlt++;
|
|
7097
|
-
const src = m[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
7098
|
-
if (src) {
|
|
6987
|
+
if (bytes == null) {
|
|
6988
|
+
const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
|
|
6989
|
+
const r = read(g);
|
|
6990
|
+
status = status ?? r.status;
|
|
6991
|
+
ct = ct ?? r.ct;
|
|
6992
|
+
if (r.cr && r.cr.includes("/")) {
|
|
6993
|
+
const tail = r.cr.split("/")[1];
|
|
6994
|
+
if (tail && tail !== "*") bytes = Number(tail);
|
|
6995
|
+
} else if (r.len) bytes = Number(r.len);
|
|
7099
6996
|
try {
|
|
7100
|
-
|
|
7101
|
-
if (abs.protocol === "http:" || abs.protocol === "https:") imageLinkSet.add(abs.href);
|
|
6997
|
+
await g.body?.cancel();
|
|
7102
6998
|
} catch {
|
|
7103
6999
|
}
|
|
7104
7000
|
}
|
|
7001
|
+
return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
|
|
7002
|
+
} catch (e) {
|
|
7003
|
+
return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
|
|
7004
|
+
} finally {
|
|
7005
|
+
clearTimeout(t);
|
|
7105
7006
|
}
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7007
|
+
}
|
|
7008
|
+
async function pool(items, n, fn) {
|
|
7009
|
+
const out = new Array(items.length);
|
|
7010
|
+
let i = 0;
|
|
7011
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
7012
|
+
while (i < items.length) {
|
|
7013
|
+
const idx = i++;
|
|
7014
|
+
out[idx] = await fn(items[idx]);
|
|
7110
7015
|
}
|
|
7111
|
-
}
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7016
|
+
}));
|
|
7017
|
+
return out;
|
|
7018
|
+
}
|
|
7019
|
+
async function auditImages(pages, opts = {}) {
|
|
7020
|
+
const concurrency = opts.concurrency ?? 12;
|
|
7021
|
+
const timeoutMs = opts.timeoutMs ?? 12e3;
|
|
7022
|
+
const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
|
|
7023
|
+
const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
|
|
7024
|
+
const rows = heads.map((r) => {
|
|
7025
|
+
const format = formatOf(r.contentType, r.url);
|
|
7026
|
+
return {
|
|
7027
|
+
...r,
|
|
7028
|
+
size: formatBytes(r.bytes),
|
|
7029
|
+
format,
|
|
7030
|
+
over100kb: r.bytes != null && r.bytes > OVER_BYTES,
|
|
7031
|
+
legacyFormat: format !== "unknown" && !MODERN.has(format)
|
|
7032
|
+
};
|
|
7033
|
+
});
|
|
7034
|
+
const sized = rows.filter((r) => r.bytes != null);
|
|
7035
|
+
const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
|
|
7036
|
+
const formatCounts = {};
|
|
7037
|
+
for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
|
|
7038
|
+
return {
|
|
7039
|
+
rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
|
|
7040
|
+
summary: {
|
|
7041
|
+
unique: rows.length,
|
|
7042
|
+
sized: sized.length,
|
|
7043
|
+
totalBytes,
|
|
7044
|
+
totalSize: formatBytes(totalBytes) ?? "0 B",
|
|
7045
|
+
avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
|
|
7046
|
+
over100kb: rows.filter((r) => r.over100kb).length,
|
|
7047
|
+
legacyFormat: rows.filter((r) => r.legacyFormat).length,
|
|
7048
|
+
formatCounts
|
|
7119
7049
|
}
|
|
7120
7050
|
};
|
|
7121
|
-
const canonicalSelf = canonicalUrl ? canonNorm(canonicalUrl) === canonNorm(url) : true;
|
|
7122
|
-
let indexable = true;
|
|
7123
|
-
let indexabilityReason = null;
|
|
7124
|
-
if (status !== 200) {
|
|
7125
|
-
indexable = false;
|
|
7126
|
-
indexabilityReason = "non-200";
|
|
7127
|
-
} else if (metaRobots?.includes("noindex")) {
|
|
7128
|
-
indexable = false;
|
|
7129
|
-
indexabilityReason = "noindex";
|
|
7130
|
-
} else if (xRobotsTag?.toLowerCase().includes("noindex")) {
|
|
7131
|
-
indexable = false;
|
|
7132
|
-
indexabilityReason = "x-robots-noindex";
|
|
7133
|
-
} else if (canonicalUrl && !canonicalSelf) {
|
|
7134
|
-
indexable = false;
|
|
7135
|
-
indexabilityReason = "canonicalised";
|
|
7136
|
-
}
|
|
7137
|
-
return {
|
|
7138
|
-
url,
|
|
7139
|
-
status,
|
|
7140
|
-
via,
|
|
7141
|
-
contentType,
|
|
7142
|
-
responseTimeMs,
|
|
7143
|
-
sizeBytes,
|
|
7144
|
-
redirectUrl,
|
|
7145
|
-
lastModified,
|
|
7146
|
-
xRobotsTag,
|
|
7147
|
-
title,
|
|
7148
|
-
titleLength,
|
|
7149
|
-
titlePixels,
|
|
7150
|
-
metaDescription,
|
|
7151
|
-
metaDescLength,
|
|
7152
|
-
metaKeywords,
|
|
7153
|
-
h1,
|
|
7154
|
-
h1_2,
|
|
7155
|
-
h2Count,
|
|
7156
|
-
headings,
|
|
7157
|
-
wordCount: wordCount2,
|
|
7158
|
-
textRatio,
|
|
7159
|
-
contentHash,
|
|
7160
|
-
metaRobots,
|
|
7161
|
-
relNext,
|
|
7162
|
-
relPrev,
|
|
7163
|
-
hreflang,
|
|
7164
|
-
og,
|
|
7165
|
-
twitter,
|
|
7166
|
-
ampHref,
|
|
7167
|
-
imageCount,
|
|
7168
|
-
imagesMissingAlt,
|
|
7169
|
-
imageLinks,
|
|
7170
|
-
indexable,
|
|
7171
|
-
indexabilityReason,
|
|
7172
|
-
schemaTypes,
|
|
7173
|
-
canonicalUrl,
|
|
7174
|
-
internalLinks,
|
|
7175
|
-
externalLinks,
|
|
7176
|
-
outlinks,
|
|
7177
|
-
bodyMarkdown,
|
|
7178
|
-
schema
|
|
7179
|
-
};
|
|
7180
|
-
}
|
|
7181
|
-
async function extractPagesRotating(urls, opts) {
|
|
7182
|
-
const fetched = await crawlWithRotation(urls, { apiKey: opts.kernelApiKey, concurrency: opts.concurrency, urlsPerBrowser: opts.urlsPerBrowser });
|
|
7183
|
-
return fetched.map((r) => r.html ? parsePageData(r.url, r.html, r.status || 200, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }) : emptyPageData(r.url, r.status || null, "browser"));
|
|
7184
7051
|
}
|
|
7185
|
-
|
|
7186
|
-
const
|
|
7187
|
-
|
|
7188
|
-
const
|
|
7189
|
-
|
|
7052
|
+
function renderImageSection(audit) {
|
|
7053
|
+
const s = audit.summary;
|
|
7054
|
+
const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
|
|
7055
|
+
const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
|
|
7056
|
+
const lines = [
|
|
7057
|
+
`## Images`,
|
|
7058
|
+
`**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
|
|
7059
|
+
`Formats: ${fmt}`
|
|
7060
|
+
];
|
|
7061
|
+
if (heaviest.length) {
|
|
7062
|
+
lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
|
|
7063
|
+
for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
|
|
7064
|
+
}
|
|
7065
|
+
return lines.join("\n");
|
|
7190
7066
|
}
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
};
|
|
7204
|
-
if (res.ok) {
|
|
7205
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
7206
|
-
if (ct.includes("html")) {
|
|
7207
|
-
const html = await res.text();
|
|
7208
|
-
return parsePageData(url, html, res.status, "fetch", meta);
|
|
7209
|
-
}
|
|
7210
|
-
return { ...emptyPageData(url, res.status, "fetch"), contentType: ct || null, responseTimeMs: meta.responseTimeMs ?? null };
|
|
7211
|
-
}
|
|
7212
|
-
if ((res.status === 403 || res.status === 429) && kernelApiKey) {
|
|
7213
|
-
const html = await fetchWithKernel(url);
|
|
7214
|
-
return parsePageData(url, html, 200, "browser");
|
|
7215
|
-
}
|
|
7216
|
-
return { ...emptyPageData(url, res.status, "fetch"), responseTimeMs: meta.responseTimeMs ?? null, redirectUrl: meta.redirectUrl ?? null };
|
|
7217
|
-
} catch {
|
|
7218
|
-
if (kernelApiKey) {
|
|
7219
|
-
try {
|
|
7220
|
-
const html = await fetchWithKernel(url);
|
|
7221
|
-
return parsePageData(url, html, 200, "browser");
|
|
7222
|
-
} catch {
|
|
7067
|
+
var OVER_BYTES, MODERN, decodeEntities, dedupKey, formatOf;
|
|
7068
|
+
var init_image_audit = __esm({
|
|
7069
|
+
"src/api/image-audit.ts"() {
|
|
7070
|
+
"use strict";
|
|
7071
|
+
OVER_BYTES = 100 * 1024;
|
|
7072
|
+
MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
|
|
7073
|
+
decodeEntities = (u) => u.replace(/&/g, "&").replace(/�?38;/g, "&").replace(/&/gi, "&").trim();
|
|
7074
|
+
dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
|
|
7075
|
+
formatOf = (ct, url) => {
|
|
7076
|
+
if (ct) {
|
|
7077
|
+
const m = ct.split(";")[0].trim().toLowerCase();
|
|
7078
|
+
if (m.startsWith("image/")) return m.replace("image/", "");
|
|
7223
7079
|
}
|
|
7224
|
-
|
|
7225
|
-
|
|
7080
|
+
return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
|
|
7081
|
+
};
|
|
7226
7082
|
}
|
|
7083
|
+
});
|
|
7084
|
+
|
|
7085
|
+
// src/api/seo-link-graph.ts
|
|
7086
|
+
function normalize(u) {
|
|
7087
|
+
return u.split("#")[0].replace(/\/$/, "");
|
|
7227
7088
|
}
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
const
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7089
|
+
function buildLinkGraph(pages, startUrl) {
|
|
7090
|
+
const statusByUrl = /* @__PURE__ */ new Map();
|
|
7091
|
+
for (const p of pages) statusByUrl.set(normalize(p.url), p.status);
|
|
7092
|
+
const edges = [];
|
|
7093
|
+
for (const p of pages) {
|
|
7094
|
+
for (const l of p.outlinks ?? []) {
|
|
7095
|
+
const nofollow = (l.rel ?? "").split(/\s+/).includes("nofollow");
|
|
7096
|
+
edges.push({
|
|
7097
|
+
from: p.url,
|
|
7098
|
+
to: l.href,
|
|
7099
|
+
anchor: l.anchor,
|
|
7100
|
+
rel: l.rel,
|
|
7101
|
+
internal: l.internal,
|
|
7102
|
+
nofollow,
|
|
7103
|
+
targetStatus: statusByUrl.get(normalize(l.href)) ?? null
|
|
7104
|
+
});
|
|
7234
7105
|
}
|
|
7235
|
-
}
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
const
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
if (rotating) {
|
|
7249
|
-
const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
|
|
7250
|
-
const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
|
|
7251
|
-
const followLinks = !seeded;
|
|
7252
|
-
const seen = /* @__PURE__ */ new Set();
|
|
7253
|
-
const frontier = [];
|
|
7254
|
-
const enqueue = (u) => {
|
|
7255
|
-
const n = normalizeUrl(u, opts.startUrl);
|
|
7256
|
-
if (n && !seen.has(n)) {
|
|
7257
|
-
seen.add(n);
|
|
7258
|
-
frontier.push(n);
|
|
7259
|
-
}
|
|
7260
|
-
};
|
|
7261
|
-
if (seeded) {
|
|
7262
|
-
for (const u of opts.seedUrls) enqueue(u);
|
|
7263
|
-
} else {
|
|
7264
|
-
enqueue(opts.startUrl);
|
|
7265
|
-
for (const u of await discoverSitemapUrls2(opts.startUrl, maxPages)) enqueue(u);
|
|
7106
|
+
}
|
|
7107
|
+
const inboundFrom = /* @__PURE__ */ new Map();
|
|
7108
|
+
const inboundAnchors = /* @__PURE__ */ new Map();
|
|
7109
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
7110
|
+
for (const e of edges) {
|
|
7111
|
+
if (!e.internal) continue;
|
|
7112
|
+
const to = normalize(e.to);
|
|
7113
|
+
const from = normalize(e.from);
|
|
7114
|
+
if (!inboundFrom.has(to)) inboundFrom.set(to, /* @__PURE__ */ new Set());
|
|
7115
|
+
inboundFrom.get(to).add(from);
|
|
7116
|
+
if (e.anchor) {
|
|
7117
|
+
if (!inboundAnchors.has(to)) inboundAnchors.set(to, []);
|
|
7118
|
+
inboundAnchors.get(to).push(e.anchor);
|
|
7266
7119
|
}
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
const capacity = maxPages - pages.length;
|
|
7271
|
-
const wave = frontier.splice(0, Math.min(capacity, waveCap));
|
|
7272
|
-
const fetched = await crawlWithRotation(wave, {
|
|
7273
|
-
apiKey: opts.kernelApiKey,
|
|
7274
|
-
concurrency: browserConcurrency,
|
|
7275
|
-
urlsPerBrowser,
|
|
7276
|
-
onPage: (p) => {
|
|
7277
|
-
done++;
|
|
7278
|
-
opts.onProgress?.({ done, discovered: seen.size, frontier: frontier.length, lastOk: p.ok ? 1 : 0, lastFail: p.ok ? 0 : 1, phase: p.phase });
|
|
7279
|
-
}
|
|
7280
|
-
});
|
|
7281
|
-
for (const r of fetched) {
|
|
7282
|
-
const page = r.html ? parsePageData(r.url, r.html, r.status || 200, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }) : emptyPageData(r.url, r.status || null, "browser");
|
|
7283
|
-
pages.push(page);
|
|
7284
|
-
browserRetries++;
|
|
7285
|
-
if (followLinks && r.html) {
|
|
7286
|
-
for (const l of page.outlinks) if (l.internal) enqueue(l.href);
|
|
7287
|
-
}
|
|
7288
|
-
}
|
|
7120
|
+
if (!e.nofollow && (statusByUrl.get(to) === 200 || !statusByUrl.has(to))) {
|
|
7121
|
+
if (!adjacency.has(from)) adjacency.set(from, /* @__PURE__ */ new Set());
|
|
7122
|
+
adjacency.get(from).add(to);
|
|
7289
7123
|
}
|
|
7290
|
-
truncated = frontier.length > 0;
|
|
7291
|
-
spider = makeSeedSpider(opts.startUrl, [...seen]);
|
|
7292
|
-
} else {
|
|
7293
|
-
spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
|
|
7294
|
-
const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
|
|
7295
|
-
browserRetries = spider.browserRetries;
|
|
7296
|
-
await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
|
|
7297
|
-
const data = await fetchAndParse(url, opts.kernelApiKey);
|
|
7298
|
-
if (data.via === "browser") browserRetries++;
|
|
7299
|
-
pages.push(data);
|
|
7300
|
-
});
|
|
7301
|
-
truncated = spider.truncated || pages.length >= maxPages;
|
|
7302
7124
|
}
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7125
|
+
const depth = /* @__PURE__ */ new Map();
|
|
7126
|
+
const start = normalize(startUrl);
|
|
7127
|
+
const queue = [start];
|
|
7128
|
+
depth.set(start, 0);
|
|
7129
|
+
while (queue.length > 0) {
|
|
7130
|
+
const cur = queue.shift();
|
|
7131
|
+
const d = depth.get(cur);
|
|
7132
|
+
for (const next of adjacency.get(cur) ?? []) {
|
|
7133
|
+
if (!depth.has(next)) {
|
|
7134
|
+
depth.set(next, d + 1);
|
|
7135
|
+
queue.push(next);
|
|
7136
|
+
}
|
|
7137
|
+
}
|
|
7306
7138
|
}
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
spider,
|
|
7312
|
-
durationMs: Date.now() - startMs,
|
|
7313
|
-
truncated,
|
|
7314
|
-
browserRetries,
|
|
7315
|
-
branding
|
|
7139
|
+
const topAnchorsFor = (url) => {
|
|
7140
|
+
const counts = /* @__PURE__ */ new Map();
|
|
7141
|
+
for (const a of inboundAnchors.get(url) ?? []) counts.set(a, (counts.get(a) ?? 0) + 1);
|
|
7142
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
|
|
7316
7143
|
};
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
MAX_PAGE_MARKDOWN = 4e4;
|
|
7332
|
-
turndown = new import_turndown2.default({ headingStyle: "atx", bulletListMarker: "-", codeBlockStyle: "fenced" });
|
|
7144
|
+
const metrics = /* @__PURE__ */ new Map();
|
|
7145
|
+
for (const p of pages) {
|
|
7146
|
+
const key = normalize(p.url);
|
|
7147
|
+
const inbound = inboundFrom.get(key) ?? /* @__PURE__ */ new Set();
|
|
7148
|
+
metrics.set(p.url, {
|
|
7149
|
+
url: p.url,
|
|
7150
|
+
inlinks: inbound.size,
|
|
7151
|
+
uniqueInlinks: inbound.size,
|
|
7152
|
+
outlinksInternal: (p.outlinks ?? []).filter((l) => l.internal).length,
|
|
7153
|
+
outlinksExternal: (p.outlinks ?? []).filter((l) => !l.internal).length,
|
|
7154
|
+
crawlDepth: depth.has(key) ? depth.get(key) : null,
|
|
7155
|
+
orphan: inbound.size === 0 && key !== start,
|
|
7156
|
+
topAnchors: topAnchorsFor(key)
|
|
7157
|
+
});
|
|
7333
7158
|
}
|
|
7334
|
-
}
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
var init_client = __esm({
|
|
7339
|
-
"src/inngest/client.ts"() {
|
|
7159
|
+
return { edges, metrics };
|
|
7160
|
+
}
|
|
7161
|
+
var init_seo_link_graph = __esm({
|
|
7162
|
+
"src/api/seo-link-graph.ts"() {
|
|
7340
7163
|
"use strict";
|
|
7341
|
-
import_inngest = require("inngest");
|
|
7342
|
-
inngest = new import_inngest.Inngest({ id: "mcp-scraper" });
|
|
7343
7164
|
}
|
|
7344
7165
|
});
|
|
7345
7166
|
|
|
7346
|
-
// src/
|
|
7347
|
-
function
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
let
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
open = "[";
|
|
7359
|
-
close = "]";
|
|
7360
|
-
} else if (arrStart === -1) {
|
|
7361
|
-
start = objStart;
|
|
7362
|
-
open = "{";
|
|
7363
|
-
close = "}";
|
|
7364
|
-
} else if (arrStart < objStart) {
|
|
7365
|
-
start = arrStart;
|
|
7366
|
-
open = "[";
|
|
7367
|
-
close = "]";
|
|
7368
|
-
} else {
|
|
7369
|
-
start = objStart;
|
|
7370
|
-
open = "{";
|
|
7371
|
-
close = "}";
|
|
7167
|
+
// src/api/seo-link-report.ts
|
|
7168
|
+
function registrableDomain(host) {
|
|
7169
|
+
const h = host.replace(/^www\./, "").toLowerCase();
|
|
7170
|
+
const parts = h.split(".");
|
|
7171
|
+
return parts.length <= 2 ? h : parts.slice(-2).join(".");
|
|
7172
|
+
}
|
|
7173
|
+
function buildLinkReport(edges, metrics, siteUrl) {
|
|
7174
|
+
let siteReg = "";
|
|
7175
|
+
try {
|
|
7176
|
+
siteReg = registrableDomain(new URL(siteUrl).hostname);
|
|
7177
|
+
} catch {
|
|
7178
|
+
siteReg = "";
|
|
7372
7179
|
}
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
if (
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
}
|
|
7386
|
-
if (ch === '"') {
|
|
7387
|
-
inStr = !inStr;
|
|
7388
|
-
continue;
|
|
7389
|
-
}
|
|
7390
|
-
if (inStr) continue;
|
|
7391
|
-
if (ch === open) depth++;
|
|
7392
|
-
else if (ch === close) {
|
|
7393
|
-
if (--depth === 0) return text.slice(start, i + 1);
|
|
7394
|
-
}
|
|
7180
|
+
const internalEdges = edges.filter((e) => e.internal);
|
|
7181
|
+
const pages = metrics.length;
|
|
7182
|
+
const orphans = metrics.filter((m) => m.orphan).length;
|
|
7183
|
+
const brokenInternal = internalEdges.filter((e) => e.targetStatus != null && e.targetStatus >= 400).length;
|
|
7184
|
+
const sumInlinks = metrics.reduce((a, m) => a + m.inlinks, 0);
|
|
7185
|
+
const sumOutlinks = metrics.reduce((a, m) => a + m.outlinksInternal + m.outlinksExternal, 0);
|
|
7186
|
+
const distribution = { zero: 0, oneToTwo: 0, threeToTen: 0, elevenPlus: 0 };
|
|
7187
|
+
for (const m of metrics) {
|
|
7188
|
+
if (m.inlinks === 0) distribution.zero++;
|
|
7189
|
+
else if (m.inlinks <= 2) distribution.oneToTwo++;
|
|
7190
|
+
else if (m.inlinks <= 10) distribution.threeToTen++;
|
|
7191
|
+
else distribution.elevenPlus++;
|
|
7395
7192
|
}
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
const prompt = attempt === 0 ? promptFn(attempt) : promptFn(attempt, lastError?.message);
|
|
7402
|
-
let raw;
|
|
7193
|
+
const topByInlinks = [...metrics].sort((a, b) => b.inlinks - a.inlinks).slice(0, 20).map((m) => ({ url: m.url, inlinks: m.inlinks, outlinksInternal: m.outlinksInternal, outlinksExternal: m.outlinksExternal }));
|
|
7194
|
+
const domMap = /* @__PURE__ */ new Map();
|
|
7195
|
+
let externalTotal = 0;
|
|
7196
|
+
for (const e of edges) {
|
|
7197
|
+
let domain;
|
|
7403
7198
|
try {
|
|
7404
|
-
|
|
7405
|
-
} catch
|
|
7406
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
7199
|
+
domain = registrableDomain(new URL(e.to).hostname);
|
|
7200
|
+
} catch {
|
|
7407
7201
|
continue;
|
|
7408
7202
|
}
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7203
|
+
if (!domain || domain === siteReg) continue;
|
|
7204
|
+
externalTotal++;
|
|
7205
|
+
const d = domMap.get(domain) ?? { links: 0, nofollow: 0, pages: /* @__PURE__ */ new Set() };
|
|
7206
|
+
d.links++;
|
|
7207
|
+
if (e.nofollow) d.nofollow++;
|
|
7208
|
+
d.pages.add(e.from);
|
|
7209
|
+
domMap.set(domain, d);
|
|
7412
7210
|
}
|
|
7413
|
-
const
|
|
7414
|
-
|
|
7211
|
+
const externalDomains = [...domMap.entries()].map(([domain, d]) => ({ domain, links: d.links, nofollow: d.nofollow, pages: d.pages.size })).sort((a, b) => b.links - a.links);
|
|
7212
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
7213
|
+
return {
|
|
7214
|
+
summary: {
|
|
7215
|
+
internal: {
|
|
7216
|
+
totalLinks: internalEdges.length,
|
|
7217
|
+
pages,
|
|
7218
|
+
orphans,
|
|
7219
|
+
brokenInternal,
|
|
7220
|
+
avgInlinks: pages ? round(sumInlinks / pages) : 0,
|
|
7221
|
+
avgOutlinks: pages ? round(sumOutlinks / pages) : 0,
|
|
7222
|
+
distribution,
|
|
7223
|
+
topByInlinks
|
|
7224
|
+
},
|
|
7225
|
+
external: {
|
|
7226
|
+
totalLinks: externalTotal,
|
|
7227
|
+
uniqueDomains: externalDomains.length,
|
|
7228
|
+
topDomains: externalDomains.slice(0, 20)
|
|
7229
|
+
}
|
|
7230
|
+
},
|
|
7231
|
+
externalDomains
|
|
7232
|
+
};
|
|
7415
7233
|
}
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7234
|
+
function renderLinkReport(r) {
|
|
7235
|
+
const s = r.summary;
|
|
7236
|
+
const lines = [
|
|
7237
|
+
`## Link analysis`,
|
|
7238
|
+
`**Internal:** ${s.internal.totalLinks} links \xB7 ${s.internal.pages} pages \xB7 avg ${s.internal.avgInlinks} inlinks/page \xB7 ${s.internal.orphans} orphans \xB7 ${s.internal.brokenInternal} broken`,
|
|
7239
|
+
`**External:** ${s.external.totalLinks} links to ${s.external.uniqueDomains} domains`,
|
|
7240
|
+
``,
|
|
7241
|
+
`### Inlink distribution`,
|
|
7242
|
+
`- 0 inlinks (orphans): ${s.internal.distribution.zero}`,
|
|
7243
|
+
`- 1\u20132: ${s.internal.distribution.oneToTwo}`,
|
|
7244
|
+
`- 3\u201310: ${s.internal.distribution.threeToTen}`,
|
|
7245
|
+
`- 11+: ${s.internal.distribution.elevenPlus}`,
|
|
7246
|
+
``,
|
|
7247
|
+
`### Top 20 internal pages by inlinks`,
|
|
7248
|
+
`| inlinks | out (int/ext) | URL |`,
|
|
7249
|
+
`|---|---|---|`,
|
|
7250
|
+
...s.internal.topByInlinks.map((p) => `| ${p.inlinks} | ${p.outlinksInternal}/${p.outlinksExternal} | ${p.url} |`),
|
|
7251
|
+
``,
|
|
7252
|
+
`### Top 20 external domains by links`,
|
|
7253
|
+
`| links | nofollow | from pages | domain |`,
|
|
7254
|
+
`|---|---|---|---|`,
|
|
7255
|
+
...s.external.topDomains.map((d) => `| ${d.links} | ${d.nofollow} | ${d.pages} | ${d.domain} |`)
|
|
7256
|
+
];
|
|
7257
|
+
return lines.join("\n");
|
|
7258
|
+
}
|
|
7259
|
+
var init_seo_link_report = __esm({
|
|
7260
|
+
"src/api/seo-link-report.ts"() {
|
|
7419
7261
|
"use strict";
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
DeepInfraLlmClient = class {
|
|
7423
|
-
apiKey;
|
|
7424
|
-
model;
|
|
7425
|
-
totalCostUsd = 0;
|
|
7426
|
-
constructor(apiKey, model = "Qwen/Qwen3.6-35B-A3B") {
|
|
7427
|
-
this.apiKey = apiKey;
|
|
7428
|
-
this.model = model;
|
|
7429
|
-
}
|
|
7430
|
-
async complete(prompt) {
|
|
7431
|
-
const resp = await fetch("https://api.deepinfra.com/v1/openai/chat/completions", {
|
|
7432
|
-
method: "POST",
|
|
7433
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.apiKey}` },
|
|
7434
|
-
body: JSON.stringify({ model: this.model, messages: [{ role: "user", content: prompt }], max_tokens: 8192 })
|
|
7435
|
-
});
|
|
7436
|
-
if (!resp.ok) throw new Error(`DeepInfra error ${resp.status}: ${await resp.text()}`);
|
|
7437
|
-
const data = await resp.json();
|
|
7438
|
-
void recordVendorUsage({ vendor: "deepinfra_qwen", model: this.model, units: data.usage?.completion_tokens ?? 0, unitType: "tokens" });
|
|
7439
|
-
return data.choices[0].message.content;
|
|
7440
|
-
}
|
|
7441
|
-
async completeJson(prompt) {
|
|
7442
|
-
const raw = await this.complete(prompt);
|
|
7443
|
-
try {
|
|
7444
|
-
return JSON.parse(extractJson(raw));
|
|
7445
|
-
} catch {
|
|
7446
|
-
const coerced = await this.complete(
|
|
7447
|
-
`Convert the following text to a valid JSON object. Output ONLY the JSON, no markdown, no explanation:
|
|
7262
|
+
}
|
|
7263
|
+
});
|
|
7448
7264
|
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7265
|
+
// src/api/seo-issues.ts
|
|
7266
|
+
function dupes(pages, key) {
|
|
7267
|
+
const groups = /* @__PURE__ */ new Map();
|
|
7268
|
+
for (const p of pages) {
|
|
7269
|
+
const k = key(p);
|
|
7270
|
+
if (k === null || k === void 0 || k === "") continue;
|
|
7271
|
+
const ks = String(k);
|
|
7272
|
+
if (!groups.has(ks)) groups.set(ks, []);
|
|
7273
|
+
groups.get(ks).push(p.url);
|
|
7274
|
+
}
|
|
7275
|
+
return [...groups.values()].filter((g) => g.length > 1).flat();
|
|
7276
|
+
}
|
|
7277
|
+
function computeIssues(pages, metrics, precomputedBrokenLinkPages) {
|
|
7278
|
+
const group = (urls) => ({ count: urls.length, urls: urls.slice(0, 200) });
|
|
7279
|
+
const where = (fn) => pages.filter(fn).map((p) => p.url);
|
|
7280
|
+
const ok = (p) => p.status === 200;
|
|
7281
|
+
const pathname = (u) => {
|
|
7282
|
+
try {
|
|
7283
|
+
return new URL(u).pathname;
|
|
7284
|
+
} catch {
|
|
7285
|
+
return "";
|
|
7286
|
+
}
|
|
7287
|
+
};
|
|
7288
|
+
const report = {
|
|
7289
|
+
"title.missing": group(where((p) => ok(p) && !p.title)),
|
|
7290
|
+
"title.duplicate": group(dupes(pages.filter(ok), (p) => p.title)),
|
|
7291
|
+
"title.tooLong": group(where((p) => (p.titleLength ?? 0) > TITLE_MAX || (p.titlePixels ?? 0) > TITLE_PX_MAX)),
|
|
7292
|
+
"title.tooShort": group(where((p) => p.title != null && (p.titleLength ?? 0) < TITLE_MIN)),
|
|
7293
|
+
"title.sameAsH1": group(where((p) => !!p.title && !!p.h1 && p.title.trim() === p.h1.trim())),
|
|
7294
|
+
"meta.missing": group(where((p) => ok(p) && !p.metaDescription)),
|
|
7295
|
+
"meta.duplicate": group(dupes(pages.filter(ok), (p) => p.metaDescription)),
|
|
7296
|
+
"meta.tooLong": group(where((p) => (p.metaDescLength ?? 0) > META_MAX)),
|
|
7297
|
+
"meta.tooShort": group(where((p) => p.metaDescription != null && (p.metaDescLength ?? 0) < META_MIN)),
|
|
7298
|
+
"h1.missing": group(where((p) => ok(p) && !p.h1)),
|
|
7299
|
+
"h1.multiple": group(where((p) => !!p.h1_2)),
|
|
7300
|
+
"h1.duplicate": group(dupes(pages.filter(ok), (p) => p.h1)),
|
|
7301
|
+
"h1.tooLong": group(where((p) => (p.h1?.length ?? 0) > H1_MAX)),
|
|
7302
|
+
"h2.missing": group(where((p) => ok(p) && p.h2Count === 0)),
|
|
7303
|
+
"indexability.nonIndexable": group(where((p) => !p.indexable)),
|
|
7304
|
+
"indexability.noindex": group(where((p) => p.indexabilityReason === "noindex")),
|
|
7305
|
+
"canonical.missing": group(where((p) => ok(p) && !p.canonicalUrl)),
|
|
7306
|
+
"canonical.canonicalised": group(where((p) => p.indexabilityReason === "canonicalised")),
|
|
7307
|
+
"response.broken4xx": group(where((p) => (p.status ?? 0) >= 400 && (p.status ?? 0) < 500)),
|
|
7308
|
+
"response.error5xx": group(where((p) => (p.status ?? 0) >= 500)),
|
|
7309
|
+
"response.redirect3xx": group(where((p) => (p.status ?? 0) >= 300 && (p.status ?? 0) < 400)),
|
|
7310
|
+
"response.noResponse": group(where((p) => p.status === null)),
|
|
7311
|
+
"content.thin": group(where((p) => ok(p) && p.wordCount > 0 && p.wordCount < THIN_WORDS)),
|
|
7312
|
+
"content.exactDuplicate": group(dupes(pages.filter((p) => ok(p) && p.contentHash), (p) => p.contentHash)),
|
|
7313
|
+
"images.missingAlt": group(where((p) => p.imagesMissingAlt > 0)),
|
|
7314
|
+
"schema.missing": group(where((p) => ok(p) && p.schemaTypes.length === 0)),
|
|
7315
|
+
"url.tooLong": group(where((p) => p.url.length > URL_MAX)),
|
|
7316
|
+
"url.uppercase": group(where((p) => /[A-Z]/.test(pathname(p.url)))),
|
|
7317
|
+
"url.underscores": group(where((p) => pathname(p.url).includes("_"))),
|
|
7318
|
+
"links.orphan": group([...metrics.values()].filter((m) => m.orphan).map((m) => m.url))
|
|
7319
|
+
};
|
|
7320
|
+
let brokenLinkPages = precomputedBrokenLinkPages;
|
|
7321
|
+
if (!brokenLinkPages) {
|
|
7322
|
+
const normUrl2 = (u) => {
|
|
7323
|
+
try {
|
|
7324
|
+
const x = new URL(u);
|
|
7325
|
+
x.hash = "";
|
|
7326
|
+
return (x.origin + x.pathname).replace(/\/+$/, "") + x.search;
|
|
7327
|
+
} catch {
|
|
7328
|
+
return u.replace(/#.*$/, "").replace(/\/+$/, "");
|
|
7488
7329
|
}
|
|
7489
7330
|
};
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
lastServedBy = null;
|
|
7498
|
-
lastPrimaryError = null;
|
|
7499
|
-
get totalCostUsd() {
|
|
7500
|
-
return this.primary.totalCostUsd + this.secondary.totalCostUsd;
|
|
7501
|
-
}
|
|
7502
|
-
async complete(prompt) {
|
|
7503
|
-
try {
|
|
7504
|
-
const result = await this.primary.complete(prompt);
|
|
7505
|
-
this.lastServedBy = "primary";
|
|
7506
|
-
this.lastPrimaryError = null;
|
|
7507
|
-
return result;
|
|
7508
|
-
} catch (err) {
|
|
7509
|
-
this.lastPrimaryError = String(err);
|
|
7510
|
-
console.warn("[FallbackLlmClient] primary failed, using secondary:", err);
|
|
7511
|
-
this.lastServedBy = "secondary";
|
|
7512
|
-
return this.secondary.complete(prompt);
|
|
7513
|
-
}
|
|
7514
|
-
}
|
|
7515
|
-
async completeJson(prompt) {
|
|
7516
|
-
try {
|
|
7517
|
-
const result = await this.primary.completeJson(prompt);
|
|
7518
|
-
this.lastServedBy = "primary";
|
|
7519
|
-
this.lastPrimaryError = null;
|
|
7520
|
-
return result;
|
|
7521
|
-
} catch (err) {
|
|
7522
|
-
this.lastPrimaryError = String(err);
|
|
7523
|
-
console.warn("[FallbackLlmClient] primary failed, using secondary:", err);
|
|
7524
|
-
this.lastServedBy = "secondary";
|
|
7525
|
-
return this.secondary.completeJson(prompt);
|
|
7526
|
-
}
|
|
7331
|
+
const statusByUrl = new Map(pages.map((p) => [normUrl2(p.url), p.status]));
|
|
7332
|
+
brokenLinkPages = /* @__PURE__ */ new Set();
|
|
7333
|
+
for (const p of pages) {
|
|
7334
|
+
for (const l of p.outlinks ?? []) {
|
|
7335
|
+
if (!l.internal) continue;
|
|
7336
|
+
const st = statusByUrl.get(normUrl2(l.href));
|
|
7337
|
+
if (st != null && st >= 400) brokenLinkPages.add(p.url);
|
|
7527
7338
|
}
|
|
7528
|
-
}
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
report["links.brokenInternal"] = group([...brokenLinkPages]);
|
|
7342
|
+
return report;
|
|
7343
|
+
}
|
|
7344
|
+
function renderIssueReport(siteUrl, pages, report, metrics) {
|
|
7345
|
+
const total = pages.length;
|
|
7346
|
+
const sev = (key) => key.startsWith("response.broken") || key.startsWith("response.error") || key.startsWith("links.broken") ? "\u{1F534}" : key.startsWith("title.missing") || key.startsWith("h1.missing") || key.startsWith("indexability") || key.startsWith("canonical.missing") ? "\u{1F7E0}" : "\u{1F7E1}";
|
|
7347
|
+
const rows = Object.entries(report).filter(([, g]) => g.count > 0).sort((a, b) => b[1].count - a[1].count).map(([k, g]) => `| ${sev(k)} | \`${k}\` | ${g.count} | ${g.urls.slice(0, 3).join(" \xB7 ")}${g.urls.length > 3 ? " \u2026" : ""} |`);
|
|
7348
|
+
const depths = [...metrics.values()].map((m) => m.crawlDepth).filter((d) => d != null);
|
|
7349
|
+
const maxDepth = depths.length ? Math.max(...depths) : 0;
|
|
7350
|
+
const orphans = [...metrics.values()].filter((m) => m.orphan).length;
|
|
7351
|
+
const topLinked = [...metrics.values()].sort((a, b) => b.inlinks - a.inlinks).slice(0, 5);
|
|
7352
|
+
return [
|
|
7353
|
+
`# SEO Crawl Report: ${siteUrl}`,
|
|
7354
|
+
`**${total} pages crawled** \xB7 max crawl depth ${maxDepth} \xB7 ${orphans} orphan page(s)`,
|
|
7355
|
+
`
|
|
7356
|
+
## Issues
|
|
7357
|
+
| | Issue | Count | Examples |
|
|
7358
|
+
|---|-------|-------|----------|
|
|
7359
|
+
${rows.join("\n") || "| \u2705 | none | 0 | \u2014 |"}`,
|
|
7360
|
+
`
|
|
7361
|
+
## Most-linked pages
|
|
7362
|
+
${topLinked.map((m) => `- ${m.inlinks} inlinks \xB7 depth ${m.crawlDepth ?? "\u2014"} \xB7 ${m.url}`).join("\n")}`,
|
|
7363
|
+
`
|
|
7364
|
+
_Thresholds: title ${TITLE_MAX}ch/${TITLE_PX_MAX}px, meta ${META_MAX}ch, H1 ${H1_MAX}ch, thin <${THIN_WORDS} words, URL ${URL_MAX}ch. Pixel widths are estimates._`
|
|
7365
|
+
].join("\n");
|
|
7366
|
+
}
|
|
7367
|
+
var THIN_WORDS, TITLE_MAX, TITLE_PX_MAX, TITLE_MIN, META_MAX, META_MIN, H1_MAX, URL_MAX;
|
|
7368
|
+
var init_seo_issues = __esm({
|
|
7369
|
+
"src/api/seo-issues.ts"() {
|
|
7370
|
+
"use strict";
|
|
7371
|
+
THIN_WORDS = 200;
|
|
7372
|
+
TITLE_MAX = 60;
|
|
7373
|
+
TITLE_PX_MAX = 561;
|
|
7374
|
+
TITLE_MIN = 30;
|
|
7375
|
+
META_MAX = 155;
|
|
7376
|
+
META_MIN = 70;
|
|
7377
|
+
H1_MAX = 70;
|
|
7378
|
+
URL_MAX = 115;
|
|
7529
7379
|
}
|
|
7530
7380
|
});
|
|
7531
7381
|
|
|
7532
|
-
// src/
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7382
|
+
// src/api/seo-audit.ts
|
|
7383
|
+
function buildSeoAudit(pages, startUrl) {
|
|
7384
|
+
const { edges, metrics } = buildLinkGraph(pages, startUrl);
|
|
7385
|
+
const issues = computeIssues(pages, metrics);
|
|
7386
|
+
const { summary } = buildLinkReport(edges, [...metrics.values()], startUrl);
|
|
7387
|
+
return {
|
|
7388
|
+
issues,
|
|
7389
|
+
linkSummary: summary,
|
|
7390
|
+
pageMetrics: [...metrics.values()].map((m) => ({
|
|
7391
|
+
url: m.url,
|
|
7392
|
+
inboundInternal: m.inlinks,
|
|
7393
|
+
outboundInternal: m.outlinksInternal,
|
|
7394
|
+
orphan: m.orphan
|
|
7395
|
+
}))
|
|
7396
|
+
};
|
|
7397
|
+
}
|
|
7398
|
+
var init_seo_audit = __esm({
|
|
7399
|
+
"src/api/seo-audit.ts"() {
|
|
7536
7400
|
"use strict";
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
const body = await response.text();
|
|
7541
|
-
return { status: response.status, body };
|
|
7542
|
-
}
|
|
7543
|
-
async post(url, body, headers) {
|
|
7544
|
-
const response = await fetch(url, {
|
|
7545
|
-
method: "POST",
|
|
7546
|
-
headers: { "Content-Type": "application/json", ...headers },
|
|
7547
|
-
body: JSON.stringify(body)
|
|
7548
|
-
});
|
|
7549
|
-
const responseBody = await response.text();
|
|
7550
|
-
return { status: response.status, body: responseBody };
|
|
7551
|
-
}
|
|
7552
|
-
};
|
|
7401
|
+
init_seo_link_graph();
|
|
7402
|
+
init_seo_link_report();
|
|
7403
|
+
init_seo_issues();
|
|
7553
7404
|
}
|
|
7554
7405
|
});
|
|
7555
7406
|
|
|
7556
|
-
// src/
|
|
7557
|
-
function
|
|
7558
|
-
|
|
7559
|
-
const
|
|
7560
|
-
|
|
7561
|
-
const flags = [];
|
|
7562
|
-
const rec = item;
|
|
7563
|
-
const anchor = typeof rec["anchor_text"] === "string" ? rec["anchor_text"].trim().toLowerCase() : null;
|
|
7564
|
-
if (anchor !== null) {
|
|
7565
|
-
const isVague = VAGUE_ANCHOR_BLOCKLIST.some(
|
|
7566
|
-
(blocked) => blocked.toLowerCase() === anchor
|
|
7567
|
-
);
|
|
7568
|
-
if (isVague) flags.push("vague_anchor");
|
|
7569
|
-
}
|
|
7570
|
-
const outlinks = typeof rec["outlink_count"] === "number" ? rec["outlink_count"] : null;
|
|
7571
|
-
if (outlinks !== null && outlinks >= EXCESSIVE_OUTLINKS_THRESHOLD) {
|
|
7572
|
-
flags.push("excessive_outlinks");
|
|
7573
|
-
}
|
|
7574
|
-
if (context !== null && context !== void 0 && typeof context["resolveFlags"] === "function") {
|
|
7575
|
-
const resolveFlags = context["resolveFlags"];
|
|
7576
|
-
const contextualCandidates = [
|
|
7577
|
-
"seo_only_link",
|
|
7578
|
-
"exact_match_repetition",
|
|
7579
|
-
"orphaned_support_page",
|
|
7580
|
-
"crawl_depth_regression",
|
|
7581
|
-
"low_content_source",
|
|
7582
|
-
"circular_authority_trap"
|
|
7583
|
-
];
|
|
7584
|
-
const contextFlags = resolveFlags(item, contextualCandidates);
|
|
7585
|
-
flags.push(...contextFlags);
|
|
7586
|
-
}
|
|
7587
|
-
if (flags.length === 0) {
|
|
7588
|
-
passed.push(item);
|
|
7589
|
-
} else {
|
|
7590
|
-
flagged.push({ item, flags });
|
|
7591
|
-
}
|
|
7592
|
-
}
|
|
7593
|
-
return { passed, flagged };
|
|
7407
|
+
// src/api/site-extractor.ts
|
|
7408
|
+
function estimatePixels(s) {
|
|
7409
|
+
let px = 0;
|
|
7410
|
+
for (const ch of s) px += PIXEL_WIDTHS[ch] ?? (ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? 10 : 8);
|
|
7411
|
+
return px;
|
|
7594
7412
|
}
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7413
|
+
function emptyPageData(url, status, via) {
|
|
7414
|
+
return {
|
|
7415
|
+
url,
|
|
7416
|
+
status,
|
|
7417
|
+
via,
|
|
7418
|
+
contentType: null,
|
|
7419
|
+
responseTimeMs: null,
|
|
7420
|
+
sizeBytes: null,
|
|
7421
|
+
redirectUrl: null,
|
|
7422
|
+
lastModified: null,
|
|
7423
|
+
xRobotsTag: null,
|
|
7424
|
+
title: null,
|
|
7425
|
+
titleLength: null,
|
|
7426
|
+
titlePixels: null,
|
|
7427
|
+
metaDescription: null,
|
|
7428
|
+
metaDescLength: null,
|
|
7429
|
+
metaKeywords: null,
|
|
7430
|
+
h1: null,
|
|
7431
|
+
h1_2: null,
|
|
7432
|
+
h2Count: 0,
|
|
7433
|
+
headings: [],
|
|
7434
|
+
wordCount: 0,
|
|
7435
|
+
textRatio: 0,
|
|
7436
|
+
contentHash: "",
|
|
7437
|
+
metaRobots: null,
|
|
7438
|
+
relNext: null,
|
|
7439
|
+
relPrev: null,
|
|
7440
|
+
hreflang: [],
|
|
7441
|
+
og: null,
|
|
7442
|
+
twitter: null,
|
|
7443
|
+
ampHref: null,
|
|
7444
|
+
imageCount: 0,
|
|
7445
|
+
imagesMissingAlt: 0,
|
|
7446
|
+
imageLinks: [],
|
|
7447
|
+
indexable: status === 200,
|
|
7448
|
+
indexabilityReason: status === 200 ? null : "non-200",
|
|
7449
|
+
inSitemap: null,
|
|
7450
|
+
schemaTypes: [],
|
|
7451
|
+
canonicalUrl: null,
|
|
7452
|
+
internalLinks: 0,
|
|
7453
|
+
externalLinks: 0,
|
|
7454
|
+
outlinks: [],
|
|
7455
|
+
bodyMarkdown: "",
|
|
7456
|
+
schema: []
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
function makeSeedSpider(startUrl, urls) {
|
|
7460
|
+
return {
|
|
7461
|
+
startUrl,
|
|
7462
|
+
urls: urls.map((url) => ({ url, status: 200, linkedFrom: "seed", via: "fetch" })),
|
|
7463
|
+
totalFound: urls.length,
|
|
7464
|
+
durationMs: 0,
|
|
7465
|
+
truncated: false,
|
|
7466
|
+
browserRetries: 0,
|
|
7467
|
+
sitemapUrls: []
|
|
7468
|
+
};
|
|
7469
|
+
}
|
|
7470
|
+
function parsePageData(url, html, status, via, resp) {
|
|
7471
|
+
const origin = new URL(url).origin;
|
|
7472
|
+
const headers = resp?.headers ?? {};
|
|
7473
|
+
const contentType = headers["content-type"] ?? null;
|
|
7474
|
+
const lastModified = headers["last-modified"] ?? null;
|
|
7475
|
+
const xRobotsTag = headers["x-robots-tag"] ?? null;
|
|
7476
|
+
const responseTimeMs = resp?.responseTimeMs ?? null;
|
|
7477
|
+
const redirectUrl = resp?.redirectUrl ?? headers["location"] ?? null;
|
|
7478
|
+
const sizeBytes = headers["content-length"] ? Number(headers["content-length"]) : Buffer.byteLength(html);
|
|
7479
|
+
const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? null;
|
|
7480
|
+
const metaDescription = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i)?.[1]?.trim() ?? null;
|
|
7481
|
+
const canonicalUrl = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? html.match(/<link[^>]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i)?.[1]?.trim() ?? null;
|
|
7482
|
+
const headings = [];
|
|
7483
|
+
for (const m of html.matchAll(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi)) {
|
|
7484
|
+
const text = m[2].replace(/<[^>]+>/g, "").trim();
|
|
7485
|
+
if (text) headings.push({ level: Number(m[1]), text: text.slice(0, 200) });
|
|
7486
|
+
}
|
|
7487
|
+
const h1 = headings.find((h) => h.level === 1)?.text ?? null;
|
|
7488
|
+
const bodyText = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
7489
|
+
const wordCount2 = bodyText.split(" ").filter((w) => w.length > 2).length;
|
|
7490
|
+
const schemaTypes = [];
|
|
7491
|
+
const schema = [];
|
|
7492
|
+
for (const m of html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
7493
|
+
try {
|
|
7494
|
+
const parsed = JSON.parse(m[1]);
|
|
7495
|
+
if (Array.isArray(parsed)) schema.push(...parsed);
|
|
7496
|
+
else schema.push(parsed);
|
|
7497
|
+
const collect = (obj) => {
|
|
7498
|
+
if (!obj || typeof obj !== "object") return;
|
|
7499
|
+
const o = obj;
|
|
7500
|
+
if (typeof o["@type"] === "string") schemaTypes.push(o["@type"]);
|
|
7501
|
+
else if (Array.isArray(o["@type"])) schemaTypes.push(...o["@type"].filter((t) => typeof t === "string"));
|
|
7502
|
+
if (Array.isArray(o["@graph"])) o["@graph"].forEach(collect);
|
|
7503
|
+
};
|
|
7504
|
+
collect(parsed);
|
|
7505
|
+
} catch {
|
|
7506
|
+
}
|
|
7507
|
+
}
|
|
7508
|
+
const mainHtml = (html.match(/<main[^>]*>([\s\S]*?)<\/main>/i)?.[1] ?? html.match(/<article[^>]*>([\s\S]*?)<\/article>/i)?.[1] ?? html.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? html).replace(/<(script|style|nav|header|footer|aside|noscript|svg|iframe)[^>]*>[\s\S]*?<\/\1>/gi, "");
|
|
7509
|
+
let bodyMarkdown = "";
|
|
7510
|
+
try {
|
|
7511
|
+
bodyMarkdown = turndown.turndown(mainHtml).replace(/\n{3,}/g, "\n\n").trim().slice(0, MAX_PAGE_MARKDOWN);
|
|
7512
|
+
} catch {
|
|
7513
|
+
bodyMarkdown = bodyText.slice(0, MAX_PAGE_MARKDOWN);
|
|
7514
|
+
}
|
|
7515
|
+
const outlinks = [];
|
|
7516
|
+
for (const m of html.matchAll(/<a\b([^>]*?)href\s*=\s*["']([^"'#][^"']*)["']([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
|
7517
|
+
try {
|
|
7518
|
+
const abs = new URL(m[2], url);
|
|
7519
|
+
if (abs.protocol !== "http:" && abs.protocol !== "https:") continue;
|
|
7520
|
+
const attrs = `${m[1]} ${m[3]}`;
|
|
7521
|
+
const rel = attrs.match(/\brel\s*=\s*["']([^"']*)["']/i)?.[1]?.toLowerCase() ?? null;
|
|
7522
|
+
const anchor = m[4].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
7523
|
+
outlinks.push({ href: abs.href, anchor, rel, internal: abs.origin === origin });
|
|
7524
|
+
} catch {
|
|
7525
|
+
}
|
|
7526
|
+
}
|
|
7527
|
+
const internalLinks = outlinks.filter((l) => l.internal).length;
|
|
7528
|
+
const externalLinks = outlinks.length - internalLinks;
|
|
7529
|
+
const titleLength = title?.length ?? null;
|
|
7530
|
+
const titlePixels = title ? estimatePixels(title) : null;
|
|
7531
|
+
const metaDescLength = metaDescription?.length ?? null;
|
|
7532
|
+
const metaKeywords = html.match(/<meta[^>]+name=["']keywords["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7533
|
+
const h1List = headings.filter((h) => h.level === 1);
|
|
7534
|
+
const h1_2 = h1List[1]?.text ?? null;
|
|
7535
|
+
const h2Count = headings.filter((h) => h.level === 2).length;
|
|
7536
|
+
const textRatio = Number((bodyText.length / Math.max(1, html.length)).toFixed(3));
|
|
7537
|
+
const contentHash = (0, import_node_crypto3.createHash)("sha1").update(bodyText).digest("hex");
|
|
7538
|
+
const metaRobots = html.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i)?.[1]?.trim().toLowerCase() ?? null;
|
|
7539
|
+
const relNext = html.match(/<link[^>]+rel=["']next["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7540
|
+
const relPrev = html.match(/<link[^>]+rel=["']prev["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7541
|
+
const hreflang = [];
|
|
7542
|
+
for (const m of html.matchAll(/<link[^>]+rel=["']alternate["'][^>]*>/gi)) {
|
|
7543
|
+
const lang = m[0].match(/hreflang=["']([^"']*)["']/i)?.[1];
|
|
7544
|
+
const href = m[0].match(/href=["']([^"']*)["']/i)?.[1];
|
|
7545
|
+
if (lang && href) hreflang.push({ lang, href });
|
|
7546
|
+
}
|
|
7547
|
+
const ogTag = (p) => html.match(new RegExp(`<meta[^>]+property=["']og:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
|
|
7548
|
+
const og = ogTag("title") || ogTag("description") || ogTag("image") || ogTag("type") ? { title: ogTag("title"), description: ogTag("description"), image: ogTag("image"), type: ogTag("type") } : null;
|
|
7549
|
+
const twTag = (p) => html.match(new RegExp(`<meta[^>]+name=["']twitter:${p}["'][^>]+content=["']([^"']*)["']`, "i"))?.[1]?.trim();
|
|
7550
|
+
const twitter = twTag("card") || twTag("title") || twTag("description") ? { card: twTag("card"), title: twTag("title"), description: twTag("description") } : null;
|
|
7551
|
+
const ampHref = html.match(/<link[^>]+rel=["']amphtml["'][^>]+href=["']([^"']*)["']/i)?.[1]?.trim() ?? null;
|
|
7552
|
+
let imageCount = 0;
|
|
7553
|
+
let imagesMissingAlt = 0;
|
|
7554
|
+
const imageLinkSet = /* @__PURE__ */ new Set();
|
|
7555
|
+
for (const m of html.matchAll(/<img\b([^>]*)>/gi)) {
|
|
7556
|
+
imageCount++;
|
|
7557
|
+
const alt = m[1].match(/\balt\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
7558
|
+
if (!alt || !alt.trim()) imagesMissingAlt++;
|
|
7559
|
+
const src = m[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
7560
|
+
if (src) {
|
|
7561
|
+
try {
|
|
7562
|
+
const abs = new URL(src, url);
|
|
7563
|
+
if (abs.protocol === "http:" || abs.protocol === "https:") imageLinkSet.add(abs.href);
|
|
7564
|
+
} catch {
|
|
7565
|
+
}
|
|
7566
|
+
}
|
|
7567
|
+
}
|
|
7568
|
+
if (og?.image) {
|
|
7569
|
+
try {
|
|
7570
|
+
imageLinkSet.add(new URL(og.image, url).href);
|
|
7571
|
+
} catch {
|
|
7572
|
+
}
|
|
7573
|
+
}
|
|
7574
|
+
const imageLinks = [...imageLinkSet].slice(0, 300);
|
|
7575
|
+
const canonNorm = (u) => {
|
|
7576
|
+
try {
|
|
7577
|
+
const x = new URL(u, url);
|
|
7578
|
+
return (x.origin + x.pathname).replace(/\/+$/, "");
|
|
7579
|
+
} catch {
|
|
7580
|
+
return u.replace(/\/+$/, "");
|
|
7581
|
+
}
|
|
7582
|
+
};
|
|
7583
|
+
const canonicalSelf = canonicalUrl ? canonNorm(canonicalUrl) === canonNorm(url) : true;
|
|
7584
|
+
let indexable = true;
|
|
7585
|
+
let indexabilityReason = null;
|
|
7586
|
+
if (status !== 200) {
|
|
7587
|
+
indexable = false;
|
|
7588
|
+
indexabilityReason = "non-200";
|
|
7589
|
+
} else if (metaRobots?.includes("noindex")) {
|
|
7590
|
+
indexable = false;
|
|
7591
|
+
indexabilityReason = "noindex";
|
|
7592
|
+
} else if (xRobotsTag?.toLowerCase().includes("noindex")) {
|
|
7593
|
+
indexable = false;
|
|
7594
|
+
indexabilityReason = "x-robots-noindex";
|
|
7595
|
+
} else if (canonicalUrl && !canonicalSelf) {
|
|
7596
|
+
indexable = false;
|
|
7597
|
+
indexabilityReason = "canonicalised";
|
|
7598
|
+
}
|
|
7599
|
+
return {
|
|
7600
|
+
url,
|
|
7601
|
+
status,
|
|
7602
|
+
via,
|
|
7603
|
+
contentType,
|
|
7604
|
+
responseTimeMs,
|
|
7605
|
+
sizeBytes,
|
|
7606
|
+
redirectUrl,
|
|
7607
|
+
lastModified,
|
|
7608
|
+
xRobotsTag,
|
|
7609
|
+
title,
|
|
7610
|
+
titleLength,
|
|
7611
|
+
titlePixels,
|
|
7612
|
+
metaDescription,
|
|
7613
|
+
metaDescLength,
|
|
7614
|
+
metaKeywords,
|
|
7615
|
+
h1,
|
|
7616
|
+
h1_2,
|
|
7617
|
+
h2Count,
|
|
7618
|
+
headings,
|
|
7619
|
+
wordCount: wordCount2,
|
|
7620
|
+
textRatio,
|
|
7621
|
+
contentHash,
|
|
7622
|
+
metaRobots,
|
|
7623
|
+
relNext,
|
|
7624
|
+
relPrev,
|
|
7625
|
+
hreflang,
|
|
7626
|
+
og,
|
|
7627
|
+
twitter,
|
|
7628
|
+
ampHref,
|
|
7629
|
+
imageCount,
|
|
7630
|
+
imagesMissingAlt,
|
|
7631
|
+
imageLinks,
|
|
7632
|
+
indexable,
|
|
7633
|
+
indexabilityReason,
|
|
7634
|
+
inSitemap: null,
|
|
7635
|
+
schemaTypes,
|
|
7636
|
+
canonicalUrl,
|
|
7637
|
+
internalLinks,
|
|
7638
|
+
externalLinks,
|
|
7639
|
+
outlinks,
|
|
7640
|
+
bodyMarkdown,
|
|
7641
|
+
schema
|
|
7642
|
+
};
|
|
7643
|
+
}
|
|
7644
|
+
async function extractPagesRotating(urls, opts) {
|
|
7645
|
+
const fetched = await crawlWithRotation(urls, { apiKey: opts.kernelApiKey, concurrency: opts.concurrency, urlsPerBrowser: opts.urlsPerBrowser });
|
|
7646
|
+
return fetched.map((r) => r.html ? parsePageData(r.url, r.html, r.status || 200, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }) : emptyPageData(r.url, r.status || null, "browser"));
|
|
7647
|
+
}
|
|
7648
|
+
async function discoverUrlsWithSitemap(startUrl, maxPages, kernelApiKey) {
|
|
7649
|
+
const sitemapUrls = await discoverSitemapUrls2(startUrl, maxPages);
|
|
7650
|
+
if (sitemapUrls.length > 0) return { urls: sitemapUrls, sitemapUrls };
|
|
7651
|
+
const spider = await spiderSite({ startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey });
|
|
7652
|
+
return { urls: spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url), sitemapUrls: spider.sitemapUrls };
|
|
7653
|
+
}
|
|
7654
|
+
async function fetchAndParse(url, kernelApiKey) {
|
|
7655
|
+
const startedAt = Date.now();
|
|
7656
|
+
try {
|
|
7657
|
+
const res = await fetch(url, {
|
|
7658
|
+
headers: { "User-Agent": UA2, "Accept": "text/html,application/xhtml+xml" },
|
|
7659
|
+
signal: AbortSignal.timeout(1e4),
|
|
7660
|
+
redirect: "manual"
|
|
7661
|
+
});
|
|
7662
|
+
const meta = {
|
|
7663
|
+
headers: Object.fromEntries([...res.headers.entries()].map(([k, v]) => [k.toLowerCase(), v])),
|
|
7664
|
+
responseTimeMs: Date.now() - startedAt,
|
|
7665
|
+
redirectUrl: res.headers.get("location")
|
|
7666
|
+
};
|
|
7667
|
+
if (res.ok) {
|
|
7668
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
7669
|
+
if (ct.includes("html")) {
|
|
7670
|
+
const html = await res.text();
|
|
7671
|
+
return parsePageData(url, html, res.status, "fetch", meta);
|
|
7672
|
+
}
|
|
7673
|
+
return { ...emptyPageData(url, res.status, "fetch"), contentType: ct || null, responseTimeMs: meta.responseTimeMs ?? null };
|
|
7674
|
+
}
|
|
7675
|
+
if ((res.status === 403 || res.status === 429) && kernelApiKey) {
|
|
7676
|
+
const html = await fetchWithKernel(url);
|
|
7677
|
+
return parsePageData(url, html, 200, "browser");
|
|
7678
|
+
}
|
|
7679
|
+
return { ...emptyPageData(url, res.status, "fetch"), responseTimeMs: meta.responseTimeMs ?? null, redirectUrl: meta.redirectUrl ?? null };
|
|
7680
|
+
} catch {
|
|
7681
|
+
if (kernelApiKey) {
|
|
7682
|
+
try {
|
|
7683
|
+
const html = await fetchWithKernel(url);
|
|
7684
|
+
return parsePageData(url, html, 200, "browser");
|
|
7685
|
+
} catch {
|
|
7686
|
+
}
|
|
7687
|
+
}
|
|
7688
|
+
return emptyPageData(url, null, "fetch");
|
|
7689
|
+
}
|
|
7690
|
+
}
|
|
7691
|
+
async function runWithConcurrency(items, concurrency, fn) {
|
|
7692
|
+
let i = 0;
|
|
7693
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
7694
|
+
while (i < items.length) {
|
|
7695
|
+
const item = items[i++];
|
|
7696
|
+
await fn(item);
|
|
7697
|
+
}
|
|
7698
|
+
});
|
|
7699
|
+
await Promise.all(workers);
|
|
7700
|
+
}
|
|
7701
|
+
async function extractSite(opts) {
|
|
7702
|
+
const startMs = Date.now();
|
|
7703
|
+
const maxPages = Math.min(opts.maxPages ?? 100, 1e4);
|
|
7704
|
+
const concurrency = Math.min(opts.concurrency ?? EXTRACT_CONCURRENCY, 10);
|
|
7705
|
+
const rotating = Boolean(opts.rotateProxyEvery && opts.kernelApiKey);
|
|
7706
|
+
const seeded = opts.seedUrls && opts.seedUrls.length > 0;
|
|
7707
|
+
const pages = [];
|
|
7708
|
+
let browserRetries = 0;
|
|
7709
|
+
let truncated = false;
|
|
7710
|
+
let spider;
|
|
7711
|
+
let sitemapUrls = [];
|
|
7712
|
+
if (rotating) {
|
|
7713
|
+
const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
|
|
7714
|
+
const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
|
|
7715
|
+
const followLinks = !seeded;
|
|
7716
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7717
|
+
const frontier = [];
|
|
7718
|
+
const enqueue = (u) => {
|
|
7719
|
+
const n = normalizeUrl(u, opts.startUrl);
|
|
7720
|
+
if (n && !seen.has(n)) {
|
|
7721
|
+
seen.add(n);
|
|
7722
|
+
frontier.push(n);
|
|
7723
|
+
}
|
|
7724
|
+
};
|
|
7725
|
+
if (seeded) {
|
|
7726
|
+
for (const u of opts.seedUrls) enqueue(u);
|
|
7727
|
+
} else {
|
|
7728
|
+
enqueue(opts.startUrl);
|
|
7729
|
+
sitemapUrls = await discoverSitemapUrls2(opts.startUrl, maxPages);
|
|
7730
|
+
for (const u of sitemapUrls) enqueue(u);
|
|
7731
|
+
}
|
|
7732
|
+
const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
|
|
7733
|
+
let done = 0;
|
|
7734
|
+
while (frontier.length > 0 && pages.length < maxPages) {
|
|
7735
|
+
const capacity = maxPages - pages.length;
|
|
7736
|
+
const wave = frontier.splice(0, Math.min(capacity, waveCap));
|
|
7737
|
+
const fetched = await crawlWithRotation(wave, {
|
|
7738
|
+
apiKey: opts.kernelApiKey,
|
|
7739
|
+
concurrency: browserConcurrency,
|
|
7740
|
+
urlsPerBrowser,
|
|
7741
|
+
onPage: (p) => {
|
|
7742
|
+
done++;
|
|
7743
|
+
opts.onProgress?.({ done, discovered: seen.size, frontier: frontier.length, lastOk: p.ok ? 1 : 0, lastFail: p.ok ? 0 : 1, phase: p.phase });
|
|
7744
|
+
}
|
|
7745
|
+
});
|
|
7746
|
+
for (const r of fetched) {
|
|
7747
|
+
const page = r.html ? parsePageData(r.url, r.html, r.status || 200, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }) : emptyPageData(r.url, r.status || null, "browser");
|
|
7748
|
+
pages.push(page);
|
|
7749
|
+
browserRetries++;
|
|
7750
|
+
if (followLinks && r.html) {
|
|
7751
|
+
for (const l of page.outlinks) if (l.internal) enqueue(l.href);
|
|
7752
|
+
}
|
|
7753
|
+
}
|
|
7754
|
+
}
|
|
7755
|
+
truncated = frontier.length > 0;
|
|
7756
|
+
spider = makeSeedSpider(opts.startUrl, [...seen]);
|
|
7757
|
+
} else {
|
|
7758
|
+
spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
|
|
7759
|
+
sitemapUrls = spider.sitemapUrls;
|
|
7760
|
+
const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
|
|
7761
|
+
browserRetries = spider.browserRetries;
|
|
7762
|
+
await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
|
|
7763
|
+
const data = await fetchAndParse(url, opts.kernelApiKey);
|
|
7764
|
+
if (data.via === "browser") browserRetries++;
|
|
7765
|
+
pages.push(data);
|
|
7766
|
+
});
|
|
7767
|
+
truncated = spider.truncated || pages.length >= maxPages;
|
|
7768
|
+
}
|
|
7769
|
+
const sitemapSet = sitemapUrls.length > 0 ? new Set(sitemapUrls.map((u) => normalizeUrl(u, opts.startUrl) ?? u)) : null;
|
|
7770
|
+
for (const p of pages) p.inSitemap = sitemapSet ? sitemapSet.has(normalizeUrl(p.url, opts.startUrl) ?? p.url) : null;
|
|
7771
|
+
let branding = null;
|
|
7772
|
+
if (opts.formats?.includes("branding") && opts.kernelApiKey) {
|
|
7773
|
+
branding = await extractBranding(opts.startUrl, opts.kernelApiKey).catch(() => null);
|
|
7774
|
+
}
|
|
7775
|
+
const seoAudit = opts.formats?.includes("issues") ? buildSeoAudit(pages, opts.startUrl) : null;
|
|
7776
|
+
let imageAudit = null;
|
|
7777
|
+
if (opts.formats?.includes("images")) {
|
|
7778
|
+
imageAudit = await auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
|
|
7779
|
+
}
|
|
7780
|
+
return {
|
|
7781
|
+
startUrl: opts.startUrl,
|
|
7782
|
+
pages,
|
|
7783
|
+
totalPages: pages.length,
|
|
7784
|
+
spider,
|
|
7785
|
+
durationMs: Date.now() - startMs,
|
|
7786
|
+
truncated,
|
|
7787
|
+
browserRetries,
|
|
7788
|
+
branding,
|
|
7789
|
+
seoAudit,
|
|
7790
|
+
imageAudit
|
|
7791
|
+
};
|
|
7792
|
+
}
|
|
7793
|
+
var import_turndown2, import_node_crypto3, PIXEL_WIDTHS, UA2, EXTRACT_CONCURRENCY, MAX_PAGE_MARKDOWN, turndown;
|
|
7794
|
+
var init_site_extractor = __esm({
|
|
7795
|
+
"src/api/site-extractor.ts"() {
|
|
7796
|
+
"use strict";
|
|
7797
|
+
import_turndown2 = __toESM(require("turndown"), 1);
|
|
7798
|
+
import_node_crypto3 = require("crypto");
|
|
7799
|
+
init_site_mapper();
|
|
7800
|
+
init_kernel_fetch();
|
|
7801
|
+
init_rotating_proxy_crawl();
|
|
7802
|
+
init_screenshot();
|
|
7803
|
+
init_image_audit();
|
|
7804
|
+
init_seo_audit();
|
|
7805
|
+
PIXEL_WIDTHS = { i: 4, l: 4, j: 4, ".": 4, ",": 4, "'": 4, t: 6, f: 6, r: 6, " ": 4, m: 14, w: 13, W: 16, M: 16 };
|
|
7806
|
+
UA2 = "Mozilla/5.0 (compatible; ThorbitBot/1.0; +https://thorbit.ai)";
|
|
7807
|
+
EXTRACT_CONCURRENCY = 6;
|
|
7808
|
+
MAX_PAGE_MARKDOWN = 4e4;
|
|
7809
|
+
turndown = new import_turndown2.default({ headingStyle: "atx", bulletListMarker: "-", codeBlockStyle: "fenced" });
|
|
7810
|
+
}
|
|
7811
|
+
});
|
|
7812
|
+
|
|
7813
|
+
// src/inngest/client.ts
|
|
7814
|
+
var import_inngest, inngest;
|
|
7815
|
+
var init_client = __esm({
|
|
7816
|
+
"src/inngest/client.ts"() {
|
|
7817
|
+
"use strict";
|
|
7818
|
+
import_inngest = require("inngest");
|
|
7819
|
+
inngest = new import_inngest.Inngest({ id: "mcp-scraper" });
|
|
7820
|
+
}
|
|
7821
|
+
});
|
|
7822
|
+
|
|
7823
|
+
// src/lib/llm-parse-with-retry.ts
|
|
7824
|
+
function extractJson(raw) {
|
|
7825
|
+
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
|
7826
|
+
text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
|
|
7827
|
+
if (text.startsWith("{") || text.startsWith("[")) return text;
|
|
7828
|
+
const objStart = text.indexOf("{");
|
|
7829
|
+
const arrStart = text.indexOf("[");
|
|
7830
|
+
let start = -1;
|
|
7831
|
+
let open, close;
|
|
7832
|
+
if (objStart === -1 && arrStart === -1) return text;
|
|
7833
|
+
if (objStart === -1) {
|
|
7834
|
+
start = arrStart;
|
|
7835
|
+
open = "[";
|
|
7836
|
+
close = "]";
|
|
7837
|
+
} else if (arrStart === -1) {
|
|
7838
|
+
start = objStart;
|
|
7839
|
+
open = "{";
|
|
7840
|
+
close = "}";
|
|
7841
|
+
} else if (arrStart < objStart) {
|
|
7842
|
+
start = arrStart;
|
|
7843
|
+
open = "[";
|
|
7844
|
+
close = "]";
|
|
7845
|
+
} else {
|
|
7846
|
+
start = objStart;
|
|
7847
|
+
open = "{";
|
|
7848
|
+
close = "}";
|
|
7849
|
+
}
|
|
7850
|
+
let depth = 0;
|
|
7851
|
+
let inStr = false;
|
|
7852
|
+
let escape = false;
|
|
7853
|
+
for (let i = start; i < text.length; i++) {
|
|
7854
|
+
const ch = text[i];
|
|
7855
|
+
if (escape) {
|
|
7856
|
+
escape = false;
|
|
7857
|
+
continue;
|
|
7858
|
+
}
|
|
7859
|
+
if (ch === "\\" && inStr) {
|
|
7860
|
+
escape = true;
|
|
7861
|
+
continue;
|
|
7862
|
+
}
|
|
7863
|
+
if (ch === '"') {
|
|
7864
|
+
inStr = !inStr;
|
|
7865
|
+
continue;
|
|
7866
|
+
}
|
|
7867
|
+
if (inStr) continue;
|
|
7868
|
+
if (ch === open) depth++;
|
|
7869
|
+
else if (ch === close) {
|
|
7870
|
+
if (--depth === 0) return text.slice(start, i + 1);
|
|
7871
|
+
}
|
|
7872
|
+
}
|
|
7873
|
+
return text.slice(start);
|
|
7874
|
+
}
|
|
7875
|
+
async function llmParseWithRetry(schema, promptFn, llm) {
|
|
7876
|
+
let lastError;
|
|
7877
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
7878
|
+
const prompt = attempt === 0 ? promptFn(attempt) : promptFn(attempt, lastError?.message);
|
|
7879
|
+
let raw;
|
|
7880
|
+
try {
|
|
7881
|
+
raw = await llm.completeJson(prompt);
|
|
7882
|
+
} catch (e) {
|
|
7883
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
7884
|
+
continue;
|
|
7885
|
+
}
|
|
7886
|
+
const result = schema.safeParse(raw);
|
|
7887
|
+
if (result.success) return result.data;
|
|
7888
|
+
lastError = result.error;
|
|
7889
|
+
}
|
|
7890
|
+
const detail = lastError?.message?.slice(0, 200) ?? "unknown error";
|
|
7891
|
+
throw new Error(`LLM failed to produce valid output after 3 attempts: ${detail}`);
|
|
7892
|
+
}
|
|
7893
|
+
var import_sdk4, DeepInfraLlmClient, OpenRouterLlmClient, FallbackLlmClient;
|
|
7894
|
+
var init_llm_parse_with_retry = __esm({
|
|
7895
|
+
"src/lib/llm-parse-with-retry.ts"() {
|
|
7896
|
+
"use strict";
|
|
7897
|
+
import_sdk4 = __toESM(require("@anthropic-ai/sdk"), 1);
|
|
7898
|
+
init_cost_telemetry();
|
|
7899
|
+
DeepInfraLlmClient = class {
|
|
7900
|
+
apiKey;
|
|
7901
|
+
model;
|
|
7902
|
+
totalCostUsd = 0;
|
|
7903
|
+
constructor(apiKey, model = "Qwen/Qwen3.6-35B-A3B") {
|
|
7904
|
+
this.apiKey = apiKey;
|
|
7905
|
+
this.model = model;
|
|
7906
|
+
}
|
|
7907
|
+
async complete(prompt) {
|
|
7908
|
+
const resp = await fetch("https://api.deepinfra.com/v1/openai/chat/completions", {
|
|
7909
|
+
method: "POST",
|
|
7910
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.apiKey}` },
|
|
7911
|
+
body: JSON.stringify({ model: this.model, messages: [{ role: "user", content: prompt }], max_tokens: 8192 })
|
|
7912
|
+
});
|
|
7913
|
+
if (!resp.ok) throw new Error(`DeepInfra error ${resp.status}: ${await resp.text()}`);
|
|
7914
|
+
const data = await resp.json();
|
|
7915
|
+
void recordVendorUsage({ vendor: "deepinfra_qwen", model: this.model, units: data.usage?.completion_tokens ?? 0, unitType: "tokens" });
|
|
7916
|
+
return data.choices[0].message.content;
|
|
7917
|
+
}
|
|
7918
|
+
async completeJson(prompt) {
|
|
7919
|
+
const raw = await this.complete(prompt);
|
|
7920
|
+
try {
|
|
7921
|
+
return JSON.parse(extractJson(raw));
|
|
7922
|
+
} catch {
|
|
7923
|
+
const coerced = await this.complete(
|
|
7924
|
+
`Convert the following text to a valid JSON object. Output ONLY the JSON, no markdown, no explanation:
|
|
7925
|
+
|
|
7926
|
+
${raw}`
|
|
7927
|
+
);
|
|
7928
|
+
return JSON.parse(extractJson(coerced));
|
|
7929
|
+
}
|
|
7930
|
+
}
|
|
7931
|
+
};
|
|
7932
|
+
OpenRouterLlmClient = class {
|
|
7933
|
+
apiKey;
|
|
7934
|
+
model;
|
|
7935
|
+
totalCostUsd = 0;
|
|
7936
|
+
constructor(apiKey, model = "qwen/qwen3-235b-a22b") {
|
|
7937
|
+
this.apiKey = apiKey;
|
|
7938
|
+
this.model = model;
|
|
7939
|
+
}
|
|
7940
|
+
async complete(prompt) {
|
|
7941
|
+
const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
7942
|
+
method: "POST",
|
|
7943
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.apiKey}` },
|
|
7944
|
+
body: JSON.stringify({ model: this.model, messages: [{ role: "user", content: prompt }], max_tokens: 8192 })
|
|
7945
|
+
});
|
|
7946
|
+
if (!resp.ok) throw new Error(`OpenRouter error ${resp.status}: ${await resp.text()}`);
|
|
7947
|
+
const data = await resp.json();
|
|
7948
|
+
const cost = data.usage?.cost ?? 0;
|
|
7949
|
+
this.totalCostUsd += cost;
|
|
7950
|
+
void recordVendorUsage({ vendor: "openrouter", model: this.model, units: cost, unitType: "usd" });
|
|
7951
|
+
return data.choices[0].message.content;
|
|
7952
|
+
}
|
|
7953
|
+
async completeJson(prompt) {
|
|
7954
|
+
const raw = await this.complete(prompt);
|
|
7955
|
+
try {
|
|
7956
|
+
return JSON.parse(extractJson(raw));
|
|
7957
|
+
} catch {
|
|
7958
|
+
const coerced = await this.complete(
|
|
7959
|
+
`Convert the following text to a valid JSON object. Output ONLY the JSON, no markdown, no explanation:
|
|
7960
|
+
|
|
7961
|
+
${raw}`
|
|
7962
|
+
);
|
|
7963
|
+
return JSON.parse(extractJson(coerced));
|
|
7964
|
+
}
|
|
7965
|
+
}
|
|
7966
|
+
};
|
|
7967
|
+
FallbackLlmClient = class {
|
|
7968
|
+
constructor(primary, secondary) {
|
|
7969
|
+
this.primary = primary;
|
|
7970
|
+
this.secondary = secondary;
|
|
7971
|
+
}
|
|
7972
|
+
primary;
|
|
7973
|
+
secondary;
|
|
7974
|
+
lastServedBy = null;
|
|
7975
|
+
lastPrimaryError = null;
|
|
7976
|
+
get totalCostUsd() {
|
|
7977
|
+
return this.primary.totalCostUsd + this.secondary.totalCostUsd;
|
|
7978
|
+
}
|
|
7979
|
+
async complete(prompt) {
|
|
7980
|
+
try {
|
|
7981
|
+
const result = await this.primary.complete(prompt);
|
|
7982
|
+
this.lastServedBy = "primary";
|
|
7983
|
+
this.lastPrimaryError = null;
|
|
7984
|
+
return result;
|
|
7985
|
+
} catch (err) {
|
|
7986
|
+
this.lastPrimaryError = String(err);
|
|
7987
|
+
console.warn("[FallbackLlmClient] primary failed, using secondary:", err);
|
|
7988
|
+
this.lastServedBy = "secondary";
|
|
7989
|
+
return this.secondary.complete(prompt);
|
|
7990
|
+
}
|
|
7991
|
+
}
|
|
7992
|
+
async completeJson(prompt) {
|
|
7993
|
+
try {
|
|
7994
|
+
const result = await this.primary.completeJson(prompt);
|
|
7995
|
+
this.lastServedBy = "primary";
|
|
7996
|
+
this.lastPrimaryError = null;
|
|
7997
|
+
return result;
|
|
7998
|
+
} catch (err) {
|
|
7999
|
+
this.lastPrimaryError = String(err);
|
|
8000
|
+
console.warn("[FallbackLlmClient] primary failed, using secondary:", err);
|
|
8001
|
+
this.lastServedBy = "secondary";
|
|
8002
|
+
return this.secondary.completeJson(prompt);
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
};
|
|
8006
|
+
}
|
|
8007
|
+
});
|
|
8008
|
+
|
|
8009
|
+
// src/lib/http-client.ts
|
|
8010
|
+
var FetchHttpClient;
|
|
8011
|
+
var init_http_client = __esm({
|
|
8012
|
+
"src/lib/http-client.ts"() {
|
|
8013
|
+
"use strict";
|
|
8014
|
+
FetchHttpClient = class {
|
|
8015
|
+
async get(url, headers) {
|
|
8016
|
+
const response = await fetch(url, { headers });
|
|
8017
|
+
const body = await response.text();
|
|
8018
|
+
return { status: response.status, body };
|
|
8019
|
+
}
|
|
8020
|
+
async post(url, body, headers) {
|
|
8021
|
+
const response = await fetch(url, {
|
|
8022
|
+
method: "POST",
|
|
8023
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
8024
|
+
body: JSON.stringify(body)
|
|
8025
|
+
});
|
|
8026
|
+
const responseBody = await response.text();
|
|
8027
|
+
return { status: response.status, body: responseBody };
|
|
8028
|
+
}
|
|
8029
|
+
};
|
|
8030
|
+
}
|
|
8031
|
+
});
|
|
8032
|
+
|
|
8033
|
+
// src/lib/site-architecture-auditor/rules.ts
|
|
8034
|
+
function applyGuardrailRules(raw, context) {
|
|
8035
|
+
const passed = [];
|
|
8036
|
+
const flagged = [];
|
|
8037
|
+
for (const item of raw) {
|
|
8038
|
+
const flags = [];
|
|
8039
|
+
const rec = item;
|
|
8040
|
+
const anchor = typeof rec["anchor_text"] === "string" ? rec["anchor_text"].trim().toLowerCase() : null;
|
|
8041
|
+
if (anchor !== null) {
|
|
8042
|
+
const isVague = VAGUE_ANCHOR_BLOCKLIST.some(
|
|
8043
|
+
(blocked) => blocked.toLowerCase() === anchor
|
|
8044
|
+
);
|
|
8045
|
+
if (isVague) flags.push("vague_anchor");
|
|
8046
|
+
}
|
|
8047
|
+
const outlinks = typeof rec["outlink_count"] === "number" ? rec["outlink_count"] : null;
|
|
8048
|
+
if (outlinks !== null && outlinks >= EXCESSIVE_OUTLINKS_THRESHOLD) {
|
|
8049
|
+
flags.push("excessive_outlinks");
|
|
8050
|
+
}
|
|
8051
|
+
if (context !== null && context !== void 0 && typeof context["resolveFlags"] === "function") {
|
|
8052
|
+
const resolveFlags = context["resolveFlags"];
|
|
8053
|
+
const contextualCandidates = [
|
|
8054
|
+
"seo_only_link",
|
|
8055
|
+
"exact_match_repetition",
|
|
8056
|
+
"orphaned_support_page",
|
|
8057
|
+
"crawl_depth_regression",
|
|
8058
|
+
"low_content_source",
|
|
8059
|
+
"circular_authority_trap"
|
|
8060
|
+
];
|
|
8061
|
+
const contextFlags = resolveFlags(item, contextualCandidates);
|
|
8062
|
+
flags.push(...contextFlags);
|
|
8063
|
+
}
|
|
8064
|
+
if (flags.length === 0) {
|
|
8065
|
+
passed.push(item);
|
|
8066
|
+
} else {
|
|
8067
|
+
flagged.push({ item, flags });
|
|
8068
|
+
}
|
|
8069
|
+
}
|
|
8070
|
+
return { passed, flagged };
|
|
8071
|
+
}
|
|
8072
|
+
var import_zod2, PageTypeSchema, ClassificationConfidenceSchema, ARCHITECTURE_SCORE_WEIGHTS, ILES_WEIGHTS, PriorityTierSchema, EXCESSIVE_OUTLINKS_THRESHOLD, VAGUE_ANCHOR_BLOCKLIST, GuardrailFlagNameSchema, RequiredReportSectionSchema, LocationArchitecturePatternSchema;
|
|
8073
|
+
var init_rules = __esm({
|
|
8074
|
+
"src/lib/site-architecture-auditor/rules.ts"() {
|
|
8075
|
+
"use strict";
|
|
8076
|
+
import_zod2 = require("zod");
|
|
8077
|
+
PageTypeSchema = import_zod2.z.enum([
|
|
8078
|
+
"homepage",
|
|
8079
|
+
"core_service",
|
|
8080
|
+
"sub_service",
|
|
8081
|
+
"location",
|
|
8082
|
+
"service_location",
|
|
8083
|
+
"industry",
|
|
8084
|
+
"product",
|
|
8085
|
+
"pricing",
|
|
8086
|
+
"faq",
|
|
8087
|
+
"blog_post",
|
|
8088
|
+
"guide_resource",
|
|
8089
|
+
"case_study",
|
|
8090
|
+
"comparison",
|
|
8091
|
+
"competitor",
|
|
8092
|
+
"about",
|
|
8093
|
+
"contact",
|
|
8094
|
+
"team",
|
|
8095
|
+
"careers",
|
|
8096
|
+
"privacy_policy",
|
|
8097
|
+
"terms",
|
|
8098
|
+
"html_sitemap",
|
|
8099
|
+
"login_portal",
|
|
7623
8100
|
"support_help_center"
|
|
7624
8101
|
]);
|
|
7625
8102
|
ClassificationConfidenceSchema = import_zod2.z.enum([
|
|
@@ -9602,883 +10079,449 @@ var init_site_audit_service = __esm({
|
|
|
9602
10079
|
topic_cluster: null,
|
|
9603
10080
|
location_signals: c.locationSignals
|
|
9604
10081
|
});
|
|
9605
|
-
}
|
|
9606
|
-
if (unclassifiedUrls.length > 0) {
|
|
9607
|
-
const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
|
|
9608
|
-
const llmResults = await llmParseWithRetry(
|
|
9609
|
-
import_zod10.z.array(PageTypeClassificationRowSchema),
|
|
9610
|
-
(attempt, error) => {
|
|
9611
|
-
void attempt;
|
|
9612
|
-
void error;
|
|
9613
|
-
return buildMetricsClassifyPrompt({
|
|
9614
|
-
urls: unclassifiedBatch,
|
|
9615
|
-
siteDomain: clientDomain
|
|
9616
|
-
});
|
|
9617
|
-
},
|
|
9618
|
-
this.deps.llm
|
|
9619
|
-
);
|
|
9620
|
-
allClassifications.push(...llmResults);
|
|
9621
|
-
}
|
|
9622
|
-
}
|
|
9623
|
-
});
|
|
9624
|
-
void sessionPath;
|
|
9625
|
-
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase3-classify", {
|
|
9626
|
-
classifications: allClassifications,
|
|
9627
|
-
totalClassified: allClassifications.length
|
|
9628
|
-
});
|
|
9629
|
-
}
|
|
9630
|
-
async runComparePhase(jobId, payload) {
|
|
9631
|
-
void payload;
|
|
9632
|
-
const job = await this.deps.repo.getSiteAuditJob(jobId);
|
|
9633
|
-
if (!job) throw new Error(`[site-audit-service] runComparePhase: job ${jobId} not found`);
|
|
9634
|
-
const request = JSON.parse(job.request);
|
|
9635
|
-
const clientDomain = request.clientDomain;
|
|
9636
|
-
const result = await this.runLlmBilled(
|
|
9637
|
-
job.user_id,
|
|
9638
|
-
`${jobId}:phase4-compare`,
|
|
9639
|
-
() => llmParseWithRetry(
|
|
9640
|
-
CompareRecommendOutputSchema,
|
|
9641
|
-
(attempt, error) => {
|
|
9642
|
-
void attempt;
|
|
9643
|
-
void error;
|
|
9644
|
-
return buildCompareRecommendPrompt({
|
|
9645
|
-
clientDomain,
|
|
9646
|
-
clientMoneyPages: [],
|
|
9647
|
-
competitorSites: [],
|
|
9648
|
-
clientUrlMetrics: [],
|
|
9649
|
-
clientPageTypes: []
|
|
9650
|
-
});
|
|
9651
|
-
},
|
|
9652
|
-
this.deps.llm
|
|
9653
|
-
)
|
|
9654
|
-
);
|
|
9655
|
-
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase4-compare", result);
|
|
9656
|
-
return result;
|
|
9657
|
-
}
|
|
9658
|
-
async runSynthesizePhase(jobId, payload) {
|
|
9659
|
-
void payload;
|
|
9660
|
-
const job = await this.deps.repo.getSiteAuditJob(jobId);
|
|
9661
|
-
if (!job) throw new Error(`[site-audit-service] runSynthesizePhase: job ${jobId} not found`);
|
|
9662
|
-
const request = JSON.parse(job.request);
|
|
9663
|
-
const clientDomain = request.clientDomain;
|
|
9664
|
-
const sessionPath = request.sessionPath;
|
|
9665
|
-
const architectureScoreComponents = {};
|
|
9666
|
-
for (const [dim, weight] of Object.entries(ARCHITECTURE_SCORE_WEIGHTS)) {
|
|
9667
|
-
architectureScoreComponents[dim] = weight * 50;
|
|
9668
|
-
}
|
|
9669
|
-
const architectureTotal = Object.values(architectureScoreComponents).reduce((s, v) => s + v, 0);
|
|
9670
|
-
const ilesComponents = {};
|
|
9671
|
-
for (const [dim, weight] of Object.entries(ILES_WEIGHTS)) {
|
|
9672
|
-
ilesComponents[dim] = weight * 50;
|
|
9673
|
-
}
|
|
9674
|
-
const ilesTotal = Object.values(ilesComponents).reduce((s, v) => s + v, 0);
|
|
9675
|
-
const preComputedArchitectureScore = {
|
|
9676
|
-
total: architectureTotal,
|
|
9677
|
-
components: architectureScoreComponents
|
|
9678
|
-
};
|
|
9679
|
-
const preComputedIlesScore = {
|
|
9680
|
-
total: ilesTotal,
|
|
9681
|
-
components: ilesComponents
|
|
9682
|
-
};
|
|
9683
|
-
const reportMarkdown = await this.runLlmBilled(
|
|
9684
|
-
job.user_id,
|
|
9685
|
-
`${jobId}:phase5-synthesize`,
|
|
9686
|
-
() => llmParseWithRetry(
|
|
9687
|
-
import_zod10.z.string(),
|
|
9688
|
-
(attempt, error) => {
|
|
9689
|
-
void attempt;
|
|
9690
|
-
void error;
|
|
9691
|
-
return buildScoreSynthesizePrompt({
|
|
9692
|
-
preComputedArchitectureScore,
|
|
9693
|
-
preComputedIlesScore,
|
|
9694
|
-
rankedBacklog: [],
|
|
9695
|
-
clientDomain,
|
|
9696
|
-
sessionPath
|
|
9697
|
-
});
|
|
9698
|
-
},
|
|
9699
|
-
this.deps.llm
|
|
9700
|
-
)
|
|
9701
|
-
);
|
|
9702
|
-
const auditResult = SiteAuditResultSchema.parse({
|
|
9703
|
-
jobId,
|
|
9704
|
-
clientDomain,
|
|
9705
|
-
architectureScore: preComputedArchitectureScore,
|
|
9706
|
-
ilesScore: preComputedIlesScore,
|
|
9707
|
-
rankedBacklog: [],
|
|
9708
|
-
linkRecommendations: [],
|
|
9709
|
-
newPagesRecommended: [],
|
|
9710
|
-
guardrailFlags: [],
|
|
9711
|
-
reportMarkdown
|
|
9712
|
-
});
|
|
9713
|
-
await this.deps.repo.completeSiteAuditJob(jobId, auditResult);
|
|
9714
|
-
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase5-synthesize", {
|
|
9715
|
-
architectureScore: auditResult.architectureScore,
|
|
9716
|
-
ilesScore: auditResult.ilesScore
|
|
9717
|
-
});
|
|
9718
|
-
}
|
|
9719
|
-
dispatchPhase(_phase, _jobId, _payload) {
|
|
9720
|
-
throw new Error("dispatchPhase delegates to phases.ts \u2014 see saa-011");
|
|
9721
|
-
}
|
|
9722
|
-
async _readCompetitorDomains(request) {
|
|
9723
|
-
try {
|
|
9724
|
-
const response = await this.deps.http.get(`file://${request.competitorDomainsCsvPath}`);
|
|
9725
|
-
const lines = response.body.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
9726
|
-
const domains = [];
|
|
9727
|
-
for (const line of lines) {
|
|
9728
|
-
if (line.includes(",")) {
|
|
9729
|
-
domains.push(...line.split(",").map((d) => d.trim()).filter((d) => d.length > 0));
|
|
9730
|
-
} else {
|
|
9731
|
-
domains.push(line);
|
|
9732
|
-
}
|
|
9733
|
-
}
|
|
9734
|
-
return domains.filter((d) => d.includes("."));
|
|
9735
|
-
} catch {
|
|
9736
|
-
return [];
|
|
9737
|
-
}
|
|
9738
|
-
}
|
|
9739
|
-
};
|
|
9740
|
-
}
|
|
9741
|
-
});
|
|
9742
|
-
|
|
9743
|
-
// src/services/site-architecture-auditor/site-audit-repository.ts
|
|
9744
|
-
function rowToSiteAuditJobRow(row) {
|
|
9745
|
-
return SiteAuditJobRowSchema.parse({
|
|
9746
|
-
id: String(row.id),
|
|
9747
|
-
user_id: Number(row.user_id),
|
|
9748
|
-
status: row.status != null ? String(row.status) : "pending",
|
|
9749
|
-
client_domain: String(row.client_domain),
|
|
9750
|
-
session_path: String(row.session_path),
|
|
9751
|
-
request: String(row.request),
|
|
9752
|
-
result: row.result != null ? String(row.result) : null,
|
|
9753
|
-
error: row.error != null ? String(row.error) : null,
|
|
9754
|
-
created_at: String(row.created_at),
|
|
9755
|
-
updated_at: String(row.updated_at)
|
|
9756
|
-
});
|
|
9757
|
-
}
|
|
9758
|
-
var SiteAuditRepository;
|
|
9759
|
-
var init_site_audit_repository = __esm({
|
|
9760
|
-
"src/services/site-architecture-auditor/site-audit-repository.ts"() {
|
|
9761
|
-
"use strict";
|
|
9762
|
-
init_db();
|
|
9763
|
-
init_db();
|
|
9764
|
-
SiteAuditRepository = class {
|
|
9765
|
-
db;
|
|
9766
|
-
constructor() {
|
|
9767
|
-
this.db = getDb();
|
|
9768
|
-
}
|
|
9769
|
-
async createSiteAuditJob(jobId, userId, request) {
|
|
9770
|
-
await this.db.execute({
|
|
9771
|
-
sql: `INSERT INTO site_audit_jobs
|
|
9772
|
-
(id, user_id, status, client_domain, session_path, request, created_at, updated_at)
|
|
9773
|
-
VALUES (?, ?, 'pending', ?, ?, ?, datetime('now'), datetime('now'))`,
|
|
9774
|
-
args: [
|
|
9775
|
-
jobId,
|
|
9776
|
-
userId,
|
|
9777
|
-
request.clientDomain,
|
|
9778
|
-
request.sessionPath,
|
|
9779
|
-
JSON.stringify(request)
|
|
9780
|
-
]
|
|
9781
|
-
});
|
|
9782
|
-
}
|
|
9783
|
-
async claimPendingSiteAuditJob() {
|
|
9784
|
-
const upd = await this.db.execute(
|
|
9785
|
-
`UPDATE site_audit_jobs
|
|
9786
|
-
SET status = 'running', updated_at = datetime('now')
|
|
9787
|
-
WHERE id = (
|
|
9788
|
-
SELECT id FROM site_audit_jobs
|
|
9789
|
-
WHERE status = 'pending'
|
|
9790
|
-
ORDER BY created_at ASC
|
|
9791
|
-
LIMIT 1
|
|
9792
|
-
)
|
|
9793
|
-
RETURNING *`
|
|
9794
|
-
);
|
|
9795
|
-
if (upd.rowsAffected === 0) return null;
|
|
9796
|
-
const raw = upd.rows[0];
|
|
9797
|
-
if (!raw) return null;
|
|
9798
|
-
return rowToSiteAuditJobRow(raw);
|
|
9799
|
-
}
|
|
9800
|
-
async getSiteAuditJob(jobId) {
|
|
9801
|
-
const res = await this.db.execute({
|
|
9802
|
-
sql: `SELECT * FROM site_audit_jobs WHERE id = ?`,
|
|
9803
|
-
args: [jobId]
|
|
9804
|
-
});
|
|
9805
|
-
const raw = res.rows[0];
|
|
9806
|
-
if (!raw) return null;
|
|
9807
|
-
return rowToSiteAuditJobRow(raw);
|
|
9808
|
-
}
|
|
9809
|
-
async listSiteAuditJobs(userId) {
|
|
9810
|
-
const res = userId != null ? await this.db.execute({
|
|
9811
|
-
sql: `SELECT * FROM site_audit_jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
|
9812
|
-
args: [userId]
|
|
9813
|
-
}) : await this.db.execute(
|
|
9814
|
-
`SELECT * FROM site_audit_jobs ORDER BY created_at DESC LIMIT 50`
|
|
9815
|
-
);
|
|
9816
|
-
return res.rows.map((r) => rowToSiteAuditJobRow(r));
|
|
9817
|
-
}
|
|
9818
|
-
async updateSiteAuditJobState(jobId, status) {
|
|
9819
|
-
await this.db.execute({
|
|
9820
|
-
sql: `UPDATE site_audit_jobs SET status = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
9821
|
-
args: [status, jobId]
|
|
9822
|
-
});
|
|
9823
|
-
}
|
|
9824
|
-
async completeSiteAuditJob(jobId, result) {
|
|
9825
|
-
await this.db.execute({
|
|
9826
|
-
sql: `UPDATE site_audit_jobs
|
|
9827
|
-
SET status = 'complete', result = ?, updated_at = datetime('now')
|
|
9828
|
-
WHERE id = ?`,
|
|
9829
|
-
args: [JSON.stringify(result), jobId]
|
|
9830
|
-
});
|
|
9831
|
-
}
|
|
9832
|
-
async failSiteAuditJob(jobId, error) {
|
|
9833
|
-
await this.db.execute({
|
|
9834
|
-
sql: `UPDATE site_audit_jobs
|
|
9835
|
-
SET status = 'failed', error = ?, updated_at = datetime('now')
|
|
9836
|
-
WHERE id = ?`,
|
|
9837
|
-
args: [error, jobId]
|
|
10082
|
+
}
|
|
10083
|
+
if (unclassifiedUrls.length > 0) {
|
|
10084
|
+
const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
|
|
10085
|
+
const llmResults = await llmParseWithRetry(
|
|
10086
|
+
import_zod10.z.array(PageTypeClassificationRowSchema),
|
|
10087
|
+
(attempt, error) => {
|
|
10088
|
+
void attempt;
|
|
10089
|
+
void error;
|
|
10090
|
+
return buildMetricsClassifyPrompt({
|
|
10091
|
+
urls: unclassifiedBatch,
|
|
10092
|
+
siteDomain: clientDomain
|
|
10093
|
+
});
|
|
10094
|
+
},
|
|
10095
|
+
this.deps.llm
|
|
10096
|
+
);
|
|
10097
|
+
allClassifications.push(...llmResults);
|
|
10098
|
+
}
|
|
10099
|
+
}
|
|
9838
10100
|
});
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
|
|
9843
|
-
VALUES (?, ?, ?, datetime('now'))`,
|
|
9844
|
-
args: [jobId, phase, JSON.stringify(output)]
|
|
9845
|
-
}).catch((err) => {
|
|
9846
|
-
console.warn(
|
|
9847
|
-
"[site-audit-repository] logSiteAuditPhaseComplete failed:",
|
|
9848
|
-
err instanceof Error ? err.message : String(err)
|
|
9849
|
-
);
|
|
10101
|
+
void sessionPath;
|
|
10102
|
+
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase3-classify", {
|
|
10103
|
+
classifications: allClassifications,
|
|
10104
|
+
totalClassified: allClassifications.length
|
|
9850
10105
|
});
|
|
9851
10106
|
}
|
|
9852
|
-
|
|
9853
|
-
|
|
9854
|
-
|
|
9855
|
-
|
|
9856
|
-
|
|
9857
|
-
|
|
9858
|
-
|
|
9859
|
-
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
this.locationClassifierScript = path.join(__dirname, "scripts", "location_classifier.py");
|
|
9880
|
-
}
|
|
9881
|
-
async runGraphMetrics(input) {
|
|
9882
|
-
const validatedInput = PythonGraphInputSchema.parse(input);
|
|
9883
|
-
let stdout;
|
|
9884
|
-
let stderr;
|
|
9885
|
-
try {
|
|
9886
|
-
({ stdout, stderr } = await execFile2(
|
|
9887
|
-
"python3",
|
|
9888
|
-
[this.graphMetricsScript, "--input", JSON.stringify(validatedInput)],
|
|
9889
|
-
{ timeout: 12e4 }
|
|
9890
|
-
));
|
|
9891
|
-
} catch (err) {
|
|
9892
|
-
const execError = err;
|
|
9893
|
-
throw new Error(`python-subprocess-001: ${execError.stderr ?? String(err)}`);
|
|
9894
|
-
}
|
|
9895
|
-
void stderr;
|
|
9896
|
-
return PythonGraphOutputSchema.parse(JSON.parse(stdout));
|
|
9897
|
-
}
|
|
9898
|
-
async runLocationClassifier(input) {
|
|
9899
|
-
const validatedInput = LocationClassifierInputSchema.parse(input);
|
|
9900
|
-
let stdout;
|
|
9901
|
-
let stderr;
|
|
9902
|
-
try {
|
|
9903
|
-
({ stdout, stderr } = await execFile2(
|
|
9904
|
-
"python3",
|
|
9905
|
-
[this.locationClassifierScript, "--input", JSON.stringify(validatedInput)],
|
|
9906
|
-
{ timeout: 12e4 }
|
|
9907
|
-
));
|
|
9908
|
-
} catch (err) {
|
|
9909
|
-
const execError = err;
|
|
9910
|
-
throw new Error(`python-subprocess-001: ${execError.stderr ?? String(err)}`);
|
|
9911
|
-
}
|
|
9912
|
-
void stderr;
|
|
9913
|
-
return LocationClassifierOutputSchema.parse(JSON.parse(stdout));
|
|
10107
|
+
async runComparePhase(jobId, payload) {
|
|
10108
|
+
void payload;
|
|
10109
|
+
const job = await this.deps.repo.getSiteAuditJob(jobId);
|
|
10110
|
+
if (!job) throw new Error(`[site-audit-service] runComparePhase: job ${jobId} not found`);
|
|
10111
|
+
const request = JSON.parse(job.request);
|
|
10112
|
+
const clientDomain = request.clientDomain;
|
|
10113
|
+
const result = await this.runLlmBilled(
|
|
10114
|
+
job.user_id,
|
|
10115
|
+
`${jobId}:phase4-compare`,
|
|
10116
|
+
() => llmParseWithRetry(
|
|
10117
|
+
CompareRecommendOutputSchema,
|
|
10118
|
+
(attempt, error) => {
|
|
10119
|
+
void attempt;
|
|
10120
|
+
void error;
|
|
10121
|
+
return buildCompareRecommendPrompt({
|
|
10122
|
+
clientDomain,
|
|
10123
|
+
clientMoneyPages: [],
|
|
10124
|
+
competitorSites: [],
|
|
10125
|
+
clientUrlMetrics: [],
|
|
10126
|
+
clientPageTypes: []
|
|
10127
|
+
});
|
|
10128
|
+
},
|
|
10129
|
+
this.deps.llm
|
|
10130
|
+
)
|
|
10131
|
+
);
|
|
10132
|
+
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase4-compare", result);
|
|
10133
|
+
return result;
|
|
9914
10134
|
}
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
}
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
|
|
9948
|
-
|
|
9949
|
-
|
|
9950
|
-
|
|
9951
|
-
|
|
9952
|
-
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
9960
|
-
|
|
9961
|
-
|
|
9962
|
-
|
|
9963
|
-
|
|
9964
|
-
|
|
9965
|
-
|
|
9966
|
-
|
|
9967
|
-
|
|
9968
|
-
|
|
9969
|
-
|
|
9970
|
-
|
|
9971
|
-
|
|
9972
|
-
|
|
9973
|
-
|
|
9974
|
-
console.warn(
|
|
9975
|
-
"[phases] handleBuildGraphPhase: phase failed for job",
|
|
9976
|
-
payload.jobId,
|
|
9977
|
-
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
9978
|
-
);
|
|
9979
|
-
}
|
|
9980
|
-
}
|
|
9981
|
-
}
|
|
9982
|
-
async function handleClassifyPhase(service, raw) {
|
|
9983
|
-
const payload = SiteAuditClassifyPayloadSchema.parse(raw);
|
|
9984
|
-
const limit = (0, import_p_limit2.default)(3);
|
|
9985
|
-
const tasks = [
|
|
9986
|
-
limit(() => service.runClassifyPhase(payload.jobId, payload))
|
|
9987
|
-
];
|
|
9988
|
-
const results = await Promise.allSettled(tasks);
|
|
9989
|
-
for (const result of results) {
|
|
9990
|
-
if (result.status === "rejected") {
|
|
9991
|
-
console.warn(
|
|
9992
|
-
"[phases] handleClassifyPhase: phase failed for job",
|
|
9993
|
-
payload.jobId,
|
|
9994
|
-
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
9995
|
-
);
|
|
9996
|
-
}
|
|
9997
|
-
}
|
|
9998
|
-
}
|
|
9999
|
-
async function handleComparePhase(service, raw) {
|
|
10000
|
-
const payload = SiteAuditComparePayloadSchema.parse(raw);
|
|
10001
|
-
const compareResult = await service.runComparePhase(payload.jobId, payload);
|
|
10002
|
-
const rawRecommendations = compareResult.linkRecommendations ?? [];
|
|
10003
|
-
const resolveFlagsStub = (_item, _candidates) => {
|
|
10004
|
-
return [];
|
|
10005
|
-
};
|
|
10006
|
-
const guardrailResult = applyGuardrailRules(rawRecommendations, { resolveFlags: resolveFlagsStub });
|
|
10007
|
-
console.warn(
|
|
10008
|
-
"[phases] handleComparePhase: guardrail check complete \u2014 passed:",
|
|
10009
|
-
guardrailResult.passed.length,
|
|
10010
|
-
"flagged:",
|
|
10011
|
-
guardrailResult.flagged.length,
|
|
10012
|
-
"(context-dependent flags require site-graph state not yet exposed by runComparePhase)"
|
|
10013
|
-
);
|
|
10014
|
-
}
|
|
10015
|
-
async function handleSynthesizePhase(service, raw) {
|
|
10016
|
-
const payload = SiteAuditSynthesizePayloadSchema.parse(raw);
|
|
10017
|
-
await service.runSynthesizePhase(payload.jobId, payload);
|
|
10018
|
-
}
|
|
10019
|
-
var import_p_limit2;
|
|
10020
|
-
var init_phases = __esm({
|
|
10021
|
-
"src/services/site-architecture-auditor/phases.ts"() {
|
|
10022
|
-
"use strict";
|
|
10023
|
-
import_p_limit2 = __toESM(require("p-limit"), 1);
|
|
10024
|
-
init_schemas();
|
|
10025
|
-
init_rules();
|
|
10026
|
-
}
|
|
10027
|
-
});
|
|
10028
|
-
|
|
10029
|
-
// src/inngest/functions/site-audit.ts
|
|
10030
|
-
var siteAuditFn;
|
|
10031
|
-
var init_site_audit = __esm({
|
|
10032
|
-
"src/inngest/functions/site-audit.ts"() {
|
|
10033
|
-
"use strict";
|
|
10034
|
-
init_client();
|
|
10035
|
-
init_factory();
|
|
10036
|
-
init_phases();
|
|
10037
|
-
siteAuditFn = inngest.createFunction(
|
|
10038
|
-
{ id: "site-audit", triggers: [{ event: "site-architecture-auditor/audit.requested" }] },
|
|
10039
|
-
async ({ event, step }) => {
|
|
10040
|
-
const { phase, payload } = event.data;
|
|
10041
|
-
await step.run("dispatch-phase", async () => {
|
|
10042
|
-
const service = makeSiteAuditService();
|
|
10043
|
-
await dispatchSiteAuditPhase(service, phase, payload);
|
|
10135
|
+
async runSynthesizePhase(jobId, payload) {
|
|
10136
|
+
void payload;
|
|
10137
|
+
const job = await this.deps.repo.getSiteAuditJob(jobId);
|
|
10138
|
+
if (!job) throw new Error(`[site-audit-service] runSynthesizePhase: job ${jobId} not found`);
|
|
10139
|
+
const request = JSON.parse(job.request);
|
|
10140
|
+
const clientDomain = request.clientDomain;
|
|
10141
|
+
const sessionPath = request.sessionPath;
|
|
10142
|
+
const architectureScoreComponents = {};
|
|
10143
|
+
for (const [dim, weight] of Object.entries(ARCHITECTURE_SCORE_WEIGHTS)) {
|
|
10144
|
+
architectureScoreComponents[dim] = weight * 50;
|
|
10145
|
+
}
|
|
10146
|
+
const architectureTotal = Object.values(architectureScoreComponents).reduce((s, v) => s + v, 0);
|
|
10147
|
+
const ilesComponents = {};
|
|
10148
|
+
for (const [dim, weight] of Object.entries(ILES_WEIGHTS)) {
|
|
10149
|
+
ilesComponents[dim] = weight * 50;
|
|
10150
|
+
}
|
|
10151
|
+
const ilesTotal = Object.values(ilesComponents).reduce((s, v) => s + v, 0);
|
|
10152
|
+
const preComputedArchitectureScore = {
|
|
10153
|
+
total: architectureTotal,
|
|
10154
|
+
components: architectureScoreComponents
|
|
10155
|
+
};
|
|
10156
|
+
const preComputedIlesScore = {
|
|
10157
|
+
total: ilesTotal,
|
|
10158
|
+
components: ilesComponents
|
|
10159
|
+
};
|
|
10160
|
+
const reportMarkdown = await this.runLlmBilled(
|
|
10161
|
+
job.user_id,
|
|
10162
|
+
`${jobId}:phase5-synthesize`,
|
|
10163
|
+
() => llmParseWithRetry(
|
|
10164
|
+
import_zod10.z.string(),
|
|
10165
|
+
(attempt, error) => {
|
|
10166
|
+
void attempt;
|
|
10167
|
+
void error;
|
|
10168
|
+
return buildScoreSynthesizePrompt({
|
|
10169
|
+
preComputedArchitectureScore,
|
|
10170
|
+
preComputedIlesScore,
|
|
10171
|
+
rankedBacklog: [],
|
|
10172
|
+
clientDomain,
|
|
10173
|
+
sessionPath
|
|
10174
|
+
});
|
|
10175
|
+
},
|
|
10176
|
+
this.deps.llm
|
|
10177
|
+
)
|
|
10178
|
+
);
|
|
10179
|
+
const auditResult = SiteAuditResultSchema.parse({
|
|
10180
|
+
jobId,
|
|
10181
|
+
clientDomain,
|
|
10182
|
+
architectureScore: preComputedArchitectureScore,
|
|
10183
|
+
ilesScore: preComputedIlesScore,
|
|
10184
|
+
rankedBacklog: [],
|
|
10185
|
+
linkRecommendations: [],
|
|
10186
|
+
newPagesRecommended: [],
|
|
10187
|
+
guardrailFlags: [],
|
|
10188
|
+
reportMarkdown
|
|
10189
|
+
});
|
|
10190
|
+
await this.deps.repo.completeSiteAuditJob(jobId, auditResult);
|
|
10191
|
+
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase5-synthesize", {
|
|
10192
|
+
architectureScore: auditResult.architectureScore,
|
|
10193
|
+
ilesScore: auditResult.ilesScore
|
|
10044
10194
|
});
|
|
10045
|
-
return { phase, status: "done" };
|
|
10046
10195
|
}
|
|
10047
|
-
|
|
10048
|
-
|
|
10049
|
-
});
|
|
10050
|
-
|
|
10051
|
-
// src/api/seo-link-graph.ts
|
|
10052
|
-
function normalize(u) {
|
|
10053
|
-
return u.split("#")[0].replace(/\/$/, "");
|
|
10054
|
-
}
|
|
10055
|
-
function buildLinkGraph(pages, startUrl) {
|
|
10056
|
-
const statusByUrl = /* @__PURE__ */ new Map();
|
|
10057
|
-
for (const p of pages) statusByUrl.set(normalize(p.url), p.status);
|
|
10058
|
-
const edges = [];
|
|
10059
|
-
for (const p of pages) {
|
|
10060
|
-
for (const l of p.outlinks ?? []) {
|
|
10061
|
-
const nofollow = (l.rel ?? "").split(/\s+/).includes("nofollow");
|
|
10062
|
-
edges.push({
|
|
10063
|
-
from: p.url,
|
|
10064
|
-
to: l.href,
|
|
10065
|
-
anchor: l.anchor,
|
|
10066
|
-
rel: l.rel,
|
|
10067
|
-
internal: l.internal,
|
|
10068
|
-
nofollow,
|
|
10069
|
-
targetStatus: statusByUrl.get(normalize(l.href)) ?? null
|
|
10070
|
-
});
|
|
10071
|
-
}
|
|
10072
|
-
}
|
|
10073
|
-
const inboundFrom = /* @__PURE__ */ new Map();
|
|
10074
|
-
const inboundAnchors = /* @__PURE__ */ new Map();
|
|
10075
|
-
const adjacency = /* @__PURE__ */ new Map();
|
|
10076
|
-
for (const e of edges) {
|
|
10077
|
-
if (!e.internal) continue;
|
|
10078
|
-
const to = normalize(e.to);
|
|
10079
|
-
const from = normalize(e.from);
|
|
10080
|
-
if (!inboundFrom.has(to)) inboundFrom.set(to, /* @__PURE__ */ new Set());
|
|
10081
|
-
inboundFrom.get(to).add(from);
|
|
10082
|
-
if (e.anchor) {
|
|
10083
|
-
if (!inboundAnchors.has(to)) inboundAnchors.set(to, []);
|
|
10084
|
-
inboundAnchors.get(to).push(e.anchor);
|
|
10085
|
-
}
|
|
10086
|
-
if (!e.nofollow && (statusByUrl.get(to) === 200 || !statusByUrl.has(to))) {
|
|
10087
|
-
if (!adjacency.has(from)) adjacency.set(from, /* @__PURE__ */ new Set());
|
|
10088
|
-
adjacency.get(from).add(to);
|
|
10089
|
-
}
|
|
10090
|
-
}
|
|
10091
|
-
const depth = /* @__PURE__ */ new Map();
|
|
10092
|
-
const start = normalize(startUrl);
|
|
10093
|
-
const queue = [start];
|
|
10094
|
-
depth.set(start, 0);
|
|
10095
|
-
while (queue.length > 0) {
|
|
10096
|
-
const cur = queue.shift();
|
|
10097
|
-
const d = depth.get(cur);
|
|
10098
|
-
for (const next of adjacency.get(cur) ?? []) {
|
|
10099
|
-
if (!depth.has(next)) {
|
|
10100
|
-
depth.set(next, d + 1);
|
|
10101
|
-
queue.push(next);
|
|
10196
|
+
dispatchPhase(_phase, _jobId, _payload) {
|
|
10197
|
+
throw new Error("dispatchPhase delegates to phases.ts \u2014 see saa-011");
|
|
10102
10198
|
}
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
10107
|
-
|
|
10108
|
-
|
|
10109
|
-
|
|
10110
|
-
|
|
10111
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
|
|
10121
|
-
orphan: inbound.size === 0 && key !== start,
|
|
10122
|
-
topAnchors: topAnchorsFor(key)
|
|
10123
|
-
});
|
|
10124
|
-
}
|
|
10125
|
-
return { edges, metrics };
|
|
10126
|
-
}
|
|
10127
|
-
var init_seo_link_graph = __esm({
|
|
10128
|
-
"src/api/seo-link-graph.ts"() {
|
|
10129
|
-
"use strict";
|
|
10199
|
+
async _readCompetitorDomains(request) {
|
|
10200
|
+
try {
|
|
10201
|
+
const response = await this.deps.http.get(`file://${request.competitorDomainsCsvPath}`);
|
|
10202
|
+
const lines = response.body.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
10203
|
+
const domains = [];
|
|
10204
|
+
for (const line of lines) {
|
|
10205
|
+
if (line.includes(",")) {
|
|
10206
|
+
domains.push(...line.split(",").map((d) => d.trim()).filter((d) => d.length > 0));
|
|
10207
|
+
} else {
|
|
10208
|
+
domains.push(line);
|
|
10209
|
+
}
|
|
10210
|
+
}
|
|
10211
|
+
return domains.filter((d) => d.includes("."));
|
|
10212
|
+
} catch {
|
|
10213
|
+
return [];
|
|
10214
|
+
}
|
|
10215
|
+
}
|
|
10216
|
+
};
|
|
10130
10217
|
}
|
|
10131
10218
|
});
|
|
10132
10219
|
|
|
10133
|
-
// src/
|
|
10134
|
-
function
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
const pages = metrics.length;
|
|
10148
|
-
const orphans = metrics.filter((m) => m.orphan).length;
|
|
10149
|
-
const brokenInternal = internalEdges.filter((e) => e.targetStatus != null && e.targetStatus >= 400).length;
|
|
10150
|
-
const sumInlinks = metrics.reduce((a, m) => a + m.inlinks, 0);
|
|
10151
|
-
const sumOutlinks = metrics.reduce((a, m) => a + m.outlinksInternal + m.outlinksExternal, 0);
|
|
10152
|
-
const distribution = { zero: 0, oneToTwo: 0, threeToTen: 0, elevenPlus: 0 };
|
|
10153
|
-
for (const m of metrics) {
|
|
10154
|
-
if (m.inlinks === 0) distribution.zero++;
|
|
10155
|
-
else if (m.inlinks <= 2) distribution.oneToTwo++;
|
|
10156
|
-
else if (m.inlinks <= 10) distribution.threeToTen++;
|
|
10157
|
-
else distribution.elevenPlus++;
|
|
10158
|
-
}
|
|
10159
|
-
const topByInlinks = [...metrics].sort((a, b) => b.inlinks - a.inlinks).slice(0, 20).map((m) => ({ url: m.url, inlinks: m.inlinks, outlinksInternal: m.outlinksInternal, outlinksExternal: m.outlinksExternal }));
|
|
10160
|
-
const domMap = /* @__PURE__ */ new Map();
|
|
10161
|
-
let externalTotal = 0;
|
|
10162
|
-
for (const e of edges) {
|
|
10163
|
-
let domain;
|
|
10164
|
-
try {
|
|
10165
|
-
domain = registrableDomain(new URL(e.to).hostname);
|
|
10166
|
-
} catch {
|
|
10167
|
-
continue;
|
|
10168
|
-
}
|
|
10169
|
-
if (!domain || domain === siteReg) continue;
|
|
10170
|
-
externalTotal++;
|
|
10171
|
-
const d = domMap.get(domain) ?? { links: 0, nofollow: 0, pages: /* @__PURE__ */ new Set() };
|
|
10172
|
-
d.links++;
|
|
10173
|
-
if (e.nofollow) d.nofollow++;
|
|
10174
|
-
d.pages.add(e.from);
|
|
10175
|
-
domMap.set(domain, d);
|
|
10176
|
-
}
|
|
10177
|
-
const externalDomains = [...domMap.entries()].map(([domain, d]) => ({ domain, links: d.links, nofollow: d.nofollow, pages: d.pages.size })).sort((a, b) => b.links - a.links);
|
|
10178
|
-
const round = (n) => Math.round(n * 10) / 10;
|
|
10179
|
-
return {
|
|
10180
|
-
summary: {
|
|
10181
|
-
internal: {
|
|
10182
|
-
totalLinks: internalEdges.length,
|
|
10183
|
-
pages,
|
|
10184
|
-
orphans,
|
|
10185
|
-
brokenInternal,
|
|
10186
|
-
avgInlinks: pages ? round(sumInlinks / pages) : 0,
|
|
10187
|
-
avgOutlinks: pages ? round(sumOutlinks / pages) : 0,
|
|
10188
|
-
distribution,
|
|
10189
|
-
topByInlinks
|
|
10190
|
-
},
|
|
10191
|
-
external: {
|
|
10192
|
-
totalLinks: externalTotal,
|
|
10193
|
-
uniqueDomains: externalDomains.length,
|
|
10194
|
-
topDomains: externalDomains.slice(0, 20)
|
|
10195
|
-
}
|
|
10196
|
-
},
|
|
10197
|
-
externalDomains
|
|
10198
|
-
};
|
|
10199
|
-
}
|
|
10200
|
-
function renderLinkReport(r) {
|
|
10201
|
-
const s = r.summary;
|
|
10202
|
-
const lines = [
|
|
10203
|
-
`## Link analysis`,
|
|
10204
|
-
`**Internal:** ${s.internal.totalLinks} links \xB7 ${s.internal.pages} pages \xB7 avg ${s.internal.avgInlinks} inlinks/page \xB7 ${s.internal.orphans} orphans \xB7 ${s.internal.brokenInternal} broken`,
|
|
10205
|
-
`**External:** ${s.external.totalLinks} links to ${s.external.uniqueDomains} domains`,
|
|
10206
|
-
``,
|
|
10207
|
-
`### Inlink distribution`,
|
|
10208
|
-
`- 0 inlinks (orphans): ${s.internal.distribution.zero}`,
|
|
10209
|
-
`- 1\u20132: ${s.internal.distribution.oneToTwo}`,
|
|
10210
|
-
`- 3\u201310: ${s.internal.distribution.threeToTen}`,
|
|
10211
|
-
`- 11+: ${s.internal.distribution.elevenPlus}`,
|
|
10212
|
-
``,
|
|
10213
|
-
`### Top 20 internal pages by inlinks`,
|
|
10214
|
-
`| inlinks | out (int/ext) | URL |`,
|
|
10215
|
-
`|---|---|---|`,
|
|
10216
|
-
...s.internal.topByInlinks.map((p) => `| ${p.inlinks} | ${p.outlinksInternal}/${p.outlinksExternal} | ${p.url} |`),
|
|
10217
|
-
``,
|
|
10218
|
-
`### Top 20 external domains by links`,
|
|
10219
|
-
`| links | nofollow | from pages | domain |`,
|
|
10220
|
-
`|---|---|---|---|`,
|
|
10221
|
-
...s.external.topDomains.map((d) => `| ${d.links} | ${d.nofollow} | ${d.pages} | ${d.domain} |`)
|
|
10222
|
-
];
|
|
10223
|
-
return lines.join("\n");
|
|
10220
|
+
// src/services/site-architecture-auditor/site-audit-repository.ts
|
|
10221
|
+
function rowToSiteAuditJobRow(row) {
|
|
10222
|
+
return SiteAuditJobRowSchema.parse({
|
|
10223
|
+
id: String(row.id),
|
|
10224
|
+
user_id: Number(row.user_id),
|
|
10225
|
+
status: row.status != null ? String(row.status) : "pending",
|
|
10226
|
+
client_domain: String(row.client_domain),
|
|
10227
|
+
session_path: String(row.session_path),
|
|
10228
|
+
request: String(row.request),
|
|
10229
|
+
result: row.result != null ? String(row.result) : null,
|
|
10230
|
+
error: row.error != null ? String(row.error) : null,
|
|
10231
|
+
created_at: String(row.created_at),
|
|
10232
|
+
updated_at: String(row.updated_at)
|
|
10233
|
+
});
|
|
10224
10234
|
}
|
|
10225
|
-
var
|
|
10226
|
-
|
|
10235
|
+
var SiteAuditRepository;
|
|
10236
|
+
var init_site_audit_repository = __esm({
|
|
10237
|
+
"src/services/site-architecture-auditor/site-audit-repository.ts"() {
|
|
10227
10238
|
"use strict";
|
|
10239
|
+
init_db();
|
|
10240
|
+
init_db();
|
|
10241
|
+
SiteAuditRepository = class {
|
|
10242
|
+
db;
|
|
10243
|
+
constructor() {
|
|
10244
|
+
this.db = getDb();
|
|
10245
|
+
}
|
|
10246
|
+
async createSiteAuditJob(jobId, userId, request) {
|
|
10247
|
+
await this.db.execute({
|
|
10248
|
+
sql: `INSERT INTO site_audit_jobs
|
|
10249
|
+
(id, user_id, status, client_domain, session_path, request, created_at, updated_at)
|
|
10250
|
+
VALUES (?, ?, 'pending', ?, ?, ?, datetime('now'), datetime('now'))`,
|
|
10251
|
+
args: [
|
|
10252
|
+
jobId,
|
|
10253
|
+
userId,
|
|
10254
|
+
request.clientDomain,
|
|
10255
|
+
request.sessionPath,
|
|
10256
|
+
JSON.stringify(request)
|
|
10257
|
+
]
|
|
10258
|
+
});
|
|
10259
|
+
}
|
|
10260
|
+
async claimPendingSiteAuditJob() {
|
|
10261
|
+
const upd = await this.db.execute(
|
|
10262
|
+
`UPDATE site_audit_jobs
|
|
10263
|
+
SET status = 'running', updated_at = datetime('now')
|
|
10264
|
+
WHERE id = (
|
|
10265
|
+
SELECT id FROM site_audit_jobs
|
|
10266
|
+
WHERE status = 'pending'
|
|
10267
|
+
ORDER BY created_at ASC
|
|
10268
|
+
LIMIT 1
|
|
10269
|
+
)
|
|
10270
|
+
RETURNING *`
|
|
10271
|
+
);
|
|
10272
|
+
if (upd.rowsAffected === 0) return null;
|
|
10273
|
+
const raw = upd.rows[0];
|
|
10274
|
+
if (!raw) return null;
|
|
10275
|
+
return rowToSiteAuditJobRow(raw);
|
|
10276
|
+
}
|
|
10277
|
+
async getSiteAuditJob(jobId) {
|
|
10278
|
+
const res = await this.db.execute({
|
|
10279
|
+
sql: `SELECT * FROM site_audit_jobs WHERE id = ?`,
|
|
10280
|
+
args: [jobId]
|
|
10281
|
+
});
|
|
10282
|
+
const raw = res.rows[0];
|
|
10283
|
+
if (!raw) return null;
|
|
10284
|
+
return rowToSiteAuditJobRow(raw);
|
|
10285
|
+
}
|
|
10286
|
+
async listSiteAuditJobs(userId) {
|
|
10287
|
+
const res = userId != null ? await this.db.execute({
|
|
10288
|
+
sql: `SELECT * FROM site_audit_jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
|
10289
|
+
args: [userId]
|
|
10290
|
+
}) : await this.db.execute(
|
|
10291
|
+
`SELECT * FROM site_audit_jobs ORDER BY created_at DESC LIMIT 50`
|
|
10292
|
+
);
|
|
10293
|
+
return res.rows.map((r) => rowToSiteAuditJobRow(r));
|
|
10294
|
+
}
|
|
10295
|
+
async updateSiteAuditJobState(jobId, status) {
|
|
10296
|
+
await this.db.execute({
|
|
10297
|
+
sql: `UPDATE site_audit_jobs SET status = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
10298
|
+
args: [status, jobId]
|
|
10299
|
+
});
|
|
10300
|
+
}
|
|
10301
|
+
async completeSiteAuditJob(jobId, result) {
|
|
10302
|
+
await this.db.execute({
|
|
10303
|
+
sql: `UPDATE site_audit_jobs
|
|
10304
|
+
SET status = 'complete', result = ?, updated_at = datetime('now')
|
|
10305
|
+
WHERE id = ?`,
|
|
10306
|
+
args: [JSON.stringify(result), jobId]
|
|
10307
|
+
});
|
|
10308
|
+
}
|
|
10309
|
+
async failSiteAuditJob(jobId, error) {
|
|
10310
|
+
await this.db.execute({
|
|
10311
|
+
sql: `UPDATE site_audit_jobs
|
|
10312
|
+
SET status = 'failed', error = ?, updated_at = datetime('now')
|
|
10313
|
+
WHERE id = ?`,
|
|
10314
|
+
args: [error, jobId]
|
|
10315
|
+
});
|
|
10316
|
+
}
|
|
10317
|
+
async logSiteAuditPhaseComplete(jobId, phase, output) {
|
|
10318
|
+
await this.db.execute({
|
|
10319
|
+
sql: `INSERT OR IGNORE INTO site_audit_phase_log (job_id, phase, output_summary, completed_at)
|
|
10320
|
+
VALUES (?, ?, ?, datetime('now'))`,
|
|
10321
|
+
args: [jobId, phase, JSON.stringify(output)]
|
|
10322
|
+
}).catch((err) => {
|
|
10323
|
+
console.warn(
|
|
10324
|
+
"[site-audit-repository] logSiteAuditPhaseComplete failed:",
|
|
10325
|
+
err instanceof Error ? err.message : String(err)
|
|
10326
|
+
);
|
|
10327
|
+
});
|
|
10328
|
+
}
|
|
10329
|
+
};
|
|
10228
10330
|
}
|
|
10229
10331
|
});
|
|
10230
10332
|
|
|
10231
|
-
// src/
|
|
10232
|
-
|
|
10233
|
-
|
|
10234
|
-
|
|
10235
|
-
|
|
10236
|
-
|
|
10237
|
-
|
|
10238
|
-
|
|
10239
|
-
|
|
10240
|
-
|
|
10241
|
-
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
|
|
10246
|
-
|
|
10247
|
-
|
|
10248
|
-
|
|
10249
|
-
|
|
10250
|
-
|
|
10251
|
-
|
|
10252
|
-
|
|
10253
|
-
|
|
10254
|
-
|
|
10255
|
-
"title.missing": group(where((p) => ok(p) && !p.title)),
|
|
10256
|
-
"title.duplicate": group(dupes(pages.filter(ok), (p) => p.title)),
|
|
10257
|
-
"title.tooLong": group(where((p) => (p.titleLength ?? 0) > TITLE_MAX || (p.titlePixels ?? 0) > TITLE_PX_MAX)),
|
|
10258
|
-
"title.tooShort": group(where((p) => p.title != null && (p.titleLength ?? 0) < TITLE_MIN)),
|
|
10259
|
-
"title.sameAsH1": group(where((p) => !!p.title && !!p.h1 && p.title.trim() === p.h1.trim())),
|
|
10260
|
-
"meta.missing": group(where((p) => ok(p) && !p.metaDescription)),
|
|
10261
|
-
"meta.duplicate": group(dupes(pages.filter(ok), (p) => p.metaDescription)),
|
|
10262
|
-
"meta.tooLong": group(where((p) => (p.metaDescLength ?? 0) > META_MAX)),
|
|
10263
|
-
"meta.tooShort": group(where((p) => p.metaDescription != null && (p.metaDescLength ?? 0) < META_MIN)),
|
|
10264
|
-
"h1.missing": group(where((p) => ok(p) && !p.h1)),
|
|
10265
|
-
"h1.multiple": group(where((p) => !!p.h1_2)),
|
|
10266
|
-
"h1.duplicate": group(dupes(pages.filter(ok), (p) => p.h1)),
|
|
10267
|
-
"h1.tooLong": group(where((p) => (p.h1?.length ?? 0) > H1_MAX)),
|
|
10268
|
-
"h2.missing": group(where((p) => ok(p) && p.h2Count === 0)),
|
|
10269
|
-
"indexability.nonIndexable": group(where((p) => !p.indexable)),
|
|
10270
|
-
"indexability.noindex": group(where((p) => p.indexabilityReason === "noindex")),
|
|
10271
|
-
"canonical.missing": group(where((p) => ok(p) && !p.canonicalUrl)),
|
|
10272
|
-
"canonical.canonicalised": group(where((p) => p.indexabilityReason === "canonicalised")),
|
|
10273
|
-
"response.broken4xx": group(where((p) => (p.status ?? 0) >= 400 && (p.status ?? 0) < 500)),
|
|
10274
|
-
"response.error5xx": group(where((p) => (p.status ?? 0) >= 500)),
|
|
10275
|
-
"response.redirect3xx": group(where((p) => (p.status ?? 0) >= 300 && (p.status ?? 0) < 400)),
|
|
10276
|
-
"response.noResponse": group(where((p) => p.status === null)),
|
|
10277
|
-
"content.thin": group(where((p) => ok(p) && p.wordCount > 0 && p.wordCount < THIN_WORDS)),
|
|
10278
|
-
"content.exactDuplicate": group(dupes(pages.filter((p) => ok(p) && p.contentHash), (p) => p.contentHash)),
|
|
10279
|
-
"images.missingAlt": group(where((p) => p.imagesMissingAlt > 0)),
|
|
10280
|
-
"schema.missing": group(where((p) => ok(p) && p.schemaTypes.length === 0)),
|
|
10281
|
-
"url.tooLong": group(where((p) => p.url.length > URL_MAX)),
|
|
10282
|
-
"url.uppercase": group(where((p) => /[A-Z]/.test(pathname(p.url)))),
|
|
10283
|
-
"url.underscores": group(where((p) => pathname(p.url).includes("_"))),
|
|
10284
|
-
"links.orphan": group([...metrics.values()].filter((m) => m.orphan).map((m) => m.url))
|
|
10285
|
-
};
|
|
10286
|
-
let brokenLinkPages = precomputedBrokenLinkPages;
|
|
10287
|
-
if (!brokenLinkPages) {
|
|
10288
|
-
const normUrl2 = (u) => {
|
|
10289
|
-
try {
|
|
10290
|
-
const x = new URL(u);
|
|
10291
|
-
x.hash = "";
|
|
10292
|
-
return (x.origin + x.pathname).replace(/\/+$/, "") + x.search;
|
|
10293
|
-
} catch {
|
|
10294
|
-
return u.replace(/#.*$/, "").replace(/\/+$/, "");
|
|
10333
|
+
// src/services/site-architecture-auditor/python/graph-runner.ts
|
|
10334
|
+
var childProcess, path, util, import_node_url, import_meta, execFile2, __dirname, SiteAuditPythonRunner;
|
|
10335
|
+
var init_graph_runner = __esm({
|
|
10336
|
+
"src/services/site-architecture-auditor/python/graph-runner.ts"() {
|
|
10337
|
+
"use strict";
|
|
10338
|
+
childProcess = __toESM(require("child_process"), 1);
|
|
10339
|
+
path = __toESM(require("path"), 1);
|
|
10340
|
+
util = __toESM(require("util"), 1);
|
|
10341
|
+
import_node_url = require("url");
|
|
10342
|
+
init_schemas();
|
|
10343
|
+
import_meta = {};
|
|
10344
|
+
execFile2 = util.promisify(childProcess.execFile);
|
|
10345
|
+
__dirname = path.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
10346
|
+
SiteAuditPythonRunner = class {
|
|
10347
|
+
graphMetricsScript;
|
|
10348
|
+
locationClassifierScript;
|
|
10349
|
+
constructor() {
|
|
10350
|
+
try {
|
|
10351
|
+
childProcess.execFileSync("python3", ["-c", "import networkx"]);
|
|
10352
|
+
} catch {
|
|
10353
|
+
throw new Error("SiteAuditPythonRunner: networkx not available");
|
|
10354
|
+
}
|
|
10355
|
+
this.graphMetricsScript = path.join(__dirname, "scripts", "compute_graph_metrics.py");
|
|
10356
|
+
this.locationClassifierScript = path.join(__dirname, "scripts", "location_classifier.py");
|
|
10295
10357
|
}
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
10299
|
-
|
|
10300
|
-
|
|
10301
|
-
|
|
10302
|
-
|
|
10303
|
-
|
|
10358
|
+
async runGraphMetrics(input) {
|
|
10359
|
+
const validatedInput = PythonGraphInputSchema.parse(input);
|
|
10360
|
+
let stdout;
|
|
10361
|
+
let stderr;
|
|
10362
|
+
try {
|
|
10363
|
+
({ stdout, stderr } = await execFile2(
|
|
10364
|
+
"python3",
|
|
10365
|
+
[this.graphMetricsScript, "--input", JSON.stringify(validatedInput)],
|
|
10366
|
+
{ timeout: 12e4 }
|
|
10367
|
+
));
|
|
10368
|
+
} catch (err) {
|
|
10369
|
+
const execError = err;
|
|
10370
|
+
throw new Error(`python-subprocess-001: ${execError.stderr ?? String(err)}`);
|
|
10371
|
+
}
|
|
10372
|
+
void stderr;
|
|
10373
|
+
return PythonGraphOutputSchema.parse(JSON.parse(stdout));
|
|
10304
10374
|
}
|
|
10305
|
-
|
|
10306
|
-
|
|
10307
|
-
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
10313
|
-
|
|
10314
|
-
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
|
|
10318
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
10329
|
-
|
|
10330
|
-
|
|
10331
|
-
].
|
|
10375
|
+
async runLocationClassifier(input) {
|
|
10376
|
+
const validatedInput = LocationClassifierInputSchema.parse(input);
|
|
10377
|
+
let stdout;
|
|
10378
|
+
let stderr;
|
|
10379
|
+
try {
|
|
10380
|
+
({ stdout, stderr } = await execFile2(
|
|
10381
|
+
"python3",
|
|
10382
|
+
[this.locationClassifierScript, "--input", JSON.stringify(validatedInput)],
|
|
10383
|
+
{ timeout: 12e4 }
|
|
10384
|
+
));
|
|
10385
|
+
} catch (err) {
|
|
10386
|
+
const execError = err;
|
|
10387
|
+
throw new Error(`python-subprocess-001: ${execError.stderr ?? String(err)}`);
|
|
10388
|
+
}
|
|
10389
|
+
void stderr;
|
|
10390
|
+
return LocationClassifierOutputSchema.parse(JSON.parse(stdout));
|
|
10391
|
+
}
|
|
10392
|
+
};
|
|
10393
|
+
}
|
|
10394
|
+
});
|
|
10395
|
+
|
|
10396
|
+
// src/services/site-architecture-auditor/factory.ts
|
|
10397
|
+
function makeSiteAuditService() {
|
|
10398
|
+
const deepInfraKey = process.env["DEEPINFRA_API_KEY"];
|
|
10399
|
+
if (!deepInfraKey) throw new Error("DEEPINFRA_API_KEY is required");
|
|
10400
|
+
const primary = new DeepInfraLlmClient(deepInfraKey);
|
|
10401
|
+
const llm = process.env["OPENROUTER_API_KEY"] ? new FallbackLlmClient(primary, new OpenRouterLlmClient(process.env["OPENROUTER_API_KEY"])) : primary;
|
|
10402
|
+
const repo = new SiteAuditRepository();
|
|
10403
|
+
const http = new FetchHttpClient();
|
|
10404
|
+
const python = new SiteAuditPythonRunner();
|
|
10405
|
+
return new SiteAuditService({ repo, llm, http, python });
|
|
10332
10406
|
}
|
|
10333
|
-
var
|
|
10334
|
-
|
|
10335
|
-
"src/api/seo-issues.ts"() {
|
|
10407
|
+
var init_factory = __esm({
|
|
10408
|
+
"src/services/site-architecture-auditor/factory.ts"() {
|
|
10336
10409
|
"use strict";
|
|
10337
|
-
|
|
10338
|
-
|
|
10339
|
-
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
META_MIN = 70;
|
|
10343
|
-
H1_MAX = 70;
|
|
10344
|
-
URL_MAX = 115;
|
|
10410
|
+
init_llm_parse_with_retry();
|
|
10411
|
+
init_http_client();
|
|
10412
|
+
init_site_audit_service();
|
|
10413
|
+
init_site_audit_repository();
|
|
10414
|
+
init_graph_runner();
|
|
10345
10415
|
}
|
|
10346
10416
|
});
|
|
10347
10417
|
|
|
10348
|
-
// src/
|
|
10349
|
-
function
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10357
|
-
|
|
10358
|
-
|
|
10359
|
-
|
|
10360
|
-
|
|
10361
|
-
|
|
10362
|
-
|
|
10363
|
-
|
|
10418
|
+
// src/services/site-architecture-auditor/phases.ts
|
|
10419
|
+
async function dispatchSiteAuditPhase(service, phase, raw) {
|
|
10420
|
+
switch (phase) {
|
|
10421
|
+
case "phase1-ingest":
|
|
10422
|
+
return handleIngestPhase(service, raw);
|
|
10423
|
+
case "phase2-build-graph":
|
|
10424
|
+
return handleBuildGraphPhase(service, raw);
|
|
10425
|
+
case "phase3-classify":
|
|
10426
|
+
return handleClassifyPhase(service, raw);
|
|
10427
|
+
case "phase4-compare":
|
|
10428
|
+
return handleComparePhase(service, raw);
|
|
10429
|
+
case "phase5-synthesize":
|
|
10430
|
+
return handleSynthesizePhase(service, raw);
|
|
10431
|
+
default: {
|
|
10432
|
+
const _exhaustive = phase;
|
|
10433
|
+
throw new Error("Unknown phase: " + String(_exhaustive));
|
|
10434
|
+
}
|
|
10364
10435
|
}
|
|
10365
|
-
return [...byKey.values()];
|
|
10366
10436
|
}
|
|
10367
|
-
async function
|
|
10368
|
-
const
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
}
|
|
10387
|
-
if (bytes == null) {
|
|
10388
|
-
const g = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: ctrl.signal });
|
|
10389
|
-
const r = read(g);
|
|
10390
|
-
status = status ?? r.status;
|
|
10391
|
-
ct = ct ?? r.ct;
|
|
10392
|
-
if (r.cr && r.cr.includes("/")) {
|
|
10393
|
-
const tail = r.cr.split("/")[1];
|
|
10394
|
-
if (tail && tail !== "*") bytes = Number(tail);
|
|
10395
|
-
} else if (r.len) bytes = Number(r.len);
|
|
10396
|
-
try {
|
|
10397
|
-
await g.body?.cancel();
|
|
10398
|
-
} catch {
|
|
10399
|
-
}
|
|
10437
|
+
async function handleIngestPhase(service, raw) {
|
|
10438
|
+
const payload = SiteAuditIngestPayloadSchema.parse(raw);
|
|
10439
|
+
await service.runIngestPhase(payload.jobId, payload);
|
|
10440
|
+
await service.runEnrichPhase(payload.jobId, payload);
|
|
10441
|
+
}
|
|
10442
|
+
async function handleBuildGraphPhase(service, raw) {
|
|
10443
|
+
const payload = SiteAuditBuildGraphPayloadSchema.parse(raw);
|
|
10444
|
+
const limit = (0, import_p_limit2.default)(3);
|
|
10445
|
+
const tasks = [
|
|
10446
|
+
limit(() => service.runBuildGraphPhase(payload.jobId, payload))
|
|
10447
|
+
];
|
|
10448
|
+
const results = await Promise.allSettled(tasks);
|
|
10449
|
+
for (const result of results) {
|
|
10450
|
+
if (result.status === "rejected") {
|
|
10451
|
+
console.warn(
|
|
10452
|
+
"[phases] handleBuildGraphPhase: phase failed for job",
|
|
10453
|
+
payload.jobId,
|
|
10454
|
+
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
10455
|
+
);
|
|
10400
10456
|
}
|
|
10401
|
-
return { url, status, bytes: Number.isFinite(bytes) ? bytes : null, contentType: ct };
|
|
10402
|
-
} catch (e) {
|
|
10403
|
-
return { url, status: null, bytes: null, contentType: null, error: String(e.message || e) };
|
|
10404
|
-
} finally {
|
|
10405
|
-
clearTimeout(t);
|
|
10406
10457
|
}
|
|
10407
10458
|
}
|
|
10408
|
-
async function
|
|
10409
|
-
const
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10459
|
+
async function handleClassifyPhase(service, raw) {
|
|
10460
|
+
const payload = SiteAuditClassifyPayloadSchema.parse(raw);
|
|
10461
|
+
const limit = (0, import_p_limit2.default)(3);
|
|
10462
|
+
const tasks = [
|
|
10463
|
+
limit(() => service.runClassifyPhase(payload.jobId, payload))
|
|
10464
|
+
];
|
|
10465
|
+
const results = await Promise.allSettled(tasks);
|
|
10466
|
+
for (const result of results) {
|
|
10467
|
+
if (result.status === "rejected") {
|
|
10468
|
+
console.warn(
|
|
10469
|
+
"[phases] handleClassifyPhase: phase failed for job",
|
|
10470
|
+
payload.jobId,
|
|
10471
|
+
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
10472
|
+
);
|
|
10415
10473
|
}
|
|
10416
|
-
}
|
|
10417
|
-
return out;
|
|
10474
|
+
}
|
|
10418
10475
|
}
|
|
10419
|
-
async function
|
|
10420
|
-
const
|
|
10421
|
-
const
|
|
10422
|
-
const
|
|
10423
|
-
const
|
|
10424
|
-
|
|
10425
|
-
const format = formatOf(r.contentType, r.url);
|
|
10426
|
-
return {
|
|
10427
|
-
...r,
|
|
10428
|
-
size: formatBytes(r.bytes),
|
|
10429
|
-
format,
|
|
10430
|
-
over100kb: r.bytes != null && r.bytes > OVER_BYTES,
|
|
10431
|
-
legacyFormat: format !== "unknown" && !MODERN.has(format)
|
|
10432
|
-
};
|
|
10433
|
-
});
|
|
10434
|
-
const sized = rows.filter((r) => r.bytes != null);
|
|
10435
|
-
const totalBytes = sized.reduce((a, r) => a + r.bytes, 0);
|
|
10436
|
-
const formatCounts = {};
|
|
10437
|
-
for (const r of rows) formatCounts[r.format] = (formatCounts[r.format] || 0) + 1;
|
|
10438
|
-
return {
|
|
10439
|
-
rows: rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)),
|
|
10440
|
-
summary: {
|
|
10441
|
-
unique: rows.length,
|
|
10442
|
-
sized: sized.length,
|
|
10443
|
-
totalBytes,
|
|
10444
|
-
totalSize: formatBytes(totalBytes) ?? "0 B",
|
|
10445
|
-
avgSize: formatBytes(Math.round(totalBytes / (sized.length || 1))) ?? "0 B",
|
|
10446
|
-
over100kb: rows.filter((r) => r.over100kb).length,
|
|
10447
|
-
legacyFormat: rows.filter((r) => r.legacyFormat).length,
|
|
10448
|
-
formatCounts
|
|
10449
|
-
}
|
|
10476
|
+
async function handleComparePhase(service, raw) {
|
|
10477
|
+
const payload = SiteAuditComparePayloadSchema.parse(raw);
|
|
10478
|
+
const compareResult = await service.runComparePhase(payload.jobId, payload);
|
|
10479
|
+
const rawRecommendations = compareResult.linkRecommendations ?? [];
|
|
10480
|
+
const resolveFlagsStub = (_item, _candidates) => {
|
|
10481
|
+
return [];
|
|
10450
10482
|
};
|
|
10483
|
+
const guardrailResult = applyGuardrailRules(rawRecommendations, { resolveFlags: resolveFlagsStub });
|
|
10484
|
+
console.warn(
|
|
10485
|
+
"[phases] handleComparePhase: guardrail check complete \u2014 passed:",
|
|
10486
|
+
guardrailResult.passed.length,
|
|
10487
|
+
"flagged:",
|
|
10488
|
+
guardrailResult.flagged.length,
|
|
10489
|
+
"(context-dependent flags require site-graph state not yet exposed by runComparePhase)"
|
|
10490
|
+
);
|
|
10451
10491
|
}
|
|
10452
|
-
function
|
|
10453
|
-
const
|
|
10454
|
-
|
|
10455
|
-
const heaviest = audit.rows.filter((r) => r.over100kb).slice(0, 15);
|
|
10456
|
-
const lines = [
|
|
10457
|
-
`## Images`,
|
|
10458
|
-
`**${s.unique} unique images** \xB7 ${s.totalSize} total \xB7 ${s.avgSize} avg \xB7 ${s.over100kb} over 100 KB \xB7 ${s.legacyFormat} legacy format`,
|
|
10459
|
-
`Formats: ${fmt}`
|
|
10460
|
-
];
|
|
10461
|
-
if (heaviest.length) {
|
|
10462
|
-
lines.push("", "| Size | Format | URL |", "|------|--------|-----|");
|
|
10463
|
-
for (const r of heaviest) lines.push(`| ${r.size} | ${r.format} | ${r.url} |`);
|
|
10464
|
-
}
|
|
10465
|
-
return lines.join("\n");
|
|
10492
|
+
async function handleSynthesizePhase(service, raw) {
|
|
10493
|
+
const payload = SiteAuditSynthesizePayloadSchema.parse(raw);
|
|
10494
|
+
await service.runSynthesizePhase(payload.jobId, payload);
|
|
10466
10495
|
}
|
|
10467
|
-
var
|
|
10468
|
-
var
|
|
10469
|
-
"src/
|
|
10496
|
+
var import_p_limit2;
|
|
10497
|
+
var init_phases = __esm({
|
|
10498
|
+
"src/services/site-architecture-auditor/phases.ts"() {
|
|
10470
10499
|
"use strict";
|
|
10471
|
-
|
|
10472
|
-
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10500
|
+
import_p_limit2 = __toESM(require("p-limit"), 1);
|
|
10501
|
+
init_schemas();
|
|
10502
|
+
init_rules();
|
|
10503
|
+
}
|
|
10504
|
+
});
|
|
10505
|
+
|
|
10506
|
+
// src/inngest/functions/site-audit.ts
|
|
10507
|
+
var siteAuditFn;
|
|
10508
|
+
var init_site_audit = __esm({
|
|
10509
|
+
"src/inngest/functions/site-audit.ts"() {
|
|
10510
|
+
"use strict";
|
|
10511
|
+
init_client();
|
|
10512
|
+
init_factory();
|
|
10513
|
+
init_phases();
|
|
10514
|
+
siteAuditFn = inngest.createFunction(
|
|
10515
|
+
{ id: "site-audit", triggers: [{ event: "site-architecture-auditor/audit.requested" }] },
|
|
10516
|
+
async ({ event, step }) => {
|
|
10517
|
+
const { phase, payload } = event.data;
|
|
10518
|
+
await step.run("dispatch-phase", async () => {
|
|
10519
|
+
const service = makeSiteAuditService();
|
|
10520
|
+
await dispatchSiteAuditPhase(service, phase, payload);
|
|
10521
|
+
});
|
|
10522
|
+
return { phase, status: "done" };
|
|
10479
10523
|
}
|
|
10480
|
-
|
|
10481
|
-
};
|
|
10524
|
+
);
|
|
10482
10525
|
}
|
|
10483
10526
|
});
|
|
10484
10527
|
|
|
@@ -10912,6 +10955,7 @@ ${renderImageSection(extras.imageAudit)}`;
|
|
|
10912
10955
|
(0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(dir, "images.jsonl"), extras.imageAudit.rows.map((r) => JSON.stringify(r)).join("\n"));
|
|
10913
10956
|
(0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(dir, "images-summary.json"), JSON.stringify(extras.imageAudit.summary, null, 2));
|
|
10914
10957
|
}
|
|
10958
|
+
if (extras.seoAudit) (0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(dir, "seo-audit.json"), JSON.stringify(extras.seoAudit, null, 2));
|
|
10915
10959
|
if (extras.branding) (0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(dir, "branding.json"), JSON.stringify(extras.branding, null, 2));
|
|
10916
10960
|
const artifactFiles = [
|
|
10917
10961
|
{ name: "report.md", type: "text/markdown" },
|
|
@@ -10927,6 +10971,7 @@ ${renderImageSection(extras.imageAudit)}`;
|
|
|
10927
10971
|
artifactFiles.push({ name: "images.jsonl", type: "application/x-ndjson" });
|
|
10928
10972
|
artifactFiles.push({ name: "images-summary.json", type: "application/json" });
|
|
10929
10973
|
}
|
|
10974
|
+
if (extras.seoAudit) artifactFiles.push({ name: "seo-audit.json", type: "application/json" });
|
|
10930
10975
|
if (extras.branding) artifactFiles.push({ name: "branding.json", type: "application/json" });
|
|
10931
10976
|
const zip = new import_yazl.ZipFile();
|
|
10932
10977
|
const zipOut = (0, import_node_fs3.createWriteStream)((0, import_node_path3.join)(dir, "bundle.zip"));
|
|
@@ -10974,6 +11019,7 @@ var init_site_extract = __esm({
|
|
|
10974
11019
|
"use strict";
|
|
10975
11020
|
init_client();
|
|
10976
11021
|
init_site_extractor();
|
|
11022
|
+
init_seo_audit();
|
|
10977
11023
|
init_seo_link_graph();
|
|
10978
11024
|
init_seo_link_report();
|
|
10979
11025
|
init_seo_issues();
|
|
@@ -11012,12 +11058,13 @@ var init_site_extract = __esm({
|
|
|
11012
11058
|
const maxPages = Number(job.options.maxPages ?? 1e4);
|
|
11013
11059
|
const concurrency = Number(job.options.concurrency ?? 1);
|
|
11014
11060
|
const urlsPerBrowser = Number(job.options.urlsPerBrowser ?? job.options.rotateProxyEvery ?? 10);
|
|
11015
|
-
const
|
|
11016
|
-
const discovered = await
|
|
11017
|
-
const
|
|
11018
|
-
await setExtractJobTotal(jobId,
|
|
11019
|
-
return
|
|
11061
|
+
const seedRaw = await step.run("discover", async () => {
|
|
11062
|
+
const discovered = await discoverUrlsWithSitemap(job.startUrl, maxPages, key);
|
|
11063
|
+
const urls = discovered.urls.length ? discovered.urls : [job.startUrl];
|
|
11064
|
+
await setExtractJobTotal(jobId, urls.length);
|
|
11065
|
+
return { urls, sitemapUrls: discovered.sitemapUrls };
|
|
11020
11066
|
});
|
|
11067
|
+
const seed = Array.isArray(seedRaw) ? { urls: seedRaw, sitemapUrls: [] } : seedRaw;
|
|
11021
11068
|
const siteHost = (() => {
|
|
11022
11069
|
try {
|
|
11023
11070
|
return new URL(job.startUrl).hostname.replace(/^www\./, "");
|
|
@@ -11041,8 +11088,9 @@ var init_site_extract = __esm({
|
|
|
11041
11088
|
return false;
|
|
11042
11089
|
}
|
|
11043
11090
|
};
|
|
11044
|
-
const
|
|
11045
|
-
const
|
|
11091
|
+
const sitemapSet = seed.sitemapUrls.length > 0 ? new Set(seed.sitemapUrls.map(norm)) : null;
|
|
11092
|
+
const queue = [...seed.urls];
|
|
11093
|
+
const seen = new Set(seed.urls.map(norm));
|
|
11046
11094
|
let crawled = 0;
|
|
11047
11095
|
const MAX_BATCHES = Math.ceil(maxPages / BATCH) + 30;
|
|
11048
11096
|
for (let i = 0; i < MAX_BATCHES && crawled < maxPages && crawled < queue.length; i++) {
|
|
@@ -11050,7 +11098,7 @@ var init_site_extract = __esm({
|
|
|
11050
11098
|
if (!batch.length) break;
|
|
11051
11099
|
const newLinks = await step.run(`crawl-batch-${i}`, async () => {
|
|
11052
11100
|
const pages = await extractPagesRotating(batch, { kernelApiKey: key, concurrency, urlsPerBrowser });
|
|
11053
|
-
await saveExtractPages(jobId, pages);
|
|
11101
|
+
await saveExtractPages(jobId, pages.map((p) => ({ ...p, inSitemap: sitemapSet ? sitemapSet.has(norm(p.url)) : null })));
|
|
11054
11102
|
const found = [];
|
|
11055
11103
|
for (const p of pages) for (const l of p.outlinks ?? []) {
|
|
11056
11104
|
if (l.internal && l.href && sameSite(l.href)) found.push(l.href);
|
|
@@ -11074,9 +11122,13 @@ var init_site_extract = __esm({
|
|
|
11074
11122
|
const pages = await getExtractedPages(jobId);
|
|
11075
11123
|
return auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
|
|
11076
11124
|
}) : null;
|
|
11125
|
+
const seoAudit = Array.isArray(job.options.formats) && job.options.formats.includes("issues") ? await step.run("seo-audit", async () => {
|
|
11126
|
+
const pages = await getExtractedPages(jobId);
|
|
11127
|
+
return buildSeoAudit(pages, job.startUrl);
|
|
11128
|
+
}) : null;
|
|
11077
11129
|
const artifacts = await step.run("finalize", async () => {
|
|
11078
11130
|
const { assembleExtractArtifacts: assembleExtractArtifacts2 } = await Promise.resolve().then(() => (init_extract_bundle(), extract_bundle_exports));
|
|
11079
|
-
const stored = await assembleExtractArtifacts2(job, { branding, imageAudit });
|
|
11131
|
+
const stored = await assembleExtractArtifacts2(job, { branding, imageAudit, seoAudit });
|
|
11080
11132
|
await completeExtractJob(jobId, stored);
|
|
11081
11133
|
return stored;
|
|
11082
11134
|
});
|
|
@@ -13963,7 +14015,7 @@ var init_server_schemas = __esm({
|
|
|
13963
14015
|
rotateProxies: import_zod13.z.boolean().optional(),
|
|
13964
14016
|
rotateProxyEvery: import_zod13.z.number().int().min(1).max(100).optional(),
|
|
13965
14017
|
background: import_zod13.z.boolean().optional(),
|
|
13966
|
-
formats: import_zod13.z.array(import_zod13.z.enum(["markdown", "links", "json", "images", "branding"])).optional()
|
|
14018
|
+
formats: import_zod13.z.array(import_zod13.z.enum(["markdown", "links", "json", "images", "branding", "issues"])).optional()
|
|
13967
14019
|
});
|
|
13968
14020
|
YoutubeHarvestBodySchema = import_zod13.z.object({
|
|
13969
14021
|
mode: import_zod13.z.enum(["search", "channel"]),
|
|
@@ -27400,7 +27452,7 @@ var PACKAGE_VERSION;
|
|
|
27400
27452
|
var init_version = __esm({
|
|
27401
27453
|
"src/version.ts"() {
|
|
27402
27454
|
"use strict";
|
|
27403
|
-
PACKAGE_VERSION = "0.
|
|
27455
|
+
PACKAGE_VERSION = "0.8.0";
|
|
27404
27456
|
}
|
|
27405
27457
|
});
|
|
27406
27458
|
|
|
@@ -38839,6 +38891,7 @@ async function createSignupStripeCustomer(email) {
|
|
|
38839
38891
|
}
|
|
38840
38892
|
function opForCostPath(p) {
|
|
38841
38893
|
if (p === "/harvest" || p === "/harvest/sync") return "harvest";
|
|
38894
|
+
if (p === "/diff-page") return "diff_page";
|
|
38842
38895
|
if (p === "/extract-url") return "page_scrape";
|
|
38843
38896
|
if (p === "/extract-site") return "extract_site";
|
|
38844
38897
|
if (p === "/map-urls") return "url_map";
|
|
@@ -39182,6 +39235,13 @@ var init_server = __esm({
|
|
|
39182
39235
|
c.header("Cache-Control", "public, max-age=60");
|
|
39183
39236
|
return c.json(CATALOG);
|
|
39184
39237
|
});
|
|
39238
|
+
app.get("/rates", (c) => {
|
|
39239
|
+
const costs = CREDIT_COST_CATALOG.map(({ aliases, ...cost }) => ({
|
|
39240
|
+
...cost,
|
|
39241
|
+
...cost.notes ? { notes: cost.notes.replace(/ via fal\.ai/g, "") } : {}
|
|
39242
|
+
}));
|
|
39243
|
+
return c.json({ creditsPerDollarStarter: Math.round(1 / 15e-5), costs });
|
|
39244
|
+
});
|
|
39185
39245
|
app.get("/me", async (c) => {
|
|
39186
39246
|
const token = (0, import_cookie2.getCookie)(c, "session");
|
|
39187
39247
|
if (!token) return c.json({ authenticated: false });
|