geo-ai-search-optimization 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-ai-search-optimization",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,6 +54,9 @@ import { generateAutoFix, renderAutoFixMarkdown, writeAutoFixOutput } from "./au
54
54
  import { savePageSnapshot, buildPageTrend, renderPageTrendMarkdown, writePageTrendOutput } from "./page-snapshot.js";
55
55
  import { diagnose, renderDiagnoseMarkdown, writeDiagnoseOutput } from "./diagnose.js";
56
56
  import { comparePages, renderCompareMarkdown, writeCompareOutput } from "./compare.js";
57
+ import { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput } from "./deep-benchmark.js";
58
+ import { quickSummary, renderSummaryMarkdown } from "./summary.js";
59
+ import { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv } from "./csv-export.js";
57
60
 
58
61
  export const SITE_OPS_HELP_LINES = [
59
62
  " geo-ai-search-optimization doctor [--json]",
@@ -99,7 +102,9 @@ export const SITE_OPS_HELP_LINES = [
99
102
  " geo-ai-search-optimization auto-fix <url-or-file> [--json] [--out <file>]",
100
103
  " geo-ai-search-optimization page-trend <url-or-file> [--data-dir <dir>] [--last <n>] [--json] [--out <file>]",
101
104
  " geo-ai-search-optimization diagnose <url-or-dir-or-file> [--json] [--out <file>]",
102
- " geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--out <file>]"
105
+ " geo-ai-search-optimization compare <url-or-file-A> <url-or-file-B> [--json] [--csv] [--out <file>]",
106
+ " geo-ai-search-optimization deep-benchmark <url> --competitors <url1,url2,...> [--concurrency <n>] [--json] [--csv] [--out <file>]",
107
+ " geo-ai-search-optimization summary <url-or-dir-or-file>"
103
108
  ];
104
109
 
105
110
  const passthroughWriteOutput = async (outputPath) => outputPath;
@@ -705,6 +710,40 @@ const handleBatchFullPageAudit = createStructuredOutputCommandHandler({
705
710
  getOutputJson: (args) => hasFlag(args, "--json")
706
711
  });
707
712
 
713
+ const handleDeepBenchmark = createStructuredOutputCommandHandler({
714
+ commandLabel: "deep benchmark",
715
+ execute: async (args) => {
716
+ const ownUrl = args.find((value) => !value.startsWith("-"));
717
+ if (!ownUrl) throw new Error("deep-benchmark requires your URL as the first argument");
718
+ const competitorsRaw = getFlagValue(args, "--competitors");
719
+ if (!competitorsRaw) throw new Error("deep-benchmark requires --competitors <url1,url2,...>");
720
+ const competitorUrls = competitorsRaw.split(",").map((u) => u.trim()).filter(Boolean);
721
+ const concurrencyValue = getFlagValue(args, "--concurrency");
722
+ return deepBenchmark(ownUrl, competitorUrls, {
723
+ concurrency: concurrencyValue ? parsePositiveInteger(concurrencyValue, "--concurrency") : 2
724
+ });
725
+ },
726
+ renderMarkdown: (report, args) => {
727
+ if (args && hasFlag(args, "--csv")) return deepBenchmarkToCsv(report);
728
+ return renderDeepBenchmarkMarkdown(report);
729
+ },
730
+ writeOutput: writeDeepBenchmarkOutput,
731
+ getOutputJson: (args) => hasFlag(args, "--json")
732
+ });
733
+
734
+ const handleSummary = async (args) => {
735
+ const input = args.find((value) => !value.startsWith("-"));
736
+ if (!input) throw new Error("summary requires a URL, file path, or directory");
737
+ const result = await quickSummary(input);
738
+
739
+ if (hasFlag(args, "--json")) {
740
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
741
+ return;
742
+ }
743
+
744
+ process.stdout.write(renderSummaryMarkdown(result));
745
+ };
746
+
708
747
  const handleDiagnose = createStructuredOutputCommandHandler({
709
748
  commandLabel: "diagnose",
710
749
  execute: async (args) => {
@@ -724,11 +763,34 @@ const handleCompare = createStructuredOutputCommandHandler({
724
763
  if (nonFlags.length < 2) throw new Error("compare requires two URLs or file paths: <A> <B>");
725
764
  return comparePages(nonFlags[0], nonFlags[1]);
726
765
  },
727
- renderMarkdown: renderCompareMarkdown,
766
+ renderMarkdown: (report) => renderCompareMarkdown(report),
728
767
  writeOutput: writeCompareOutput,
729
- getOutputJson: (args) => hasFlag(args, "--json")
768
+ getOutputJson: (args) => {
769
+ if (hasFlag(args, "--csv")) return false; // Let renderMarkdown handle CSV
770
+ return hasFlag(args, "--json");
771
+ }
730
772
  });
731
773
 
774
+ // Override compare to support --csv
775
+ const _originalCompare = handleCompare;
776
+ const handleCompareCsv = async (args) => {
777
+ if (hasFlag(args, "--csv")) {
778
+ const nonFlags = args.filter((value) => !value.startsWith("-"));
779
+ if (nonFlags.length < 2) throw new Error("compare requires two URLs or file paths: <A> <B>");
780
+ const result = await comparePages(nonFlags[0], nonFlags[1]);
781
+ const csv = compareToCsv(result);
782
+ const outputPath = getFlagValue(args, "--out");
783
+ if (outputPath) {
784
+ const resolved = await writeCompareOutput(outputPath, csv);
785
+ process.stdout.write(`已保存 compare CSV:${resolved}\n`);
786
+ } else {
787
+ process.stdout.write(csv + "\n");
788
+ }
789
+ return;
790
+ }
791
+ return _originalCompare(args);
792
+ };
793
+
732
794
  const handleAutoFix = createStructuredOutputCommandHandler({
733
795
  commandLabel: "auto-fix",
734
796
  execute: async (args) => {
@@ -851,5 +913,7 @@ export const SITE_OPS_COMMAND_HANDLERS = {
851
913
  "auto-fix": handleAutoFix,
852
914
  "page-trend": handlePageTrend,
853
915
  diagnose: handleDiagnose,
854
- compare: handleCompare
916
+ compare: handleCompareCsv,
917
+ "deep-benchmark": handleDeepBenchmark,
918
+ summary: handleSummary
855
919
  };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * CSV export utility for structured audit data.
3
+ */
4
+
5
+ function escCsv(value) {
6
+ if (value === null || value === undefined) return "";
7
+ const str = String(value);
8
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
9
+ return `"${str.replace(/"/g, '""')}"`;
10
+ }
11
+ return str;
12
+ }
13
+
14
+ function toCsvRow(values) {
15
+ return values.map(escCsv).join(",");
16
+ }
17
+
18
+ /**
19
+ * Convert a full-page-audit result to CSV.
20
+ */
21
+ export function fullPageAuditToCsv(report) {
22
+ const rows = [toCsvRow(["Dimension", "Score", "Label", "Weight"])];
23
+
24
+ const weights = {
25
+ base: 15, citability: 12, eeat: 12, readability: 8, headingStructure: 6,
26
+ internalLinks: 5, socialMeta: 5, platformReady: 8, schema: 7,
27
+ freshness: 8, security: 7, topics: 7
28
+ };
29
+
30
+ for (const [dim, data] of Object.entries(report.dimensions || {})) {
31
+ rows.push(toCsvRow([dim, data.score, data.label, `${weights[dim] || 0}%`]));
32
+ }
33
+
34
+ rows.push(toCsvRow(["COMPOSITE", report.compositeScore, report.compositeLabel, "100%"]));
35
+ return rows.join("\n");
36
+ }
37
+
38
+ /**
39
+ * Convert a batch-full-page-audit result to CSV.
40
+ */
41
+ export function batchFullPageAuditToCsv(report) {
42
+ const dimKeys = Object.keys(report.dimensionAverages || {});
43
+ const header = ["URL", "Composite", "Label", ...dimKeys];
44
+ const rows = [toCsvRow(header)];
45
+
46
+ for (const result of report.results || []) {
47
+ const dimScores = dimKeys.map((k) => result.dimensions?.[k]?.score ?? "");
48
+ rows.push(toCsvRow([result.input, result.compositeScore, result.compositeLabel, ...dimScores]));
49
+ }
50
+
51
+ // Averages row
52
+ const avgScores = dimKeys.map((k) => report.dimensionAverages[k] ?? "");
53
+ rows.push(toCsvRow(["AVERAGE", report.avgScore, "", ...avgScores]));
54
+
55
+ return rows.join("\n");
56
+ }
57
+
58
+ /**
59
+ * Convert a deep-benchmark result to CSV.
60
+ */
61
+ export function deepBenchmarkToCsv(report) {
62
+ // Score board
63
+ const sections = [];
64
+
65
+ sections.push("# Score Board");
66
+ const boardRows = [toCsvRow(["Rank", "URL", "Composite", "Label", "IsOwn"])];
67
+ report.scoreBoard.forEach((entry, i) => {
68
+ boardRows.push(toCsvRow([i + 1, entry.url, entry.composite, entry.label, entry.isOwn]));
69
+ });
70
+ sections.push(boardRows.join("\n"));
71
+
72
+ // Dimension comparison
73
+ sections.push("\n# Dimension Comparison");
74
+ const dimHeader = ["Dimension", "You", "CompetitorAvg", "Delta", "Rank"];
75
+ const dimRows = [toCsvRow(dimHeader)];
76
+ for (const [dim, data] of Object.entries(report.dimensionComparison || {})) {
77
+ dimRows.push(toCsvRow([dim, data.own, data.competitorAvg, data.delta, data.rank]));
78
+ }
79
+ sections.push(dimRows.join("\n"));
80
+
81
+ return sections.join("\n\n");
82
+ }
83
+
84
+ /**
85
+ * Convert a compare result to CSV.
86
+ */
87
+ export function compareToCsv(report) {
88
+ const rows = [toCsvRow(["Dimension", "Page A", "Page B", "Delta", "Winner"])];
89
+
90
+ for (const [dim, data] of Object.entries(report.dimensions || {})) {
91
+ rows.push(toCsvRow([dim, data.scoreA, data.scoreB, data.delta, data.winner]));
92
+ }
93
+
94
+ rows.push(toCsvRow(["COMPOSITE", report.compositeA, report.compositeB, report.compositeDelta, report.overallWinner]));
95
+ return rows.join("\n");
96
+ }
97
+
98
+ /**
99
+ * Generic object-array to CSV converter.
100
+ */
101
+ export function arrayToCsv(items, columns) {
102
+ if (!items || items.length === 0) return "";
103
+
104
+ const keys = columns || Object.keys(items[0]);
105
+ const rows = [toCsvRow(keys)];
106
+
107
+ for (const item of items) {
108
+ rows.push(toCsvRow(keys.map((k) => item[k])));
109
+ }
110
+
111
+ return rows.join("\n");
112
+ }
@@ -0,0 +1,231 @@
1
+ import { writeScanOutput } from "./scan.js";
2
+ import { fullPageAudit } from "./full-page-audit.js";
3
+
4
+ /**
5
+ * Deep benchmark: full 12-dimension comparison against N competitors.
6
+ */
7
+
8
+ async function auditSafe(url, options) {
9
+ try {
10
+ const result = await fullPageAudit(url, options);
11
+ return { ok: true, url, data: result };
12
+ } catch (err) {
13
+ return { ok: false, url, error: err.message };
14
+ }
15
+ }
16
+
17
+ export async function deepBenchmark(ownUrl, competitorUrls, options = {}) {
18
+ const concurrency = options.concurrency || 2;
19
+ const allUrls = [ownUrl, ...competitorUrls];
20
+
21
+ // Audit all URLs with concurrency
22
+ const results = [];
23
+ const chunks = [];
24
+ for (let i = 0; i < allUrls.length; i += concurrency) {
25
+ chunks.push(allUrls.slice(i, i + concurrency));
26
+ }
27
+
28
+ for (const chunk of chunks) {
29
+ const chunkResults = await Promise.all(chunk.map((url) => auditSafe(url, options)));
30
+ results.push(...chunkResults);
31
+ }
32
+
33
+ const ownResult = results[0];
34
+ const competitorResults = results.slice(1);
35
+ const successful = results.filter((r) => r.ok);
36
+
37
+ // Dimension comparison
38
+ const allDimKeys = new Set();
39
+ for (const r of successful) {
40
+ for (const key of Object.keys(r.data.dimensions)) {
41
+ allDimKeys.add(key);
42
+ }
43
+ }
44
+
45
+ const dimensionComparison = {};
46
+ for (const dim of allDimKeys) {
47
+ const ownScore = ownResult.ok ? (ownResult.data.dimensions[dim]?.score ?? 0) : null;
48
+ const compScores = competitorResults.map((r) =>
49
+ r.ok ? (r.data.dimensions[dim]?.score ?? 0) : null
50
+ );
51
+ const validCompScores = compScores.filter((s) => s !== null);
52
+ const avgComp = validCompScores.length > 0
53
+ ? Math.round(validCompScores.reduce((a, b) => a + b, 0) / validCompScores.length)
54
+ : null;
55
+
56
+ dimensionComparison[dim] = {
57
+ own: ownScore,
58
+ competitors: compScores,
59
+ competitorAvg: avgComp,
60
+ delta: ownScore !== null && avgComp !== null ? ownScore - avgComp : null,
61
+ rank: null // Computed below
62
+ };
63
+ }
64
+
65
+ // Compute ranks per dimension
66
+ for (const dim of allDimKeys) {
67
+ const scores = results
68
+ .filter((r) => r.ok)
69
+ .map((r) => ({ url: r.url, score: r.data.dimensions[dim]?.score ?? 0 }))
70
+ .sort((a, b) => b.score - a.score);
71
+
72
+ const ownRank = scores.findIndex((s) => s.url === ownUrl) + 1;
73
+ dimensionComparison[dim].rank = ownRank || null;
74
+ }
75
+
76
+ // Overall scores
77
+ const scoreBoard = results.map((r) => ({
78
+ url: r.url,
79
+ isOwn: r.url === ownUrl,
80
+ composite: r.ok ? r.data.compositeScore : null,
81
+ label: r.ok ? r.data.compositeLabel : "Failed",
82
+ error: r.ok ? null : r.error
83
+ })).sort((a, b) => (b.composite ?? -1) - (a.composite ?? -1));
84
+
85
+ const ownRank = scoreBoard.findIndex((s) => s.isOwn) + 1;
86
+
87
+ // Strengths and weaknesses vs competitor average
88
+ const strengths = [];
89
+ const weaknesses = [];
90
+
91
+ for (const [dim, data] of Object.entries(dimensionComparison)) {
92
+ if (data.delta === null) continue;
93
+ if (data.delta >= 10) strengths.push({ dimension: dim, delta: data.delta, rank: data.rank });
94
+ if (data.delta <= -10) weaknesses.push({ dimension: dim, delta: data.delta, rank: data.rank });
95
+ }
96
+
97
+ strengths.sort((a, b) => b.delta - a.delta);
98
+ weaknesses.sort((a, b) => a.delta - b.delta);
99
+
100
+ // Platform comparison
101
+ const platformComparison = [];
102
+ if (ownResult.ok && ownResult.data.platformSummary) {
103
+ for (const ownPlatform of ownResult.data.platformSummary) {
104
+ const compPlatformScores = competitorResults
105
+ .filter((r) => r.ok && r.data.platformSummary)
106
+ .map((r) => r.data.platformSummary.find((p) => p.platform === ownPlatform.platform)?.score ?? 0);
107
+ const avgCompPlatform = compPlatformScores.length > 0
108
+ ? Math.round(compPlatformScores.reduce((a, b) => a + b, 0) / compPlatformScores.length)
109
+ : 0;
110
+
111
+ platformComparison.push({
112
+ platform: ownPlatform.platform,
113
+ ownScore: ownPlatform.score,
114
+ competitorAvg: avgCompPlatform,
115
+ delta: ownPlatform.score - avgCompPlatform
116
+ });
117
+ }
118
+ }
119
+
120
+ // Actionable gaps
121
+ const gaps = weaknesses.map((w) => ({
122
+ dimension: w.dimension,
123
+ gap: Math.abs(w.delta),
124
+ action: `Improve ${w.dimension} (${Math.abs(w.delta)} points behind competitor average)`
125
+ }));
126
+
127
+ const ownComposite = ownResult.ok ? ownResult.data.compositeScore : 0;
128
+ const avgCompComposite = successful.length > 1
129
+ ? Math.round(successful.filter((r) => r.url !== ownUrl).reduce((sum, r) => sum + r.data.compositeScore, 0) / (successful.length - 1))
130
+ : null;
131
+
132
+ return {
133
+ kind: "geo-deep-benchmark",
134
+ ownUrl,
135
+ competitorUrls,
136
+ ownComposite,
137
+ competitorAvgComposite: avgCompComposite,
138
+ compositeDelta: avgCompComposite !== null ? ownComposite - avgCompComposite : null,
139
+ ownRank,
140
+ totalCompetitors: competitorUrls.length,
141
+ scoreBoard,
142
+ dimensionComparison,
143
+ strengths,
144
+ weaknesses,
145
+ gaps,
146
+ platformComparison,
147
+ successful: successful.length,
148
+ failed: results.length - successful.length,
149
+ summary: `Deep benchmark: You rank #${ownRank}/${results.length}. Composite: ${ownComposite} vs avg ${avgCompComposite ?? "—"}. ${strengths.length} strength(s), ${weaknesses.length} weakness(es).`
150
+ };
151
+ }
152
+
153
+ const DIM_LABELS = {
154
+ base: "Base", citability: "Citability", eeat: "E-E-A-T", readability: "Readability",
155
+ headingStructure: "Headings", internalLinks: "Links", socialMeta: "Social",
156
+ platformReady: "Platforms", schema: "Schema", freshness: "Freshness",
157
+ security: "Security", topics: "Topics"
158
+ };
159
+
160
+ export function renderDeepBenchmarkMarkdown(report) {
161
+ const lines = [
162
+ "# GEO Deep Benchmark",
163
+ "",
164
+ `- Your URL: \`${report.ownUrl}\``,
165
+ `- Competitors: \`${report.totalCompetitors}\``,
166
+ `- **Your rank: #${report.ownRank}/${report.scoreBoard.length}**`,
167
+ `- Your composite: \`${report.ownComposite}/100\` vs competitor avg \`${report.competitorAvgComposite ?? "—"}/100\``,
168
+ `- Summary: ${report.summary}`,
169
+ "",
170
+ "## Score Board",
171
+ "",
172
+ "| Rank | URL | Composite | Label |",
173
+ "|------|-----|-----------|-------|"
174
+ ];
175
+
176
+ report.scoreBoard.forEach((entry, i) => {
177
+ const marker = entry.isOwn ? " **← You**" : "";
178
+ lines.push(`| ${i + 1} | ${entry.url.slice(0, 45)} | ${entry.composite ?? "—"} | ${entry.label}${marker} |`);
179
+ });
180
+
181
+ lines.push(
182
+ "",
183
+ "## Dimension Comparison (You vs Competitor Avg)",
184
+ "",
185
+ "| Dimension | You | Comp Avg | Δ | Rank |",
186
+ "|-----------|-----|----------|---|------|"
187
+ );
188
+
189
+ const sorted = Object.entries(report.dimensionComparison)
190
+ .sort((a, b) => (a[1].delta ?? 0) - (b[1].delta ?? 0));
191
+
192
+ for (const [dim, data] of sorted) {
193
+ const icon = (data.delta ?? 0) >= 5 ? "🟢" : (data.delta ?? 0) <= -5 ? "🔴" : "🟡";
194
+ lines.push(`| ${icon} ${DIM_LABELS[dim] || dim} | ${data.own ?? "—"} | ${data.competitorAvg ?? "—"} | ${data.delta !== null ? (data.delta >= 0 ? "+" : "") + data.delta : "—"} | #${data.rank ?? "—"} |`);
195
+ }
196
+
197
+ if (report.strengths.length > 0) {
198
+ lines.push("", "## Your Strengths (vs competitor avg)", "");
199
+ for (const s of report.strengths) {
200
+ lines.push(`- 🟢 **${DIM_LABELS[s.dimension] || s.dimension}**: +${s.delta} ahead (rank #${s.rank})`);
201
+ }
202
+ }
203
+
204
+ if (report.weaknesses.length > 0) {
205
+ lines.push("", "## Your Weaknesses (vs competitor avg)", "");
206
+ for (const w of report.weaknesses) {
207
+ lines.push(`- 🔴 **${DIM_LABELS[w.dimension] || w.dimension}**: ${w.delta} behind (rank #${w.rank})`);
208
+ }
209
+ }
210
+
211
+ if (report.platformComparison.length > 0) {
212
+ lines.push("", "## Platform Readiness vs Competitors", "", "| Platform | You | Comp Avg | Δ |", "|----------|-----|----------|---|");
213
+ for (const p of report.platformComparison) {
214
+ lines.push(`| ${p.platform} | ${p.ownScore} | ${p.competitorAvg} | ${p.delta >= 0 ? "+" : ""}${p.delta} |`);
215
+ }
216
+ }
217
+
218
+ if (report.gaps.length > 0) {
219
+ lines.push("", "## Priority Gaps to Close", "");
220
+ for (const g of report.gaps) {
221
+ lines.push(`- **${DIM_LABELS[g.dimension] || g.dimension}**: ${g.gap} points behind → ${g.action}`);
222
+ }
223
+ }
224
+
225
+ lines.push("");
226
+ return lines.join("\n");
227
+ }
228
+
229
+ export async function writeDeepBenchmarkOutput(outputPath, content) {
230
+ return writeScanOutput(outputPath, content);
231
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared fetch utilities with retry, timeout, and rate limiting.
3
+ */
4
+
5
+ const DEFAULT_TIMEOUT = 10_000;
6
+ const DEFAULT_RETRIES = 2;
7
+ const DEFAULT_RETRY_DELAY = 1000;
8
+ const USER_AGENT = "geo-ai-search-optimization/2.2.1";
9
+
10
+ export async function fetchWithRetry(url, options = {}) {
11
+ const timeout = options.timeout || DEFAULT_TIMEOUT;
12
+ const retries = options.retries ?? DEFAULT_RETRIES;
13
+ const retryDelay = options.retryDelay || DEFAULT_RETRY_DELAY;
14
+ const headers = { "user-agent": USER_AGENT, ...options.headers };
15
+
16
+ let lastError;
17
+
18
+ for (let attempt = 0; attempt <= retries; attempt++) {
19
+ try {
20
+ const response = await fetch(url, {
21
+ redirect: "follow",
22
+ headers,
23
+ signal: AbortSignal.timeout(timeout)
24
+ });
25
+ return response;
26
+ } catch (err) {
27
+ lastError = err;
28
+ if (attempt < retries) {
29
+ await new Promise((r) => setTimeout(r, retryDelay * (attempt + 1)));
30
+ }
31
+ }
32
+ }
33
+
34
+ throw new Error(`Failed to fetch ${url} after ${retries + 1} attempts: ${lastError.message}`);
35
+ }
36
+
37
+ export async function fetchText(url, options = {}) {
38
+ const response = await fetchWithRetry(url, options);
39
+ if (!response.ok) {
40
+ throw new Error(`HTTP ${response.status} for ${url}`);
41
+ }
42
+ return response.text();
43
+ }
44
+
45
+ export async function fetchTextSafe(url, options = {}) {
46
+ try {
47
+ return { ok: true, text: await fetchText(url, options), url };
48
+ } catch (err) {
49
+ return { ok: false, error: err.message, url };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Run multiple fetches with concurrency control.
55
+ */
56
+ export async function fetchBatch(urls, fn, options = {}) {
57
+ const concurrency = options.concurrency || 3;
58
+ const results = [];
59
+
60
+ const chunks = [];
61
+ for (let i = 0; i < urls.length; i += concurrency) {
62
+ chunks.push(urls.slice(i, i + concurrency));
63
+ }
64
+
65
+ for (const chunk of chunks) {
66
+ const chunkResults = await Promise.all(chunk.map((url) => fn(url)));
67
+ results.push(...chunkResults);
68
+
69
+ // Rate limit between chunks
70
+ if (options.delayBetweenChunks && chunks.indexOf(chunk) < chunks.length - 1) {
71
+ await new Promise((r) => setTimeout(r, options.delayBetweenChunks));
72
+ }
73
+ }
74
+
75
+ return results;
76
+ }
package/src/index.js CHANGED
@@ -85,4 +85,8 @@ export { batchFullPageAudit, renderBatchFullPageAuditMarkdown, writeBatchFullPag
85
85
  export { generateAutoFix, renderAutoFixMarkdown, writeAutoFixOutput } from "./auto-fix.js";
86
86
  export { diagnose, renderDiagnoseMarkdown, writeDiagnoseOutput } from "./diagnose.js";
87
87
  export { comparePages, renderCompareMarkdown, writeCompareOutput } from "./compare.js";
88
+ export { deepBenchmark, renderDeepBenchmarkMarkdown, writeDeepBenchmarkOutput } from "./deep-benchmark.js";
89
+ export { quickSummary, renderSummaryMarkdown } from "./summary.js";
90
+ export { fetchWithRetry, fetchText, fetchTextSafe, fetchBatch } from "./fetch-utils.js";
91
+ export { fullPageAuditToCsv, batchFullPageAuditToCsv, deepBenchmarkToCsv, compareToCsv, arrayToCsv } from "./csv-export.js";
88
92
  export { savePageSnapshot, listPageSnapshots, loadPageSnapshot, buildPageTrend, renderPageTrendMarkdown, writePageTrendOutput } from "./page-snapshot.js";
package/src/summary.js ADDED
@@ -0,0 +1,28 @@
1
+ import { diagnose } from "./diagnose.js";
2
+
3
+ /**
4
+ * Quick summary: one-line score output for any input.
5
+ * Designed for scripting, CI, and quick checks.
6
+ *
7
+ * Output format: SCORE/100 LABEL INPUT
8
+ * Example: 72/100 Moderate https://example.com
9
+ */
10
+
11
+ export async function quickSummary(input, options = {}) {
12
+ const result = await diagnose(input, options);
13
+
14
+ return {
15
+ kind: "geo-summary",
16
+ input,
17
+ score: result.score,
18
+ label: result.scoreLabel,
19
+ type: result.analysisType,
20
+ oneLiner: `${result.score}/100 ${result.scoreLabel} ${input}`,
21
+ topIssue: result.quickWins[0] || null,
22
+ nextCommand: result.nextCommands[0]?.cmd || null
23
+ };
24
+ }
25
+
26
+ export function renderSummaryMarkdown(report) {
27
+ return `${report.score}/100 (${report.label}) — ${report.input}${report.topIssue ? `\nTop issue: ${report.topIssue}` : ""}\n`;
28
+ }