weftens 0.0.1 → 0.1.1

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 (101) hide show
  1. package/README.md +178 -12
  2. package/agent-cores/amazon-ppc/package.json +17 -0
  3. package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
  4. package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
  5. package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
  6. package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
  7. package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
  8. package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
  9. package/agent-cores/content-ops/package.json +17 -0
  10. package/agent-cores/content-ops/src/cli.mjs +51 -0
  11. package/agent-cores/content-ops/src/inventory.mjs +343 -0
  12. package/agent-cores/content-ops/src/parse.mjs +121 -0
  13. package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
  14. package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
  15. package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
  16. package/agent-cores/meta-ads/package.json +16 -0
  17. package/agent-cores/meta-ads/src/analyze.mjs +166 -0
  18. package/agent-cores/meta-ads/src/cli.mjs +39 -0
  19. package/agent-cores/meta-ads/src/parse.mjs +123 -0
  20. package/agent-cores/meta-ads/test/analyze.test.js +72 -0
  21. package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
  22. package/agent-cores/paid-search/package.json +17 -0
  23. package/agent-cores/paid-search/src/analyze.mjs +303 -0
  24. package/agent-cores/paid-search/src/cli.mjs +57 -0
  25. package/agent-cores/paid-search/src/parse.mjs +158 -0
  26. package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
  27. package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
  28. package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
  29. package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
  30. package/agent-cores/paid-search/test/parse.test.mjs +56 -0
  31. package/agent-cores/seo-content/package.json +17 -0
  32. package/agent-cores/seo-content/src/cli.mjs +41 -0
  33. package/agent-cores/seo-content/src/parse.mjs +141 -0
  34. package/agent-cores/seo-content/src/strategy.mjs +266 -0
  35. package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
  36. package/agent-cores/seo-content/test/parse.test.mjs +59 -0
  37. package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
  38. package/agent-cores/seo-technical/package.json +17 -0
  39. package/agent-cores/seo-technical/src/audit.mjs +233 -0
  40. package/agent-cores/seo-technical/src/cli.mjs +48 -0
  41. package/agent-cores/seo-technical/src/fetch.mjs +51 -0
  42. package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
  43. package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
  44. package/agent-cores/supply-verify/package.json +17 -0
  45. package/agent-cores/supply-verify/src/cli.mjs +44 -0
  46. package/agent-cores/supply-verify/src/verify.mjs +303 -0
  47. package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
  48. package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
  49. package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
  50. package/agents/agent2060.md +99 -0
  51. package/agents/amazon-ppc.md +42 -0
  52. package/agents/claims-content.md +35 -0
  53. package/agents/content-ops.md +46 -0
  54. package/agents/content-strategy.md +13 -0
  55. package/agents/disclosure-qc.md +13 -0
  56. package/agents/feed-optimizer.md +13 -0
  57. package/agents/growth-analyst.md +24 -0
  58. package/agents/hilbert.md +55 -0
  59. package/agents/ideator.md +13 -0
  60. package/agents/lippmann.md +68 -0
  61. package/agents/paid-search.md +35 -0
  62. package/agents/pio.md +34 -0
  63. package/agents/researcher.md +13 -0
  64. package/agents/richy.md +72 -0
  65. package/agents/script-editor.md +13 -0
  66. package/agents/seo-content.md +57 -0
  67. package/agents/seo-technical.md +53 -0
  68. package/agents/showrunner.md +13 -0
  69. package/agents/social-voice.md +38 -0
  70. package/agents/supply-verify.md +38 -0
  71. package/agents/video-editor.md +13 -0
  72. package/agents/writer.md +45 -0
  73. package/bin/weftens.js +234 -0
  74. package/content-org/.preflight/telemetry.jsonl +6 -0
  75. package/content-org/briefs/BRIEFS.md +47 -0
  76. package/content-org/config.mjs +78 -0
  77. package/content-org/cost.mjs +36 -0
  78. package/content-org/lead.js +64 -0
  79. package/content-org/metrics.mjs +166 -0
  80. package/content-org/orchestrator.mjs +246 -0
  81. package/content-org/package.json +14 -0
  82. package/content-org/producer.mjs +59 -0
  83. package/content-org/publish.mjs +34 -0
  84. package/content-org/review.mjs +32 -0
  85. package/content-org/studio/README.md +27 -0
  86. package/content-org/studio/assemble.sh +48 -0
  87. package/content-org/test.mjs +110 -0
  88. package/index.js +32 -0
  89. package/package.json +49 -10
  90. package/providers.json.example +9 -0
  91. package/src/agent-router.js +124 -0
  92. package/src/agent.js +123 -0
  93. package/src/format.js +66 -0
  94. package/src/invoke.js +129 -0
  95. package/src/pio-client.js +142 -0
  96. package/src/provider.js +81 -0
  97. package/src/registry.js +160 -0
  98. package/src/richy-client.js +216 -0
  99. package/src/router.js +34 -0
  100. package/src/synthesize.js +111 -0
  101. package/src/weftens.js +61 -0
@@ -0,0 +1,343 @@
1
+ // Deterministic content-operations engine. No LLM.
2
+ //
3
+ // Input: rows from a CMS export (url, title, published date, last-updated,
4
+ // word count, pageviews, meta description). Output: a per-URL inventory with a
5
+ // keep / update / retire decision and the evidence for it, a metadata-gap list,
6
+ // and a migration-readiness QA summary.
7
+ //
8
+ // Every decision is a pure function of the row's numbers against fixed thresholds,
9
+ // plus a fixed asOf date (defaults to the latest date in the export, so results
10
+ // never depend on the wall clock — same input always yields the same output).
11
+
12
+ import { parseExport } from './parse.mjs';
13
+
14
+ export const DEFAULT_THRESHOLDS = Object.freeze({
15
+ staleDays: 365, // not touched in a year → aging
16
+ veryStaleDays: 730, // not touched in two years → decayed
17
+ lowTrafficViews: 50, // at or below this over the reporting window → low value
18
+ highTrafficViews: 500, // at or above this → protect it, never retire
19
+ thinWords: 300, // below this → thin content (ROT candidate)
20
+ titleMin: 10,
21
+ titleMax: 65,
22
+ descMin: 50,
23
+ descMax: 160,
24
+ });
25
+
26
+ // --- field normalization: map an export's own headers onto the canonical schema ---
27
+
28
+ const FIELD_ALIASES = {
29
+ url: ['url', 'link', 'address', 'page', 'permalink', 'loc'],
30
+ title: ['title', 'page title', 'name', 'heading', 'h1'],
31
+ published: ['published', 'published date', 'publish date', 'publishdate', 'published_date', 'date', 'created', 'created date', 'first published'],
32
+ updated: ['updated', 'last updated', 'last-updated', 'lastupdated', 'last_updated', 'modified', 'last modified', 'lastmod', 'updated date'],
33
+ wordCount: ['word count', 'wordcount', 'words', 'word_count', 'length'],
34
+ pageviews: ['pageviews', 'page views', 'views', 'traffic', 'sessions', 'visits', 'page_views'],
35
+ metaDescription: ['meta description', 'metadescription', 'meta_description', 'description', 'meta desc', 'seo description', 'excerpt'],
36
+ };
37
+
38
+ function buildHeaderMap(sampleRow) {
39
+ const map = {};
40
+ const keys = Object.keys(sampleRow);
41
+ const lowered = keys.map((k) => k.trim().toLowerCase());
42
+ for (const [canonical, aliases] of Object.entries(FIELD_ALIASES)) {
43
+ for (const alias of aliases) {
44
+ const idx = lowered.indexOf(alias);
45
+ if (idx !== -1) {
46
+ map[canonical] = keys[idx];
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ return map;
52
+ }
53
+
54
+ function toNumber(v) {
55
+ if (v === null || v === undefined) return null;
56
+ const s = String(v).trim().replace(/,/g, '');
57
+ if (s === '') return null;
58
+ const n = Number(s);
59
+ return Number.isFinite(n) ? n : null;
60
+ }
61
+
62
+ // Parse a date to a UTC-midnight epoch (ms). Accepts YYYY-MM-DD and anything
63
+ // Date can parse; returns null on failure so "missing" is distinguishable from 0.
64
+ function toDateMs(v) {
65
+ if (v === null || v === undefined) return null;
66
+ const s = String(v).trim();
67
+ if (s === '') return null;
68
+ const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
69
+ if (iso) {
70
+ return Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
71
+ }
72
+ const t = Date.parse(s);
73
+ return Number.isNaN(t) ? null : t;
74
+ }
75
+
76
+ const DAY = 86_400_000;
77
+ const daysBetween = (aMs, bMs) => Math.round((aMs - bMs) / DAY);
78
+
79
+ function cleanStr(v) {
80
+ if (v === null || v === undefined) return '';
81
+ return String(v).trim();
82
+ }
83
+
84
+ // --- the decision core ---
85
+
86
+ function decide(item, asOfMs, T) {
87
+ const reasons = [];
88
+ const { pageviews, updatedMs, publishedMs, wordCount } = item;
89
+
90
+ // freshness is measured from last-update when present, else from publish
91
+ const freshMs = updatedMs ?? publishedMs;
92
+ const staleDays = freshMs !== null ? daysBetween(asOfMs, freshMs) : null;
93
+
94
+ const hasTraffic = pageviews !== null;
95
+ const hasFresh = staleDays !== null;
96
+
97
+ // If we can't judge value or freshness, we don't guess — we route to review.
98
+ if (!hasTraffic || !hasFresh) {
99
+ const missing = [];
100
+ if (!hasTraffic) missing.push('pageviews');
101
+ if (!hasFresh) missing.push('published/updated date');
102
+ reasons.push(`insufficient evidence to auto-decide (missing ${missing.join(' and ')})`);
103
+ return { decision: 'review', reasons, staleDays, confidence: 'low' };
104
+ }
105
+
106
+ const views = pageviews;
107
+ const words = wordCount; // may be null; only used in ROT branch guarded below
108
+
109
+ // 1) Protect valuable pages: high traffic is never retired.
110
+ if (views >= T.highTrafficViews) {
111
+ if (staleDays >= T.staleDays) {
112
+ reasons.push(`high traffic (${views} views) but not updated in ${staleDays} days → refresh, do not retire`);
113
+ return { decision: 'update', reasons, staleDays, confidence: 'high' };
114
+ }
115
+ reasons.push(`high traffic (${views} views) and fresh (updated ${staleDays} days ago)`);
116
+ return { decision: 'keep', reasons, staleDays, confidence: 'high' };
117
+ }
118
+
119
+ // 2) Low traffic: candidates for retirement or refresh.
120
+ if (views <= T.lowTrafficViews) {
121
+ if (staleDays >= T.veryStaleDays) {
122
+ reasons.push(`stale (${staleDays} days without update) and low traffic (${views} views) → ROT, retire with redirect`);
123
+ return { decision: 'retire', reasons, staleDays, confidence: 'high' };
124
+ }
125
+ if (words !== null && words < T.thinWords) {
126
+ reasons.push(`thin content (${words} words) and low traffic (${views} views) → ROT, retire with redirect`);
127
+ return { decision: 'retire', reasons, staleDays, confidence: 'medium' };
128
+ }
129
+ if (staleDays >= T.staleDays) {
130
+ reasons.push(`low traffic (${views} views) and aging (${staleDays} days) but not yet ROT → refresh and reassess`);
131
+ return { decision: 'update', reasons, staleDays, confidence: 'medium' };
132
+ }
133
+ reasons.push(`low traffic (${views} views) but recent (${staleDays} days) → keep and monitor`);
134
+ return { decision: 'keep', reasons, staleDays, confidence: 'low' };
135
+ }
136
+
137
+ // 3) Mid traffic: keep unless it has decayed past very-stale.
138
+ if (staleDays >= T.veryStaleDays) {
139
+ reasons.push(`moderate traffic (${views} views) but decayed (${staleDays} days without update) → refresh`);
140
+ return { decision: 'update', reasons, staleDays, confidence: 'medium' };
141
+ }
142
+ reasons.push(`moderate traffic (${views} views) and acceptable freshness (${staleDays} days)`);
143
+ return { decision: 'keep', reasons, staleDays, confidence: 'medium' };
144
+ }
145
+
146
+ // --- metadata gap checks (mechanical, per item) ---
147
+
148
+ function metadataGaps(item, T) {
149
+ const gaps = [];
150
+ if (item.title === '') gaps.push({ field: 'title', issue: 'missing', severity: 'fail' });
151
+ else if (item.title.length < T.titleMin) gaps.push({ field: 'title', issue: `too short (${item.title.length} chars, aim ${T.titleMin}-${T.titleMax})`, severity: 'warn' });
152
+ else if (item.title.length > T.titleMax) gaps.push({ field: 'title', issue: `too long (${item.title.length} chars, aim ${T.titleMin}-${T.titleMax})`, severity: 'warn' });
153
+
154
+ if (item.metaDescription === '') gaps.push({ field: 'metaDescription', issue: 'missing', severity: 'warn' });
155
+ else if (item.metaDescription.length < T.descMin) gaps.push({ field: 'metaDescription', issue: `too short (${item.metaDescription.length} chars, aim ${T.descMin}-${T.descMax})`, severity: 'warn' });
156
+ else if (item.metaDescription.length > T.descMax) gaps.push({ field: 'metaDescription', issue: `too long (${item.metaDescription.length} chars, aim ${T.descMin}-${T.descMax})`, severity: 'warn' });
157
+
158
+ if (item.url === '') gaps.push({ field: 'url', issue: 'missing', severity: 'fail' });
159
+
160
+ // data-completeness gaps that block an evidence-based decision
161
+ if (item.publishedMs === null && item.updatedMs === null) gaps.push({ field: 'date', issue: 'no published or updated date', severity: 'fail' });
162
+ if (item.pageviews === null) gaps.push({ field: 'pageviews', issue: 'missing traffic data', severity: 'warn' });
163
+ if (item.wordCount === null) gaps.push({ field: 'wordCount', issue: 'missing word count', severity: 'info' });
164
+
165
+ return gaps;
166
+ }
167
+
168
+ // --- main entry ---
169
+
170
+ export function buildInventory(rows, options = {}) {
171
+ const T = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
172
+ const arr = Array.isArray(rows) ? rows : [];
173
+
174
+ if (arr.length === 0) {
175
+ return {
176
+ asOf: options.asOf ?? null,
177
+ thresholds: T,
178
+ counts: { total: 0, keep: 0, update: 0, retire: 0, review: 0 },
179
+ unmappedFields: [],
180
+ headerMap: {},
181
+ items: [],
182
+ metadataGaps: [],
183
+ duplicates: { titles: [], urls: [] },
184
+ migrationReadiness: {
185
+ totalUrls: 0,
186
+ redirectsRequired: 0,
187
+ redirectLedger: [],
188
+ itemsWithGaps: 0,
189
+ blockingGaps: 0,
190
+ fieldCoverage: {},
191
+ readinessScore: 0,
192
+ blockers: ['export is empty — nothing to inventory'],
193
+ },
194
+ };
195
+ }
196
+
197
+ const headerMap = buildHeaderMap(arr[0]);
198
+
199
+ // normalize each row to the canonical schema
200
+ const normalized = arr.map((raw, idx) => {
201
+ const g = (canonical) => (headerMap[canonical] !== undefined ? raw[headerMap[canonical]] : undefined);
202
+ return {
203
+ rowIndex: idx,
204
+ url: cleanStr(g('url')),
205
+ title: cleanStr(g('title')),
206
+ metaDescription: cleanStr(g('metaDescription')),
207
+ publishedMs: toDateMs(g('published')),
208
+ updatedMs: toDateMs(g('updated')),
209
+ wordCount: toNumber(g('wordCount')),
210
+ pageviews: toNumber(g('pageviews')),
211
+ };
212
+ });
213
+
214
+ // asOf: explicit option, else the latest date seen anywhere in the export.
215
+ let asOfMs = options.asOf ? toDateMs(options.asOf) : null;
216
+ if (asOfMs === null) {
217
+ for (const it of normalized) {
218
+ for (const ms of [it.publishedMs, it.updatedMs]) {
219
+ if (ms !== null && (asOfMs === null || ms > asOfMs)) asOfMs = ms;
220
+ }
221
+ }
222
+ }
223
+ if (asOfMs === null) asOfMs = Date.UTC(1970, 0, 1); // no dates at all; decisions will route to review
224
+
225
+ // duplicate detection (a taxonomy/QA signal migrations must resolve)
226
+ const titleIndex = new Map();
227
+ const urlIndex = new Map();
228
+ for (const it of normalized) {
229
+ if (it.title) {
230
+ const k = it.title.toLowerCase();
231
+ (titleIndex.get(k) || titleIndex.set(k, []).get(k)).push(it.url || `(row ${it.rowIndex})`);
232
+ }
233
+ if (it.url) {
234
+ const k = it.url.toLowerCase();
235
+ (urlIndex.get(k) || urlIndex.set(k, []).get(k)).push(it.rowIndex);
236
+ }
237
+ }
238
+ const dupTitles = [...titleIndex.entries()].filter(([, v]) => v.length > 1).map(([title, urls]) => ({ title, count: urls.length, urls }));
239
+ const dupUrls = [...urlIndex.entries()].filter(([, v]) => v.length > 1).map(([url, rows2]) => ({ url, count: rows2.length, rows: rows2 }));
240
+
241
+ // per-item decision + gaps
242
+ const items = normalized.map((it) => {
243
+ const { decision, reasons, staleDays, confidence } = decide(it, asOfMs, T);
244
+ const gaps = metadataGaps(it, T);
245
+ return {
246
+ url: it.url,
247
+ title: it.title,
248
+ decision,
249
+ confidence,
250
+ reasons,
251
+ staleDays,
252
+ pageviews: it.pageviews,
253
+ wordCount: it.wordCount,
254
+ metadataGaps: gaps,
255
+ redirectTarget: decision === 'retire' ? null : undefined, // null = decision needed, not omission
256
+ };
257
+ });
258
+
259
+ const counts = { total: items.length, keep: 0, update: 0, retire: 0, review: 0 };
260
+ for (const it of items) counts[it.decision] += 1;
261
+
262
+ // flat metadata-gap list for quick triage, ordered fail → warn → info
263
+ const sevOrder = { fail: 0, warn: 1, info: 2 };
264
+ const flatGaps = [];
265
+ for (const it of items) {
266
+ for (const g of it.metadataGaps) flatGaps.push({ url: it.url || '(no url)', ...g });
267
+ }
268
+ flatGaps.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
269
+
270
+ // migration readiness
271
+ const redirectLedger = items
272
+ .filter((it) => it.decision === 'retire')
273
+ .map((it) => ({ from: it.url || '(no url)', to: null, status: 301, resolved: false, note: 'redirect target UNRESOLVED — a decision, not an omission' }));
274
+
275
+ const itemsWithGaps = items.filter((it) => it.metadataGaps.length > 0).length;
276
+ const blockingGaps = flatGaps.filter((g) => g.severity === 'fail').length;
277
+
278
+ const fieldCoverage = fieldCoverageReport(normalized);
279
+
280
+ const blockers = [];
281
+ if (redirectLedger.length > 0) blockers.push(`${redirectLedger.length} retire decision(s) need a redirect target before launch`);
282
+ if (blockingGaps > 0) blockers.push(`${blockingGaps} blocking metadata gap(s) (missing url/title/date)`);
283
+ if (dupUrls.length > 0) blockers.push(`${dupUrls.length} duplicate URL(s) in the export`);
284
+ if (counts.review > 0) blockers.push(`${counts.review} item(s) lack the data to auto-decide (manual review)`);
285
+ for (const [field, cov] of Object.entries(fieldCoverage)) {
286
+ if (cov.present === 0) blockers.push(`source field "${field}" is absent from the export — no destination to map`);
287
+ }
288
+
289
+ // readiness score: start at 100, subtract weighted penalties, floor at 0.
290
+ const penalty =
291
+ redirectLedger.length * 2 +
292
+ blockingGaps * 3 +
293
+ dupUrls.length * 4 +
294
+ counts.review * 2 +
295
+ (flatGaps.length - blockingGaps) * 1;
296
+ const readinessScore = Math.max(0, Math.min(100, 100 - Math.round((penalty / Math.max(1, items.length * 6)) * 100)));
297
+
298
+ // unmapped source headers (a field with no canonical home is a mapping decision)
299
+ const mappedHeaders = new Set(Object.values(headerMap));
300
+ const unmappedFields = Object.keys(arr[0]).filter((h) => !mappedHeaders.has(h));
301
+
302
+ return {
303
+ asOf: new Date(asOfMs).toISOString().slice(0, 10),
304
+ thresholds: T,
305
+ counts,
306
+ headerMap,
307
+ unmappedFields,
308
+ items,
309
+ metadataGaps: flatGaps,
310
+ duplicates: { titles: dupTitles, urls: dupUrls },
311
+ migrationReadiness: {
312
+ totalUrls: items.length,
313
+ redirectsRequired: redirectLedger.length,
314
+ redirectLedger,
315
+ itemsWithGaps,
316
+ blockingGaps,
317
+ fieldCoverage,
318
+ readinessScore,
319
+ blockers,
320
+ },
321
+ };
322
+ }
323
+
324
+ function fieldCoverageReport(normalized) {
325
+ const fields = ['url', 'title', 'metaDescription', 'publishedMs', 'updatedMs', 'wordCount', 'pageviews'];
326
+ const report = {};
327
+ for (const f of fields) {
328
+ let present = 0;
329
+ for (const it of normalized) {
330
+ const v = it[f];
331
+ const has = f === 'publishedMs' || f === 'updatedMs' || f === 'wordCount' || f === 'pageviews' ? v !== null : v !== '';
332
+ if (has) present += 1;
333
+ }
334
+ report[f] = { present, total: normalized.length, pct: Math.round((present / normalized.length) * 100) };
335
+ }
336
+ return report;
337
+ }
338
+
339
+ // Convenience: parse raw text and build the inventory in one call.
340
+ export function inventoryFromText(text, hint = null, options = {}) {
341
+ const rows = parseExport(text, hint);
342
+ return buildInventory(rows, options);
343
+ }
@@ -0,0 +1,121 @@
1
+ // Dependency-free parsing for CMS exports. Two input shapes:
2
+ // - CSV (RFC-4180-ish: quoted fields, escaped quotes "", commas/newlines inside quotes)
3
+ // - JSON (an array of row objects, or { rows: [...] } / { items: [...] })
4
+ //
5
+ // Both normalize to an array of raw row objects keyed by the export's own headers.
6
+ // Field-name normalization to the canonical schema happens in inventory.mjs.
7
+
8
+ // --- CSV ---
9
+
10
+ // State-machine parser. Handles quoted fields, "" escapes, and CR/LF inside quotes.
11
+ // Returns an array of arrays (rows of string cells).
12
+ export function parseCsv(text) {
13
+ const rows = [];
14
+ let row = [];
15
+ let field = '';
16
+ let inQuotes = false;
17
+ let i = 0;
18
+ const n = text.length;
19
+ let started = false; // did we see any char on this line?
20
+
21
+ const pushField = () => {
22
+ row.push(field);
23
+ field = '';
24
+ };
25
+ const pushRow = () => {
26
+ row.push(field);
27
+ field = '';
28
+ rows.push(row);
29
+ row = [];
30
+ started = false;
31
+ };
32
+
33
+ while (i < n) {
34
+ const c = text[i];
35
+
36
+ if (inQuotes) {
37
+ if (c === '"') {
38
+ if (text[i + 1] === '"') {
39
+ field += '"';
40
+ i += 2;
41
+ continue;
42
+ }
43
+ inQuotes = false;
44
+ i += 1;
45
+ continue;
46
+ }
47
+ field += c;
48
+ i += 1;
49
+ continue;
50
+ }
51
+
52
+ if (c === '"') {
53
+ inQuotes = true;
54
+ started = true;
55
+ i += 1;
56
+ continue;
57
+ }
58
+ if (c === ',') {
59
+ pushField();
60
+ started = true;
61
+ i += 1;
62
+ continue;
63
+ }
64
+ if (c === '\r') {
65
+ // swallow CR; handle the LF (or lone CR) as the line break
66
+ if (text[i + 1] === '\n') i += 1;
67
+ pushRow();
68
+ i += 1;
69
+ continue;
70
+ }
71
+ if (c === '\n') {
72
+ pushRow();
73
+ i += 1;
74
+ continue;
75
+ }
76
+ field += c;
77
+ started = true;
78
+ i += 1;
79
+ }
80
+
81
+ // flush trailing field/row if the file doesn't end in a newline
82
+ if (field.length > 0 || row.length > 0 || started) pushRow();
83
+
84
+ return rows;
85
+ }
86
+
87
+ // CSV text -> array of row objects keyed by header. Blank lines are dropped.
88
+ export function csvToRecords(text) {
89
+ const rows = parseCsv(text).filter((r) => !(r.length === 1 && r[0].trim() === ''));
90
+ if (rows.length === 0) return [];
91
+ const headers = rows[0].map((h) => h.trim());
92
+ const records = [];
93
+ for (let r = 1; r < rows.length; r += 1) {
94
+ const cells = rows[r];
95
+ const obj = {};
96
+ for (let c = 0; c < headers.length; c += 1) {
97
+ obj[headers[c]] = cells[c] !== undefined ? cells[c] : '';
98
+ }
99
+ records.push(obj);
100
+ }
101
+ return records;
102
+ }
103
+
104
+ // --- dispatch ---
105
+
106
+ // Auto-detect JSON vs CSV from the text and return raw row objects.
107
+ export function parseExport(text, hint = null) {
108
+ const trimmed = text.replace(/^/, '').trimStart(); // strip BOM
109
+ const looksJson = hint === 'json' || (hint !== 'csv' && (trimmed.startsWith('[') || trimmed.startsWith('{')));
110
+
111
+ if (looksJson) {
112
+ const data = JSON.parse(trimmed);
113
+ if (Array.isArray(data)) return data;
114
+ if (data && Array.isArray(data.rows)) return data.rows;
115
+ if (data && Array.isArray(data.items)) return data.items;
116
+ if (data && Array.isArray(data.records)) return data.records;
117
+ throw new Error('JSON export must be an array, or an object with a rows/items/records array.');
118
+ }
119
+
120
+ return csvToRecords(text);
121
+ }
@@ -0,0 +1,13 @@
1
+ url,title,published,last-updated,word count,pageviews,meta description,author
2
+ https://acme.example/old-news-2019,Old News From 2019,2019-01-10,2019-01-15,800,12,An announcement from early 2019 that has not been touched since.,J. Smith
3
+ https://acme.example/tags/misc,Miscellaneous Tag Archive Page,2025-12-20,2026-01-01,90,8,,A. Bot
4
+ https://acme.example/pricing-guide,Pricing Guide,2022-06-01,2024-03-01,2400,4200,"This is a deliberately overlong meta description written to exceed the recommended one hundred and sixty character ceiling so the engine flags it as too long for a snippet.",M. Lee
5
+ https://acme.example/,Acme — Home,2020-01-01,2026-06-15,650,9000,"The homepage of Acme, updated recently and carrying the most traffic on the whole site.",Team
6
+ https://acme.example/blog/mid-article,A Mid-Traffic Article Worth Refreshing,2021-04-01,2025-05-01,1200,30,A long-form article with modest traffic that has aged past a year without a refresh.,J. Smith
7
+ https://acme.example/blog/new-post,A Freshly Published Post,2026-06-18,2026-06-20,900,20,A brand new post that has not accumulated traffic yet but is current.,A. Bot
8
+ https://acme.example/docs/reference,The Complete and Exhaustive Reference Documentation for Every Single Public API Endpoint,2019-05-01,2023-01-01,5000,200,Long-lived reference doc with steady moderate traffic that has decayed past two years.,M. Lee
9
+ https://acme.example/guide,Complete Guide,2024-01-01,2026-03-01,1800,300,A moderately trafficked guide kept current within the last few months of the reporting window.,Team
10
+ https://acme.example/reports/quarterly,Quarterly Report Archive,2025-01-01,2025-01-01,700,,An archived report where the traffic column was never populated by the analytics export.,J. Smith
11
+ https://acme.example/team/jane-doe,Jane Doe,2026-04-01,2026-05-01,400,600,,Team
12
+ https://acme.example/guide-legacy,Complete Guide,2023-10-01,2026-02-01,1500,150,A second page sharing the exact same title as the primary guide — a taxonomy collision.,Team
13
+ https://acme.example/orphan,Orphaned Page With No Dates,,,200,5,A page whose export row is missing both the published and the last-updated date fields.,A. Bot
@@ -0,0 +1,31 @@
1
+ {
2
+ "rows": [
3
+ {
4
+ "url": "https://acme.example/legacy-faq",
5
+ "title": "Legacy FAQ",
6
+ "published": "2018-03-01",
7
+ "lastUpdated": "2018-09-01",
8
+ "wordCount": 1100,
9
+ "pageviews": 7,
10
+ "metaDescription": "An old FAQ nobody has updated in years and almost nobody reads."
11
+ },
12
+ {
13
+ "url": "https://acme.example/flagship",
14
+ "title": "Flagship Product Overview",
15
+ "published": "2021-01-01",
16
+ "lastUpdated": "2023-06-01",
17
+ "wordCount": 2200,
18
+ "pageviews": 8800,
19
+ "metaDescription": "The overview page for the flagship product, high traffic but overdue for a refresh."
20
+ },
21
+ {
22
+ "url": "https://acme.example/whats-new",
23
+ "title": "What's New",
24
+ "published": "2026-05-01",
25
+ "lastUpdated": "2026-05-15",
26
+ "wordCount": 500,
27
+ "pageviews": 640,
28
+ "metaDescription": "The most recent changelog entry with strong traffic and a current date."
29
+ }
30
+ ]
31
+ }