mcp-scraper 0.7.0 → 0.9.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 +1100 -458
- 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 +87 -1
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +3 -3
- package/dist/{chunk-YGRZU7IR.js → chunk-FZG427QD.js} +90 -3
- package/dist/chunk-FZG427QD.js.map +1 -0
- package/dist/{chunk-GWRIO6JT.js → chunk-R7EETU7Z.js} +134 -134
- package/dist/chunk-R7EETU7Z.js.map +1 -0
- package/dist/chunk-RD5JOAWS.js +7 -0
- package/dist/chunk-RD5JOAWS.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-E34MNLI3.js} +567 -28
- package/dist/server-E34MNLI3.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/chunk-YGRZU7IR.js.map +0 -1
- package/dist/extract-bundle-COS56ZDO.js.map +0 -1
- package/dist/server-JNY4XPZE.js.map +0 -1
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,6 +6945,465 @@ var init_rotating_proxy_crawl = __esm({
|
|
|
6944
6945
|
}
|
|
6945
6946
|
});
|
|
6946
6947
|
|
|
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`;
|
|
6954
|
+
}
|
|
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);
|
|
6964
|
+
}
|
|
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
|
+
});
|
|
6976
|
+
try {
|
|
6977
|
+
let bytes = null;
|
|
6978
|
+
let ct = null;
|
|
6979
|
+
let status = null;
|
|
6980
|
+
try {
|
|
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);
|
|
6985
|
+
} catch {
|
|
6986
|
+
}
|
|
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);
|
|
6996
|
+
try {
|
|
6997
|
+
await g.body?.cancel();
|
|
6998
|
+
} catch {
|
|
6999
|
+
}
|
|
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);
|
|
7006
|
+
}
|
|
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]);
|
|
7015
|
+
}
|
|
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
|
|
7049
|
+
}
|
|
7050
|
+
};
|
|
7051
|
+
}
|
|
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");
|
|
7066
|
+
}
|
|
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/", "");
|
|
7079
|
+
}
|
|
7080
|
+
return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
|
|
7081
|
+
};
|
|
7082
|
+
}
|
|
7083
|
+
});
|
|
7084
|
+
|
|
7085
|
+
// src/api/seo-link-graph.ts
|
|
7086
|
+
function normalize(u) {
|
|
7087
|
+
return u.split("#")[0].replace(/\/$/, "");
|
|
7088
|
+
}
|
|
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
|
+
});
|
|
7105
|
+
}
|
|
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);
|
|
7119
|
+
}
|
|
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);
|
|
7123
|
+
}
|
|
7124
|
+
}
|
|
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
|
+
}
|
|
7138
|
+
}
|
|
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]);
|
|
7143
|
+
};
|
|
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
|
+
});
|
|
7158
|
+
}
|
|
7159
|
+
return { edges, metrics };
|
|
7160
|
+
}
|
|
7161
|
+
var init_seo_link_graph = __esm({
|
|
7162
|
+
"src/api/seo-link-graph.ts"() {
|
|
7163
|
+
"use strict";
|
|
7164
|
+
}
|
|
7165
|
+
});
|
|
7166
|
+
|
|
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 = "";
|
|
7179
|
+
}
|
|
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++;
|
|
7192
|
+
}
|
|
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;
|
|
7198
|
+
try {
|
|
7199
|
+
domain = registrableDomain(new URL(e.to).hostname);
|
|
7200
|
+
} catch {
|
|
7201
|
+
continue;
|
|
7202
|
+
}
|
|
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);
|
|
7210
|
+
}
|
|
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
|
+
};
|
|
7233
|
+
}
|
|
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"() {
|
|
7261
|
+
"use strict";
|
|
7262
|
+
}
|
|
7263
|
+
});
|
|
7264
|
+
|
|
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(/\/+$/, "");
|
|
7329
|
+
}
|
|
7330
|
+
};
|
|
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);
|
|
7338
|
+
}
|
|
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;
|
|
7379
|
+
}
|
|
7380
|
+
});
|
|
7381
|
+
|
|
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"() {
|
|
7400
|
+
"use strict";
|
|
7401
|
+
init_seo_link_graph();
|
|
7402
|
+
init_seo_link_report();
|
|
7403
|
+
init_seo_issues();
|
|
7404
|
+
}
|
|
7405
|
+
});
|
|
7406
|
+
|
|
6947
7407
|
// src/api/site-extractor.ts
|
|
6948
7408
|
function estimatePixels(s) {
|
|
6949
7409
|
let px = 0;
|
|
@@ -6986,6 +7446,7 @@ function emptyPageData(url, status, via) {
|
|
|
6986
7446
|
imageLinks: [],
|
|
6987
7447
|
indexable: status === 200,
|
|
6988
7448
|
indexabilityReason: status === 200 ? null : "non-200",
|
|
7449
|
+
inSitemap: null,
|
|
6989
7450
|
schemaTypes: [],
|
|
6990
7451
|
canonicalUrl: null,
|
|
6991
7452
|
internalLinks: 0,
|
|
@@ -7002,7 +7463,8 @@ function makeSeedSpider(startUrl, urls) {
|
|
|
7002
7463
|
totalFound: urls.length,
|
|
7003
7464
|
durationMs: 0,
|
|
7004
7465
|
truncated: false,
|
|
7005
|
-
browserRetries: 0
|
|
7466
|
+
browserRetries: 0,
|
|
7467
|
+
sitemapUrls: []
|
|
7006
7468
|
};
|
|
7007
7469
|
}
|
|
7008
7470
|
function parsePageData(url, html, status, via, resp) {
|
|
@@ -7169,6 +7631,7 @@ function parsePageData(url, html, status, via, resp) {
|
|
|
7169
7631
|
imageLinks,
|
|
7170
7632
|
indexable,
|
|
7171
7633
|
indexabilityReason,
|
|
7634
|
+
inSitemap: null,
|
|
7172
7635
|
schemaTypes,
|
|
7173
7636
|
canonicalUrl,
|
|
7174
7637
|
internalLinks,
|
|
@@ -7182,11 +7645,11 @@ async function extractPagesRotating(urls, opts) {
|
|
|
7182
7645
|
const fetched = await crawlWithRotation(urls, { apiKey: opts.kernelApiKey, concurrency: opts.concurrency, urlsPerBrowser: opts.urlsPerBrowser });
|
|
7183
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"));
|
|
7184
7647
|
}
|
|
7185
|
-
async function
|
|
7648
|
+
async function discoverUrlsWithSitemap(startUrl, maxPages, kernelApiKey) {
|
|
7186
7649
|
const sitemapUrls = await discoverSitemapUrls2(startUrl, maxPages);
|
|
7187
|
-
if (sitemapUrls.length > 0) return sitemapUrls;
|
|
7650
|
+
if (sitemapUrls.length > 0) return { urls: sitemapUrls, sitemapUrls };
|
|
7188
7651
|
const spider = await spiderSite({ startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey });
|
|
7189
|
-
return spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
|
|
7652
|
+
return { urls: spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url), sitemapUrls: spider.sitemapUrls };
|
|
7190
7653
|
}
|
|
7191
7654
|
async function fetchAndParse(url, kernelApiKey) {
|
|
7192
7655
|
const startedAt = Date.now();
|
|
@@ -7245,6 +7708,7 @@ async function extractSite(opts) {
|
|
|
7245
7708
|
let browserRetries = 0;
|
|
7246
7709
|
let truncated = false;
|
|
7247
7710
|
let spider;
|
|
7711
|
+
let sitemapUrls = [];
|
|
7248
7712
|
if (rotating) {
|
|
7249
7713
|
const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
|
|
7250
7714
|
const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
|
|
@@ -7262,7 +7726,8 @@ async function extractSite(opts) {
|
|
|
7262
7726
|
for (const u of opts.seedUrls) enqueue(u);
|
|
7263
7727
|
} else {
|
|
7264
7728
|
enqueue(opts.startUrl);
|
|
7265
|
-
|
|
7729
|
+
sitemapUrls = await discoverSitemapUrls2(opts.startUrl, maxPages);
|
|
7730
|
+
for (const u of sitemapUrls) enqueue(u);
|
|
7266
7731
|
}
|
|
7267
7732
|
const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
|
|
7268
7733
|
let done = 0;
|
|
@@ -7291,6 +7756,7 @@ async function extractSite(opts) {
|
|
|
7291
7756
|
spider = makeSeedSpider(opts.startUrl, [...seen]);
|
|
7292
7757
|
} else {
|
|
7293
7758
|
spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
|
|
7759
|
+
sitemapUrls = spider.sitemapUrls;
|
|
7294
7760
|
const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
|
|
7295
7761
|
browserRetries = spider.browserRetries;
|
|
7296
7762
|
await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
|
|
@@ -7300,10 +7766,17 @@ async function extractSite(opts) {
|
|
|
7300
7766
|
});
|
|
7301
7767
|
truncated = spider.truncated || pages.length >= maxPages;
|
|
7302
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;
|
|
7303
7771
|
let branding = null;
|
|
7304
7772
|
if (opts.formats?.includes("branding") && opts.kernelApiKey) {
|
|
7305
7773
|
branding = await extractBranding(opts.startUrl, opts.kernelApiKey).catch(() => null);
|
|
7306
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
|
+
}
|
|
7307
7780
|
return {
|
|
7308
7781
|
startUrl: opts.startUrl,
|
|
7309
7782
|
pages,
|
|
@@ -7312,7 +7785,9 @@ async function extractSite(opts) {
|
|
|
7312
7785
|
durationMs: Date.now() - startMs,
|
|
7313
7786
|
truncated,
|
|
7314
7787
|
browserRetries,
|
|
7315
|
-
branding
|
|
7788
|
+
branding,
|
|
7789
|
+
seoAudit,
|
|
7790
|
+
imageAudit
|
|
7316
7791
|
};
|
|
7317
7792
|
}
|
|
7318
7793
|
var import_turndown2, import_node_crypto3, PIXEL_WIDTHS, UA2, EXTRACT_CONCURRENCY, MAX_PAGE_MARKDOWN, turndown;
|
|
@@ -7325,6 +7800,8 @@ var init_site_extractor = __esm({
|
|
|
7325
7800
|
init_kernel_fetch();
|
|
7326
7801
|
init_rotating_proxy_crawl();
|
|
7327
7802
|
init_screenshot();
|
|
7803
|
+
init_image_audit();
|
|
7804
|
+
init_seo_audit();
|
|
7328
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 };
|
|
7329
7806
|
UA2 = "Mozilla/5.0 (compatible; ThorbitBot/1.0; +https://thorbit.ai)";
|
|
7330
7807
|
EXTRACT_CONCURRENCY = 6;
|
|
@@ -10048,440 +10525,6 @@ var init_site_audit = __esm({
|
|
|
10048
10525
|
}
|
|
10049
10526
|
});
|
|
10050
10527
|
|
|
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);
|
|
10102
|
-
}
|
|
10103
|
-
}
|
|
10104
|
-
}
|
|
10105
|
-
const topAnchorsFor = (url) => {
|
|
10106
|
-
const counts = /* @__PURE__ */ new Map();
|
|
10107
|
-
for (const a of inboundAnchors.get(url) ?? []) counts.set(a, (counts.get(a) ?? 0) + 1);
|
|
10108
|
-
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
|
|
10109
|
-
};
|
|
10110
|
-
const metrics = /* @__PURE__ */ new Map();
|
|
10111
|
-
for (const p of pages) {
|
|
10112
|
-
const key = normalize(p.url);
|
|
10113
|
-
const inbound = inboundFrom.get(key) ?? /* @__PURE__ */ new Set();
|
|
10114
|
-
metrics.set(p.url, {
|
|
10115
|
-
url: p.url,
|
|
10116
|
-
inlinks: inbound.size,
|
|
10117
|
-
uniqueInlinks: inbound.size,
|
|
10118
|
-
outlinksInternal: (p.outlinks ?? []).filter((l) => l.internal).length,
|
|
10119
|
-
outlinksExternal: (p.outlinks ?? []).filter((l) => !l.internal).length,
|
|
10120
|
-
crawlDepth: depth.has(key) ? depth.get(key) : null,
|
|
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";
|
|
10130
|
-
}
|
|
10131
|
-
});
|
|
10132
|
-
|
|
10133
|
-
// src/api/seo-link-report.ts
|
|
10134
|
-
function registrableDomain(host) {
|
|
10135
|
-
const h = host.replace(/^www\./, "").toLowerCase();
|
|
10136
|
-
const parts = h.split(".");
|
|
10137
|
-
return parts.length <= 2 ? h : parts.slice(-2).join(".");
|
|
10138
|
-
}
|
|
10139
|
-
function buildLinkReport(edges, metrics, siteUrl) {
|
|
10140
|
-
let siteReg = "";
|
|
10141
|
-
try {
|
|
10142
|
-
siteReg = registrableDomain(new URL(siteUrl).hostname);
|
|
10143
|
-
} catch {
|
|
10144
|
-
siteReg = "";
|
|
10145
|
-
}
|
|
10146
|
-
const internalEdges = edges.filter((e) => e.internal);
|
|
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");
|
|
10224
|
-
}
|
|
10225
|
-
var init_seo_link_report = __esm({
|
|
10226
|
-
"src/api/seo-link-report.ts"() {
|
|
10227
|
-
"use strict";
|
|
10228
|
-
}
|
|
10229
|
-
});
|
|
10230
|
-
|
|
10231
|
-
// src/api/seo-issues.ts
|
|
10232
|
-
function dupes(pages, key) {
|
|
10233
|
-
const groups = /* @__PURE__ */ new Map();
|
|
10234
|
-
for (const p of pages) {
|
|
10235
|
-
const k = key(p);
|
|
10236
|
-
if (k === null || k === void 0 || k === "") continue;
|
|
10237
|
-
const ks = String(k);
|
|
10238
|
-
if (!groups.has(ks)) groups.set(ks, []);
|
|
10239
|
-
groups.get(ks).push(p.url);
|
|
10240
|
-
}
|
|
10241
|
-
return [...groups.values()].filter((g) => g.length > 1).flat();
|
|
10242
|
-
}
|
|
10243
|
-
function computeIssues(pages, metrics, precomputedBrokenLinkPages) {
|
|
10244
|
-
const group = (urls) => ({ count: urls.length, urls: urls.slice(0, 200) });
|
|
10245
|
-
const where = (fn) => pages.filter(fn).map((p) => p.url);
|
|
10246
|
-
const ok = (p) => p.status === 200;
|
|
10247
|
-
const pathname = (u) => {
|
|
10248
|
-
try {
|
|
10249
|
-
return new URL(u).pathname;
|
|
10250
|
-
} catch {
|
|
10251
|
-
return "";
|
|
10252
|
-
}
|
|
10253
|
-
};
|
|
10254
|
-
const report = {
|
|
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(/\/+$/, "");
|
|
10295
|
-
}
|
|
10296
|
-
};
|
|
10297
|
-
const statusByUrl = new Map(pages.map((p) => [normUrl2(p.url), p.status]));
|
|
10298
|
-
brokenLinkPages = /* @__PURE__ */ new Set();
|
|
10299
|
-
for (const p of pages) {
|
|
10300
|
-
for (const l of p.outlinks ?? []) {
|
|
10301
|
-
if (!l.internal) continue;
|
|
10302
|
-
const st = statusByUrl.get(normUrl2(l.href));
|
|
10303
|
-
if (st != null && st >= 400) brokenLinkPages.add(p.url);
|
|
10304
|
-
}
|
|
10305
|
-
}
|
|
10306
|
-
}
|
|
10307
|
-
report["links.brokenInternal"] = group([...brokenLinkPages]);
|
|
10308
|
-
return report;
|
|
10309
|
-
}
|
|
10310
|
-
function renderIssueReport(siteUrl, pages, report, metrics) {
|
|
10311
|
-
const total = pages.length;
|
|
10312
|
-
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}";
|
|
10313
|
-
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" : ""} |`);
|
|
10314
|
-
const depths = [...metrics.values()].map((m) => m.crawlDepth).filter((d) => d != null);
|
|
10315
|
-
const maxDepth = depths.length ? Math.max(...depths) : 0;
|
|
10316
|
-
const orphans = [...metrics.values()].filter((m) => m.orphan).length;
|
|
10317
|
-
const topLinked = [...metrics.values()].sort((a, b) => b.inlinks - a.inlinks).slice(0, 5);
|
|
10318
|
-
return [
|
|
10319
|
-
`# SEO Crawl Report: ${siteUrl}`,
|
|
10320
|
-
`**${total} pages crawled** \xB7 max crawl depth ${maxDepth} \xB7 ${orphans} orphan page(s)`,
|
|
10321
|
-
`
|
|
10322
|
-
## Issues
|
|
10323
|
-
| | Issue | Count | Examples |
|
|
10324
|
-
|---|-------|-------|----------|
|
|
10325
|
-
${rows.join("\n") || "| \u2705 | none | 0 | \u2014 |"}`,
|
|
10326
|
-
`
|
|
10327
|
-
## Most-linked pages
|
|
10328
|
-
${topLinked.map((m) => `- ${m.inlinks} inlinks \xB7 depth ${m.crawlDepth ?? "\u2014"} \xB7 ${m.url}`).join("\n")}`,
|
|
10329
|
-
`
|
|
10330
|
-
_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._`
|
|
10331
|
-
].join("\n");
|
|
10332
|
-
}
|
|
10333
|
-
var THIN_WORDS, TITLE_MAX, TITLE_PX_MAX, TITLE_MIN, META_MAX, META_MIN, H1_MAX, URL_MAX;
|
|
10334
|
-
var init_seo_issues = __esm({
|
|
10335
|
-
"src/api/seo-issues.ts"() {
|
|
10336
|
-
"use strict";
|
|
10337
|
-
THIN_WORDS = 200;
|
|
10338
|
-
TITLE_MAX = 60;
|
|
10339
|
-
TITLE_PX_MAX = 561;
|
|
10340
|
-
TITLE_MIN = 30;
|
|
10341
|
-
META_MAX = 155;
|
|
10342
|
-
META_MIN = 70;
|
|
10343
|
-
H1_MAX = 70;
|
|
10344
|
-
URL_MAX = 115;
|
|
10345
|
-
}
|
|
10346
|
-
});
|
|
10347
|
-
|
|
10348
|
-
// src/api/image-audit.ts
|
|
10349
|
-
function formatBytes(n) {
|
|
10350
|
-
if (n == null) return null;
|
|
10351
|
-
if (n < 1024) return `${n} B`;
|
|
10352
|
-
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
10353
|
-
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
10354
|
-
}
|
|
10355
|
-
function collectUrls(pages) {
|
|
10356
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
10357
|
-
for (const p of pages) for (const raw of p.imageLinks || []) {
|
|
10358
|
-
const u = decodeEntities(raw);
|
|
10359
|
-
if (!/^https?:\/\//i.test(u)) continue;
|
|
10360
|
-
if (/[{}]|%7[bd]/i.test(u)) continue;
|
|
10361
|
-
const k = dedupKey(u);
|
|
10362
|
-
const prev = byKey.get(k);
|
|
10363
|
-
if (!prev || /^https:/i.test(u) && /^http:/i.test(prev)) byKey.set(k, u);
|
|
10364
|
-
}
|
|
10365
|
-
return [...byKey.values()];
|
|
10366
|
-
}
|
|
10367
|
-
async function sizeAndType(url, timeoutMs) {
|
|
10368
|
-
const ctrl = new AbortController();
|
|
10369
|
-
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
10370
|
-
const read = (res) => ({
|
|
10371
|
-
len: res.headers.get("content-length"),
|
|
10372
|
-
cr: res.headers.get("content-range"),
|
|
10373
|
-
ct: res.headers.get("content-type"),
|
|
10374
|
-
status: res.status
|
|
10375
|
-
});
|
|
10376
|
-
try {
|
|
10377
|
-
let bytes = null;
|
|
10378
|
-
let ct = null;
|
|
10379
|
-
let status = null;
|
|
10380
|
-
try {
|
|
10381
|
-
const h = read(await fetch(url, { method: "HEAD", redirect: "follow", signal: ctrl.signal }));
|
|
10382
|
-
status = h.status;
|
|
10383
|
-
ct = h.ct;
|
|
10384
|
-
if (h.len) bytes = Number(h.len);
|
|
10385
|
-
} catch {
|
|
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
|
-
}
|
|
10400
|
-
}
|
|
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
|
-
}
|
|
10407
|
-
}
|
|
10408
|
-
async function pool(items, n, fn) {
|
|
10409
|
-
const out = new Array(items.length);
|
|
10410
|
-
let i = 0;
|
|
10411
|
-
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
10412
|
-
while (i < items.length) {
|
|
10413
|
-
const idx = i++;
|
|
10414
|
-
out[idx] = await fn(items[idx]);
|
|
10415
|
-
}
|
|
10416
|
-
}));
|
|
10417
|
-
return out;
|
|
10418
|
-
}
|
|
10419
|
-
async function auditImages(pages, opts = {}) {
|
|
10420
|
-
const concurrency = opts.concurrency ?? 12;
|
|
10421
|
-
const timeoutMs = opts.timeoutMs ?? 12e3;
|
|
10422
|
-
const urls = collectUrls(pages).slice(0, opts.max ?? 5e3);
|
|
10423
|
-
const heads = await pool(urls, concurrency, (u) => sizeAndType(u, timeoutMs));
|
|
10424
|
-
const rows = heads.map((r) => {
|
|
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
|
-
}
|
|
10450
|
-
};
|
|
10451
|
-
}
|
|
10452
|
-
function renderImageSection(audit) {
|
|
10453
|
-
const s = audit.summary;
|
|
10454
|
-
const fmt = Object.entries(s.formatCounts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(" ");
|
|
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");
|
|
10466
|
-
}
|
|
10467
|
-
var OVER_BYTES, MODERN, decodeEntities, dedupKey, formatOf;
|
|
10468
|
-
var init_image_audit = __esm({
|
|
10469
|
-
"src/api/image-audit.ts"() {
|
|
10470
|
-
"use strict";
|
|
10471
|
-
OVER_BYTES = 100 * 1024;
|
|
10472
|
-
MODERN = /* @__PURE__ */ new Set(["webp", "avif", "svg", "svg+xml"]);
|
|
10473
|
-
decodeEntities = (u) => u.replace(/&/g, "&").replace(/�?38;/g, "&").replace(/&/gi, "&").trim();
|
|
10474
|
-
dedupKey = (u) => u.replace(/^https?:\/\//i, "//").replace(/\/$/, "");
|
|
10475
|
-
formatOf = (ct, url) => {
|
|
10476
|
-
if (ct) {
|
|
10477
|
-
const m = ct.split(";")[0].trim().toLowerCase();
|
|
10478
|
-
if (m.startsWith("image/")) return m.replace("image/", "");
|
|
10479
|
-
}
|
|
10480
|
-
return (url.split("?")[0].match(/\.([a-z0-9]+)$/i)?.[1] || "unknown").toLowerCase();
|
|
10481
|
-
};
|
|
10482
|
-
}
|
|
10483
|
-
});
|
|
10484
|
-
|
|
10485
10528
|
// src/api/site-extract-repository.ts
|
|
10486
10529
|
var site_extract_repository_exports = {};
|
|
10487
10530
|
__export(site_extract_repository_exports, {
|
|
@@ -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.9.0";
|
|
27404
27456
|
}
|
|
27405
27457
|
});
|
|
27406
27458
|
|
|
@@ -27573,7 +27625,7 @@ var init_output_schema_registry = __esm({
|
|
|
27573
27625
|
});
|
|
27574
27626
|
|
|
27575
27627
|
// src/mcp/mcp-tool-schemas.ts
|
|
27576
|
-
var import_zod33, HarvestPaaInputSchema, ExtractUrlInputSchema, DiffPageInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, RedditThreadInputSchema, VideoFrameAnalysisInputSchema, VideoFrameAnalysisStatusInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, GoogleAdsSearchInputSchema, GoogleAdsPageIntelInputSchema, GoogleAdsTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, TrustpilotReviewsInputSchema, G2ReviewsInputSchema, ReviewCardSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, ArtifactPointerOutputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, DiffPageOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, MapsPlaceIntelOutputSchema, TrustpilotReviewsOutputSchema, G2ReviewsOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, VideoFrameAnalysisOutputSchema, VideoFrameAnalysisStatusOutputSchema, RedditThreadOutputSchema, FacebookPageIntelOutputSchema, GoogleAdsSearchOutputSchema, GoogleAdsPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, GoogleAdsTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema, ReportArtifactReadInputSchema, ReportArtifactReadOutputSchema;
|
|
27628
|
+
var import_zod33, HarvestPaaInputSchema, ExtractUrlInputSchema, DiffPageInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, RedditThreadInputSchema, VideoFrameAnalysisInputSchema, VideoFrameAnalysisStatusInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, GoogleAdsSearchInputSchema, GoogleAdsPageIntelInputSchema, GoogleAdsTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, TrustpilotReviewsInputSchema, G2ReviewsInputSchema, ReviewCardSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, ArtifactPointerOutputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, DiffPageOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, MapsPlaceIntelOutputSchema, TrustpilotReviewsOutputSchema, G2ReviewsOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, VideoFrameAnalysisOutputSchema, VideoFrameAnalysisStatusOutputSchema, RedditThreadOutputSchema, FacebookPageIntelOutputSchema, GoogleAdsSearchOutputSchema, GoogleAdsPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, GoogleAdsTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema, ReportArtifactReadInputSchema, ReportArtifactReadOutputSchema, ListServiceConnectionsInputSchema, ListServiceConnectionsOutputSchema, SlackSendMessageInputSchema, SlackSendMessageOutputSchema, GmailSendMessageInputSchema, GmailSendMessageOutputSchema, GoogleCalendarCreateEventInputSchema, GoogleCalendarCreateEventOutputSchema;
|
|
27577
27629
|
var init_mcp_tool_schemas = __esm({
|
|
27578
27630
|
"src/mcp/mcp-tool-schemas.ts"() {
|
|
27579
27631
|
"use strict";
|
|
@@ -28598,6 +28650,52 @@ var init_mcp_tool_schemas = __esm({
|
|
|
28598
28650
|
totalBytes: import_zod33.z.number().int().min(0),
|
|
28599
28651
|
nextOffset: import_zod33.z.number().int().min(0).nullable()
|
|
28600
28652
|
};
|
|
28653
|
+
ListServiceConnectionsInputSchema = {};
|
|
28654
|
+
ListServiceConnectionsOutputSchema = {
|
|
28655
|
+
connections: import_zod33.z.array(import_zod33.z.object({
|
|
28656
|
+
connectionId: import_zod33.z.string(),
|
|
28657
|
+
providerConfigKey: import_zod33.z.string(),
|
|
28658
|
+
label: import_zod33.z.string(),
|
|
28659
|
+
status: import_zod33.z.string(),
|
|
28660
|
+
actionsEnabled: import_zod33.z.boolean()
|
|
28661
|
+
}))
|
|
28662
|
+
};
|
|
28663
|
+
SlackSendMessageInputSchema = {
|
|
28664
|
+
connectionId: import_zod33.z.string().min(1).describe("A Slack connectionId from list_service_connections, with actionsEnabled true."),
|
|
28665
|
+
channel: import_zod33.z.string().min(1).describe(`Slack channel ID to send to, e.g. "C1234567890". Get this from the connection's own read tools, not guessed.`),
|
|
28666
|
+
text: import_zod33.z.string().min(1).max(4e3).describe("Message text to send.")
|
|
28667
|
+
};
|
|
28668
|
+
SlackSendMessageOutputSchema = {
|
|
28669
|
+
ok: import_zod33.z.boolean(),
|
|
28670
|
+
result: import_zod33.z.unknown().optional(),
|
|
28671
|
+
error: NullableString
|
|
28672
|
+
};
|
|
28673
|
+
GmailSendMessageInputSchema = {
|
|
28674
|
+
connectionId: import_zod33.z.string().min(1).describe("A Gmail connectionId from list_service_connections, with actionsEnabled true."),
|
|
28675
|
+
to: import_zod33.z.string().email().describe("Recipient email address."),
|
|
28676
|
+
subject: import_zod33.z.string().min(1).max(500).describe("Email subject line."),
|
|
28677
|
+
body: import_zod33.z.string().min(1).max(5e4).describe("Plain-text email body.")
|
|
28678
|
+
};
|
|
28679
|
+
GmailSendMessageOutputSchema = {
|
|
28680
|
+
ok: import_zod33.z.boolean(),
|
|
28681
|
+
result: import_zod33.z.unknown().optional(),
|
|
28682
|
+
error: NullableString
|
|
28683
|
+
};
|
|
28684
|
+
GoogleCalendarCreateEventInputSchema = {
|
|
28685
|
+
connectionId: import_zod33.z.string().min(1).describe("A Google Calendar connectionId from list_service_connections, with actionsEnabled true."),
|
|
28686
|
+
calendarId: import_zod33.z.string().min(1).default("primary").describe('Calendar to create the event in. Default "primary".'),
|
|
28687
|
+
summary: import_zod33.z.string().min(1).max(500).describe("Event title."),
|
|
28688
|
+
description: import_zod33.z.string().max(5e3).optional().describe("Event description."),
|
|
28689
|
+
location: import_zod33.z.string().max(500).optional().describe("Event location."),
|
|
28690
|
+
startDateTime: import_zod33.z.string().min(1).describe('Start time, ISO 8601, e.g. "2026-07-15T09:00:00-06:00".'),
|
|
28691
|
+
endDateTime: import_zod33.z.string().min(1).describe('End time, ISO 8601, e.g. "2026-07-15T10:00:00-06:00".'),
|
|
28692
|
+
timeZone: import_zod33.z.string().max(100).optional().describe('IANA timezone, e.g. "America/Denver". Applies to both start and end.')
|
|
28693
|
+
};
|
|
28694
|
+
GoogleCalendarCreateEventOutputSchema = {
|
|
28695
|
+
ok: import_zod33.z.boolean(),
|
|
28696
|
+
result: import_zod33.z.unknown().optional(),
|
|
28697
|
+
error: NullableString
|
|
28698
|
+
};
|
|
28601
28699
|
}
|
|
28602
28700
|
});
|
|
28603
28701
|
|
|
@@ -29277,6 +29375,34 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
|
|
|
29277
29375
|
openWorldHint: false
|
|
29278
29376
|
}
|
|
29279
29377
|
}, async (input) => formatCreditsInfo(await executor.creditsInfo(input), input));
|
|
29378
|
+
server.registerTool("list_service_connections", {
|
|
29379
|
+
title: "List Connected Services",
|
|
29380
|
+
description: "List the third-party service connections (Slack, Gmail, Google Calendar) the account has authorized, with each connection's id and whether it is enabled for live actions. Get a connectionId from here before calling slack_send_message, gmail_send_message, or google_calendar_create_event.",
|
|
29381
|
+
inputSchema: ListServiceConnectionsInputSchema,
|
|
29382
|
+
outputSchema: recordOutputSchema("list_service_connections", ListServiceConnectionsOutputSchema),
|
|
29383
|
+
annotations: { title: "List Connected Services", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
29384
|
+
}, async (input) => executor.listServiceConnections(input));
|
|
29385
|
+
server.registerTool("slack_send_message", {
|
|
29386
|
+
title: "Send Slack Message",
|
|
29387
|
+
description: "Send a message to a Slack channel through a connected, action-enabled Slack connection. Requires a connectionId from list_service_connections with actionsEnabled true; the person must have explicitly turned actions on for that connection.",
|
|
29388
|
+
inputSchema: SlackSendMessageInputSchema,
|
|
29389
|
+
outputSchema: recordOutputSchema("slack_send_message", SlackSendMessageOutputSchema),
|
|
29390
|
+
annotations: { title: "Send Slack Message", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
29391
|
+
}, async (input) => executor.slackSendMessage(input));
|
|
29392
|
+
server.registerTool("gmail_send_message", {
|
|
29393
|
+
title: "Send Gmail Message",
|
|
29394
|
+
description: "Send an email through a connected, action-enabled Gmail connection. Requires a connectionId from list_service_connections with actionsEnabled true; the person must have explicitly turned actions on for that connection.",
|
|
29395
|
+
inputSchema: GmailSendMessageInputSchema,
|
|
29396
|
+
outputSchema: recordOutputSchema("gmail_send_message", GmailSendMessageOutputSchema),
|
|
29397
|
+
annotations: { title: "Send Gmail Message", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
29398
|
+
}, async (input) => executor.gmailSendMessage(input));
|
|
29399
|
+
server.registerTool("google_calendar_create_event", {
|
|
29400
|
+
title: "Create Calendar Event",
|
|
29401
|
+
description: "Create an event on a connected, action-enabled Google Calendar connection. Requires a connectionId from list_service_connections with actionsEnabled true; the person must have explicitly turned actions on for that connection.",
|
|
29402
|
+
inputSchema: GoogleCalendarCreateEventInputSchema,
|
|
29403
|
+
outputSchema: recordOutputSchema("google_calendar_create_event", GoogleCalendarCreateEventOutputSchema),
|
|
29404
|
+
annotations: { title: "Create Calendar Event", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
29405
|
+
}, async (input) => executor.googleCalendarCreateEvent(input));
|
|
29280
29406
|
}
|
|
29281
29407
|
var import_mcp, import_node_fs9, import_node_path12, import_node_crypto10;
|
|
29282
29408
|
var init_paa_mcp_server = __esm({
|
|
@@ -29585,6 +29711,18 @@ var init_http_mcp_tool_executor = __esm({
|
|
|
29585
29711
|
creditsInfo(input) {
|
|
29586
29712
|
return this.call("/billing/credits", input);
|
|
29587
29713
|
}
|
|
29714
|
+
listServiceConnections(input) {
|
|
29715
|
+
return this.getJson("/schedule-connections");
|
|
29716
|
+
}
|
|
29717
|
+
slackSendMessage(input) {
|
|
29718
|
+
return this.call("/schedule-connections/actions/slack/send-message", input);
|
|
29719
|
+
}
|
|
29720
|
+
gmailSendMessage(input) {
|
|
29721
|
+
return this.call("/schedule-connections/actions/gmail/send-message", input);
|
|
29722
|
+
}
|
|
29723
|
+
googleCalendarCreateEvent(input) {
|
|
29724
|
+
return this.call("/schedule-connections/actions/google-calendar/create-event", input);
|
|
29725
|
+
}
|
|
29588
29726
|
captureSerpSnapshot(input) {
|
|
29589
29727
|
return this.call("/serp-intelligence/capture", input, this.serpIntelligenceTimeoutMs);
|
|
29590
29728
|
}
|
|
@@ -38653,6 +38791,312 @@ var init_memory_db = __esm({
|
|
|
38653
38791
|
}
|
|
38654
38792
|
});
|
|
38655
38793
|
|
|
38794
|
+
// src/api/nango-control.ts
|
|
38795
|
+
function controlBaseUrl() {
|
|
38796
|
+
return (process.env.NANGO_CONTROL_URL?.trim() || DEFAULT_NANGO_CONTROL_URL).replace(/\/$/, "");
|
|
38797
|
+
}
|
|
38798
|
+
function controlSecret() {
|
|
38799
|
+
const secret2 = process.env.SCHEDULE_INTEGRATIONS_SECRET?.trim();
|
|
38800
|
+
if (!secret2) throw new NangoControlError("Scheduled service connections are not configured.", 503);
|
|
38801
|
+
return secret2;
|
|
38802
|
+
}
|
|
38803
|
+
function isRecord(value) {
|
|
38804
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
38805
|
+
}
|
|
38806
|
+
function unwrapData(value) {
|
|
38807
|
+
if (isRecord(value) && isRecord(value.data)) return value.data;
|
|
38808
|
+
return value;
|
|
38809
|
+
}
|
|
38810
|
+
function cleanString(value, max = 300) {
|
|
38811
|
+
if (typeof value !== "string") return null;
|
|
38812
|
+
const trimmed = value.trim();
|
|
38813
|
+
return trimmed ? trimmed.slice(0, max) : null;
|
|
38814
|
+
}
|
|
38815
|
+
function firstString(record, keys, max = 300) {
|
|
38816
|
+
for (const key of keys) {
|
|
38817
|
+
const value = cleanString(record[key], max);
|
|
38818
|
+
if (value) return value;
|
|
38819
|
+
}
|
|
38820
|
+
return null;
|
|
38821
|
+
}
|
|
38822
|
+
function cleanTools(value) {
|
|
38823
|
+
if (!Array.isArray(value)) return [];
|
|
38824
|
+
const tools = value.map((tool) => cleanString(tool, 200)).filter((tool) => !!tool);
|
|
38825
|
+
return [...new Set(tools)].slice(0, 200);
|
|
38826
|
+
}
|
|
38827
|
+
function cleanStringArray(value, maxItems = 20, maxLength = 100) {
|
|
38828
|
+
if (!Array.isArray(value)) return [];
|
|
38829
|
+
const items = value.map((item) => cleanString(item, maxLength)).filter((item) => !!item);
|
|
38830
|
+
return [...new Set(items)].slice(0, maxItems);
|
|
38831
|
+
}
|
|
38832
|
+
function cleanHttpsUrl(value) {
|
|
38833
|
+
const candidate = cleanString(value, 2e3);
|
|
38834
|
+
if (!candidate) return null;
|
|
38835
|
+
try {
|
|
38836
|
+
const url = new URL(candidate);
|
|
38837
|
+
return url.protocol === "https:" ? url.toString() : null;
|
|
38838
|
+
} catch {
|
|
38839
|
+
return null;
|
|
38840
|
+
}
|
|
38841
|
+
}
|
|
38842
|
+
function arrayFromPayload(value, keys) {
|
|
38843
|
+
const unwrapped = unwrapData(value);
|
|
38844
|
+
if (Array.isArray(unwrapped)) return unwrapped;
|
|
38845
|
+
if (!isRecord(unwrapped)) return [];
|
|
38846
|
+
for (const key of keys) {
|
|
38847
|
+
if (Array.isArray(unwrapped[key])) return unwrapped[key];
|
|
38848
|
+
}
|
|
38849
|
+
return [];
|
|
38850
|
+
}
|
|
38851
|
+
async function controlRequest(path6, init) {
|
|
38852
|
+
const response = await fetch(`${controlBaseUrl()}${path6}`, {
|
|
38853
|
+
...init,
|
|
38854
|
+
headers: {
|
|
38855
|
+
accept: "application/json",
|
|
38856
|
+
authorization: `Bearer ${controlSecret()}`,
|
|
38857
|
+
...init?.body ? { "content-type": "application/json" } : {},
|
|
38858
|
+
...init?.headers ?? {}
|
|
38859
|
+
},
|
|
38860
|
+
signal: AbortSignal.timeout(2e4)
|
|
38861
|
+
}).catch((err) => {
|
|
38862
|
+
throw new NangoControlError(`Scheduled service connection control is unavailable: ${err instanceof Error ? err.message : "network error"}`);
|
|
38863
|
+
});
|
|
38864
|
+
const text = await response.text();
|
|
38865
|
+
let body = {};
|
|
38866
|
+
try {
|
|
38867
|
+
body = text ? JSON.parse(text) : {};
|
|
38868
|
+
} catch {
|
|
38869
|
+
body = {};
|
|
38870
|
+
}
|
|
38871
|
+
if (!response.ok) {
|
|
38872
|
+
const upstream = isRecord(body) ? cleanString(body.error, 300) : null;
|
|
38873
|
+
throw new NangoControlError(upstream || `Scheduled service connection control failed (${response.status}).`);
|
|
38874
|
+
}
|
|
38875
|
+
return body;
|
|
38876
|
+
}
|
|
38877
|
+
function sanitizeScheduleConnectionSelections(value) {
|
|
38878
|
+
if (value == null) return [];
|
|
38879
|
+
if (!Array.isArray(value)) throw new ScheduleConnectionValidationError("connections must be an array.");
|
|
38880
|
+
if (value.length > 20) throw new ScheduleConnectionValidationError("A scheduled action can use at most 20 service connections.");
|
|
38881
|
+
const selections = [];
|
|
38882
|
+
const seen = /* @__PURE__ */ new Set();
|
|
38883
|
+
for (const item of value) {
|
|
38884
|
+
if (!isRecord(item)) throw new ScheduleConnectionValidationError("Each service connection must be an object.");
|
|
38885
|
+
const connectionId = firstString(item, ["connectionId", "connection_id"]);
|
|
38886
|
+
const providerConfigKey = firstString(item, ["providerConfigKey", "provider_config_key", "integrationId", "integration_id"]);
|
|
38887
|
+
const allowedTools = cleanTools(item.allowedTools ?? item.allowed_tools ?? item.allowedCapabilities ?? item.allowed_capabilities);
|
|
38888
|
+
if (!connectionId || !providerConfigKey) throw new ScheduleConnectionValidationError("Each service connection needs a connectionId and providerConfigKey.");
|
|
38889
|
+
if (allowedTools.length === 0) throw new ScheduleConnectionValidationError("Each selected service needs at least one allowed tool.");
|
|
38890
|
+
const key = `${providerConfigKey}:${connectionId}`;
|
|
38891
|
+
if (seen.has(key)) continue;
|
|
38892
|
+
seen.add(key);
|
|
38893
|
+
selections.push({ connectionId, providerConfigKey, allowedTools });
|
|
38894
|
+
}
|
|
38895
|
+
return selections;
|
|
38896
|
+
}
|
|
38897
|
+
async function getNangoCatalog() {
|
|
38898
|
+
const body = await controlRequest("/api/internal/nango/catalog");
|
|
38899
|
+
const rows = arrayFromPayload(body, ["providers", "catalog", "integrations", "services"]);
|
|
38900
|
+
const result = [];
|
|
38901
|
+
for (const row of rows) {
|
|
38902
|
+
if (!isRecord(row)) continue;
|
|
38903
|
+
const providerConfigKey = firstString(row, ["providerConfigKey", "provider_config_key", "integrationId", "integration_id", "id"]);
|
|
38904
|
+
if (!providerConfigKey) continue;
|
|
38905
|
+
const provider = firstString(row, ["provider"]);
|
|
38906
|
+
const label = firstString(row, ["label", "displayName", "display_name", "name"]) || providerConfigKey;
|
|
38907
|
+
const description = firstString(row, ["description"], 500);
|
|
38908
|
+
const logoUrl = cleanHttpsUrl(row.logoUrl ?? row.logo_url ?? row.logo);
|
|
38909
|
+
const docsUrl = cleanHttpsUrl(row.docsUrl ?? row.docs_url ?? row.docs);
|
|
38910
|
+
const authMode = firstString(row, ["authMode", "auth_mode"], 100);
|
|
38911
|
+
const categories = cleanStringArray(row.categories);
|
|
38912
|
+
const disabledTools = DISABLED_NANGO_TOOLS[providerConfigKey];
|
|
38913
|
+
const safeDefaultAllowedTools = cleanTools(
|
|
38914
|
+
row.safeDefaultAllowedTools ?? row.safe_default_allowed_tools ?? row.defaultAllowedTools ?? row.default_allowed_tools ?? row.allowedTools ?? row.allowed_tools
|
|
38915
|
+
).filter((tool) => !disabledTools?.has(tool));
|
|
38916
|
+
result.push({
|
|
38917
|
+
providerConfigKey,
|
|
38918
|
+
provider,
|
|
38919
|
+
label,
|
|
38920
|
+
description,
|
|
38921
|
+
logoUrl,
|
|
38922
|
+
docsUrl,
|
|
38923
|
+
authMode,
|
|
38924
|
+
categories,
|
|
38925
|
+
safeDefaultAllowedTools
|
|
38926
|
+
});
|
|
38927
|
+
}
|
|
38928
|
+
return result;
|
|
38929
|
+
}
|
|
38930
|
+
async function getNangoConnections(identity) {
|
|
38931
|
+
const query = new URLSearchParams({ identity }).toString();
|
|
38932
|
+
const body = await controlRequest(`/api/internal/nango/connections?${query}`);
|
|
38933
|
+
const rows = arrayFromPayload(body, ["connections"]);
|
|
38934
|
+
const result = [];
|
|
38935
|
+
for (const row of rows) {
|
|
38936
|
+
if (!isRecord(row)) continue;
|
|
38937
|
+
const connectionId = firstString(row, ["connectionId", "connection_id", "id"]);
|
|
38938
|
+
const providerConfigKey = firstString(row, ["providerConfigKey", "provider_config_key", "integrationId", "integration_id", "provider"]);
|
|
38939
|
+
if (!connectionId || !providerConfigKey) continue;
|
|
38940
|
+
const provider = firstString(row, ["provider"]);
|
|
38941
|
+
const label = firstString(row, ["label", "accountLabel", "account_label", "displayName", "display_name", "endUserEmail", "end_user_email"]) || providerConfigKey;
|
|
38942
|
+
const rawStatus = firstString(row, ["status"], 64) || "connected";
|
|
38943
|
+
const reconnectRequired = row.reconnectRequired === true || row.reconnect_required === true || rawStatus === "needs_reauth" || rawStatus === "reauth_required" || rawStatus === "invalid" || rawStatus === "error" || rawStatus === "revoked" || rawStatus === "disabled";
|
|
38944
|
+
result.push({
|
|
38945
|
+
connectionId,
|
|
38946
|
+
providerConfigKey,
|
|
38947
|
+
provider,
|
|
38948
|
+
label,
|
|
38949
|
+
status: reconnectRequired ? "needs_reauth" : "connected",
|
|
38950
|
+
reconnectRequired,
|
|
38951
|
+
actionsEnabled: row.actionsEnabled === true || row.actions_enabled === true,
|
|
38952
|
+
createdAt: firstString(row, ["createdAt", "created_at"], 100),
|
|
38953
|
+
updatedAt: firstString(row, ["updatedAt", "updated_at"], 100)
|
|
38954
|
+
});
|
|
38955
|
+
}
|
|
38956
|
+
return result;
|
|
38957
|
+
}
|
|
38958
|
+
function sanitizeConnectSession(body) {
|
|
38959
|
+
const data = unwrapData(body);
|
|
38960
|
+
if (!isRecord(data)) throw new NangoControlError("The connection service returned an invalid connect session.");
|
|
38961
|
+
const connectLink = firstString(data, ["connectLink", "connect_link"], 2e3);
|
|
38962
|
+
if (!connectLink) throw new NangoControlError("The connection service did not return a connect link.");
|
|
38963
|
+
return {
|
|
38964
|
+
connectLink,
|
|
38965
|
+
sessionToken: firstString(data, ["sessionToken", "session_token", "token"], 2e3),
|
|
38966
|
+
expiresAt: firstString(data, ["expiresAt", "expires_at"], 100)
|
|
38967
|
+
};
|
|
38968
|
+
}
|
|
38969
|
+
async function createNangoConnectSession(identity, providerConfigKey) {
|
|
38970
|
+
const body = await controlRequest("/api/internal/nango/connect-session", {
|
|
38971
|
+
method: "POST",
|
|
38972
|
+
body: JSON.stringify({ identity, email: identity, provider: providerConfigKey })
|
|
38973
|
+
});
|
|
38974
|
+
return sanitizeConnectSession(body);
|
|
38975
|
+
}
|
|
38976
|
+
async function createNangoReconnectSession(identity, connectionId) {
|
|
38977
|
+
const body = await controlRequest("/api/internal/nango/reconnect-session", {
|
|
38978
|
+
method: "POST",
|
|
38979
|
+
body: JSON.stringify({ identity, connectionId, email: identity })
|
|
38980
|
+
});
|
|
38981
|
+
return sanitizeConnectSession(body);
|
|
38982
|
+
}
|
|
38983
|
+
function sanitizeBindingRows(body, defaultScheduleActionId) {
|
|
38984
|
+
const data = unwrapData(body);
|
|
38985
|
+
const flattened = [];
|
|
38986
|
+
if (isRecord(data) && isRecord(data.bindings) && !Array.isArray(data.bindings)) {
|
|
38987
|
+
for (const [scheduleActionId, value] of Object.entries(data.bindings)) {
|
|
38988
|
+
if (Array.isArray(value)) value.forEach((row) => flattened.push({ row, scheduleActionId }));
|
|
38989
|
+
}
|
|
38990
|
+
} else {
|
|
38991
|
+
const rows = arrayFromPayload(data, ["bindings", "connections"]);
|
|
38992
|
+
for (const row of rows) {
|
|
38993
|
+
if (isRecord(row) && Array.isArray(row.connections)) {
|
|
38994
|
+
const scheduleActionId = firstString(row, ["scheduleActionId", "schedule_action_id", "scheduleId", "schedule_id"]) || defaultScheduleActionId;
|
|
38995
|
+
row.connections.forEach((connection) => flattened.push({ row: connection, scheduleActionId }));
|
|
38996
|
+
} else {
|
|
38997
|
+
flattened.push({ row, scheduleActionId: defaultScheduleActionId });
|
|
38998
|
+
}
|
|
38999
|
+
}
|
|
39000
|
+
}
|
|
39001
|
+
const result = [];
|
|
39002
|
+
for (const item of flattened) {
|
|
39003
|
+
if (!isRecord(item.row)) continue;
|
|
39004
|
+
const connectionId = firstString(item.row, ["connectionId", "connection_id"]);
|
|
39005
|
+
const providerConfigKey = firstString(item.row, ["providerConfigKey", "provider_config_key", "integrationId", "integration_id", "provider"]);
|
|
39006
|
+
if (!connectionId || !providerConfigKey) continue;
|
|
39007
|
+
result.push({
|
|
39008
|
+
scheduleActionId: firstString(item.row, ["scheduleActionId", "schedule_action_id", "scheduleId", "schedule_id"]) || item.scheduleActionId,
|
|
39009
|
+
connectionId,
|
|
39010
|
+
providerConfigKey,
|
|
39011
|
+
allowedTools: cleanTools(item.row.allowedTools ?? item.row.allowed_tools ?? item.row.allowedCapabilities ?? item.row.allowed_capabilities),
|
|
39012
|
+
label: firstString(item.row, ["label", "accountLabel", "account_label", "displayName", "display_name"])
|
|
39013
|
+
});
|
|
39014
|
+
}
|
|
39015
|
+
return result;
|
|
39016
|
+
}
|
|
39017
|
+
async function getScheduleConnectionBindings(identity, scheduleActionId) {
|
|
39018
|
+
const query = new URLSearchParams({ identity, scheduleId: scheduleActionId });
|
|
39019
|
+
const body = await controlRequest(`/api/internal/nango/bindings?${query.toString()}`);
|
|
39020
|
+
return sanitizeBindingRows(body, scheduleActionId);
|
|
39021
|
+
}
|
|
39022
|
+
async function putScheduleConnectionBindings(identity, scheduleActionId, connections) {
|
|
39023
|
+
const body = await controlRequest("/api/internal/nango/bindings", {
|
|
39024
|
+
method: "PUT",
|
|
39025
|
+
body: JSON.stringify({
|
|
39026
|
+
identity,
|
|
39027
|
+
scheduleId: scheduleActionId,
|
|
39028
|
+
connections: connections.map((connection) => ({
|
|
39029
|
+
connectionId: connection.connectionId,
|
|
39030
|
+
allowedTools: connection.allowedTools
|
|
39031
|
+
}))
|
|
39032
|
+
})
|
|
39033
|
+
});
|
|
39034
|
+
const sanitized = sanitizeBindingRows(body, scheduleActionId);
|
|
39035
|
+
const expectedByConnectionId = new Map(connections.map((connection) => [
|
|
39036
|
+
connection.connectionId,
|
|
39037
|
+
{
|
|
39038
|
+
providerConfigKey: connection.providerConfigKey,
|
|
39039
|
+
allowedTools: [...connection.allowedTools].sort()
|
|
39040
|
+
}
|
|
39041
|
+
]));
|
|
39042
|
+
const responseMatchesRequest = sanitized.length === connections.length && sanitized.every((binding) => {
|
|
39043
|
+
const expected = expectedByConnectionId.get(binding.connectionId);
|
|
39044
|
+
return !!expected && binding.scheduleActionId === scheduleActionId && binding.providerConfigKey === expected.providerConfigKey && [...binding.allowedTools].sort().join("\0") === expected.allowedTools.join("\0");
|
|
39045
|
+
});
|
|
39046
|
+
if (!responseMatchesRequest) {
|
|
39047
|
+
throw new NangoControlError("Scheduled service connection control returned an invalid binding response.");
|
|
39048
|
+
}
|
|
39049
|
+
return sanitized;
|
|
39050
|
+
}
|
|
39051
|
+
async function deleteScheduleConnectionBindings(identity, scheduleActionId) {
|
|
39052
|
+
await controlRequest("/api/internal/nango/bindings", {
|
|
39053
|
+
method: "DELETE",
|
|
39054
|
+
body: JSON.stringify({ identity, scheduleId: scheduleActionId })
|
|
39055
|
+
});
|
|
39056
|
+
}
|
|
39057
|
+
async function setScheduleConnectionActionsEnabled(identity, connectionId, enabled) {
|
|
39058
|
+
const body = await controlRequest("/api/internal/nango/connections/actions/enable", {
|
|
39059
|
+
method: "POST",
|
|
39060
|
+
body: JSON.stringify({ identity, connectionId, enabled })
|
|
39061
|
+
});
|
|
39062
|
+
const data = unwrapData(body);
|
|
39063
|
+
if (!isRecord(data) || !isRecord(data.connection)) return enabled;
|
|
39064
|
+
return data.connection.actionsEnabled === true;
|
|
39065
|
+
}
|
|
39066
|
+
async function callScheduleConnectionAction(identity, connectionId, input) {
|
|
39067
|
+
const body = await controlRequest("/api/internal/nango/connections/actions/call", {
|
|
39068
|
+
method: "POST",
|
|
39069
|
+
body: JSON.stringify({ identity, connectionId, input })
|
|
39070
|
+
});
|
|
39071
|
+
const data = unwrapData(body);
|
|
39072
|
+
return isRecord(data) ? data.result ?? data : data;
|
|
39073
|
+
}
|
|
39074
|
+
var DEFAULT_NANGO_CONTROL_URL, DISABLED_NANGO_TOOLS, NangoControlError, ScheduleConnectionValidationError;
|
|
39075
|
+
var init_nango_control = __esm({
|
|
39076
|
+
"src/api/nango-control.ts"() {
|
|
39077
|
+
"use strict";
|
|
39078
|
+
DEFAULT_NANGO_CONTROL_URL = "https://mcp-scraper-scheduler.vercel.app";
|
|
39079
|
+
DISABLED_NANGO_TOOLS = {
|
|
39080
|
+
"google-mail": /* @__PURE__ */ new Set(["list-filters"]),
|
|
39081
|
+
slack: /* @__PURE__ */ new Set(["search-files", "search-messages"])
|
|
39082
|
+
};
|
|
39083
|
+
NangoControlError = class extends Error {
|
|
39084
|
+
status;
|
|
39085
|
+
constructor(message, status = 502) {
|
|
39086
|
+
super(message);
|
|
39087
|
+
this.name = "NangoControlError";
|
|
39088
|
+
this.status = status;
|
|
39089
|
+
}
|
|
39090
|
+
};
|
|
39091
|
+
ScheduleConnectionValidationError = class extends Error {
|
|
39092
|
+
constructor(message) {
|
|
39093
|
+
super(message);
|
|
39094
|
+
this.name = "ScheduleConnectionValidationError";
|
|
39095
|
+
}
|
|
39096
|
+
};
|
|
39097
|
+
}
|
|
39098
|
+
});
|
|
39099
|
+
|
|
38656
39100
|
// src/api/webhook.ts
|
|
38657
39101
|
async function deliverWebhook(url, payload, retries = 3) {
|
|
38658
39102
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
@@ -38839,6 +39283,7 @@ async function createSignupStripeCustomer(email) {
|
|
|
38839
39283
|
}
|
|
38840
39284
|
function opForCostPath(p) {
|
|
38841
39285
|
if (p === "/harvest" || p === "/harvest/sync") return "harvest";
|
|
39286
|
+
if (p === "/diff-page") return "diff_page";
|
|
38842
39287
|
if (p === "/extract-url") return "page_scrape";
|
|
38843
39288
|
if (p === "/extract-site") return "extract_site";
|
|
38844
39289
|
if (p === "/map-urls") return "url_map";
|
|
@@ -38877,6 +39322,20 @@ function maybeProvisionInBackground(user, vaultCount) {
|
|
|
38877
39322
|
}
|
|
38878
39323
|
})();
|
|
38879
39324
|
}
|
|
39325
|
+
function scheduleConnectionError(c, err, fallback) {
|
|
39326
|
+
if (err instanceof ScheduleConnectionValidationError) return c.json({ ok: false, error: err.message }, 400);
|
|
39327
|
+
if (err instanceof NangoControlError) return c.json({ ok: false, error: err.message }, err.status);
|
|
39328
|
+
console.error("[schedule-connections]", err instanceof Error ? err.message : String(err));
|
|
39329
|
+
return c.json({ ok: false, error: fallback }, 502);
|
|
39330
|
+
}
|
|
39331
|
+
function providerConfigKeyFrom(value) {
|
|
39332
|
+
if (typeof value !== "string") return null;
|
|
39333
|
+
const key = value.trim();
|
|
39334
|
+
return key && key.length <= 200 ? key : null;
|
|
39335
|
+
}
|
|
39336
|
+
function bindingsForSchedule(bindings, scheduleActionId) {
|
|
39337
|
+
return bindings.filter((binding) => binding.scheduleActionId === scheduleActionId);
|
|
39338
|
+
}
|
|
38880
39339
|
function combineAbortSignals(signals) {
|
|
38881
39340
|
const controller = new AbortController();
|
|
38882
39341
|
const abortFrom = (signal) => {
|
|
@@ -38984,6 +39443,7 @@ var init_server = __esm({
|
|
|
38984
39443
|
init_session();
|
|
38985
39444
|
init_memory();
|
|
38986
39445
|
init_memory_db();
|
|
39446
|
+
init_nango_control();
|
|
38987
39447
|
init_concurrency_gates();
|
|
38988
39448
|
secureCookies2 = process.env.NODE_ENV === "production" || process.env.VERCEL === "1";
|
|
38989
39449
|
isProduction2 = secureCookies2;
|
|
@@ -39182,6 +39642,13 @@ var init_server = __esm({
|
|
|
39182
39642
|
c.header("Cache-Control", "public, max-age=60");
|
|
39183
39643
|
return c.json(CATALOG);
|
|
39184
39644
|
});
|
|
39645
|
+
app.get("/rates", (c) => {
|
|
39646
|
+
const costs = CREDIT_COST_CATALOG.map(({ aliases, ...cost }) => ({
|
|
39647
|
+
...cost,
|
|
39648
|
+
...cost.notes ? { notes: cost.notes.replace(/ via fal\.ai/g, "") } : {}
|
|
39649
|
+
}));
|
|
39650
|
+
return c.json({ creditsPerDollarStarter: Math.round(1 / 15e-5), costs });
|
|
39651
|
+
});
|
|
39185
39652
|
app.get("/me", async (c) => {
|
|
39186
39653
|
const token = (0, import_cookie2.getCookie)(c, "session");
|
|
39187
39654
|
if (!token) return c.json({ authenticated: false });
|
|
@@ -39509,6 +39976,103 @@ var init_server = __esm({
|
|
|
39509
39976
|
return c.json({ error: message }, 500);
|
|
39510
39977
|
}
|
|
39511
39978
|
});
|
|
39979
|
+
app.get("/schedule-connection-catalog", auth2, async (c) => {
|
|
39980
|
+
try {
|
|
39981
|
+
return c.json({ ok: true, catalog: await getNangoCatalog() });
|
|
39982
|
+
} catch (err) {
|
|
39983
|
+
return scheduleConnectionError(c, err, "Unable to load the service connection catalog.");
|
|
39984
|
+
}
|
|
39985
|
+
});
|
|
39986
|
+
app.get("/schedule-connections", auth2, async (c) => {
|
|
39987
|
+
try {
|
|
39988
|
+
const user = c.get("user");
|
|
39989
|
+
return c.json({ ok: true, connections: await getNangoConnections(user.email) });
|
|
39990
|
+
} catch (err) {
|
|
39991
|
+
return scheduleConnectionError(c, err, "Unable to load service connections.");
|
|
39992
|
+
}
|
|
39993
|
+
});
|
|
39994
|
+
app.post("/schedule-connections/session", auth2, async (c) => {
|
|
39995
|
+
const user = c.get("user");
|
|
39996
|
+
const body = await c.req.json().catch(() => ({}));
|
|
39997
|
+
const providerConfigKey = providerConfigKeyFrom(body.providerConfigKey);
|
|
39998
|
+
if (!providerConfigKey) return c.json({ ok: false, error: "providerConfigKey is required." }, 400);
|
|
39999
|
+
try {
|
|
40000
|
+
const session = await createNangoConnectSession(user.email, providerConfigKey);
|
|
40001
|
+
return c.json({ ok: true, ...session });
|
|
40002
|
+
} catch (err) {
|
|
40003
|
+
return scheduleConnectionError(c, err, "Unable to start the service connection flow.");
|
|
40004
|
+
}
|
|
40005
|
+
});
|
|
40006
|
+
app.post("/schedule-connections/:id/reconnect-session", auth2, async (c) => {
|
|
40007
|
+
const user = c.get("user");
|
|
40008
|
+
try {
|
|
40009
|
+
const session = await createNangoReconnectSession(user.email, c.req.param("id"));
|
|
40010
|
+
return c.json({ ok: true, ...session });
|
|
40011
|
+
} catch (err) {
|
|
40012
|
+
return scheduleConnectionError(c, err, "Unable to start the service reconnection flow.");
|
|
40013
|
+
}
|
|
40014
|
+
});
|
|
40015
|
+
app.post("/schedule-connections/:id/enable-actions", auth2, async (c) => {
|
|
40016
|
+
const user = c.get("user");
|
|
40017
|
+
const body = await c.req.json().catch(() => ({}));
|
|
40018
|
+
if (typeof body.enabled !== "boolean") return c.json({ ok: false, error: "enabled must be a boolean." }, 400);
|
|
40019
|
+
try {
|
|
40020
|
+
const actionsEnabled = await setScheduleConnectionActionsEnabled(user.email, c.req.param("id"), body.enabled);
|
|
40021
|
+
return c.json({ ok: true, actionsEnabled });
|
|
40022
|
+
} catch (err) {
|
|
40023
|
+
return scheduleConnectionError(c, err, "Unable to update this connection's action setting.");
|
|
40024
|
+
}
|
|
40025
|
+
});
|
|
40026
|
+
app.post("/schedule-connections/actions/slack/send-message", auth2, async (c) => {
|
|
40027
|
+
const user = c.get("user");
|
|
40028
|
+
const body = await c.req.json().catch(() => ({}));
|
|
40029
|
+
const connectionId = providerConfigKeyFrom(body.connectionId);
|
|
40030
|
+
if (!connectionId || typeof body.channel !== "string" || typeof body.text !== "string") {
|
|
40031
|
+
return c.json({ ok: false, error: "connectionId, channel, and text are required." }, 400);
|
|
40032
|
+
}
|
|
40033
|
+
try {
|
|
40034
|
+
const result = await callScheduleConnectionAction(user.email, connectionId, { channel: body.channel, text: body.text });
|
|
40035
|
+
return c.json({ ok: true, result });
|
|
40036
|
+
} catch (err) {
|
|
40037
|
+
return scheduleConnectionError(c, err, "Unable to send the Slack message.");
|
|
40038
|
+
}
|
|
40039
|
+
});
|
|
40040
|
+
app.post("/schedule-connections/actions/gmail/send-message", auth2, async (c) => {
|
|
40041
|
+
const user = c.get("user");
|
|
40042
|
+
const body = await c.req.json().catch(() => ({}));
|
|
40043
|
+
const connectionId = providerConfigKeyFrom(body.connectionId);
|
|
40044
|
+
if (!connectionId || typeof body.to !== "string" || typeof body.subject !== "string" || typeof body.body !== "string") {
|
|
40045
|
+
return c.json({ ok: false, error: "connectionId, to, subject, and body are required." }, 400);
|
|
40046
|
+
}
|
|
40047
|
+
try {
|
|
40048
|
+
const result = await callScheduleConnectionAction(user.email, connectionId, { to: body.to, subject: body.subject, body: body.body });
|
|
40049
|
+
return c.json({ ok: true, result });
|
|
40050
|
+
} catch (err) {
|
|
40051
|
+
return scheduleConnectionError(c, err, "Unable to send the email.");
|
|
40052
|
+
}
|
|
40053
|
+
});
|
|
40054
|
+
app.post("/schedule-connections/actions/google-calendar/create-event", auth2, async (c) => {
|
|
40055
|
+
const user = c.get("user");
|
|
40056
|
+
const body = await c.req.json().catch(() => ({}));
|
|
40057
|
+
const connectionId = providerConfigKeyFrom(body.connectionId);
|
|
40058
|
+
if (!connectionId || typeof body.summary !== "string" || typeof body.startDateTime !== "string" || typeof body.endDateTime !== "string") {
|
|
40059
|
+
return c.json({ ok: false, error: "connectionId, summary, startDateTime, and endDateTime are required." }, 400);
|
|
40060
|
+
}
|
|
40061
|
+
const timeZone = typeof body.timeZone === "string" ? body.timeZone : void 0;
|
|
40062
|
+
try {
|
|
40063
|
+
const result = await callScheduleConnectionAction(user.email, connectionId, {
|
|
40064
|
+
calendarId: typeof body.calendarId === "string" ? body.calendarId : "primary",
|
|
40065
|
+
summary: body.summary,
|
|
40066
|
+
description: typeof body.description === "string" ? body.description : void 0,
|
|
40067
|
+
location: typeof body.location === "string" ? body.location : void 0,
|
|
40068
|
+
start: { dateTime: body.startDateTime, timeZone },
|
|
40069
|
+
end: { dateTime: body.endDateTime, timeZone }
|
|
40070
|
+
});
|
|
40071
|
+
return c.json({ ok: true, result });
|
|
40072
|
+
} catch (err) {
|
|
40073
|
+
return scheduleConnectionError(c, err, "Unable to create the calendar event.");
|
|
40074
|
+
}
|
|
40075
|
+
});
|
|
39512
40076
|
app.post("/schedule-link", auth2, async (c) => {
|
|
39513
40077
|
try {
|
|
39514
40078
|
const user = c.get("user");
|
|
@@ -39527,15 +40091,58 @@ var init_server = __esm({
|
|
|
39527
40091
|
const { key, error } = await getOrCreateUserMemoryKey(user);
|
|
39528
40092
|
if (!key) return c.json({ ok: false, error: error ?? "memory unavailable" }, 503);
|
|
39529
40093
|
const r = await memoryCall("listScheduledActionsTool", {}, key);
|
|
39530
|
-
return c.json(r);
|
|
40094
|
+
if (!r.ok || !Array.isArray(r.actions)) return c.json(r);
|
|
40095
|
+
try {
|
|
40096
|
+
const actions = await Promise.all(r.actions.map(async (action) => {
|
|
40097
|
+
const id = typeof action.id === "string" ? action.id : "";
|
|
40098
|
+
if (!id) return { ...action, connections: [] };
|
|
40099
|
+
const bindings = await getScheduleConnectionBindings(user.email, id);
|
|
40100
|
+
return { ...action, connections: bindingsForSchedule(bindings, id) };
|
|
40101
|
+
}));
|
|
40102
|
+
return c.json({
|
|
40103
|
+
...r,
|
|
40104
|
+
actions
|
|
40105
|
+
});
|
|
40106
|
+
} catch (err) {
|
|
40107
|
+
console.warn("[schedule-actions] connection bindings unavailable:", err instanceof Error ? err.message : String(err));
|
|
40108
|
+
return c.json({
|
|
40109
|
+
...r,
|
|
40110
|
+
actions: r.actions.map((action) => ({ ...action, connections: [] })),
|
|
40111
|
+
connectionBindingsUnavailable: true
|
|
40112
|
+
});
|
|
40113
|
+
}
|
|
39531
40114
|
});
|
|
39532
40115
|
app.post("/schedule-actions", auth2, async (c) => {
|
|
39533
40116
|
const user = c.get("user");
|
|
39534
40117
|
const { key, error } = await getOrCreateUserMemoryKey(user);
|
|
39535
40118
|
if (!key) return c.json({ ok: false, error: error ?? "memory unavailable" }, 503);
|
|
39536
40119
|
const body = await c.req.json().catch(() => ({}));
|
|
39537
|
-
const
|
|
39538
|
-
|
|
40120
|
+
const hasConnections = Object.prototype.hasOwnProperty.call(body, "connections");
|
|
40121
|
+
let connections;
|
|
40122
|
+
try {
|
|
40123
|
+
connections = sanitizeScheduleConnectionSelections(body.connections);
|
|
40124
|
+
} catch (err) {
|
|
40125
|
+
return scheduleConnectionError(c, err, "Invalid service connections.");
|
|
40126
|
+
}
|
|
40127
|
+
const actionBody = { ...body };
|
|
40128
|
+
delete actionBody.connections;
|
|
40129
|
+
const r = await memoryCall("createScheduledActionTool", actionBody, key);
|
|
40130
|
+
if (!r.ok || !hasConnections) return c.json(r);
|
|
40131
|
+
if (connections.length === 0) return c.json({ ...r, connections: [] });
|
|
40132
|
+
if (!r.id) {
|
|
40133
|
+
console.error("[schedule-actions] create returned no id while service connections were requested");
|
|
40134
|
+
return c.json({ ok: false, error: "The scheduled action was created without a usable id; service connections were not attached." }, 502);
|
|
40135
|
+
}
|
|
40136
|
+
try {
|
|
40137
|
+
const bound = await putScheduleConnectionBindings(user.email, r.id, connections);
|
|
40138
|
+
return c.json({ ...r, connections: bound });
|
|
40139
|
+
} catch (err) {
|
|
40140
|
+
const rollback = await memoryCall("deleteScheduledActionTool", { id: r.id }, key).catch(() => ({ ok: false }));
|
|
40141
|
+
if (!rollback.ok) console.error("[schedule-actions] failed to compensate action after connection binding failure", r.id);
|
|
40142
|
+
const response = scheduleConnectionError(c, err, "The scheduled action was not created because its service connections could not be attached.");
|
|
40143
|
+
response.headers.set("x-schedule-create-rolled-back", rollback.ok ? "true" : "false");
|
|
40144
|
+
return response;
|
|
40145
|
+
}
|
|
39539
40146
|
});
|
|
39540
40147
|
app.post("/schedule-actions/propose", auth2, async (c) => {
|
|
39541
40148
|
const user = c.get("user");
|
|
@@ -39545,6 +40152,33 @@ var init_server = __esm({
|
|
|
39545
40152
|
const r = await memoryCall("proposeScheduledActionTool", body, key);
|
|
39546
40153
|
return c.json(r);
|
|
39547
40154
|
});
|
|
40155
|
+
app.get("/schedule-actions/:id/connections", auth2, async (c) => {
|
|
40156
|
+
try {
|
|
40157
|
+
const user = c.get("user");
|
|
40158
|
+
const scheduleActionId = c.req.param("id");
|
|
40159
|
+
const bindings = await getScheduleConnectionBindings(user.email, scheduleActionId);
|
|
40160
|
+
return c.json({ ok: true, connections: bindingsForSchedule(bindings, scheduleActionId) });
|
|
40161
|
+
} catch (err) {
|
|
40162
|
+
return scheduleConnectionError(c, err, "Unable to load scheduled action service connections.");
|
|
40163
|
+
}
|
|
40164
|
+
});
|
|
40165
|
+
app.put("/schedule-actions/:id/connections", auth2, async (c) => {
|
|
40166
|
+
const user = c.get("user");
|
|
40167
|
+
const body = await c.req.json().catch(() => ({}));
|
|
40168
|
+
let connections;
|
|
40169
|
+
try {
|
|
40170
|
+
connections = sanitizeScheduleConnectionSelections(body.connections);
|
|
40171
|
+
} catch (err) {
|
|
40172
|
+
return scheduleConnectionError(c, err, "Invalid service connections.");
|
|
40173
|
+
}
|
|
40174
|
+
try {
|
|
40175
|
+
const scheduleActionId = c.req.param("id");
|
|
40176
|
+
const bindings = await putScheduleConnectionBindings(user.email, scheduleActionId, connections);
|
|
40177
|
+
return c.json({ ok: true, connections: bindings });
|
|
40178
|
+
} catch (err) {
|
|
40179
|
+
return scheduleConnectionError(c, err, "Unable to update scheduled action service connections.");
|
|
40180
|
+
}
|
|
40181
|
+
});
|
|
39548
40182
|
app.post("/schedule-actions/:id/pause", auth2, async (c) => {
|
|
39549
40183
|
const user = c.get("user");
|
|
39550
40184
|
const { key, error } = await getOrCreateUserMemoryKey(user);
|
|
@@ -39563,8 +40197,16 @@ var init_server = __esm({
|
|
|
39563
40197
|
const user = c.get("user");
|
|
39564
40198
|
const { key, error } = await getOrCreateUserMemoryKey(user);
|
|
39565
40199
|
if (!key) return c.json({ ok: false, error: error ?? "memory unavailable" }, 503);
|
|
39566
|
-
const
|
|
39567
|
-
|
|
40200
|
+
const scheduleActionId = c.req.param("id");
|
|
40201
|
+
const r = await memoryCall("deleteScheduledActionTool", { id: scheduleActionId }, key);
|
|
40202
|
+
if (!r.ok) return c.json(r);
|
|
40203
|
+
try {
|
|
40204
|
+
await deleteScheduleConnectionBindings(user.email, scheduleActionId);
|
|
40205
|
+
return c.json(r);
|
|
40206
|
+
} catch (err) {
|
|
40207
|
+
console.error("[schedule-actions] action deleted but connection binding cleanup failed", scheduleActionId, err instanceof Error ? err.message : String(err));
|
|
40208
|
+
return c.json({ ...r, connectionBindingCleanupPending: true });
|
|
40209
|
+
}
|
|
39568
40210
|
});
|
|
39569
40211
|
app.post("/schedule-link/revoke", auth2, async (c) => {
|
|
39570
40212
|
try {
|