mcp-scraper 0.2.11 → 0.2.13

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.
Files changed (38) hide show
  1. package/README.md +6 -2
  2. package/dist/bin/api-server.cjs +1175 -458
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +1 -1
  5. package/dist/bin/browser-agent-stdio-server.cjs +1 -1
  6. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  7. package/dist/bin/browser-agent-stdio-server.js +2 -2
  8. package/dist/bin/mcp-scraper-cli.cjs +754 -10
  9. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-cli.js +50 -9
  11. package/dist/bin/mcp-scraper-cli.js.map +1 -1
  12. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +1 -1
  13. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  14. package/dist/bin/mcp-scraper-combined-stdio-server.js +3 -3
  15. package/dist/bin/mcp-scraper-install.cjs +7 -3
  16. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  17. package/dist/bin/mcp-scraper-install.js +7 -3
  18. package/dist/bin/mcp-scraper-install.js.map +1 -1
  19. package/dist/bin/mcp-stdio-server.cjs +1 -1
  20. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  21. package/dist/bin/mcp-stdio-server.js +2 -2
  22. package/dist/chunk-AYTOCZBS.js +1572 -0
  23. package/dist/chunk-AYTOCZBS.js.map +1 -0
  24. package/dist/{chunk-GDS6FMVQ.js → chunk-BQTWXY6G.js} +2 -2
  25. package/dist/{chunk-5YLKXKJA.js → chunk-DKJ2XCY7.js} +2 -2
  26. package/dist/chunk-RDROUQ4E.js +7 -0
  27. package/dist/chunk-RDROUQ4E.js.map +1 -0
  28. package/dist/{server-6KMHJXOL.js → server-LUNOI26E.js} +4 -4
  29. package/docs/specs/cli-agent-wiring-spec.md +4 -1
  30. package/docs/specs/seo-cli-growth-roadmap-spec.md +4 -2
  31. package/package.json +1 -1
  32. package/dist/chunk-BLLZQP4Z.js +0 -7
  33. package/dist/chunk-BLLZQP4Z.js.map +0 -1
  34. package/dist/chunk-L6IS63WS.js +0 -869
  35. package/dist/chunk-L6IS63WS.js.map +0 -1
  36. /package/dist/{chunk-GDS6FMVQ.js.map → chunk-BQTWXY6G.js.map} +0 -0
  37. /package/dist/{chunk-5YLKXKJA.js.map → chunk-DKJ2XCY7.js.map} +0 -0
  38. /package/dist/{server-6KMHJXOL.js.map → server-LUNOI26E.js.map} +0 -0
@@ -7,7 +7,7 @@ var import_promises3 = require("fs/promises");
7
7
  var import_node_path3 = require("path");
8
8
 
9
9
  // src/version.ts
10
- var PACKAGE_VERSION = "0.2.11";
10
+ var PACKAGE_VERSION = "0.2.13";
11
11
 
12
12
  // src/cli/agent-config.ts
13
13
  function apiKeyValue(options) {
@@ -234,6 +234,34 @@ var AGENT_PROMPTS = {
234
234
  "",
235
235
  "Use directory_workflow when the user wants cities selected by population and Google Maps candidates per city. Keep query and location separate. Preserve result_position, source_location, review stars/count, categories, and profile URLs for downstream CSV or directory use."
236
236
  ].join("\n"),
237
+ "map-comparison": [
238
+ "# MCP Scraper Maps Comparison Prompt",
239
+ "",
240
+ "Run the map-comparison workflow when the user wants to compare local Maps competitors in a city or across selected markets. Use `maps-results.csv`, `map-comparison.csv`, and `profile-insights.csv` as source of truth.",
241
+ "",
242
+ "Ground recommendations in result_position, review_stars, review_count, category, website presence, review topics, and profile attributes. Treat review gaps and missing websites as opportunity signals, not guaranteed ranking factors."
243
+ ].join("\n"),
244
+ "serp-comparison": [
245
+ "# MCP Scraper SERP Comparison Prompt",
246
+ "",
247
+ "Run the serp-comparison workflow when the user wants to know why competitors outrank a page or what the SERP rewards. Use organic results, extracted page headings, PAA questions, and AI Overview citations before making recommendations.",
248
+ "",
249
+ "Tie each recommendation to `content-gaps.csv`, `page-comparison.csv`, `paa-questions.csv`, or `ai-overview-citations.csv`. Do not invent missing sections or citations."
250
+ ].join("\n"),
251
+ "paa-expansion-brief": [
252
+ "# MCP Scraper PAA Expansion Brief Prompt",
253
+ "",
254
+ "Run the paa-expansion-brief workflow when the user wants to figure out what to write from People Also Ask expansion. Use `section-map.csv` to structure the brief and `paa-questions.csv` for exact customer-language headings.",
255
+ "",
256
+ "Answer the highest-priority questions directly, preserve source URLs when present, and separate evidence-backed sections from assumptions."
257
+ ].join("\n"),
258
+ "ai-overview-language": [
259
+ "# MCP Scraper AI Overview Language Prompt",
260
+ "",
261
+ "Run the ai-overview-language workflow when the user wants to know how to phrase content for AI Overview inclusion. Use `claim-patterns.csv`, `language-guidance.csv`, `ai-overview-citations.csv`, and PAA follow-ups as evidence.",
262
+ "",
263
+ "Recommend concise answer blocks, criteria/step language, citation hooks, and follow-up sections. Do not claim the target will be cited; frame the output as evidence-based language guidance."
264
+ ].join("\n"),
237
265
  "ai-citation-monitor": [
238
266
  "# MCP Scraper AI Citation Monitor Prompt",
239
267
  "",
@@ -412,7 +440,7 @@ async function openWorkflowReport(id, outputDir) {
412
440
  }
413
441
 
414
442
  // src/workflows/registry.ts
415
- var import_zod4 = require("zod");
443
+ var import_zod5 = require("zod");
416
444
 
417
445
  // src/workflows/http-client.ts
418
446
  var WorkflowHttpClient = class {
@@ -1020,11 +1048,714 @@ ${summary}
1020
1048
  }
1021
1049
  };
1022
1050
 
1051
+ // src/workflows/workflows/comparison-briefs.ts
1052
+ var import_zod4 = require("zod");
1053
+
1054
+ // src/workflows/workflows/seo-workflow-utils.ts
1055
+ var STOP_WORDS = /* @__PURE__ */ new Set([
1056
+ "about",
1057
+ "after",
1058
+ "also",
1059
+ "because",
1060
+ "been",
1061
+ "best",
1062
+ "both",
1063
+ "from",
1064
+ "have",
1065
+ "into",
1066
+ "more",
1067
+ "most",
1068
+ "near",
1069
+ "only",
1070
+ "over",
1071
+ "than",
1072
+ "that",
1073
+ "their",
1074
+ "them",
1075
+ "then",
1076
+ "there",
1077
+ "these",
1078
+ "they",
1079
+ "this",
1080
+ "what",
1081
+ "when",
1082
+ "where",
1083
+ "which",
1084
+ "while",
1085
+ "with",
1086
+ "your",
1087
+ "will",
1088
+ "would",
1089
+ "should",
1090
+ "could",
1091
+ "does",
1092
+ "were",
1093
+ "cost",
1094
+ "costs"
1095
+ ]);
1096
+ function normalizeDomain2(value) {
1097
+ if (!value) return null;
1098
+ try {
1099
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
1100
+ return url.hostname.replace(/^www\./, "").toLowerCase();
1101
+ } catch {
1102
+ return value.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]?.toLowerCase() || null;
1103
+ }
1104
+ }
1105
+ function domainFromUrl2(url) {
1106
+ return normalizeDomain2(url) ?? "";
1107
+ }
1108
+ function numberFrom2(value) {
1109
+ if (value === null || value === void 0 || value === "") return null;
1110
+ const parsed = Number(String(value).replace(/[^\d.]/g, ""));
1111
+ return Number.isFinite(parsed) ? parsed : null;
1112
+ }
1113
+ function median2(values) {
1114
+ const nums = values.filter((v) => v !== null).sort((a, b) => a - b);
1115
+ if (!nums.length) return null;
1116
+ return nums[Math.floor(nums.length / 2)] ?? null;
1117
+ }
1118
+ function textTerms(text, limit = 12) {
1119
+ const counts = /* @__PURE__ */ new Map();
1120
+ for (const token of text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/)) {
1121
+ if (token.length < 4 || STOP_WORDS.has(token)) continue;
1122
+ counts.set(token, (counts.get(token) ?? 0) + 1);
1123
+ }
1124
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([term, count]) => `${term} (${count})`).join("; ");
1125
+ }
1126
+ function classifyQuestion(question) {
1127
+ const q = question.toLowerCase();
1128
+ if (/\b(cost|price|pricing|charge|expensive|cheap)\b/.test(q)) return "cost";
1129
+ if (/\b(best|top|recommended|reviews?|compare|versus|vs)\b/.test(q)) return "comparison";
1130
+ if (/\b(how|steps?|process|way to)\b/.test(q)) return "process";
1131
+ if (/\b(why|worth|important|benefit)\b/.test(q)) return "why";
1132
+ if (/\b(near me|city|local|nearby)\b/.test(q)) return "local";
1133
+ if (/\b(can|does|do|is|are|should|will)\b/.test(q)) return "decision";
1134
+ return "definition";
1135
+ }
1136
+ function questionRows(paa) {
1137
+ return (paa?.flat ?? []).filter((row) => row.question).map((row, index) => {
1138
+ const url = row.source_cite ?? "";
1139
+ const domain = normalizeDomain2(row.source_site ?? "") ?? domainFromUrl2(url);
1140
+ return {
1141
+ position: index + 1,
1142
+ intent: classifyQuestion(row.question ?? ""),
1143
+ question: row.question ?? "",
1144
+ answer_excerpt: (row.answer ?? "").slice(0, 320),
1145
+ source_title: row.source_title ?? "",
1146
+ source_domain: domain,
1147
+ source_url: url
1148
+ };
1149
+ });
1150
+ }
1151
+ function sourceDomainRows(rows) {
1152
+ const byDomain = /* @__PURE__ */ new Map();
1153
+ for (const row of rows) {
1154
+ const domain = String(row.source_domain ?? row.domain ?? "");
1155
+ if (!domain) continue;
1156
+ const entry = byDomain.get(domain) ?? { questions: 0, urls: /* @__PURE__ */ new Set(), intents: /* @__PURE__ */ new Map() };
1157
+ entry.questions += row.question ? 1 : 0;
1158
+ if (row.source_url || row.url) entry.urls.add(String(row.source_url ?? row.url));
1159
+ if (row.intent) entry.intents.set(String(row.intent), (entry.intents.get(String(row.intent)) ?? 0) + 1);
1160
+ byDomain.set(domain, entry);
1161
+ }
1162
+ return [...byDomain.entries()].map(([domain, entry]) => ({
1163
+ domain,
1164
+ question_mentions: entry.questions,
1165
+ source_url_count: entry.urls.size,
1166
+ top_intents: [...entry.intents.entries()].sort((a, b) => b[1] - a[1]).map(([intent, count]) => `${intent} (${count})`).join("; ")
1167
+ })).sort((a, b) => Number(b.question_mentions) - Number(a.question_mentions) || String(a.domain).localeCompare(String(b.domain)));
1168
+ }
1169
+ function splitSentences(text) {
1170
+ return (text ?? "").replace(/\s+/g, " ").split(/(?<=[.!?])\s+/).map((sentence) => sentence.trim()).filter((sentence) => sentence.length > 20).slice(0, 20);
1171
+ }
1172
+ function classifySentence(sentence) {
1173
+ const s = sentence.toLowerCase();
1174
+ if (/\bis\b|\bare\b|\bmeans\b|\brefers to\b/.test(s)) return "definition";
1175
+ if (/\binclude\b|\bconsider\b|\bfactors?\b|\bcriteria\b/.test(s)) return "criteria";
1176
+ if (/\bfirst\b|\bthen\b|\bsteps?\b|\bprocess\b/.test(s)) return "process";
1177
+ if (/\bvs\b|\bthan\b|\bcompare\b|\bdifference\b/.test(s)) return "comparison";
1178
+ if (/\bcost\b|\bprice\b|\baverage\b|\brange\b/.test(s)) return "cost";
1179
+ return "claim";
1180
+ }
1181
+ function pageSummaryRow(page, source) {
1182
+ return {
1183
+ position: source.position ?? "",
1184
+ domain: source.domain,
1185
+ url: source.url,
1186
+ serp_title: source.title ?? "",
1187
+ page_title: page.title ?? "",
1188
+ h1: page.h1 ?? "",
1189
+ meta_description: page.metaDescription ?? "",
1190
+ word_count: page.wordCount ?? "",
1191
+ heading_count: page.headings?.length ?? 0,
1192
+ schema_types: (page.schemaTypes ?? []).join("; ")
1193
+ };
1194
+ }
1195
+ async function mapLimit2(items, limit, fn) {
1196
+ const out = new Array(items.length);
1197
+ let next = 0;
1198
+ async function worker() {
1199
+ while (next < items.length) {
1200
+ const index = next;
1201
+ next += 1;
1202
+ out[index] = await fn(items[index], index);
1203
+ }
1204
+ }
1205
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, () => worker()));
1206
+ return out;
1207
+ }
1208
+
1209
+ // src/workflows/workflows/comparison-briefs.ts
1210
+ var ProxyModeSchema = import_zod4.z.enum(["location", "configured", "none"]);
1211
+ var MapComparisonInputSchema = import_zod4.z.object({
1212
+ query: import_zod4.z.string().min(1),
1213
+ location: import_zod4.z.string().optional(),
1214
+ state: import_zod4.z.string().optional(),
1215
+ minPopulation: import_zod4.z.number().int().min(0).default(1e5),
1216
+ maxCities: import_zod4.z.number().int().min(1).max(100).default(5),
1217
+ maxResultsPerCity: import_zod4.z.number().int().min(1).max(50).default(20),
1218
+ hydrateTop: import_zod4.z.number().int().min(0).max(10).default(5),
1219
+ maxReviews: import_zod4.z.number().int().min(0).max(500).default(25),
1220
+ concurrency: import_zod4.z.number().int().min(1).max(5).default(5),
1221
+ proxyMode: ProxyModeSchema.default("location"),
1222
+ returnPartial: import_zod4.z.boolean().default(true)
1223
+ }).refine((input) => input.location || input.state, {
1224
+ message: "Either location or state is required for map-comparison"
1225
+ });
1226
+ var SerpComparisonInputSchema = import_zod4.z.object({
1227
+ keyword: import_zod4.z.string().min(1),
1228
+ domain: import_zod4.z.string().optional(),
1229
+ url: import_zod4.z.string().url().optional(),
1230
+ location: import_zod4.z.string().optional(),
1231
+ maxResults: import_zod4.z.number().int().min(1).max(20).default(10),
1232
+ maxQuestions: import_zod4.z.number().int().min(1).max(200).default(40),
1233
+ extractTop: import_zod4.z.number().int().min(0).max(10).default(5),
1234
+ includePaa: import_zod4.z.boolean().default(true),
1235
+ includeAiOverview: import_zod4.z.boolean().default(true),
1236
+ returnPartial: import_zod4.z.boolean().default(true)
1237
+ });
1238
+ var PaaExpansionBriefInputSchema = import_zod4.z.object({
1239
+ keyword: import_zod4.z.string().min(1),
1240
+ location: import_zod4.z.string().optional(),
1241
+ maxQuestions: import_zod4.z.number().int().min(1).max(300).default(80),
1242
+ depth: import_zod4.z.number().int().min(1).max(6).default(3),
1243
+ returnPartial: import_zod4.z.boolean().default(true)
1244
+ });
1245
+ var AiOverviewLanguageInputSchema = import_zod4.z.object({
1246
+ keyword: import_zod4.z.string().min(1),
1247
+ domain: import_zod4.z.string().optional(),
1248
+ url: import_zod4.z.string().url().optional(),
1249
+ location: import_zod4.z.string().optional(),
1250
+ maxQuestions: import_zod4.z.number().int().min(1).max(200).default(40),
1251
+ extractTop: import_zod4.z.number().int().min(0).max(8).default(3),
1252
+ returnPartial: import_zod4.z.boolean().default(true)
1253
+ });
1254
+ function businessRowsFromMaps(location, query, results) {
1255
+ return results.map((result) => ({
1256
+ source_query: query,
1257
+ source_location: location,
1258
+ city: location.split(",")[0]?.trim() ?? location,
1259
+ state: location.split(",")[1]?.trim() ?? "",
1260
+ population: "",
1261
+ result_position: result.position,
1262
+ business_name: result.name,
1263
+ review_stars: result.rating ?? "",
1264
+ review_count: result.reviewCount ?? "",
1265
+ category: result.category ?? "",
1266
+ address: result.address ?? "",
1267
+ phone: result.phone ?? "",
1268
+ website_url: result.websiteUrl ?? "",
1269
+ place_url: result.placeUrl ?? "",
1270
+ cid: result.cid ?? "",
1271
+ cid_decimal: result.cidDecimal ?? "",
1272
+ result_status: "ok",
1273
+ error: ""
1274
+ }));
1275
+ }
1276
+ function marketRows(rows) {
1277
+ const byLocation = /* @__PURE__ */ new Map();
1278
+ for (const row of rows) {
1279
+ const key = String(row.source_location ?? row.location ?? "");
1280
+ if (!key) continue;
1281
+ const list = byLocation.get(key) ?? [];
1282
+ list.push(row);
1283
+ byLocation.set(key, list);
1284
+ }
1285
+ return [...byLocation.entries()].map(([location, list]) => {
1286
+ const counts = list.map((row) => numberFrom2(row.review_count));
1287
+ const ratings = list.map((row) => numberFrom2(row.review_stars));
1288
+ const topThree = list.slice(0, 3).map((row) => numberFrom2(row.review_count)).filter((v) => v !== null);
1289
+ const categories = /* @__PURE__ */ new Map();
1290
+ for (const row of list) {
1291
+ const category = String(row.category ?? "");
1292
+ if (category) categories.set(category, (categories.get(category) ?? 0) + 1);
1293
+ }
1294
+ return {
1295
+ source_location: location,
1296
+ result_count: list.filter((row) => row.business_name).length,
1297
+ median_review_count: median2(counts) ?? "",
1298
+ median_rating: median2(ratings) ?? "",
1299
+ top_three_average_review_count: topThree.length ? Math.round(topThree.reduce((a, b) => a + b, 0) / topThree.length) : "",
1300
+ top_categories: [...categories.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([cat, count]) => `${cat} (${count})`).join("; "),
1301
+ websites_present: list.filter((row) => row.website_url).length
1302
+ };
1303
+ });
1304
+ }
1305
+ function comparisonRows(rows) {
1306
+ const benchmarkByLocation = /* @__PURE__ */ new Map();
1307
+ for (const market of marketRows(rows)) {
1308
+ benchmarkByLocation.set(String(market.source_location), numberFrom2(market.top_three_average_review_count) ?? 0);
1309
+ }
1310
+ return rows.filter((row) => row.business_name).map((row) => {
1311
+ const reviews = numberFrom2(row.review_count) ?? 0;
1312
+ const benchmark = benchmarkByLocation.get(String(row.source_location)) ?? 0;
1313
+ const websiteMissing = !row.website_url;
1314
+ const rank = numberFrom2(row.result_position) ?? 999;
1315
+ return {
1316
+ source_location: row.source_location,
1317
+ result_position: row.result_position,
1318
+ business_name: row.business_name,
1319
+ category: row.category,
1320
+ review_stars: row.review_stars,
1321
+ review_count: row.review_count,
1322
+ review_gap_to_top3_average: benchmark ? Math.max(0, benchmark - reviews) : "",
1323
+ website_url: row.website_url,
1324
+ place_url: row.place_url,
1325
+ comparison_note: rank <= 3 ? "visible leader" : websiteMissing ? "ranking without website" : reviews < benchmark ? "review-light competitor" : "visible competitor"
1326
+ };
1327
+ });
1328
+ }
1329
+ function organicRows(serp, targetDomain) {
1330
+ return (serp?.organicResults ?? []).map((result) => {
1331
+ const url = result.url ?? "";
1332
+ const domain = normalizeDomain2(result.domain ?? "") ?? domainFromUrl2(url);
1333
+ return {
1334
+ position: result.position ?? "",
1335
+ title: result.title ?? "",
1336
+ url,
1337
+ domain,
1338
+ snippet: result.snippet ?? "",
1339
+ is_target: targetDomain ? domain === targetDomain : false
1340
+ };
1341
+ });
1342
+ }
1343
+ function pageGapRows(targetPage, competitorPages) {
1344
+ const targetHeadingText = new Set((targetPage?.headings ?? []).map((h) => h.text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim()));
1345
+ const targetTerms = new Set((targetPage?.headings ?? []).flatMap((h) => h.text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean)));
1346
+ const rows = [];
1347
+ for (const { source, page } of competitorPages) {
1348
+ for (const heading of page.headings ?? []) {
1349
+ if (heading.level > 3 || !heading.text) continue;
1350
+ const normalized = heading.text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
1351
+ const terms = normalized.split(/\s+/).filter((term) => term.length > 3);
1352
+ const overlap = terms.filter((term) => targetTerms.has(term)).length;
1353
+ const covered = targetHeadingText.has(normalized) || overlap >= Math.max(2, Math.ceil(terms.length / 2));
1354
+ if (covered && targetPage) continue;
1355
+ rows.push({
1356
+ source_position: source.position ?? "",
1357
+ source_domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url),
1358
+ source_url: source.url ?? "",
1359
+ heading_level: heading.level,
1360
+ competitor_heading: heading.text,
1361
+ target_coverage: targetPage ? "not found in target headings" : "no target page extracted",
1362
+ terms: textTerms(heading.text, 6)
1363
+ });
1364
+ }
1365
+ }
1366
+ return rows.slice(0, 150);
1367
+ }
1368
+ async function extractPages(ctx, sources, warnings, labelPrefix) {
1369
+ return mapLimit2(sources.filter((source) => source.url), 2, async (source, index) => {
1370
+ try {
1371
+ const page = await ctx.client.post("/extract-url", { url: source.url }, 18e4);
1372
+ await ctx.artifacts.writeJson(`${labelPrefix} ${index + 1}`, `raw/extract-url/${labelPrefix.toLowerCase()}-${index + 1}.json`, page);
1373
+ return { source, page };
1374
+ } catch (err) {
1375
+ warnings.push(`Page extraction failed for ${source.url}: ${err instanceof Error ? err.message : String(err)}`);
1376
+ return null;
1377
+ }
1378
+ }).then((items) => items.filter((item) => item !== null));
1379
+ }
1380
+ var mapComparisonWorkflowDefinition = {
1381
+ id: "map-comparison",
1382
+ title: "Maps Comparison",
1383
+ description: "Compare Google Maps competitors by rank, reviews, stars, categories, websites, and profile/review signals.",
1384
+ inputSchema: MapComparisonInputSchema,
1385
+ async run(input, ctx) {
1386
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1387
+ const warnings = [];
1388
+ let rows;
1389
+ let directory = null;
1390
+ let mapsSearch = null;
1391
+ if (input.location) {
1392
+ mapsSearch = await ctx.client.post("/maps/search", {
1393
+ query: input.query,
1394
+ location: input.location,
1395
+ maxResults: input.maxResultsPerCity,
1396
+ proxyMode: input.proxyMode
1397
+ }, 24e4);
1398
+ rows = businessRowsFromMaps(input.location, input.query, mapsSearch.results);
1399
+ await ctx.artifacts.writeJson("Maps search raw JSON", "raw/maps-search.json", mapsSearch);
1400
+ } else {
1401
+ directory = await ctx.client.post("/directory/run", {
1402
+ query: input.query,
1403
+ state: input.state,
1404
+ minPopulation: input.minPopulation,
1405
+ maxCities: input.maxCities,
1406
+ maxResultsPerCity: input.maxResultsPerCity,
1407
+ concurrency: input.concurrency,
1408
+ proxyMode: input.proxyMode,
1409
+ saveCsv: true
1410
+ }, 9e5);
1411
+ rows = directoryRows(directory);
1412
+ await ctx.artifacts.writeJson("Directory raw JSON", "raw/directory-workflow.json", directory);
1413
+ await ctx.artifacts.writeCsv("Directory CSV", "exports/directory.csv", DIRECTORY_CSV_HEADERS, rows);
1414
+ warnings.push(...directory.warnings);
1415
+ }
1416
+ const compareRows = comparisonRows(rows);
1417
+ const selected = compareRows.slice(0, input.hydrateTop * Math.max(1, input.location ? 1 : input.maxCities));
1418
+ const hydrated = await mapLimit2(selected, 3, async (row, index) => {
1419
+ try {
1420
+ const detail = await ctx.client.post("/maps/place", {
1421
+ businessName: row.business_name,
1422
+ location: row.source_location,
1423
+ includeReviews: input.maxReviews > 0,
1424
+ maxReviews: Math.max(1, input.maxReviews)
1425
+ }, 18e4);
1426
+ await ctx.artifacts.writeJson(`${row.business_name} profile`, `raw/maps-place-intel/${index + 1}-${String(row.business_name).toLowerCase().replace(/[^a-z0-9]+/g, "-")}.json`, detail);
1427
+ return { row, detail, error: "" };
1428
+ } catch (err) {
1429
+ const message = err instanceof Error ? err.message : String(err);
1430
+ warnings.push(`Profile hydration failed for ${row.business_name}: ${message}`);
1431
+ return { row, detail: null, error: message };
1432
+ }
1433
+ });
1434
+ const profileRows = hydrated.map(({ row, detail, error }) => ({
1435
+ source_location: row.source_location,
1436
+ result_position: row.result_position,
1437
+ business_name: row.business_name,
1438
+ category: detail?.category ?? row.category,
1439
+ review_stars: detail?.rating ?? row.review_stars,
1440
+ review_count: detail?.reviewCount ?? row.review_count,
1441
+ website_url: detail?.website ?? row.website_url,
1442
+ review_topics: (detail?.reviewTopics ?? []).map((t) => `${t.label} (${t.count})`).join("; "),
1443
+ about_attributes: (detail?.aboutAttributes ?? []).map((a) => `${a.section}: ${a.attribute}`).join("; "),
1444
+ reviews_status: detail?.reviewsStatus ?? "",
1445
+ error
1446
+ }));
1447
+ const markets = marketRows(rows);
1448
+ await ctx.artifacts.writeCsv("Maps results CSV", "maps-results.csv", ["source_query", "source_location", "city", "state", "population", "result_position", "business_name", "review_stars", "review_count", "category", "address", "phone", "website_url", "place_url", "cid", "cid_decimal", "result_status", "error"], rows);
1449
+ await ctx.artifacts.writeCsv("Comparison CSV", "map-comparison.csv", ["source_location", "result_position", "business_name", "category", "review_stars", "review_count", "review_gap_to_top3_average", "website_url", "place_url", "comparison_note"], compareRows);
1450
+ await ctx.artifacts.writeCsv("Profile insights CSV", "profile-insights.csv", ["source_location", "result_position", "business_name", "category", "review_stars", "review_count", "website_url", "review_topics", "about_attributes", "reviews_status", "error"], profileRows);
1451
+ await ctx.artifacts.writeJson("Maps comparison evidence", "evidence.json", { input, directory, mapsSearch, rows, compareRows, profileRows, markets, warnings });
1452
+ await ctx.artifacts.writeText("Brief", "brief.md", [
1453
+ `# Maps Comparison: ${input.query}`,
1454
+ "",
1455
+ `Markets: ${markets.map((row) => row.source_location).join(", ")}`,
1456
+ "",
1457
+ "## How to Use",
1458
+ "- Compare rank position against review count and category patterns.",
1459
+ "- Treat review gaps and missing websites as opportunity signals, not guarantees.",
1460
+ "- Use profile topics and attributes as evidence for local content and GBP improvements."
1461
+ ].join("\n"));
1462
+ const summary = `${compareRows.length} Maps competitors compared across ${markets.length} market(s); ${profileRows.filter((row) => !row.error).length} profiles hydrated.`;
1463
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1464
+ title: "Maps Comparison",
1465
+ subtitle: `${input.query}${input.location ? ` \xB7 ${input.location}` : input.state ? ` \xB7 ${input.state}` : ""}`,
1466
+ summary,
1467
+ warnings,
1468
+ tables: [
1469
+ { title: "Market Benchmarks", columns: ["source_location", "result_count", "median_review_count", "median_rating", "top_three_average_review_count", "top_categories", "websites_present"], rows: markets },
1470
+ { title: "Competitor Comparison", columns: ["source_location", "result_position", "business_name", "category", "review_stars", "review_count", "review_gap_to_top3_average", "comparison_note"], rows: compareRows.slice(0, 120) },
1471
+ { title: "Profile Insights", columns: ["source_location", "business_name", "review_topics", "about_attributes", "error"], rows: profileRows }
1472
+ ]
1473
+ }));
1474
+ const status2 = warnings.length ? "partial" : "succeeded";
1475
+ const counts = { markets: markets.length, competitors: compareRows.length, hydratedProfiles: profileRows.filter((row) => !row.error).length };
1476
+ await ctx.artifacts.writeManifest(status2, counts, warnings, []);
1477
+ return { title: "Maps Comparison", summary, status: status2, counts, warnings, errors: [], reportPath };
1478
+ }
1479
+ };
1480
+ var serpComparisonWorkflowDefinition = {
1481
+ id: "serp-comparison",
1482
+ title: "SERP Comparison",
1483
+ description: "Compare ranking pages, SERP features, PAA evidence, AI Overview citations, and page-level content gaps.",
1484
+ inputSchema: SerpComparisonInputSchema,
1485
+ async run(input, ctx) {
1486
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1487
+ const warnings = [];
1488
+ const targetDomain = normalizeDomain2(input.domain ?? input.url ?? null);
1489
+ const serp = await ctx.client.post("/harvest/sync", {
1490
+ query: input.keyword,
1491
+ location: input.location,
1492
+ serpOnly: true,
1493
+ maxQuestions: 1,
1494
+ format: "json"
1495
+ }, 18e4);
1496
+ await ctx.artifacts.writeJson("SERP raw JSON", "raw/serp.json", serp);
1497
+ let paa = null;
1498
+ if (input.includePaa) {
1499
+ try {
1500
+ paa = await ctx.client.post("/harvest/sync", {
1501
+ query: input.keyword,
1502
+ location: input.location,
1503
+ maxQuestions: input.maxQuestions,
1504
+ format: "json"
1505
+ }, 28e4);
1506
+ await ctx.artifacts.writeJson("PAA raw JSON", "raw/paa.json", paa);
1507
+ } catch (err) {
1508
+ warnings.push(`PAA evidence unavailable: ${err instanceof Error ? err.message : String(err)}`);
1509
+ }
1510
+ }
1511
+ const organic = organicRows(serp, targetDomain).slice(0, input.maxResults);
1512
+ const organicSources = (serp.organicResults ?? []).slice(0, input.maxResults);
1513
+ const targetSource = input.url ? { position: 0, title: "Target page", url: input.url, domain: domainFromUrl2(input.url) } : organicSources.find((result) => targetDomain && (normalizeDomain2(result.domain ?? "") ?? domainFromUrl2(result.url)) === targetDomain);
1514
+ const competitorSources = organicSources.filter((result) => result.url && result.url !== targetSource?.url).filter((result) => !targetDomain || (normalizeDomain2(result.domain ?? "") ?? domainFromUrl2(result.url)) !== targetDomain).slice(0, input.extractTop);
1515
+ const targetPages = targetSource ? await extractPages(ctx, [targetSource], warnings, "Target") : [];
1516
+ const competitorPages = input.extractTop > 0 ? await extractPages(ctx, competitorSources, warnings, "Competitor") : [];
1517
+ const targetPage = targetPages[0]?.page ?? null;
1518
+ const pageRows = [
1519
+ ...targetPages.map(({ source, page }) => pageSummaryRow(page, { url: source.url ?? "", domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url), position: source.position, title: source.title })),
1520
+ ...competitorPages.map(({ source, page }) => pageSummaryRow(page, { url: source.url ?? "", domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url), position: source.position, title: source.title }))
1521
+ ];
1522
+ const gaps = pageGapRows(targetPage, competitorPages);
1523
+ const questions = questionRows(paa);
1524
+ const aiRows = (serp.aiOverview?.citations ?? []).map((citation, index) => ({
1525
+ citation_position: index + 1,
1526
+ citation_text: citation.text ?? "",
1527
+ url: citation.href ?? "",
1528
+ domain: domainFromUrl2(citation.href ?? ""),
1529
+ is_target: targetDomain ? domainFromUrl2(citation.href ?? "") === targetDomain : false
1530
+ }));
1531
+ await ctx.artifacts.writeCsv("Organic results CSV", "organic-results.csv", ["position", "title", "url", "domain", "snippet", "is_target"], organic);
1532
+ await ctx.artifacts.writeCsv("Page comparison CSV", "page-comparison.csv", ["position", "domain", "url", "serp_title", "page_title", "h1", "meta_description", "word_count", "heading_count", "schema_types"], pageRows);
1533
+ await ctx.artifacts.writeCsv("Content gaps CSV", "content-gaps.csv", ["source_position", "source_domain", "source_url", "heading_level", "competitor_heading", "target_coverage", "terms"], gaps);
1534
+ await ctx.artifacts.writeCsv("PAA questions CSV", "paa-questions.csv", ["position", "intent", "question", "answer_excerpt", "source_title", "source_domain", "source_url"], questions);
1535
+ await ctx.artifacts.writeCsv("AI Overview citations CSV", "ai-overview-citations.csv", ["citation_position", "citation_text", "url", "domain", "is_target"], aiRows);
1536
+ await ctx.artifacts.writeJson("SERP comparison evidence", "evidence.json", { input, serp, paa, organic, pageRows, gaps, questions, aiRows, warnings });
1537
+ await ctx.artifacts.writeText("Writer brief", "brief.md", [
1538
+ `# SERP Comparison Brief: ${input.keyword}`,
1539
+ "",
1540
+ `Target: ${targetDomain ?? input.url ?? "not specified"}`,
1541
+ `Location: ${input.location ?? "not specified"}`,
1542
+ "",
1543
+ "## Recommended Actions",
1544
+ "- Use `content-gaps.csv` to decide which missing sections deserve coverage.",
1545
+ "- Use `paa-questions.csv` for FAQ and answer-block candidates.",
1546
+ "- Use `ai-overview-citations.csv` to see whether the target is cited in AI Overview evidence.",
1547
+ "- Treat extracted page headings as evidence, not a complete semantic analysis."
1548
+ ].join("\n"));
1549
+ const summary = `${organic.length} organic results, ${pageRows.length} extracted pages, ${gaps.length} heading gaps, ${questions.length} PAA questions.`;
1550
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1551
+ title: "SERP Comparison",
1552
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
1553
+ summary,
1554
+ warnings,
1555
+ tables: [
1556
+ { title: "Organic Results", columns: ["position", "title", "domain", "is_target"], rows: organic },
1557
+ { title: "Page Comparison", columns: ["position", "domain", "h1", "word_count", "heading_count", "schema_types"], rows: pageRows },
1558
+ { title: "Content Gaps", columns: ["source_position", "source_domain", "competitor_heading", "target_coverage", "terms"], rows: gaps.slice(0, 80) },
1559
+ { title: "PAA Questions", columns: ["position", "intent", "question", "source_domain"], rows: questions.slice(0, 80) }
1560
+ ]
1561
+ }));
1562
+ const status2 = warnings.length ? "partial" : "succeeded";
1563
+ const counts = { organic: organic.length, pages: pageRows.length, gaps: gaps.length, questions: questions.length, aiCitations: aiRows.length };
1564
+ await ctx.artifacts.writeManifest(status2, counts, warnings, []);
1565
+ return { title: "SERP Comparison", summary, status: status2, counts, warnings, errors: [], reportPath };
1566
+ }
1567
+ };
1568
+ var paaExpansionBriefWorkflowDefinition = {
1569
+ id: "paa-expansion-brief",
1570
+ title: "PAA Expansion Brief",
1571
+ description: "Expand People Also Ask questions into an evidence-backed writer brief, section map, and source table.",
1572
+ inputSchema: PaaExpansionBriefInputSchema,
1573
+ async run(input, ctx) {
1574
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1575
+ const warnings = [];
1576
+ const paa = await ctx.client.post("/harvest/sync", {
1577
+ query: input.keyword,
1578
+ location: input.location,
1579
+ maxQuestions: input.maxQuestions,
1580
+ depth: input.depth,
1581
+ format: "json"
1582
+ }, 3e5);
1583
+ await ctx.artifacts.writeJson("PAA raw JSON", "raw/paa.json", paa);
1584
+ const questions = questionRows(paa);
1585
+ const sourceRows2 = sourceDomainRows(questions);
1586
+ const byIntent = /* @__PURE__ */ new Map();
1587
+ for (const row of questions) {
1588
+ const list = byIntent.get(String(row.intent)) ?? [];
1589
+ list.push(row);
1590
+ byIntent.set(String(row.intent), list);
1591
+ }
1592
+ const sectionRows = [...byIntent.entries()].map(([intent, rows]) => ({
1593
+ recommended_section: intent,
1594
+ question_count: rows.length,
1595
+ sample_questions: rows.slice(0, 5).map((row) => row.question).join(" | "),
1596
+ source_domains: [...new Set(rows.map((row) => row.source_domain).filter(Boolean))].slice(0, 5).join("; "),
1597
+ terms: textTerms(rows.map((row) => `${row.question} ${row.answer_excerpt}`).join(" "), 10)
1598
+ })).sort((a, b) => Number(b.question_count) - Number(a.question_count));
1599
+ await ctx.artifacts.writeCsv("PAA questions CSV", "paa-questions.csv", ["position", "intent", "question", "answer_excerpt", "source_title", "source_domain", "source_url"], questions);
1600
+ await ctx.artifacts.writeCsv("Source domains CSV", "source-domains.csv", ["domain", "question_mentions", "source_url_count", "top_intents"], sourceRows2);
1601
+ await ctx.artifacts.writeCsv("Section map CSV", "section-map.csv", ["recommended_section", "question_count", "sample_questions", "source_domains", "terms"], sectionRows);
1602
+ await ctx.artifacts.writeJson("PAA brief evidence", "evidence.json", { input, paa, questions, sourceRows: sourceRows2, sectionRows });
1603
+ await ctx.artifacts.writeText("Writer brief", "writer-brief.md", [
1604
+ `# PAA Expansion Brief: ${input.keyword}`,
1605
+ "",
1606
+ `Location: ${input.location ?? "not specified"}`,
1607
+ "",
1608
+ "## Suggested Page Structure",
1609
+ ...sectionRows.map((row) => `- ${row.recommended_section}: answer ${row.question_count} related question(s). Sample: ${row.sample_questions}`),
1610
+ "",
1611
+ "## Writing Rules",
1612
+ "- Answer the highest-frequency question in the first 60 words of each section.",
1613
+ "- Use exact customer question language from `paa-questions.csv` for H2/H3 candidates.",
1614
+ "- Use `source-domains.csv` to identify which source types Google is already rewarding.",
1615
+ "- Do not invent citations; cite only rows that have a source URL."
1616
+ ].join("\n"));
1617
+ const summary = `${questions.length} PAA questions grouped into ${sectionRows.length} writing sections.`;
1618
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1619
+ title: "PAA Expansion Brief",
1620
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
1621
+ summary,
1622
+ warnings,
1623
+ tables: [
1624
+ { title: "Section Map", columns: ["recommended_section", "question_count", "sample_questions", "source_domains", "terms"], rows: sectionRows },
1625
+ { title: "Questions", columns: ["position", "intent", "question", "source_domain"], rows: questions.slice(0, 120) },
1626
+ { title: "Source Domains", columns: ["domain", "question_mentions", "source_url_count", "top_intents"], rows: sourceRows2 }
1627
+ ]
1628
+ }));
1629
+ const counts = { questions: questions.length, sections: sectionRows.length, sourceDomains: sourceRows2.length };
1630
+ await ctx.artifacts.writeManifest("succeeded", counts, warnings, []);
1631
+ return { title: "PAA Expansion Brief", summary, status: "succeeded", counts, warnings, errors: [], reportPath };
1632
+ }
1633
+ };
1634
+ var aiOverviewLanguageWorkflowDefinition = {
1635
+ id: "ai-overview-language",
1636
+ title: "AI Overview Language Brief",
1637
+ description: "Turn AI Overview, citation, PAA, and ranking-page evidence into answer-block and citation-hook guidance.",
1638
+ inputSchema: AiOverviewLanguageInputSchema,
1639
+ async run(input, ctx) {
1640
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1641
+ const warnings = [];
1642
+ const targetDomain = normalizeDomain2(input.domain ?? input.url ?? null);
1643
+ const serp = await ctx.client.post("/harvest/sync", {
1644
+ query: input.keyword,
1645
+ location: input.location,
1646
+ serpOnly: true,
1647
+ maxQuestions: 1,
1648
+ format: "json"
1649
+ }, 18e4);
1650
+ await ctx.artifacts.writeJson("SERP raw JSON", "raw/serp.json", serp);
1651
+ let paa = null;
1652
+ try {
1653
+ paa = await ctx.client.post("/harvest/sync", {
1654
+ query: input.keyword,
1655
+ location: input.location,
1656
+ maxQuestions: input.maxQuestions,
1657
+ format: "json"
1658
+ }, 28e4);
1659
+ await ctx.artifacts.writeJson("PAA raw JSON", "raw/paa.json", paa);
1660
+ } catch (err) {
1661
+ warnings.push(`PAA evidence unavailable: ${err instanceof Error ? err.message : String(err)}`);
1662
+ }
1663
+ const citations = (serp.aiOverview?.citations ?? []).map((citation, index) => {
1664
+ const domain = domainFromUrl2(citation.href ?? "");
1665
+ return {
1666
+ citation_position: index + 1,
1667
+ citation_text: citation.text ?? "",
1668
+ url: citation.href ?? "",
1669
+ domain,
1670
+ is_target: targetDomain ? domain === targetDomain : false
1671
+ };
1672
+ });
1673
+ const aioSentences = splitSentences(serp.aiOverview?.text);
1674
+ const claimRows = aioSentences.map((sentence, index) => ({
1675
+ position: index + 1,
1676
+ claim_type: classifySentence(sentence),
1677
+ sentence,
1678
+ reusable_pattern: sentence.length > 140 ? `${sentence.slice(0, 140)}...` : sentence
1679
+ }));
1680
+ const questions = questionRows(paa);
1681
+ const citationSources = (serp.aiOverview?.citations ?? []).filter((citation) => citation.href).map((citation, index) => ({ position: index + 1, title: citation.text, url: citation.href, domain: domainFromUrl2(citation.href) })).slice(0, input.extractTop);
1682
+ const extractedCitations = input.extractTop > 0 ? await extractPages(ctx, citationSources, warnings, "Citation") : [];
1683
+ const extractedRows = extractedCitations.map(({ source, page }) => pageSummaryRow(page, { url: source.url ?? "", domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url), position: source.position, title: source.title }));
1684
+ const languageRows = [
1685
+ {
1686
+ block: "direct_answer",
1687
+ guidance: "Open with a 40-70 word answer that directly resolves the query before adding context.",
1688
+ evidence_basis: questions[0]?.question ?? input.keyword
1689
+ },
1690
+ {
1691
+ block: "criteria_or_steps",
1692
+ guidance: "List the criteria, steps, or decision factors Google is already compressing into AI Overview language.",
1693
+ evidence_basis: claimRows.filter((row) => ["criteria", "process"].includes(String(row.claim_type))).map((row) => row.sentence).slice(0, 3).join(" | ")
1694
+ },
1695
+ {
1696
+ block: "citation_hook",
1697
+ guidance: "Add source-worthy details competitors can cite: definitions, numbers, examples, process details, and named entity relationships.",
1698
+ evidence_basis: citations.map((row) => `${row.domain}: ${row.citation_text}`).slice(0, 5).join(" | ")
1699
+ },
1700
+ {
1701
+ block: "faq_followups",
1702
+ guidance: "Use PAA phrasing for follow-up sections so the page answers adjacent questions in Google language.",
1703
+ evidence_basis: questions.slice(0, 5).map((row) => row.question).join(" | ")
1704
+ }
1705
+ ];
1706
+ await ctx.artifacts.writeCsv("AI Overview citations CSV", "ai-overview-citations.csv", ["citation_position", "citation_text", "url", "domain", "is_target"], citations);
1707
+ await ctx.artifacts.writeCsv("AI Overview claim patterns CSV", "claim-patterns.csv", ["position", "claim_type", "sentence", "reusable_pattern"], claimRows);
1708
+ await ctx.artifacts.writeCsv("Language guidance CSV", "language-guidance.csv", ["block", "guidance", "evidence_basis"], languageRows);
1709
+ await ctx.artifacts.writeCsv("PAA questions CSV", "paa-questions.csv", ["position", "intent", "question", "answer_excerpt", "source_title", "source_domain", "source_url"], questions);
1710
+ await ctx.artifacts.writeCsv("Extracted citation pages CSV", "citation-pages.csv", ["position", "domain", "url", "serp_title", "page_title", "h1", "meta_description", "word_count", "heading_count", "schema_types"], extractedRows);
1711
+ await ctx.artifacts.writeJson("AI Overview language evidence", "evidence.json", { input, serp, paa, citations, claimRows, languageRows, extractedRows, warnings });
1712
+ await ctx.artifacts.writeText("Answer block template", "answer-block-template.md", [
1713
+ `# AI Overview Language Brief: ${input.keyword}`,
1714
+ "",
1715
+ `AI Overview detected: ${serp.aiOverview?.detected ? "yes" : "no"}`,
1716
+ `Target cited: ${targetDomain ? citations.some((row) => row.is_target) ? "yes" : "no" : "target not specified"}`,
1717
+ "",
1718
+ "## Direct Answer Block",
1719
+ "Write one compact answer block that starts with the answer, not background. Keep it clear enough that Google could lift it as a standalone summary.",
1720
+ "",
1721
+ "## Suggested Follow-Up Blocks",
1722
+ ...languageRows.map((row) => `- ${row.block}: ${row.guidance}`),
1723
+ "",
1724
+ "## Evidence to Mirror",
1725
+ ...claimRows.slice(0, 8).map((row) => `- ${row.claim_type}: ${row.sentence}`),
1726
+ "",
1727
+ "## Citation Hooks",
1728
+ ...citations.slice(0, 8).map((row) => `- ${row.domain}: ${row.citation_text}`)
1729
+ ].join("\n"));
1730
+ const summary = `${citations.length} AI Overview citations, ${claimRows.length} claim patterns, ${questions.length} PAA questions, ${extractedRows.length} citation pages extracted.`;
1731
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1732
+ title: "AI Overview Language Brief",
1733
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
1734
+ summary,
1735
+ warnings,
1736
+ tables: [
1737
+ { title: "Language Guidance", columns: ["block", "guidance", "evidence_basis"], rows: languageRows },
1738
+ { title: "AI Overview Citations", columns: ["citation_position", "citation_text", "domain", "is_target"], rows: citations },
1739
+ { title: "Claim Patterns", columns: ["position", "claim_type", "sentence"], rows: claimRows },
1740
+ { title: "PAA Follow-Ups", columns: ["position", "intent", "question", "source_domain"], rows: questions.slice(0, 60) }
1741
+ ]
1742
+ }));
1743
+ const status2 = warnings.length || !serp.aiOverview?.detected ? "partial" : "succeeded";
1744
+ const counts = { citations: citations.length, claimPatterns: claimRows.length, questions: questions.length, extractedCitationPages: extractedRows.length };
1745
+ await ctx.artifacts.writeManifest(status2, counts, warnings, []);
1746
+ return { title: "AI Overview Language Brief", summary, status: status2, counts, warnings, errors: [], reportPath };
1747
+ }
1748
+ };
1749
+
1023
1750
  // src/workflows/registry.ts
1024
1751
  var DEFINITIONS = [
1025
1752
  directoryWorkflowDefinition,
1026
1753
  agentPacketWorkflowDefinition,
1027
- localCompetitiveAuditWorkflowDefinition
1754
+ localCompetitiveAuditWorkflowDefinition,
1755
+ mapComparisonWorkflowDefinition,
1756
+ serpComparisonWorkflowDefinition,
1757
+ paaExpansionBriefWorkflowDefinition,
1758
+ aiOverviewLanguageWorkflowDefinition
1028
1759
  ];
1029
1760
  function listWorkflowDefinitions() {
1030
1761
  return DEFINITIONS.map(({ id, title, description }) => ({ id, title, description }));
@@ -1078,29 +1809,42 @@ function compactInput(input) {
1078
1809
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== void 0));
1079
1810
  }
1080
1811
  function workflowInput(id, opts) {
1081
- if (id === "agent-packet") {
1812
+ if (id === "agent-packet" || id === "serp-comparison" || id === "ai-overview-language") {
1082
1813
  return compactInput({
1083
1814
  keyword: opts.keyword,
1084
1815
  domain: opts.domain,
1816
+ url: opts.url,
1085
1817
  location: opts.location,
1818
+ maxResults: numberOpt(opts.maxResults),
1086
1819
  maxQuestions: numberOpt(opts.maxQuestions),
1820
+ extractTop: numberOpt(opts.extractTop),
1087
1821
  includeSerp: opts.serp === false ? false : void 0,
1088
1822
  includePaa: opts.paa === false ? false : void 0,
1089
1823
  includeAiOverview: booleanOpt(opts.includeAiOverview),
1090
1824
  returnPartial: opts.returnPartial === false ? false : void 0
1091
1825
  });
1092
1826
  }
1093
- if (id === "directory" || id === "local-competitive-audit") {
1827
+ if (id === "paa-expansion-brief") {
1828
+ return compactInput({
1829
+ keyword: opts.keyword,
1830
+ location: opts.location,
1831
+ maxQuestions: numberOpt(opts.maxQuestions),
1832
+ depth: numberOpt(opts.depth),
1833
+ returnPartial: opts.returnPartial === false ? false : void 0
1834
+ });
1835
+ }
1836
+ if (id === "directory" || id === "local-competitive-audit" || id === "map-comparison") {
1094
1837
  return compactInput({
1095
1838
  query: opts.query,
1839
+ location: opts.location,
1096
1840
  state: opts.state,
1097
1841
  minPopulation: numberOpt(opts.minPop ?? opts.minPopulation),
1098
1842
  maxCities: numberOpt(opts.maxCities),
1099
- maxResultsPerCity: numberOpt(opts.perCity ?? opts.maxResultsPerCity),
1843
+ maxResultsPerCity: numberOpt(opts.perCity ?? opts.maxResultsPerCity ?? opts.maxResults),
1100
1844
  concurrency: numberOpt(opts.concurrency),
1101
1845
  proxyMode: opts.proxyMode,
1102
- hydrateTop: id === "local-competitive-audit" ? numberOpt(opts.hydrateTop) : void 0,
1103
- maxReviews: id === "local-competitive-audit" ? numberOpt(opts.reviews ?? opts.maxReviews) : void 0,
1846
+ hydrateTop: id === "local-competitive-audit" || id === "map-comparison" ? numberOpt(opts.hydrateTop) : void 0,
1847
+ maxReviews: id === "local-competitive-audit" || id === "map-comparison" ? numberOpt(opts.reviews ?? opts.maxReviews) : void 0,
1104
1848
  returnPartial: opts.returnPartial === false ? false : void 0
1105
1849
  });
1106
1850
  }
@@ -1149,7 +1893,7 @@ async function apiRequest(path, method, opts, body) {
1149
1893
  return data;
1150
1894
  }
1151
1895
  function addWorkflowInputOptions(command) {
1152
- return command.option("--keyword <keyword>", "Agent packet keyword").option("--domain <domain>", "Target domain").option("--location <location>", "Target location").option("--max-questions <n>", "Maximum PAA questions").option("--no-serp", "Skip SERP evidence for agent packet").option("--no-paa", "Skip PAA evidence for agent packet").option("--query <query>", "Business category or workflow query").option("--state <state>", "US state").option("--min-pop <n>", "Minimum city population").option("--max-cities <n>", "Maximum selected cities").option("--per-city <n>", "Maps results per city").option("--concurrency <n>", "City search concurrency").option("--proxy-mode <mode>", "Proxy mode: location, configured, or none").option("--hydrate-top <n>", "Profiles to hydrate per city for competitive audits").option("--reviews <n>", "Review cards to collect per hydrated profile").option("--no-return-partial", "Fail instead of writing partial artifacts");
1896
+ return command.option("--keyword <keyword>", "Agent packet keyword").option("--domain <domain>", "Target domain").option("--url <url>", "Target page URL").option("--location <location>", "Target location").option("--max-results <n>", "Maximum SERP or Maps results").option("--max-questions <n>", "Maximum PAA questions").option("--extract-top <n>", "Top result pages or citation pages to extract").option("--depth <n>", "PAA expansion depth").option("--no-serp", "Skip SERP evidence for agent packet").option("--no-paa", "Skip PAA evidence for agent packet").option("--query <query>", "Business category or workflow query").option("--state <state>", "US state").option("--min-pop <n>", "Minimum city population").option("--max-cities <n>", "Maximum selected cities").option("--per-city <n>", "Maps results per city").option("--concurrency <n>", "City search concurrency").option("--proxy-mode <mode>", "Proxy mode: location, configured, or none").option("--hydrate-top <n>", "Profiles to hydrate per city for competitive audits").option("--reviews <n>", "Review cards to collect per hydrated profile").option("--no-return-partial", "Fail instead of writing partial artifacts");
1153
1897
  }
1154
1898
  function buildHumanCli() {
1155
1899
  const program = new import_commander.Command();
@@ -1181,7 +1925,7 @@ function buildHumanCli() {
1181
1925
  else writeOutput(rows.map((row) => `${row.id} ${row.title}
1182
1926
  ${row.description}`).join("\n"), false);
1183
1927
  });
1184
- workflow.command("run <id>").description("Run a workflow and save local artifacts.").option("--api-key <key>", "MCP Scraper API key").option("--api-url <url>", "MCP Scraper API URL", "https://mcpscraper.dev").option("--output-dir <path>", "Workflow output directory").option("--json", "Print machine-readable JSON").option("--keyword <keyword>", "Agent packet keyword").option("--domain <domain>", "Target domain").option("--location <location>", "Target location").option("--max-questions <n>", "Maximum PAA questions").option("--no-serp", "Skip SERP evidence for agent packet").option("--no-paa", "Skip PAA evidence for agent packet").option("--query <query>", "Business category or workflow query").option("--state <state>", "US state").option("--min-pop <n>", "Minimum city population").option("--max-cities <n>", "Maximum selected cities").option("--per-city <n>", "Maps results per city").option("--concurrency <n>", "City search concurrency").option("--proxy-mode <mode>", "Proxy mode: location, configured, or none").option("--hydrate-top <n>", "Profiles to hydrate per city for competitive audits").option("--reviews <n>", "Review cards to collect per hydrated profile").option("--no-return-partial", "Fail instead of writing partial artifacts").action(async (id, opts) => {
1928
+ workflow.command("run <id>").description("Run a workflow and save local artifacts.").option("--api-key <key>", "MCP Scraper API key").option("--api-url <url>", "MCP Scraper API URL", "https://mcpscraper.dev").option("--output-dir <path>", "Workflow output directory").option("--json", "Print machine-readable JSON").option("--keyword <keyword>", "Agent packet keyword").option("--domain <domain>", "Target domain").option("--url <url>", "Target page URL").option("--location <location>", "Target location").option("--max-results <n>", "Maximum SERP or Maps results").option("--max-questions <n>", "Maximum PAA questions").option("--extract-top <n>", "Top result pages or citation pages to extract").option("--depth <n>", "PAA expansion depth").option("--no-serp", "Skip SERP evidence for agent packet").option("--no-paa", "Skip PAA evidence for agent packet").option("--query <query>", "Business category or workflow query").option("--state <state>", "US state").option("--min-pop <n>", "Minimum city population").option("--max-cities <n>", "Maximum selected cities").option("--per-city <n>", "Maps results per city").option("--concurrency <n>", "City search concurrency").option("--proxy-mode <mode>", "Proxy mode: location, configured, or none").option("--hydrate-top <n>", "Profiles to hydrate per city for competitive audits").option("--reviews <n>", "Review cards to collect per hydrated profile").option("--no-return-partial", "Fail instead of writing partial artifacts").action(async (id, opts) => {
1185
1929
  const summary = await runWorkflow(id, workflowInput(id, opts), {
1186
1930
  apiKey: opts.apiKey,
1187
1931
  apiUrl: opts.apiUrl,