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.
@@ -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(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/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 discoverUrls(startUrl, maxPages, kernelApiKey) {
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
- for (const u of await discoverSitemapUrls2(opts.startUrl, maxPages)) enqueue(u);
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(/&amp;/g, "&").replace(/&#0?38;/g, "&").replace(/&#x26;/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 seed = await step.run("discover", async () => {
11016
- const discovered = await discoverUrls(job.startUrl, maxPages, key);
11017
- const out = discovered.length ? discovered : [job.startUrl];
11018
- await setExtractJobTotal(jobId, out.length);
11019
- return out;
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 queue = [...seed];
11045
- const seen = new Set(seed.map(norm));
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.7.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 r = await memoryCall("createScheduledActionTool", body, key);
39538
- return c.json(r);
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 r = await memoryCall("deleteScheduledActionTool", { id: c.req.param("id") }, key);
39567
- return c.json(r);
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 {