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
@@ -0,0 +1,1572 @@
1
+ // src/workflows/artifact-writer.ts
2
+ import { mkdir, readFile, stat, writeFile } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { homedir, platform } from "os";
5
+ import { dirname, join } from "path";
6
+ import { execFile } from "child_process";
7
+
8
+ // src/directory/csv.ts
9
+ function parseCsv(text) {
10
+ const rows = [];
11
+ let row = [];
12
+ let field = "";
13
+ let quoted = false;
14
+ for (let i = 0; i < text.length; i += 1) {
15
+ const ch = text[i];
16
+ const next = text[i + 1];
17
+ if (quoted) {
18
+ if (ch === '"' && next === '"') {
19
+ field += '"';
20
+ i += 1;
21
+ } else if (ch === '"') {
22
+ quoted = false;
23
+ } else {
24
+ field += ch;
25
+ }
26
+ continue;
27
+ }
28
+ if (ch === '"') {
29
+ quoted = true;
30
+ } else if (ch === ",") {
31
+ row.push(field);
32
+ field = "";
33
+ } else if (ch === "\n") {
34
+ row.push(field);
35
+ rows.push(row);
36
+ row = [];
37
+ field = "";
38
+ } else if (ch !== "\r") {
39
+ field += ch;
40
+ }
41
+ }
42
+ if (field.length > 0 || row.length > 0) {
43
+ row.push(field);
44
+ rows.push(row);
45
+ }
46
+ return rows;
47
+ }
48
+ function csvRecords(text) {
49
+ const rows = parseCsv(text).filter((row) => row.some((cell) => cell.trim() !== ""));
50
+ const header = rows[0]?.map((cell) => cell.trim()) ?? [];
51
+ return rows.slice(1).map((row) => {
52
+ const record = {};
53
+ for (let i = 0; i < header.length; i += 1) {
54
+ record[header[i]] = row[i] ?? "";
55
+ }
56
+ return record;
57
+ });
58
+ }
59
+ function csvCell(value) {
60
+ if (value === null || value === void 0) return "";
61
+ const text = String(value);
62
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
63
+ }
64
+ function rowsToCsv(headers, rows) {
65
+ return [
66
+ headers.join(","),
67
+ ...rows.map((row) => headers.map((header) => csvCell(row[header])).join(","))
68
+ ].join("\n") + "\n";
69
+ }
70
+
71
+ // src/lib/slugify.ts
72
+ function slugify(s) {
73
+ return s.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
74
+ }
75
+
76
+ // src/workflows/artifact-writer.ts
77
+ function workflowOutputBaseDir(outputDir) {
78
+ return outputDir?.trim() || process.env.MCP_SCRAPER_OUTPUT_DIR?.trim() || join(homedir(), "Downloads", "mcp-scraper");
79
+ }
80
+ function timestamp() {
81
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
82
+ }
83
+ function safeSlug(value) {
84
+ return slugify(value).replace(/^-+|-+$/g, "").slice(0, 80) || "run";
85
+ }
86
+ var ArtifactWriter = class _ArtifactWriter {
87
+ constructor(workflowId, title, runId, baseDir, runDir, startedAt, input) {
88
+ this.workflowId = workflowId;
89
+ this.title = title;
90
+ this.runId = runId;
91
+ this.baseDir = baseDir;
92
+ this.runDir = runDir;
93
+ this.startedAt = startedAt;
94
+ this.input = input;
95
+ }
96
+ workflowId;
97
+ title;
98
+ runId;
99
+ baseDir;
100
+ runDir;
101
+ startedAt;
102
+ input;
103
+ artifacts = [];
104
+ static async create(workflowId, title, input, outputDir, forcedRunId) {
105
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
106
+ const runId = forcedRunId?.trim() || `${timestamp()}-${safeSlug(workflowId)}-${Math.random().toString(36).slice(2, 8)}`;
107
+ const nameSource = String(input.keyword ?? input.query ?? input.domain ?? input.state ?? workflowId);
108
+ const baseDir = workflowOutputBaseDir(outputDir);
109
+ const runDir = join(baseDir, "workflows", workflowId, `${timestamp()}-${safeSlug(nameSource)}-${safeSlug(runId).slice(0, 20)}`);
110
+ await mkdir(runDir, { recursive: true });
111
+ return new _ArtifactWriter(workflowId, title, runId, baseDir, runDir, startedAt, input);
112
+ }
113
+ async remember(kind, label, path, rows) {
114
+ const size = await stat(path).then((s) => s.size).catch(() => void 0);
115
+ this.artifacts.push({ kind, label, path, bytes: size, rows });
116
+ return path;
117
+ }
118
+ async writeJson(label, relativePath, data) {
119
+ const path = join(this.runDir, relativePath);
120
+ await mkdir(dirname(path), { recursive: true });
121
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
122
+ return this.remember("json", label, path);
123
+ }
124
+ async writeText(label, relativePath, text, kind = "markdown") {
125
+ const path = join(this.runDir, relativePath);
126
+ await mkdir(dirname(path), { recursive: true });
127
+ await writeFile(path, text, "utf8");
128
+ return this.remember(kind, label, path);
129
+ }
130
+ async writeCsv(label, relativePath, headers, rows) {
131
+ const path = join(this.runDir, relativePath);
132
+ await mkdir(dirname(path), { recursive: true });
133
+ await writeFile(path, rowsToCsv(headers, rows), "utf8");
134
+ return this.remember("csv", label, path, rows.length);
135
+ }
136
+ async writeHtml(label, relativePath, html) {
137
+ return this.writeText(label, relativePath, html, "html_report");
138
+ }
139
+ async writeManifest(status, counts, warnings, errors) {
140
+ const manifest = {
141
+ workflow: this.workflowId,
142
+ title: this.title,
143
+ runId: this.runId,
144
+ status,
145
+ startedAt: this.startedAt,
146
+ completedAt: status === "running" ? null : (/* @__PURE__ */ new Date()).toISOString(),
147
+ input: this.input,
148
+ artifacts: this.artifacts,
149
+ warnings,
150
+ errors,
151
+ counts
152
+ };
153
+ const path = join(this.runDir, "manifest.json");
154
+ await writeFile(path, JSON.stringify(manifest, null, 2), "utf8");
155
+ await updateWorkflowIndex(manifest, path, this.baseDir);
156
+ return path;
157
+ }
158
+ };
159
+ function indexPath(baseDir = workflowOutputBaseDir()) {
160
+ return join(baseDir, "workflows", "index.json");
161
+ }
162
+ async function readIndex(baseDir) {
163
+ const path = indexPath(baseDir);
164
+ if (!existsSync(path)) return { runs: [] };
165
+ try {
166
+ return JSON.parse(await readFile(path, "utf8"));
167
+ } catch {
168
+ return { runs: [] };
169
+ }
170
+ }
171
+ async function updateWorkflowIndex(manifest, manifestPath, baseDir) {
172
+ if (manifest.status === "running") return;
173
+ const path = indexPath(baseDir);
174
+ const index = await readIndex(baseDir);
175
+ const report = manifest.artifacts.find((a) => a.kind === "html_report")?.path ?? null;
176
+ const entry = {
177
+ workflow: manifest.workflow,
178
+ runId: manifest.runId,
179
+ status: manifest.status,
180
+ startedAt: manifest.startedAt,
181
+ reportPath: report,
182
+ manifestPath,
183
+ summary: `${manifest.title} (${manifest.status})`
184
+ };
185
+ index.runs = [entry, ...index.runs.filter((r) => r.runId !== manifest.runId)].slice(0, 200);
186
+ await mkdir(dirname(path), { recursive: true });
187
+ await writeFile(path, JSON.stringify(index, null, 2), "utf8");
188
+ }
189
+ async function listWorkflowReports(outputDir) {
190
+ return (await readIndex(workflowOutputBaseDir(outputDir))).runs;
191
+ }
192
+ async function findWorkflowReport(id, outputDir) {
193
+ const runs = await listWorkflowReports(outputDir);
194
+ if (id === "last") return runs[0] ?? null;
195
+ return runs.find((run) => run.runId === id) ?? null;
196
+ }
197
+ async function openWorkflowReport(id, outputDir) {
198
+ const run = await findWorkflowReport(id, outputDir);
199
+ if (!run?.reportPath) throw new Error(`No report found for "${id}"`);
200
+ if (platform() === "darwin") {
201
+ await new Promise((resolve, reject) => {
202
+ execFile("open", [run.reportPath], (err) => err ? reject(err) : resolve());
203
+ });
204
+ }
205
+ return run.reportPath;
206
+ }
207
+
208
+ // src/workflows/registry.ts
209
+ import { z as z5 } from "zod";
210
+
211
+ // src/workflows/http-client.ts
212
+ var WorkflowHttpClient = class {
213
+ constructor(apiUrl, apiKey, fetchImpl = fetch) {
214
+ this.apiUrl = apiUrl;
215
+ this.apiKey = apiKey;
216
+ this.fetchImpl = fetchImpl;
217
+ }
218
+ apiUrl;
219
+ apiKey;
220
+ fetchImpl;
221
+ async post(path, body, timeoutMs = 18e4) {
222
+ const res = await this.fetchImpl(`${this.apiUrl.replace(/\/$/, "")}${path}`, {
223
+ method: "POST",
224
+ headers: {
225
+ "Content-Type": "application/json",
226
+ "x-api-key": this.apiKey
227
+ },
228
+ body: JSON.stringify(body),
229
+ signal: AbortSignal.timeout(timeoutMs)
230
+ });
231
+ const data = await res.json().catch(() => ({}));
232
+ if (!res.ok) {
233
+ const message = typeof data.error === "string" ? data.error : `Workflow API ${path} failed with ${res.status}`;
234
+ throw new Error(message);
235
+ }
236
+ return data;
237
+ }
238
+ };
239
+
240
+ // src/workflows/workflows/agent-packet.ts
241
+ import { z } from "zod";
242
+
243
+ // src/workflows/report-renderer.ts
244
+ function escapeHtml(value) {
245
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
246
+ }
247
+ function renderTable(table) {
248
+ const rows = table.rows.map((row) => `<tr>${table.columns.map((col) => `<td>${escapeHtml(row[col])}</td>`).join("")}</tr>`).join("\n");
249
+ return [
250
+ `<section>`,
251
+ `<h2>${escapeHtml(table.title)}</h2>`,
252
+ `<div class="table-wrap"><table>`,
253
+ `<thead><tr>${table.columns.map((col) => `<th>${escapeHtml(col)}</th>`).join("")}</tr></thead>`,
254
+ `<tbody>${rows || `<tr><td colspan="${table.columns.length}">No rows</td></tr>`}</tbody>`,
255
+ `</table></div>`,
256
+ `</section>`
257
+ ].join("\n");
258
+ }
259
+ function renderWorkflowReport(input) {
260
+ const warnings = input.warnings?.length ? `<section class="warnings"><h2>Warnings</h2><ul>${input.warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}</ul></section>` : "";
261
+ const sections = (input.sections ?? []).map((section) => `<section><h2>${escapeHtml(section.title)}</h2><p>${escapeHtml(section.body)}</p></section>`).join("\n");
262
+ const tables = (input.tables ?? []).map(renderTable).join("\n");
263
+ return [
264
+ "<!doctype html>",
265
+ '<html lang="en">',
266
+ "<head>",
267
+ '<meta charset="utf-8">',
268
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
269
+ `<title>${escapeHtml(input.title)}</title>`,
270
+ "<style>",
271
+ ':root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172026;background:#f6f7f8}',
272
+ "body{margin:0;padding:32px}main{max-width:1180px;margin:0 auto;background:#fff;border:1px solid #d9dee3;border-radius:8px;padding:28px}",
273
+ "h1{font-size:30px;line-height:1.15;margin:0 0 8px}h2{font-size:18px;margin:28px 0 12px}p{line-height:1.55;color:#3d4952}.sub{color:#66737f;margin:0 0 20px}",
274
+ ".summary{background:#eef6f8;border-left:4px solid #12829a;padding:14px 16px;margin:20px 0}.warnings{background:#fff7e8;border-left:4px solid #c87b00;padding:12px 16px}",
275
+ ".table-wrap{overflow:auto;border:1px solid #dfe5ea;border-radius:8px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border-bottom:1px solid #edf0f2;padding:9px 10px;text-align:left;vertical-align:top}th{background:#f3f5f7;color:#31404c;position:sticky;top:0}tr:last-child td{border-bottom:0}",
276
+ "</style>",
277
+ "</head>",
278
+ "<body><main>",
279
+ `<h1>${escapeHtml(input.title)}</h1>`,
280
+ input.subtitle ? `<p class="sub">${escapeHtml(input.subtitle)}</p>` : "",
281
+ `<div class="summary">${escapeHtml(input.summary)}</div>`,
282
+ warnings,
283
+ sections,
284
+ tables,
285
+ "</main></body></html>"
286
+ ].join("\n");
287
+ }
288
+
289
+ // src/workflows/workflows/agent-packet.ts
290
+ var AgentPacketInputSchema = z.object({
291
+ keyword: z.string().min(1),
292
+ domain: z.string().optional(),
293
+ location: z.string().optional(),
294
+ maxQuestions: z.number().int().min(1).max(200).default(40),
295
+ includeSerp: z.boolean().default(true),
296
+ includePaa: z.boolean().default(true),
297
+ includeAiOverview: z.boolean().default(true),
298
+ returnPartial: z.boolean().default(true)
299
+ });
300
+ function normalizeDomain(value) {
301
+ if (!value) return null;
302
+ try {
303
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
304
+ return url.hostname.replace(/^www\./, "").toLowerCase();
305
+ } catch {
306
+ return value.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]?.toLowerCase() || null;
307
+ }
308
+ }
309
+ function domainFromUrl(url) {
310
+ return normalizeDomain(url) ?? "";
311
+ }
312
+ function sourceRows(input, serp, paa) {
313
+ const target = normalizeDomain(input.domain);
314
+ const rows = [];
315
+ for (const result of serp?.organicResults ?? []) {
316
+ const domain = normalizeDomain(result.domain) ?? domainFromUrl(result.url);
317
+ rows.push({
318
+ surface: "organic",
319
+ position: result.position,
320
+ title: result.title,
321
+ url: result.url,
322
+ domain,
323
+ question: "",
324
+ answer_excerpt: result.snippet ?? "",
325
+ is_target: target ? domain === target : false,
326
+ is_competitor: target ? domain !== target : true
327
+ });
328
+ }
329
+ for (const row of paa?.flat ?? []) {
330
+ const url = row.source_cite ?? "";
331
+ const domain = normalizeDomain(row.source_site ?? "") ?? (url ? domainFromUrl(url) : "");
332
+ rows.push({
333
+ surface: "paa",
334
+ position: "",
335
+ title: row.source_title ?? row.source_site ?? "",
336
+ url,
337
+ domain,
338
+ question: row.question,
339
+ answer_excerpt: (row.answer ?? "").slice(0, 240),
340
+ is_target: target ? domain === target : false,
341
+ is_competitor: target ? Boolean(domain && domain !== target) : Boolean(domain)
342
+ });
343
+ }
344
+ for (const [i, cite] of (serp?.aiOverview?.citations ?? []).entries()) {
345
+ const url = cite.href ?? "";
346
+ const domain = url ? domainFromUrl(url) : "";
347
+ rows.push({
348
+ surface: "ai_overview",
349
+ position: i + 1,
350
+ title: cite.text ?? "",
351
+ url,
352
+ domain,
353
+ question: "",
354
+ answer_excerpt: "",
355
+ is_target: target ? domain === target : false,
356
+ is_competitor: target ? Boolean(domain && domain !== target) : Boolean(domain)
357
+ });
358
+ }
359
+ return rows;
360
+ }
361
+ function competitorRows(rows, targetDomain) {
362
+ const byDomain = /* @__PURE__ */ new Map();
363
+ for (const row of rows) {
364
+ const domain = String(row.domain ?? "");
365
+ if (!domain || domain === targetDomain) continue;
366
+ const entry = byDomain.get(domain) ?? { organic: [], paa: 0, ai: 0, urls: /* @__PURE__ */ new Set() };
367
+ if (row.surface === "organic" && row.position) entry.organic.push(Number(row.position));
368
+ if (row.surface === "paa") entry.paa += 1;
369
+ if (row.surface === "ai_overview") entry.ai += 1;
370
+ if (row.url) entry.urls.add(String(row.url));
371
+ byDomain.set(domain, entry);
372
+ }
373
+ return [...byDomain.entries()].map(([domain, entry]) => ({
374
+ domain,
375
+ organic_best_position: entry.organic.length ? Math.min(...entry.organic) : "",
376
+ organic_count: entry.organic.length,
377
+ paa_mentions: entry.paa,
378
+ ai_overview_citations: entry.ai,
379
+ source_url_count: entry.urls.size
380
+ })).sort((a, b) => Number(a.organic_best_position || 999) - Number(b.organic_best_position || 999));
381
+ }
382
+ var agentPacketWorkflowDefinition = {
383
+ id: "agent-packet",
384
+ title: "Agent-Ready SEO Packet",
385
+ description: "Create an evidence folder for AI agents from live SERP/PAA/AI search surfaces.",
386
+ inputSchema: AgentPacketInputSchema,
387
+ async run(input, ctx) {
388
+ await ctx.artifacts.writeManifest("running", {}, [], []);
389
+ const warnings = [];
390
+ const errors = [];
391
+ let serp = null;
392
+ let paa = null;
393
+ if (input.includeSerp) {
394
+ try {
395
+ serp = await ctx.client.post("/harvest/sync", {
396
+ query: input.keyword,
397
+ location: input.location,
398
+ serpOnly: true,
399
+ maxQuestions: 1,
400
+ format: "json"
401
+ }, 18e4);
402
+ } catch (err) {
403
+ warnings.push(`SERP evidence unavailable: ${err instanceof Error ? err.message : String(err)}`);
404
+ }
405
+ }
406
+ if (input.includePaa) {
407
+ try {
408
+ paa = await ctx.client.post("/harvest/sync", {
409
+ query: input.keyword,
410
+ location: input.location,
411
+ maxQuestions: input.maxQuestions,
412
+ format: "json"
413
+ }, 28e4);
414
+ } catch (err) {
415
+ warnings.push(`PAA evidence unavailable: ${err instanceof Error ? err.message : String(err)}`);
416
+ }
417
+ }
418
+ if (!serp && !paa && !input.returnPartial) throw new Error("No SEO evidence was collected");
419
+ const rows = sourceRows(input, serp, paa);
420
+ const target = normalizeDomain(input.domain);
421
+ const competitors = competitorRows(rows, target);
422
+ const evidence = {
423
+ input,
424
+ serp: serp ?? { status: "skipped" },
425
+ paa: paa ?? { status: "skipped" },
426
+ target: {
427
+ domain: target,
428
+ organicPositions: rows.filter((r) => r.surface === "organic" && r.is_target).map((r) => Number(r.position)),
429
+ citedInAiOverview: rows.some((r) => r.surface === "ai_overview" && r.is_target),
430
+ citedInPaa: rows.some((r) => r.surface === "paa" && r.is_target)
431
+ },
432
+ competitors,
433
+ warnings
434
+ };
435
+ await ctx.artifacts.writeJson("Evidence JSON", "evidence.json", evidence);
436
+ await ctx.artifacts.writeCsv("Sources CSV", "sources.csv", ["surface", "position", "title", "url", "domain", "question", "answer_excerpt", "is_target", "is_competitor"], rows);
437
+ await ctx.artifacts.writeCsv("Competitors CSV", "competitors.csv", ["domain", "organic_best_position", "organic_count", "paa_mentions", "ai_overview_citations", "source_url_count"], competitors);
438
+ const brief = [
439
+ `# SEO Evidence Brief: ${input.keyword}`,
440
+ "",
441
+ `Location: ${input.location ?? "not specified"}`,
442
+ `Target domain: ${target ?? "not specified"}`,
443
+ "",
444
+ "## Evidence Summary",
445
+ `- Organic rows: ${rows.filter((r) => r.surface === "organic").length}`,
446
+ `- PAA rows: ${rows.filter((r) => r.surface === "paa").length}`,
447
+ `- AI Overview citations: ${rows.filter((r) => r.surface === "ai_overview").length}`,
448
+ `- Competitor domains: ${competitors.length}`,
449
+ "",
450
+ "## Recommended Use",
451
+ "Use the CSV files as source of truth. Tie content recommendations to evidence rows and mark unsupported ideas as assumptions."
452
+ ].join("\n");
453
+ await ctx.artifacts.writeText("Brief", "brief.md", brief);
454
+ await ctx.artifacts.writeText("Agent tasks", "tasks.md", [
455
+ "# Agent Tasks",
456
+ "",
457
+ "- [ ] Read `evidence.json` before writing recommendations.",
458
+ "- [ ] Compare the target domain against `competitors.csv`.",
459
+ "- [ ] Use `sources.csv` for citations and source-grounded page sections.",
460
+ "- [ ] Mark unsupported recommendations as assumptions."
461
+ ].join("\n"));
462
+ await ctx.artifacts.writeText("Agent instructions", "agent-instructions.md", [
463
+ "# Agent Instructions",
464
+ "",
465
+ "You are working from an MCP Scraper SEO evidence packet. Use `evidence.json` and CSV files as source of truth. Do not invent citations. If a recommendation is not supported by evidence, mark it as an assumption."
466
+ ].join("\n"));
467
+ const summary = `${rows.length} evidence rows and ${competitors.length} competitor domains collected.`;
468
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
469
+ title: "Agent-Ready SEO Packet",
470
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
471
+ summary,
472
+ warnings,
473
+ tables: [
474
+ { title: "Competitor Domains", columns: ["domain", "organic_best_position", "organic_count", "paa_mentions", "ai_overview_citations", "source_url_count"], rows: competitors.slice(0, 50) },
475
+ { title: "Evidence Sources", columns: ["surface", "position", "title", "domain", "question", "is_target"], rows: rows.slice(0, 100) }
476
+ ]
477
+ }));
478
+ const status = warnings.length ? "partial" : "succeeded";
479
+ const counts = { sources: rows.length, competitors: competitors.length };
480
+ await ctx.artifacts.writeManifest(status, counts, warnings, errors);
481
+ return { title: "Agent-Ready SEO Packet", summary, status, counts, warnings, errors, reportPath };
482
+ }
483
+ };
484
+
485
+ // src/workflows/workflows/directory.ts
486
+ import { z as z2 } from "zod";
487
+ var DirectoryWorkflowCliInputSchema = z2.object({
488
+ query: z2.string().min(1),
489
+ state: z2.string().min(2).default("TN"),
490
+ minPopulation: z2.number().int().min(0).default(1e5),
491
+ maxCities: z2.number().int().min(1).max(100).default(25),
492
+ maxResultsPerCity: z2.number().int().min(1).max(50).default(20),
493
+ concurrency: z2.number().int().min(1).max(5).default(5),
494
+ proxyMode: z2.enum(["location", "configured", "none"]).default("location"),
495
+ saveCsv: z2.boolean().default(true)
496
+ });
497
+ function directoryRows(result) {
498
+ const rows = [];
499
+ for (const city of result.cities) {
500
+ if (!city.results.length) {
501
+ rows.push({
502
+ source_query: result.query,
503
+ source_location: city.location,
504
+ city: city.city,
505
+ state: city.state,
506
+ population: city.population,
507
+ result_position: null,
508
+ business_name: null,
509
+ review_stars: null,
510
+ review_count: null,
511
+ category: null,
512
+ address: null,
513
+ phone: null,
514
+ website_url: null,
515
+ place_url: null,
516
+ cid: null,
517
+ cid_decimal: null,
518
+ result_status: city.status,
519
+ error: city.error
520
+ });
521
+ continue;
522
+ }
523
+ for (const business of city.results) {
524
+ rows.push({
525
+ source_query: result.query,
526
+ source_location: city.location,
527
+ city: city.city,
528
+ state: city.state,
529
+ population: city.population,
530
+ result_position: business.position,
531
+ business_name: business.name,
532
+ review_stars: business.rating,
533
+ review_count: business.reviewCount,
534
+ category: business.category,
535
+ address: business.address,
536
+ phone: business.phone,
537
+ website_url: business.websiteUrl,
538
+ place_url: business.placeUrl,
539
+ cid: business.cid,
540
+ cid_decimal: business.cidDecimal,
541
+ result_status: city.status,
542
+ error: city.error
543
+ });
544
+ }
545
+ }
546
+ return rows;
547
+ }
548
+ var DIRECTORY_CSV_HEADERS = [
549
+ "source_query",
550
+ "source_location",
551
+ "city",
552
+ "state",
553
+ "population",
554
+ "result_position",
555
+ "business_name",
556
+ "review_stars",
557
+ "review_count",
558
+ "category",
559
+ "address",
560
+ "phone",
561
+ "website_url",
562
+ "place_url",
563
+ "cid",
564
+ "cid_decimal",
565
+ "result_status",
566
+ "error"
567
+ ];
568
+ var directoryWorkflowDefinition = {
569
+ id: "directory",
570
+ title: "Directory Workflow",
571
+ description: "Select city markets and export Google Maps business candidates.",
572
+ inputSchema: DirectoryWorkflowCliInputSchema,
573
+ async run(input, ctx) {
574
+ await ctx.artifacts.writeManifest("running", {}, [], []);
575
+ const result = await ctx.client.post("/directory/run", input, 9e5);
576
+ await ctx.artifacts.writeJson("Directory evidence", "evidence.json", result);
577
+ const rows = directoryRows(result);
578
+ await ctx.artifacts.writeCsv("Directory CSV", "exports/directory.csv", DIRECTORY_CSV_HEADERS, rows);
579
+ const cityRows = result.cities.map((city) => ({
580
+ city: city.city,
581
+ state: city.state,
582
+ population: city.population,
583
+ status: city.status,
584
+ result_count: city.resultCount,
585
+ error: city.error ?? ""
586
+ }));
587
+ const topRows = rows.filter((row) => row.business_name).slice(0, 100).map((row) => ({
588
+ city: row.city,
589
+ position: row.result_position,
590
+ business: row.business_name,
591
+ rating: row.review_stars,
592
+ reviews: row.review_count,
593
+ category: row.category,
594
+ website: row.website_url
595
+ }));
596
+ const summary = `${result.selectedCityCount} cities processed with ${result.totalResultCount} Maps results.`;
597
+ await ctx.artifacts.writeText("Summary", "summary.md", `# Directory Workflow
598
+
599
+ ${summary}
600
+ `);
601
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
602
+ title: "Directory Workflow",
603
+ subtitle: `${input.query} \xB7 ${input.state}`,
604
+ summary,
605
+ warnings: result.warnings,
606
+ tables: [
607
+ { title: "Cities", columns: ["city", "state", "population", "status", "result_count", "error"], rows: cityRows },
608
+ { title: "Top Results", columns: ["city", "position", "business", "rating", "reviews", "category", "website"], rows: topRows }
609
+ ]
610
+ }));
611
+ const status = result.cities.some((city) => city.status === "failed") ? "partial" : "succeeded";
612
+ const counts = { cities: result.selectedCityCount, results: result.totalResultCount, rows: rows.length };
613
+ await ctx.artifacts.writeManifest(status, counts, result.warnings, []);
614
+ return { title: "Directory Workflow", summary, status, counts, warnings: result.warnings, errors: [], reportPath };
615
+ }
616
+ };
617
+
618
+ // src/workflows/workflows/local-competitive-audit.ts
619
+ import { z as z3 } from "zod";
620
+ var LocalCompetitiveAuditInputSchema = z3.object({
621
+ query: z3.string().min(1),
622
+ state: z3.string().min(2).default("TN"),
623
+ minPopulation: z3.number().int().min(0).default(1e5),
624
+ maxCities: z3.number().int().min(1).max(100).default(25),
625
+ maxResultsPerCity: z3.number().int().min(1).max(50).default(20),
626
+ hydrateTop: z3.number().int().min(0).max(10).default(5),
627
+ maxReviews: z3.number().int().min(0).max(500).default(50),
628
+ concurrency: z3.number().int().min(1).max(5).default(5),
629
+ proxyMode: z3.enum(["location", "configured", "none"]).default("location"),
630
+ returnPartial: z3.boolean().default(true)
631
+ });
632
+ async function mapLimit(items, limit, fn) {
633
+ const out = new Array(items.length);
634
+ let next = 0;
635
+ async function worker() {
636
+ while (next < items.length) {
637
+ const index = next;
638
+ next += 1;
639
+ out[index] = await fn(items[index], index);
640
+ }
641
+ }
642
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
643
+ return out;
644
+ }
645
+ function numberFrom(value) {
646
+ if (value === null || value === void 0 || value === "") return null;
647
+ const parsed = Number(String(value).replace(/[^\d.]/g, ""));
648
+ return Number.isFinite(parsed) ? parsed : null;
649
+ }
650
+ function median(values) {
651
+ const nums = values.filter((v) => v !== null).sort((a, b) => a - b);
652
+ if (!nums.length) return null;
653
+ return nums[Math.floor(nums.length / 2)] ?? null;
654
+ }
655
+ function termsFrom(texts) {
656
+ const stop = /* @__PURE__ */ new Set(["the", "and", "for", "with", "that", "this", "was", "were", "are", "you", "our", "they", "had", "have", "not", "but", "from", "very", "great", "good"]);
657
+ const counts = /* @__PURE__ */ new Map();
658
+ for (const text of texts) {
659
+ for (const token of text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/)) {
660
+ if (token.length < 4 || stop.has(token)) continue;
661
+ counts.set(token, (counts.get(token) ?? 0) + 1);
662
+ }
663
+ }
664
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([term, count]) => `${term} (${count})`).join("; ");
665
+ }
666
+ var localCompetitiveAuditWorkflowDefinition = {
667
+ id: "local-competitive-audit",
668
+ title: "Local Competitive Audit",
669
+ description: "Audit local Maps competitors, categories, review counts, and review themes across city markets.",
670
+ inputSchema: LocalCompetitiveAuditInputSchema,
671
+ async run(input, ctx) {
672
+ await ctx.artifacts.writeManifest("running", {}, [], []);
673
+ const warnings = [];
674
+ const errors = [];
675
+ const directory = await ctx.client.post("/directory/run", {
676
+ query: input.query,
677
+ state: input.state,
678
+ minPopulation: input.minPopulation,
679
+ maxCities: input.maxCities,
680
+ maxResultsPerCity: input.maxResultsPerCity,
681
+ concurrency: input.concurrency,
682
+ proxyMode: input.proxyMode,
683
+ saveCsv: true
684
+ }, 9e5);
685
+ await ctx.artifacts.writeJson("Directory raw JSON", "raw/directory-workflow.json", directory);
686
+ const baseRows = directoryRows(directory);
687
+ await ctx.artifacts.writeCsv("Base directory CSV", "exports/directory.csv", DIRECTORY_CSV_HEADERS, baseRows);
688
+ const selected = directory.cities.flatMap(
689
+ (city) => city.results.slice(0, input.hydrateTop).map((result) => ({ city, result }))
690
+ );
691
+ const seen = /* @__PURE__ */ new Set();
692
+ const deduped = selected.filter(({ city, result }) => {
693
+ const key = result.cid ?? result.placeUrl ?? `${city.location}:${result.name.toLowerCase()}`;
694
+ if (seen.has(key)) return false;
695
+ seen.add(key);
696
+ return true;
697
+ });
698
+ const hydrated = await mapLimit(deduped, 3, async ({ city, result }, index) => {
699
+ try {
700
+ const detail = await ctx.client.post("/maps/place", {
701
+ businessName: result.name,
702
+ location: city.location,
703
+ includeReviews: input.maxReviews > 0,
704
+ maxReviews: Math.max(1, input.maxReviews)
705
+ }, 18e4);
706
+ await ctx.artifacts.writeJson(`${result.name} profile`, `raw/maps-place-intel/${index + 1}-${result.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.json`, detail);
707
+ return { city, result, detail, error: null };
708
+ } catch (err) {
709
+ const message = err instanceof Error ? err.message : String(err);
710
+ warnings.push(`Profile hydration failed for ${result.name} (${city.location}): ${message}`);
711
+ return { city, result, detail: null, error: message };
712
+ }
713
+ });
714
+ const competitorRows2 = hydrated.map(({ city, result, detail, error }) => {
715
+ const reviews = detail?.reviews ?? [];
716
+ const ownerResponses = reviews.filter((r) => r.ownerResponse).length;
717
+ return {
718
+ city: city.city,
719
+ state: city.state,
720
+ source_location: city.location,
721
+ result_position: result.position,
722
+ business_name: result.name,
723
+ category: detail?.category ?? result.category,
724
+ review_stars: detail?.rating ?? result.rating,
725
+ review_count: detail?.reviewCount ?? result.reviewCount,
726
+ phone: result.phone,
727
+ website_url: detail?.website ?? result.websiteUrl,
728
+ place_url: result.placeUrl,
729
+ cid: result.cid,
730
+ cid_decimal: result.cidDecimal,
731
+ hydrated: detail ? "true" : "false",
732
+ reviews_status: detail?.reviewsStatus ?? "",
733
+ review_topics: (detail?.reviewTopics ?? []).map((t) => `${t.label} (${t.count})`).join("; "),
734
+ owner_response_rate: reviews.length ? (ownerResponses / reviews.length).toFixed(2) : "",
735
+ error: error ?? ""
736
+ };
737
+ });
738
+ const reviewInsightRows = hydrated.map(({ city, result, detail }) => {
739
+ const reviews = detail?.reviews ?? [];
740
+ const positive = reviews.filter((r) => Number(r.stars) >= 5 && r.text).map((r) => r.text);
741
+ const negative = reviews.filter((r) => Number(r.stars) > 0 && Number(r.stars) <= 3 && r.text).map((r) => r.text);
742
+ return {
743
+ city: city.city,
744
+ business_name: result.name,
745
+ cid: result.cid,
746
+ review_count: detail?.reviewCount ?? result.reviewCount,
747
+ average_rating: detail?.rating ?? result.rating,
748
+ topics: (detail?.reviewTopics ?? []).map((t) => `${t.label} (${t.count})`).join("; "),
749
+ praise_terms: termsFrom(positive),
750
+ complaint_terms: termsFrom(negative),
751
+ positive_sample: positive[0]?.slice(0, 280) ?? "",
752
+ negative_sample: negative[0]?.slice(0, 280) ?? ""
753
+ };
754
+ });
755
+ const citySummaryRows = directory.cities.map((city) => {
756
+ const resultReviewCounts = city.results.map((r) => numberFrom(r.reviewCount));
757
+ const resultRatings = city.results.map((r) => numberFrom(r.rating));
758
+ const topThreeReviews = city.results.slice(0, 3).map((r) => numberFrom(r.reviewCount)).filter((v) => v !== null);
759
+ const categories = /* @__PURE__ */ new Map();
760
+ for (const result of city.results) {
761
+ if (!result.category) continue;
762
+ categories.set(result.category, (categories.get(result.category) ?? 0) + 1);
763
+ }
764
+ const medReviews = median(resultReviewCounts);
765
+ const medRating = median(resultRatings);
766
+ const topThreeAvg = topThreeReviews.length ? Math.round(topThreeReviews.reduce((a, b) => a + b, 0) / topThreeReviews.length) : null;
767
+ const difficulty = Math.min(100, Math.round((topThreeAvg ?? 0) / 20 + (medRating ?? 0) * 10 + city.results.filter((r) => r.websiteUrl).length));
768
+ const opportunity = Math.max(0, 100 - difficulty);
769
+ return {
770
+ city: city.city,
771
+ state: city.state,
772
+ population: city.population,
773
+ result_count: city.resultCount,
774
+ median_review_count: medReviews ?? "",
775
+ median_rating: medRating ?? "",
776
+ top_three_average_review_count: topThreeAvg ?? "",
777
+ top_categories: [...categories.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([cat, count]) => `${cat} (${count})`).join("; "),
778
+ opportunity_score: opportunity,
779
+ difficulty_score: difficulty
780
+ };
781
+ });
782
+ await ctx.artifacts.writeCsv("Competitors CSV", "competitors.csv", ["city", "state", "source_location", "result_position", "business_name", "category", "review_stars", "review_count", "phone", "website_url", "place_url", "cid", "cid_decimal", "hydrated", "reviews_status", "review_topics", "owner_response_rate", "error"], competitorRows2);
783
+ await ctx.artifacts.writeCsv("Review insights CSV", "review-insights.csv", ["city", "business_name", "cid", "review_count", "average_rating", "topics", "praise_terms", "complaint_terms", "positive_sample", "negative_sample"], reviewInsightRows);
784
+ await ctx.artifacts.writeCsv("City summary CSV", "city-summary.csv", ["city", "state", "population", "result_count", "median_review_count", "median_rating", "top_three_average_review_count", "top_categories", "opportunity_score", "difficulty_score"], citySummaryRows);
785
+ await ctx.artifacts.writeJson("Audit evidence", "evidence.json", { input, directory, hydrated, citySummaryRows, competitorRows: competitorRows2, reviewInsightRows });
786
+ await ctx.artifacts.writeText("Agent tasks", "tasks.md", [
787
+ "# Local Competitive Audit Tasks",
788
+ "",
789
+ "- [ ] Review city opportunity and difficulty scores as heuristics, not absolute truth.",
790
+ "- [ ] Use review topics and samples as customer-language evidence.",
791
+ "- [ ] Compare target GBP category, review count, and website quality against top competitors.",
792
+ "- [ ] Identify cities with low review-count leaders and weak website coverage."
793
+ ].join("\n"));
794
+ const summary = `${directory.selectedCityCount} cities, ${directory.totalResultCount} Maps results, ${hydrated.filter((h) => h.detail).length} hydrated profiles.`;
795
+ await ctx.artifacts.writeText("Summary", "summary.md", `# Local Competitive Audit
796
+
797
+ ${summary}
798
+ `);
799
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
800
+ title: "Local Competitive Audit",
801
+ subtitle: `${input.query} \xB7 ${input.state}`,
802
+ summary,
803
+ warnings,
804
+ tables: [
805
+ { title: "City Opportunity", columns: ["city", "state", "population", "result_count", "median_review_count", "median_rating", "top_three_average_review_count", "top_categories", "opportunity_score", "difficulty_score"], rows: citySummaryRows },
806
+ { title: "Hydrated Competitors", columns: ["city", "result_position", "business_name", "category", "review_stars", "review_count", "hydrated", "review_topics"], rows: competitorRows2.slice(0, 100) },
807
+ { title: "Review Insights", columns: ["city", "business_name", "topics", "praise_terms", "complaint_terms"], rows: reviewInsightRows.slice(0, 100) }
808
+ ]
809
+ }));
810
+ const status = warnings.length || directory.cities.some((city) => city.status === "failed") ? "partial" : "succeeded";
811
+ const counts = { cities: directory.selectedCityCount, mapsResults: directory.totalResultCount, hydratedProfiles: hydrated.filter((h) => h.detail).length };
812
+ await ctx.artifacts.writeManifest(status, counts, warnings, errors);
813
+ return { title: "Local Competitive Audit", summary, status, counts, warnings, errors, reportPath };
814
+ }
815
+ };
816
+
817
+ // src/workflows/workflows/comparison-briefs.ts
818
+ import { z as z4 } from "zod";
819
+
820
+ // src/workflows/workflows/seo-workflow-utils.ts
821
+ var STOP_WORDS = /* @__PURE__ */ new Set([
822
+ "about",
823
+ "after",
824
+ "also",
825
+ "because",
826
+ "been",
827
+ "best",
828
+ "both",
829
+ "from",
830
+ "have",
831
+ "into",
832
+ "more",
833
+ "most",
834
+ "near",
835
+ "only",
836
+ "over",
837
+ "than",
838
+ "that",
839
+ "their",
840
+ "them",
841
+ "then",
842
+ "there",
843
+ "these",
844
+ "they",
845
+ "this",
846
+ "what",
847
+ "when",
848
+ "where",
849
+ "which",
850
+ "while",
851
+ "with",
852
+ "your",
853
+ "will",
854
+ "would",
855
+ "should",
856
+ "could",
857
+ "does",
858
+ "were",
859
+ "cost",
860
+ "costs"
861
+ ]);
862
+ function normalizeDomain2(value) {
863
+ if (!value) return null;
864
+ try {
865
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
866
+ return url.hostname.replace(/^www\./, "").toLowerCase();
867
+ } catch {
868
+ return value.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]?.toLowerCase() || null;
869
+ }
870
+ }
871
+ function domainFromUrl2(url) {
872
+ return normalizeDomain2(url) ?? "";
873
+ }
874
+ function numberFrom2(value) {
875
+ if (value === null || value === void 0 || value === "") return null;
876
+ const parsed = Number(String(value).replace(/[^\d.]/g, ""));
877
+ return Number.isFinite(parsed) ? parsed : null;
878
+ }
879
+ function median2(values) {
880
+ const nums = values.filter((v) => v !== null).sort((a, b) => a - b);
881
+ if (!nums.length) return null;
882
+ return nums[Math.floor(nums.length / 2)] ?? null;
883
+ }
884
+ function textTerms(text, limit = 12) {
885
+ const counts = /* @__PURE__ */ new Map();
886
+ for (const token of text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/)) {
887
+ if (token.length < 4 || STOP_WORDS.has(token)) continue;
888
+ counts.set(token, (counts.get(token) ?? 0) + 1);
889
+ }
890
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([term, count]) => `${term} (${count})`).join("; ");
891
+ }
892
+ function classifyQuestion(question) {
893
+ const q = question.toLowerCase();
894
+ if (/\b(cost|price|pricing|charge|expensive|cheap)\b/.test(q)) return "cost";
895
+ if (/\b(best|top|recommended|reviews?|compare|versus|vs)\b/.test(q)) return "comparison";
896
+ if (/\b(how|steps?|process|way to)\b/.test(q)) return "process";
897
+ if (/\b(why|worth|important|benefit)\b/.test(q)) return "why";
898
+ if (/\b(near me|city|local|nearby)\b/.test(q)) return "local";
899
+ if (/\b(can|does|do|is|are|should|will)\b/.test(q)) return "decision";
900
+ return "definition";
901
+ }
902
+ function questionRows(paa) {
903
+ return (paa?.flat ?? []).filter((row) => row.question).map((row, index) => {
904
+ const url = row.source_cite ?? "";
905
+ const domain = normalizeDomain2(row.source_site ?? "") ?? domainFromUrl2(url);
906
+ return {
907
+ position: index + 1,
908
+ intent: classifyQuestion(row.question ?? ""),
909
+ question: row.question ?? "",
910
+ answer_excerpt: (row.answer ?? "").slice(0, 320),
911
+ source_title: row.source_title ?? "",
912
+ source_domain: domain,
913
+ source_url: url
914
+ };
915
+ });
916
+ }
917
+ function sourceDomainRows(rows) {
918
+ const byDomain = /* @__PURE__ */ new Map();
919
+ for (const row of rows) {
920
+ const domain = String(row.source_domain ?? row.domain ?? "");
921
+ if (!domain) continue;
922
+ const entry = byDomain.get(domain) ?? { questions: 0, urls: /* @__PURE__ */ new Set(), intents: /* @__PURE__ */ new Map() };
923
+ entry.questions += row.question ? 1 : 0;
924
+ if (row.source_url || row.url) entry.urls.add(String(row.source_url ?? row.url));
925
+ if (row.intent) entry.intents.set(String(row.intent), (entry.intents.get(String(row.intent)) ?? 0) + 1);
926
+ byDomain.set(domain, entry);
927
+ }
928
+ return [...byDomain.entries()].map(([domain, entry]) => ({
929
+ domain,
930
+ question_mentions: entry.questions,
931
+ source_url_count: entry.urls.size,
932
+ top_intents: [...entry.intents.entries()].sort((a, b) => b[1] - a[1]).map(([intent, count]) => `${intent} (${count})`).join("; ")
933
+ })).sort((a, b) => Number(b.question_mentions) - Number(a.question_mentions) || String(a.domain).localeCompare(String(b.domain)));
934
+ }
935
+ function splitSentences(text) {
936
+ return (text ?? "").replace(/\s+/g, " ").split(/(?<=[.!?])\s+/).map((sentence) => sentence.trim()).filter((sentence) => sentence.length > 20).slice(0, 20);
937
+ }
938
+ function classifySentence(sentence) {
939
+ const s = sentence.toLowerCase();
940
+ if (/\bis\b|\bare\b|\bmeans\b|\brefers to\b/.test(s)) return "definition";
941
+ if (/\binclude\b|\bconsider\b|\bfactors?\b|\bcriteria\b/.test(s)) return "criteria";
942
+ if (/\bfirst\b|\bthen\b|\bsteps?\b|\bprocess\b/.test(s)) return "process";
943
+ if (/\bvs\b|\bthan\b|\bcompare\b|\bdifference\b/.test(s)) return "comparison";
944
+ if (/\bcost\b|\bprice\b|\baverage\b|\brange\b/.test(s)) return "cost";
945
+ return "claim";
946
+ }
947
+ function pageSummaryRow(page, source) {
948
+ return {
949
+ position: source.position ?? "",
950
+ domain: source.domain,
951
+ url: source.url,
952
+ serp_title: source.title ?? "",
953
+ page_title: page.title ?? "",
954
+ h1: page.h1 ?? "",
955
+ meta_description: page.metaDescription ?? "",
956
+ word_count: page.wordCount ?? "",
957
+ heading_count: page.headings?.length ?? 0,
958
+ schema_types: (page.schemaTypes ?? []).join("; ")
959
+ };
960
+ }
961
+ async function mapLimit2(items, limit, fn) {
962
+ const out = new Array(items.length);
963
+ let next = 0;
964
+ async function worker() {
965
+ while (next < items.length) {
966
+ const index = next;
967
+ next += 1;
968
+ out[index] = await fn(items[index], index);
969
+ }
970
+ }
971
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, () => worker()));
972
+ return out;
973
+ }
974
+
975
+ // src/workflows/workflows/comparison-briefs.ts
976
+ var ProxyModeSchema = z4.enum(["location", "configured", "none"]);
977
+ var MapComparisonInputSchema = z4.object({
978
+ query: z4.string().min(1),
979
+ location: z4.string().optional(),
980
+ state: z4.string().optional(),
981
+ minPopulation: z4.number().int().min(0).default(1e5),
982
+ maxCities: z4.number().int().min(1).max(100).default(5),
983
+ maxResultsPerCity: z4.number().int().min(1).max(50).default(20),
984
+ hydrateTop: z4.number().int().min(0).max(10).default(5),
985
+ maxReviews: z4.number().int().min(0).max(500).default(25),
986
+ concurrency: z4.number().int().min(1).max(5).default(5),
987
+ proxyMode: ProxyModeSchema.default("location"),
988
+ returnPartial: z4.boolean().default(true)
989
+ }).refine((input) => input.location || input.state, {
990
+ message: "Either location or state is required for map-comparison"
991
+ });
992
+ var SerpComparisonInputSchema = z4.object({
993
+ keyword: z4.string().min(1),
994
+ domain: z4.string().optional(),
995
+ url: z4.string().url().optional(),
996
+ location: z4.string().optional(),
997
+ maxResults: z4.number().int().min(1).max(20).default(10),
998
+ maxQuestions: z4.number().int().min(1).max(200).default(40),
999
+ extractTop: z4.number().int().min(0).max(10).default(5),
1000
+ includePaa: z4.boolean().default(true),
1001
+ includeAiOverview: z4.boolean().default(true),
1002
+ returnPartial: z4.boolean().default(true)
1003
+ });
1004
+ var PaaExpansionBriefInputSchema = z4.object({
1005
+ keyword: z4.string().min(1),
1006
+ location: z4.string().optional(),
1007
+ maxQuestions: z4.number().int().min(1).max(300).default(80),
1008
+ depth: z4.number().int().min(1).max(6).default(3),
1009
+ returnPartial: z4.boolean().default(true)
1010
+ });
1011
+ var AiOverviewLanguageInputSchema = z4.object({
1012
+ keyword: z4.string().min(1),
1013
+ domain: z4.string().optional(),
1014
+ url: z4.string().url().optional(),
1015
+ location: z4.string().optional(),
1016
+ maxQuestions: z4.number().int().min(1).max(200).default(40),
1017
+ extractTop: z4.number().int().min(0).max(8).default(3),
1018
+ returnPartial: z4.boolean().default(true)
1019
+ });
1020
+ function businessRowsFromMaps(location, query, results) {
1021
+ return results.map((result) => ({
1022
+ source_query: query,
1023
+ source_location: location,
1024
+ city: location.split(",")[0]?.trim() ?? location,
1025
+ state: location.split(",")[1]?.trim() ?? "",
1026
+ population: "",
1027
+ result_position: result.position,
1028
+ business_name: result.name,
1029
+ review_stars: result.rating ?? "",
1030
+ review_count: result.reviewCount ?? "",
1031
+ category: result.category ?? "",
1032
+ address: result.address ?? "",
1033
+ phone: result.phone ?? "",
1034
+ website_url: result.websiteUrl ?? "",
1035
+ place_url: result.placeUrl ?? "",
1036
+ cid: result.cid ?? "",
1037
+ cid_decimal: result.cidDecimal ?? "",
1038
+ result_status: "ok",
1039
+ error: ""
1040
+ }));
1041
+ }
1042
+ function marketRows(rows) {
1043
+ const byLocation = /* @__PURE__ */ new Map();
1044
+ for (const row of rows) {
1045
+ const key = String(row.source_location ?? row.location ?? "");
1046
+ if (!key) continue;
1047
+ const list = byLocation.get(key) ?? [];
1048
+ list.push(row);
1049
+ byLocation.set(key, list);
1050
+ }
1051
+ return [...byLocation.entries()].map(([location, list]) => {
1052
+ const counts = list.map((row) => numberFrom2(row.review_count));
1053
+ const ratings = list.map((row) => numberFrom2(row.review_stars));
1054
+ const topThree = list.slice(0, 3).map((row) => numberFrom2(row.review_count)).filter((v) => v !== null);
1055
+ const categories = /* @__PURE__ */ new Map();
1056
+ for (const row of list) {
1057
+ const category = String(row.category ?? "");
1058
+ if (category) categories.set(category, (categories.get(category) ?? 0) + 1);
1059
+ }
1060
+ return {
1061
+ source_location: location,
1062
+ result_count: list.filter((row) => row.business_name).length,
1063
+ median_review_count: median2(counts) ?? "",
1064
+ median_rating: median2(ratings) ?? "",
1065
+ top_three_average_review_count: topThree.length ? Math.round(topThree.reduce((a, b) => a + b, 0) / topThree.length) : "",
1066
+ top_categories: [...categories.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([cat, count]) => `${cat} (${count})`).join("; "),
1067
+ websites_present: list.filter((row) => row.website_url).length
1068
+ };
1069
+ });
1070
+ }
1071
+ function comparisonRows(rows) {
1072
+ const benchmarkByLocation = /* @__PURE__ */ new Map();
1073
+ for (const market of marketRows(rows)) {
1074
+ benchmarkByLocation.set(String(market.source_location), numberFrom2(market.top_three_average_review_count) ?? 0);
1075
+ }
1076
+ return rows.filter((row) => row.business_name).map((row) => {
1077
+ const reviews = numberFrom2(row.review_count) ?? 0;
1078
+ const benchmark = benchmarkByLocation.get(String(row.source_location)) ?? 0;
1079
+ const websiteMissing = !row.website_url;
1080
+ const rank = numberFrom2(row.result_position) ?? 999;
1081
+ return {
1082
+ source_location: row.source_location,
1083
+ result_position: row.result_position,
1084
+ business_name: row.business_name,
1085
+ category: row.category,
1086
+ review_stars: row.review_stars,
1087
+ review_count: row.review_count,
1088
+ review_gap_to_top3_average: benchmark ? Math.max(0, benchmark - reviews) : "",
1089
+ website_url: row.website_url,
1090
+ place_url: row.place_url,
1091
+ comparison_note: rank <= 3 ? "visible leader" : websiteMissing ? "ranking without website" : reviews < benchmark ? "review-light competitor" : "visible competitor"
1092
+ };
1093
+ });
1094
+ }
1095
+ function organicRows(serp, targetDomain) {
1096
+ return (serp?.organicResults ?? []).map((result) => {
1097
+ const url = result.url ?? "";
1098
+ const domain = normalizeDomain2(result.domain ?? "") ?? domainFromUrl2(url);
1099
+ return {
1100
+ position: result.position ?? "",
1101
+ title: result.title ?? "",
1102
+ url,
1103
+ domain,
1104
+ snippet: result.snippet ?? "",
1105
+ is_target: targetDomain ? domain === targetDomain : false
1106
+ };
1107
+ });
1108
+ }
1109
+ function pageGapRows(targetPage, competitorPages) {
1110
+ const targetHeadingText = new Set((targetPage?.headings ?? []).map((h) => h.text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim()));
1111
+ const targetTerms = new Set((targetPage?.headings ?? []).flatMap((h) => h.text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean)));
1112
+ const rows = [];
1113
+ for (const { source, page } of competitorPages) {
1114
+ for (const heading of page.headings ?? []) {
1115
+ if (heading.level > 3 || !heading.text) continue;
1116
+ const normalized = heading.text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
1117
+ const terms = normalized.split(/\s+/).filter((term) => term.length > 3);
1118
+ const overlap = terms.filter((term) => targetTerms.has(term)).length;
1119
+ const covered = targetHeadingText.has(normalized) || overlap >= Math.max(2, Math.ceil(terms.length / 2));
1120
+ if (covered && targetPage) continue;
1121
+ rows.push({
1122
+ source_position: source.position ?? "",
1123
+ source_domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url),
1124
+ source_url: source.url ?? "",
1125
+ heading_level: heading.level,
1126
+ competitor_heading: heading.text,
1127
+ target_coverage: targetPage ? "not found in target headings" : "no target page extracted",
1128
+ terms: textTerms(heading.text, 6)
1129
+ });
1130
+ }
1131
+ }
1132
+ return rows.slice(0, 150);
1133
+ }
1134
+ async function extractPages(ctx, sources, warnings, labelPrefix) {
1135
+ return mapLimit2(sources.filter((source) => source.url), 2, async (source, index) => {
1136
+ try {
1137
+ const page = await ctx.client.post("/extract-url", { url: source.url }, 18e4);
1138
+ await ctx.artifacts.writeJson(`${labelPrefix} ${index + 1}`, `raw/extract-url/${labelPrefix.toLowerCase()}-${index + 1}.json`, page);
1139
+ return { source, page };
1140
+ } catch (err) {
1141
+ warnings.push(`Page extraction failed for ${source.url}: ${err instanceof Error ? err.message : String(err)}`);
1142
+ return null;
1143
+ }
1144
+ }).then((items) => items.filter((item) => item !== null));
1145
+ }
1146
+ var mapComparisonWorkflowDefinition = {
1147
+ id: "map-comparison",
1148
+ title: "Maps Comparison",
1149
+ description: "Compare Google Maps competitors by rank, reviews, stars, categories, websites, and profile/review signals.",
1150
+ inputSchema: MapComparisonInputSchema,
1151
+ async run(input, ctx) {
1152
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1153
+ const warnings = [];
1154
+ let rows;
1155
+ let directory = null;
1156
+ let mapsSearch = null;
1157
+ if (input.location) {
1158
+ mapsSearch = await ctx.client.post("/maps/search", {
1159
+ query: input.query,
1160
+ location: input.location,
1161
+ maxResults: input.maxResultsPerCity,
1162
+ proxyMode: input.proxyMode
1163
+ }, 24e4);
1164
+ rows = businessRowsFromMaps(input.location, input.query, mapsSearch.results);
1165
+ await ctx.artifacts.writeJson("Maps search raw JSON", "raw/maps-search.json", mapsSearch);
1166
+ } else {
1167
+ directory = await ctx.client.post("/directory/run", {
1168
+ query: input.query,
1169
+ state: input.state,
1170
+ minPopulation: input.minPopulation,
1171
+ maxCities: input.maxCities,
1172
+ maxResultsPerCity: input.maxResultsPerCity,
1173
+ concurrency: input.concurrency,
1174
+ proxyMode: input.proxyMode,
1175
+ saveCsv: true
1176
+ }, 9e5);
1177
+ rows = directoryRows(directory);
1178
+ await ctx.artifacts.writeJson("Directory raw JSON", "raw/directory-workflow.json", directory);
1179
+ await ctx.artifacts.writeCsv("Directory CSV", "exports/directory.csv", DIRECTORY_CSV_HEADERS, rows);
1180
+ warnings.push(...directory.warnings);
1181
+ }
1182
+ const compareRows = comparisonRows(rows);
1183
+ const selected = compareRows.slice(0, input.hydrateTop * Math.max(1, input.location ? 1 : input.maxCities));
1184
+ const hydrated = await mapLimit2(selected, 3, async (row, index) => {
1185
+ try {
1186
+ const detail = await ctx.client.post("/maps/place", {
1187
+ businessName: row.business_name,
1188
+ location: row.source_location,
1189
+ includeReviews: input.maxReviews > 0,
1190
+ maxReviews: Math.max(1, input.maxReviews)
1191
+ }, 18e4);
1192
+ 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);
1193
+ return { row, detail, error: "" };
1194
+ } catch (err) {
1195
+ const message = err instanceof Error ? err.message : String(err);
1196
+ warnings.push(`Profile hydration failed for ${row.business_name}: ${message}`);
1197
+ return { row, detail: null, error: message };
1198
+ }
1199
+ });
1200
+ const profileRows = hydrated.map(({ row, detail, error }) => ({
1201
+ source_location: row.source_location,
1202
+ result_position: row.result_position,
1203
+ business_name: row.business_name,
1204
+ category: detail?.category ?? row.category,
1205
+ review_stars: detail?.rating ?? row.review_stars,
1206
+ review_count: detail?.reviewCount ?? row.review_count,
1207
+ website_url: detail?.website ?? row.website_url,
1208
+ review_topics: (detail?.reviewTopics ?? []).map((t) => `${t.label} (${t.count})`).join("; "),
1209
+ about_attributes: (detail?.aboutAttributes ?? []).map((a) => `${a.section}: ${a.attribute}`).join("; "),
1210
+ reviews_status: detail?.reviewsStatus ?? "",
1211
+ error
1212
+ }));
1213
+ const markets = marketRows(rows);
1214
+ 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);
1215
+ 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);
1216
+ 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);
1217
+ await ctx.artifacts.writeJson("Maps comparison evidence", "evidence.json", { input, directory, mapsSearch, rows, compareRows, profileRows, markets, warnings });
1218
+ await ctx.artifacts.writeText("Brief", "brief.md", [
1219
+ `# Maps Comparison: ${input.query}`,
1220
+ "",
1221
+ `Markets: ${markets.map((row) => row.source_location).join(", ")}`,
1222
+ "",
1223
+ "## How to Use",
1224
+ "- Compare rank position against review count and category patterns.",
1225
+ "- Treat review gaps and missing websites as opportunity signals, not guarantees.",
1226
+ "- Use profile topics and attributes as evidence for local content and GBP improvements."
1227
+ ].join("\n"));
1228
+ const summary = `${compareRows.length} Maps competitors compared across ${markets.length} market(s); ${profileRows.filter((row) => !row.error).length} profiles hydrated.`;
1229
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1230
+ title: "Maps Comparison",
1231
+ subtitle: `${input.query}${input.location ? ` \xB7 ${input.location}` : input.state ? ` \xB7 ${input.state}` : ""}`,
1232
+ summary,
1233
+ warnings,
1234
+ tables: [
1235
+ { title: "Market Benchmarks", columns: ["source_location", "result_count", "median_review_count", "median_rating", "top_three_average_review_count", "top_categories", "websites_present"], rows: markets },
1236
+ { 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) },
1237
+ { title: "Profile Insights", columns: ["source_location", "business_name", "review_topics", "about_attributes", "error"], rows: profileRows }
1238
+ ]
1239
+ }));
1240
+ const status = warnings.length ? "partial" : "succeeded";
1241
+ const counts = { markets: markets.length, competitors: compareRows.length, hydratedProfiles: profileRows.filter((row) => !row.error).length };
1242
+ await ctx.artifacts.writeManifest(status, counts, warnings, []);
1243
+ return { title: "Maps Comparison", summary, status, counts, warnings, errors: [], reportPath };
1244
+ }
1245
+ };
1246
+ var serpComparisonWorkflowDefinition = {
1247
+ id: "serp-comparison",
1248
+ title: "SERP Comparison",
1249
+ description: "Compare ranking pages, SERP features, PAA evidence, AI Overview citations, and page-level content gaps.",
1250
+ inputSchema: SerpComparisonInputSchema,
1251
+ async run(input, ctx) {
1252
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1253
+ const warnings = [];
1254
+ const targetDomain = normalizeDomain2(input.domain ?? input.url ?? null);
1255
+ const serp = await ctx.client.post("/harvest/sync", {
1256
+ query: input.keyword,
1257
+ location: input.location,
1258
+ serpOnly: true,
1259
+ maxQuestions: 1,
1260
+ format: "json"
1261
+ }, 18e4);
1262
+ await ctx.artifacts.writeJson("SERP raw JSON", "raw/serp.json", serp);
1263
+ let paa = null;
1264
+ if (input.includePaa) {
1265
+ try {
1266
+ paa = await ctx.client.post("/harvest/sync", {
1267
+ query: input.keyword,
1268
+ location: input.location,
1269
+ maxQuestions: input.maxQuestions,
1270
+ format: "json"
1271
+ }, 28e4);
1272
+ await ctx.artifacts.writeJson("PAA raw JSON", "raw/paa.json", paa);
1273
+ } catch (err) {
1274
+ warnings.push(`PAA evidence unavailable: ${err instanceof Error ? err.message : String(err)}`);
1275
+ }
1276
+ }
1277
+ const organic = organicRows(serp, targetDomain).slice(0, input.maxResults);
1278
+ const organicSources = (serp.organicResults ?? []).slice(0, input.maxResults);
1279
+ 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);
1280
+ 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);
1281
+ const targetPages = targetSource ? await extractPages(ctx, [targetSource], warnings, "Target") : [];
1282
+ const competitorPages = input.extractTop > 0 ? await extractPages(ctx, competitorSources, warnings, "Competitor") : [];
1283
+ const targetPage = targetPages[0]?.page ?? null;
1284
+ const pageRows = [
1285
+ ...targetPages.map(({ source, page }) => pageSummaryRow(page, { url: source.url ?? "", domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url), position: source.position, title: source.title })),
1286
+ ...competitorPages.map(({ source, page }) => pageSummaryRow(page, { url: source.url ?? "", domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url), position: source.position, title: source.title }))
1287
+ ];
1288
+ const gaps = pageGapRows(targetPage, competitorPages);
1289
+ const questions = questionRows(paa);
1290
+ const aiRows = (serp.aiOverview?.citations ?? []).map((citation, index) => ({
1291
+ citation_position: index + 1,
1292
+ citation_text: citation.text ?? "",
1293
+ url: citation.href ?? "",
1294
+ domain: domainFromUrl2(citation.href ?? ""),
1295
+ is_target: targetDomain ? domainFromUrl2(citation.href ?? "") === targetDomain : false
1296
+ }));
1297
+ await ctx.artifacts.writeCsv("Organic results CSV", "organic-results.csv", ["position", "title", "url", "domain", "snippet", "is_target"], organic);
1298
+ 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);
1299
+ await ctx.artifacts.writeCsv("Content gaps CSV", "content-gaps.csv", ["source_position", "source_domain", "source_url", "heading_level", "competitor_heading", "target_coverage", "terms"], gaps);
1300
+ await ctx.artifacts.writeCsv("PAA questions CSV", "paa-questions.csv", ["position", "intent", "question", "answer_excerpt", "source_title", "source_domain", "source_url"], questions);
1301
+ await ctx.artifacts.writeCsv("AI Overview citations CSV", "ai-overview-citations.csv", ["citation_position", "citation_text", "url", "domain", "is_target"], aiRows);
1302
+ await ctx.artifacts.writeJson("SERP comparison evidence", "evidence.json", { input, serp, paa, organic, pageRows, gaps, questions, aiRows, warnings });
1303
+ await ctx.artifacts.writeText("Writer brief", "brief.md", [
1304
+ `# SERP Comparison Brief: ${input.keyword}`,
1305
+ "",
1306
+ `Target: ${targetDomain ?? input.url ?? "not specified"}`,
1307
+ `Location: ${input.location ?? "not specified"}`,
1308
+ "",
1309
+ "## Recommended Actions",
1310
+ "- Use `content-gaps.csv` to decide which missing sections deserve coverage.",
1311
+ "- Use `paa-questions.csv` for FAQ and answer-block candidates.",
1312
+ "- Use `ai-overview-citations.csv` to see whether the target is cited in AI Overview evidence.",
1313
+ "- Treat extracted page headings as evidence, not a complete semantic analysis."
1314
+ ].join("\n"));
1315
+ const summary = `${organic.length} organic results, ${pageRows.length} extracted pages, ${gaps.length} heading gaps, ${questions.length} PAA questions.`;
1316
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1317
+ title: "SERP Comparison",
1318
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
1319
+ summary,
1320
+ warnings,
1321
+ tables: [
1322
+ { title: "Organic Results", columns: ["position", "title", "domain", "is_target"], rows: organic },
1323
+ { title: "Page Comparison", columns: ["position", "domain", "h1", "word_count", "heading_count", "schema_types"], rows: pageRows },
1324
+ { title: "Content Gaps", columns: ["source_position", "source_domain", "competitor_heading", "target_coverage", "terms"], rows: gaps.slice(0, 80) },
1325
+ { title: "PAA Questions", columns: ["position", "intent", "question", "source_domain"], rows: questions.slice(0, 80) }
1326
+ ]
1327
+ }));
1328
+ const status = warnings.length ? "partial" : "succeeded";
1329
+ const counts = { organic: organic.length, pages: pageRows.length, gaps: gaps.length, questions: questions.length, aiCitations: aiRows.length };
1330
+ await ctx.artifacts.writeManifest(status, counts, warnings, []);
1331
+ return { title: "SERP Comparison", summary, status, counts, warnings, errors: [], reportPath };
1332
+ }
1333
+ };
1334
+ var paaExpansionBriefWorkflowDefinition = {
1335
+ id: "paa-expansion-brief",
1336
+ title: "PAA Expansion Brief",
1337
+ description: "Expand People Also Ask questions into an evidence-backed writer brief, section map, and source table.",
1338
+ inputSchema: PaaExpansionBriefInputSchema,
1339
+ async run(input, ctx) {
1340
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1341
+ const warnings = [];
1342
+ const paa = await ctx.client.post("/harvest/sync", {
1343
+ query: input.keyword,
1344
+ location: input.location,
1345
+ maxQuestions: input.maxQuestions,
1346
+ depth: input.depth,
1347
+ format: "json"
1348
+ }, 3e5);
1349
+ await ctx.artifacts.writeJson("PAA raw JSON", "raw/paa.json", paa);
1350
+ const questions = questionRows(paa);
1351
+ const sourceRows2 = sourceDomainRows(questions);
1352
+ const byIntent = /* @__PURE__ */ new Map();
1353
+ for (const row of questions) {
1354
+ const list = byIntent.get(String(row.intent)) ?? [];
1355
+ list.push(row);
1356
+ byIntent.set(String(row.intent), list);
1357
+ }
1358
+ const sectionRows = [...byIntent.entries()].map(([intent, rows]) => ({
1359
+ recommended_section: intent,
1360
+ question_count: rows.length,
1361
+ sample_questions: rows.slice(0, 5).map((row) => row.question).join(" | "),
1362
+ source_domains: [...new Set(rows.map((row) => row.source_domain).filter(Boolean))].slice(0, 5).join("; "),
1363
+ terms: textTerms(rows.map((row) => `${row.question} ${row.answer_excerpt}`).join(" "), 10)
1364
+ })).sort((a, b) => Number(b.question_count) - Number(a.question_count));
1365
+ await ctx.artifacts.writeCsv("PAA questions CSV", "paa-questions.csv", ["position", "intent", "question", "answer_excerpt", "source_title", "source_domain", "source_url"], questions);
1366
+ await ctx.artifacts.writeCsv("Source domains CSV", "source-domains.csv", ["domain", "question_mentions", "source_url_count", "top_intents"], sourceRows2);
1367
+ await ctx.artifacts.writeCsv("Section map CSV", "section-map.csv", ["recommended_section", "question_count", "sample_questions", "source_domains", "terms"], sectionRows);
1368
+ await ctx.artifacts.writeJson("PAA brief evidence", "evidence.json", { input, paa, questions, sourceRows: sourceRows2, sectionRows });
1369
+ await ctx.artifacts.writeText("Writer brief", "writer-brief.md", [
1370
+ `# PAA Expansion Brief: ${input.keyword}`,
1371
+ "",
1372
+ `Location: ${input.location ?? "not specified"}`,
1373
+ "",
1374
+ "## Suggested Page Structure",
1375
+ ...sectionRows.map((row) => `- ${row.recommended_section}: answer ${row.question_count} related question(s). Sample: ${row.sample_questions}`),
1376
+ "",
1377
+ "## Writing Rules",
1378
+ "- Answer the highest-frequency question in the first 60 words of each section.",
1379
+ "- Use exact customer question language from `paa-questions.csv` for H2/H3 candidates.",
1380
+ "- Use `source-domains.csv` to identify which source types Google is already rewarding.",
1381
+ "- Do not invent citations; cite only rows that have a source URL."
1382
+ ].join("\n"));
1383
+ const summary = `${questions.length} PAA questions grouped into ${sectionRows.length} writing sections.`;
1384
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1385
+ title: "PAA Expansion Brief",
1386
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
1387
+ summary,
1388
+ warnings,
1389
+ tables: [
1390
+ { title: "Section Map", columns: ["recommended_section", "question_count", "sample_questions", "source_domains", "terms"], rows: sectionRows },
1391
+ { title: "Questions", columns: ["position", "intent", "question", "source_domain"], rows: questions.slice(0, 120) },
1392
+ { title: "Source Domains", columns: ["domain", "question_mentions", "source_url_count", "top_intents"], rows: sourceRows2 }
1393
+ ]
1394
+ }));
1395
+ const counts = { questions: questions.length, sections: sectionRows.length, sourceDomains: sourceRows2.length };
1396
+ await ctx.artifacts.writeManifest("succeeded", counts, warnings, []);
1397
+ return { title: "PAA Expansion Brief", summary, status: "succeeded", counts, warnings, errors: [], reportPath };
1398
+ }
1399
+ };
1400
+ var aiOverviewLanguageWorkflowDefinition = {
1401
+ id: "ai-overview-language",
1402
+ title: "AI Overview Language Brief",
1403
+ description: "Turn AI Overview, citation, PAA, and ranking-page evidence into answer-block and citation-hook guidance.",
1404
+ inputSchema: AiOverviewLanguageInputSchema,
1405
+ async run(input, ctx) {
1406
+ await ctx.artifacts.writeManifest("running", {}, [], []);
1407
+ const warnings = [];
1408
+ const targetDomain = normalizeDomain2(input.domain ?? input.url ?? null);
1409
+ const serp = await ctx.client.post("/harvest/sync", {
1410
+ query: input.keyword,
1411
+ location: input.location,
1412
+ serpOnly: true,
1413
+ maxQuestions: 1,
1414
+ format: "json"
1415
+ }, 18e4);
1416
+ await ctx.artifacts.writeJson("SERP raw JSON", "raw/serp.json", serp);
1417
+ let paa = null;
1418
+ try {
1419
+ paa = await ctx.client.post("/harvest/sync", {
1420
+ query: input.keyword,
1421
+ location: input.location,
1422
+ maxQuestions: input.maxQuestions,
1423
+ format: "json"
1424
+ }, 28e4);
1425
+ await ctx.artifacts.writeJson("PAA raw JSON", "raw/paa.json", paa);
1426
+ } catch (err) {
1427
+ warnings.push(`PAA evidence unavailable: ${err instanceof Error ? err.message : String(err)}`);
1428
+ }
1429
+ const citations = (serp.aiOverview?.citations ?? []).map((citation, index) => {
1430
+ const domain = domainFromUrl2(citation.href ?? "");
1431
+ return {
1432
+ citation_position: index + 1,
1433
+ citation_text: citation.text ?? "",
1434
+ url: citation.href ?? "",
1435
+ domain,
1436
+ is_target: targetDomain ? domain === targetDomain : false
1437
+ };
1438
+ });
1439
+ const aioSentences = splitSentences(serp.aiOverview?.text);
1440
+ const claimRows = aioSentences.map((sentence, index) => ({
1441
+ position: index + 1,
1442
+ claim_type: classifySentence(sentence),
1443
+ sentence,
1444
+ reusable_pattern: sentence.length > 140 ? `${sentence.slice(0, 140)}...` : sentence
1445
+ }));
1446
+ const questions = questionRows(paa);
1447
+ 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);
1448
+ const extractedCitations = input.extractTop > 0 ? await extractPages(ctx, citationSources, warnings, "Citation") : [];
1449
+ const extractedRows = extractedCitations.map(({ source, page }) => pageSummaryRow(page, { url: source.url ?? "", domain: normalizeDomain2(source.domain ?? "") ?? domainFromUrl2(source.url), position: source.position, title: source.title }));
1450
+ const languageRows = [
1451
+ {
1452
+ block: "direct_answer",
1453
+ guidance: "Open with a 40-70 word answer that directly resolves the query before adding context.",
1454
+ evidence_basis: questions[0]?.question ?? input.keyword
1455
+ },
1456
+ {
1457
+ block: "criteria_or_steps",
1458
+ guidance: "List the criteria, steps, or decision factors Google is already compressing into AI Overview language.",
1459
+ evidence_basis: claimRows.filter((row) => ["criteria", "process"].includes(String(row.claim_type))).map((row) => row.sentence).slice(0, 3).join(" | ")
1460
+ },
1461
+ {
1462
+ block: "citation_hook",
1463
+ guidance: "Add source-worthy details competitors can cite: definitions, numbers, examples, process details, and named entity relationships.",
1464
+ evidence_basis: citations.map((row) => `${row.domain}: ${row.citation_text}`).slice(0, 5).join(" | ")
1465
+ },
1466
+ {
1467
+ block: "faq_followups",
1468
+ guidance: "Use PAA phrasing for follow-up sections so the page answers adjacent questions in Google language.",
1469
+ evidence_basis: questions.slice(0, 5).map((row) => row.question).join(" | ")
1470
+ }
1471
+ ];
1472
+ await ctx.artifacts.writeCsv("AI Overview citations CSV", "ai-overview-citations.csv", ["citation_position", "citation_text", "url", "domain", "is_target"], citations);
1473
+ await ctx.artifacts.writeCsv("AI Overview claim patterns CSV", "claim-patterns.csv", ["position", "claim_type", "sentence", "reusable_pattern"], claimRows);
1474
+ await ctx.artifacts.writeCsv("Language guidance CSV", "language-guidance.csv", ["block", "guidance", "evidence_basis"], languageRows);
1475
+ await ctx.artifacts.writeCsv("PAA questions CSV", "paa-questions.csv", ["position", "intent", "question", "answer_excerpt", "source_title", "source_domain", "source_url"], questions);
1476
+ 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);
1477
+ await ctx.artifacts.writeJson("AI Overview language evidence", "evidence.json", { input, serp, paa, citations, claimRows, languageRows, extractedRows, warnings });
1478
+ await ctx.artifacts.writeText("Answer block template", "answer-block-template.md", [
1479
+ `# AI Overview Language Brief: ${input.keyword}`,
1480
+ "",
1481
+ `AI Overview detected: ${serp.aiOverview?.detected ? "yes" : "no"}`,
1482
+ `Target cited: ${targetDomain ? citations.some((row) => row.is_target) ? "yes" : "no" : "target not specified"}`,
1483
+ "",
1484
+ "## Direct Answer Block",
1485
+ "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.",
1486
+ "",
1487
+ "## Suggested Follow-Up Blocks",
1488
+ ...languageRows.map((row) => `- ${row.block}: ${row.guidance}`),
1489
+ "",
1490
+ "## Evidence to Mirror",
1491
+ ...claimRows.slice(0, 8).map((row) => `- ${row.claim_type}: ${row.sentence}`),
1492
+ "",
1493
+ "## Citation Hooks",
1494
+ ...citations.slice(0, 8).map((row) => `- ${row.domain}: ${row.citation_text}`)
1495
+ ].join("\n"));
1496
+ const summary = `${citations.length} AI Overview citations, ${claimRows.length} claim patterns, ${questions.length} PAA questions, ${extractedRows.length} citation pages extracted.`;
1497
+ const reportPath = await ctx.artifacts.writeHtml("HTML report", "report.html", renderWorkflowReport({
1498
+ title: "AI Overview Language Brief",
1499
+ subtitle: `${input.keyword}${input.location ? ` \xB7 ${input.location}` : ""}`,
1500
+ summary,
1501
+ warnings,
1502
+ tables: [
1503
+ { title: "Language Guidance", columns: ["block", "guidance", "evidence_basis"], rows: languageRows },
1504
+ { title: "AI Overview Citations", columns: ["citation_position", "citation_text", "domain", "is_target"], rows: citations },
1505
+ { title: "Claim Patterns", columns: ["position", "claim_type", "sentence"], rows: claimRows },
1506
+ { title: "PAA Follow-Ups", columns: ["position", "intent", "question", "source_domain"], rows: questions.slice(0, 60) }
1507
+ ]
1508
+ }));
1509
+ const status = warnings.length || !serp.aiOverview?.detected ? "partial" : "succeeded";
1510
+ const counts = { citations: citations.length, claimPatterns: claimRows.length, questions: questions.length, extractedCitationPages: extractedRows.length };
1511
+ await ctx.artifacts.writeManifest(status, counts, warnings, []);
1512
+ return { title: "AI Overview Language Brief", summary, status, counts, warnings, errors: [], reportPath };
1513
+ }
1514
+ };
1515
+
1516
+ // src/workflows/registry.ts
1517
+ var DEFINITIONS = [
1518
+ directoryWorkflowDefinition,
1519
+ agentPacketWorkflowDefinition,
1520
+ localCompetitiveAuditWorkflowDefinition,
1521
+ mapComparisonWorkflowDefinition,
1522
+ serpComparisonWorkflowDefinition,
1523
+ paaExpansionBriefWorkflowDefinition,
1524
+ aiOverviewLanguageWorkflowDefinition
1525
+ ];
1526
+ function listWorkflowDefinitions() {
1527
+ return DEFINITIONS.map(({ id, title, description }) => ({ id, title, description }));
1528
+ }
1529
+ function workflowDefinition(id) {
1530
+ const definition = DEFINITIONS.find((def) => def.id === id);
1531
+ if (!definition) throw new Error(`Unknown workflow "${id}". Available: ${DEFINITIONS.map((def) => def.id).join(", ")}`);
1532
+ return definition;
1533
+ }
1534
+ async function runWorkflow(id, rawInput, options = {}) {
1535
+ const definition = workflowDefinition(id);
1536
+ const input = definition.inputSchema.parse(rawInput);
1537
+ const apiKey = options.apiKey?.trim() || process.env.MCP_SCRAPER_API_KEY?.trim();
1538
+ if (!apiKey) throw new Error("MCP_SCRAPER_API_KEY is required for workflow runs. Pass --api-key or set the environment variable.");
1539
+ const apiUrl = options.apiUrl?.trim() || process.env.MCP_SCRAPER_API_URL?.trim() || "https://mcpscraper.dev";
1540
+ const artifacts = await ArtifactWriter.create(definition.id, definition.title, input, options.outputDir, options.runId);
1541
+ const client = new WorkflowHttpClient(apiUrl, apiKey, options.fetchImpl);
1542
+ try {
1543
+ return await definition.run(input, {
1544
+ runId: artifacts.runId,
1545
+ startedAt: artifacts.startedAt,
1546
+ client,
1547
+ artifacts,
1548
+ signal: options.signal
1549
+ });
1550
+ } catch (err) {
1551
+ const message = err instanceof Error ? err.message : String(err);
1552
+ await artifacts.writeText("Failure", "summary.md", `# ${definition.title}
1553
+
1554
+ ${message}
1555
+ `);
1556
+ await artifacts.writeManifest("failed", {}, [], [message]);
1557
+ throw err;
1558
+ }
1559
+ }
1560
+
1561
+ export {
1562
+ csvRecords,
1563
+ rowsToCsv,
1564
+ workflowOutputBaseDir,
1565
+ listWorkflowReports,
1566
+ findWorkflowReport,
1567
+ openWorkflowReport,
1568
+ listWorkflowDefinitions,
1569
+ workflowDefinition,
1570
+ runWorkflow
1571
+ };
1572
+ //# sourceMappingURL=chunk-AYTOCZBS.js.map