@whyshu/argent-engine 0.6.0 → 0.7.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.
Files changed (68) hide show
  1. package/MANIFEST.json +64 -29
  2. package/engine/VERSION +1 -1
  3. package/engine/core/contracts/argent.contracts.json +1 -1
  4. package/engine/scripts/base-bootstrap.sh +2 -1
  5. package/engine/scripts/billing-mode-daily.sh +2 -1
  6. package/engine/scripts/build-engine-package.mjs +5 -2
  7. package/engine/scripts/capability-packs.lock.json +31 -31
  8. package/engine/scripts/check-billing-mode.sh +2 -1
  9. package/engine/scripts/collect-evidence.mjs +50 -0
  10. package/engine/scripts/customer-readiness.sh +2 -1
  11. package/engine/scripts/daily-report.sh +2 -1
  12. package/engine/scripts/dev-window.sh +2 -1
  13. package/engine/scripts/drill-clean-room.sh +32 -0
  14. package/engine/scripts/final-check.mjs +44 -0
  15. package/engine/scripts/flow-requirements.json +30 -10
  16. package/engine/scripts/flow-timers-plist.sh +2 -1
  17. package/engine/scripts/flow-timers.sh +3 -1
  18. package/engine/scripts/flow_config.py +2 -1
  19. package/engine/scripts/init-ml.sh +5 -2
  20. package/engine/scripts/invoice-table.sh +2 -1
  21. package/engine/scripts/lib/argent-datadir.sh +18 -0
  22. package/engine/scripts/lib/argent_datadir.py +23 -0
  23. package/engine/scripts/poll-fulfillment.sh +4 -2
  24. package/engine/scripts/poll-invoice.sh +4 -2
  25. package/engine/scripts/provider-select.sh +2 -1
  26. package/engine/scripts/release-argent.sh +1 -1
  27. package/engine/scripts/retire-inventory.mjs +40 -0
  28. package/engine/scripts/run-tests.sh +2 -1
  29. package/engine/scripts/s1-audit.mjs +85 -0
  30. package/engine/scripts/usage-anchor.sh +3 -2
  31. package/engine/scripts/wenyao-browser.sh +3 -2
  32. package/engine/scripts/wenyao-login.sh +3 -2
  33. package/engine/scripts/ziniao-keepalive.sh +2 -1
  34. package/engine/scripts/ziniao-post-upgrade-check.sh +2 -1
  35. package/engine/scripts/ziniao-windows.sh +2 -1
  36. package/engine/skills/README.md +86 -0
  37. package/engine/skills/argent-dev/SKILL.md +41 -0
  38. package/engine/skills/internal.json +37 -0
  39. package/engine/skills/lktop-pm/SKILL.md +35 -0
  40. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  41. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  42. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  43. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  44. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  45. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  46. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  47. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  48. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  49. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  50. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  51. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  52. package/engine/skills/ml-invoice/SKILL.md +506 -0
  53. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  54. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  55. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  56. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  57. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  58. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  59. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  60. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  61. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  62. package/engine/skills/pet-dev/SKILL.md +42 -0
  63. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  64. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  65. package/engine/version.mjs +1 -1
  66. package/engine/verticals/mercadolibre/flows.json +37 -5
  67. package/package.json +3 -2
  68. package/requirements.txt +5 -0
@@ -0,0 +1,691 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ml-store-daily-report extract script
4
+ * Extracts sales summary, reputation, and FULL shipment data
5
+ * from MercadoLibre seller pages.
6
+ *
7
+ * Designed to run as: ziniao-cli page exec --store-id <ID> --script extract.js
8
+ * OR process the text output from: ziniao-cli page content --store-id <ID>
9
+ *
10
+ * 前置检查:飞书 CLI 配置
11
+ * 推送日报前请先运行 argent feishu-setup
12
+ *
13
+ * Pages:
14
+ * - Sales: https://www.mercadolibre.com.mx/ventas/omni/listado
15
+ * - Reput.: https://www.mercadolibre.com.mx/reputacion?from=seller_menu
16
+ * - FULL: https://myaccount.mercadolibre.com.mx/shipping/inbounds
17
+ */
18
+
19
+ const { execSync } = require("child_process");
20
+
21
+ /** 检查飞书 CLI 是否已配置 */
22
+ function checkFeishu() {
23
+ try {
24
+ execSync("which lark-cli", { stdio: "ignore" });
25
+ } catch {
26
+ console.error("❌ 未检测到 lark-cli。飞书推送不可用。");
27
+ console.error(" 请先安装 lark-cli 并运行 argent feishu-setup");
28
+ process.exit(1);
29
+ }
30
+ try {
31
+ const out = execSync("lark-cli auth status 2>&1", { encoding: "utf8", timeout: 5000 });
32
+ if (!out.includes("OK") && !out.includes("ok") && !out.includes("授权")) {
33
+ console.error("❌ 飞书未授权。请运行 argent feishu-setup 配置飞书 Bot");
34
+ process.exit(1);
35
+ }
36
+ } catch {
37
+ console.error("⚠️ 飞书状态检查失败——如果未使用推送功能,可忽略。");
38
+ }
39
+ }
40
+
41
+ // 使用 --push / --feishu 参数时进行检查
42
+ const args = process.argv.slice(2);
43
+ if (args.includes("--push") || args.includes("--feishu")) checkFeishu();
44
+
45
+ /**
46
+ * Browser mode: runs via page exec, extracts from DOM
47
+ */
48
+ function extractFromDOM() {
49
+ const bodyText = document.body.innerText;
50
+ return mergeAll(bodyText);
51
+ }
52
+
53
+ /**
54
+ * CLI mode: process plain text from page content output
55
+ */
56
+ function extractFromText(pageContent) {
57
+ return mergeAll(pageContent);
58
+ }
59
+
60
+ /**
61
+ * Merge sales + reputation + FULL into one result
62
+ */
63
+ function mergeAll(text) {
64
+ // Detect if this is the Metrics page (has aggregated KPIs) or order list
65
+ const metricsPage = /绩效概览|总销售额/.test(text);
66
+ const sales = metricsPage ? parseMetricsPage(text) : parseSalesData(text);
67
+ const reputation = parseReputationData(text);
68
+ const full = parseFullData(text);
69
+ // 站点本地日期(toLocaleDateString 使用浏览器时区 = 店铺时区)
70
+ const localDate = new Date().toLocaleDateString('sv-SE');
71
+ return { date: localDate, ...sales, reputation, full };
72
+ }
73
+
74
+ // ===================================================================
75
+ // parseMetricsPage — extract KPIs from MercadoLibre Metrics page
76
+ // Page: https://www.mercadolibre.com.mx/metricas (filtered to "今天")
77
+ // Returns a sales-compatible object with pre-aggregated daily totals
78
+ // ===================================================================
79
+
80
+ function parseMetricsPage(text) {
81
+ if (!text || typeof text !== "string") {
82
+ return null;
83
+ }
84
+
85
+ // Only activate if the text looks like the metrics page
86
+ if (!/绩效概览|总销售额/.test(text)) return null;
87
+
88
+ // Extract the actual date from the page (e.g. "今天 26 7月 2026" or "今天26 7月 2026")
89
+ var dateMatch = text.match(/今天\s*(\d{1,2})\s+(\d{1,2})月\s+(\d{4})/);
90
+ var pageDate = null;
91
+ if (dateMatch) {
92
+ var d = parseInt(dateMatch[1], 10);
93
+ var m = parseInt(dateMatch[2], 10);
94
+ var y = parseInt(dateMatch[3], 10);
95
+ pageDate = y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0');
96
+ }
97
+ // Fallback: compute today in Mexico City timezone
98
+ if (!pageDate) {
99
+ try {
100
+ var mx = new Intl.DateTimeFormat('sv-SE', {timeZone: 'America/Mexico_City'});
101
+ pageDate = mx.format(new Date());
102
+ } catch(e) {
103
+ pageDate = new Date().toLocaleDateString('sv-SE');
104
+ }
105
+ }
106
+
107
+ const data = {
108
+ store: null,
109
+ date: pageDate || new Date().toLocaleDateString('sv-SE'),
110
+ orders: {
111
+ count: 0,
112
+ total_amount_mxn: 0,
113
+ currency: "MXN",
114
+ items: [],
115
+ },
116
+ shipping: { today: 0, upcoming: 0, in_transit: 0 },
117
+ after_sales: { pending: 0 },
118
+ products: [],
119
+ alerts: [],
120
+ raw_text_length: text.length,
121
+ };
122
+
123
+ // Gross sales: "总销售额 ... $ 2,652" or "$2,652"
124
+ const salesMatch = text.match(/总销售额[^$]*\$\s*([\d,]+(?:\.\d+)?)/);
125
+ if (salesMatch) {
126
+ data.orders.total_amount_mxn = parseFloat(salesMatch[1].replace(/,/g, ""));
127
+ }
128
+
129
+ // Order count: "订单数量 ... 8"
130
+ const orderMatch = text.match(/订单数量[^\d]*(\d+)/);
131
+ if (orderMatch) {
132
+ data.orders.count = parseInt(orderMatch[1], 10);
133
+ }
134
+
135
+ // Units sold: "已售件数 ... 9"
136
+ const unitsMatch = text.match(/已售件数[^\d]*(\d+)/);
137
+ const unitsSold = unitsMatch ? parseInt(unitsMatch[1], 10) : 0;
138
+
139
+ // Returns: "退货数量 ... 0"
140
+ const returnsMatch = text.match(/退货数量[^\d]*(\d+)/);
141
+ if (returnsMatch) {
142
+ data.after_sales.pending = parseInt(returnsMatch[1], 10);
143
+ }
144
+
145
+ // Cancelled: "已取消的销售数量 ... 0"
146
+ const cancelMatch = text.match(/已取消的销售数量[^\d]*(\d+)/);
147
+ const cancelled = cancelMatch ? parseInt(cancelMatch[1], 10) : 0;
148
+
149
+ // Authorization code: "今日授权码: XXX"
150
+ const authMatch = text.match(/今日授权码[::]\s*(\S+)/);
151
+ if (authMatch) {
152
+ data.authorization_code = authMatch[1];
153
+ }
154
+
155
+ // Alerts
156
+ if (data.orders.total_amount_mxn === 0) {
157
+ data.alerts.push("no_sales_data");
158
+ }
159
+ if (cancelled > 0) {
160
+ data.alerts.push(`cancelled_orders:${cancelled}`);
161
+ }
162
+
163
+ // Add a synthetic item for the metrics summary
164
+ data.orders.items.push({
165
+ sku: "METRICS",
166
+ order_id: "metrics",
167
+ price_mxn: data.orders.total_amount_mxn,
168
+ quantity: unitsSold,
169
+ status: "metrics_summary",
170
+ product_name: `指标页汇总: ${data.orders.count}单 / ${unitsSold}件`
171
+ });
172
+
173
+ return data;
174
+ }
175
+
176
+ // ===================================================================
177
+ // parseSalesData — original sales-order parser (unchanged structure)
178
+ // ===================================================================
179
+
180
+ // Spanish month abbreviations → month number
181
+ const MESES = { ene:1, feb:2, mar:3, abr:4, may:5, jun:6, jul:7, ago:8, sep:9, oct:10, nov:11, dic:12 };
182
+
183
+ // Get today's date in Mexico City timezone
184
+ function getMexicoToday() {
185
+ var fmt = new Intl.DateTimeFormat('es-MX', {
186
+ day: '2-digit', month: 'short', timeZone: 'America/Mexico_City'
187
+ });
188
+ // output like "26 jul" or "26-jul" depending on Node version
189
+ var parts = fmt.format(new Date()).replace(/\./g, '').split(/[\s-]+/);
190
+ return { day: parseInt(parts[0], 10), month: MESES[parts[1].toLowerCase()] };
191
+ }
192
+
193
+ // Extract date from an order block; returns {day, month} or null
194
+ // Supports both Spanish ("26 jul") and Chinese ("26 7月") formats
195
+ function extractOrderDate(block) {
196
+ // Spanish: "26 jul" → month name
197
+ var m = block.match(/(\d{1,2})\s+(ene|feb|mar|abr|may|jun|jul|ago|sep|oct|nov|dic)\s+\d{2}:\d{2}/i);
198
+ if (m) return { day: parseInt(m[1], 10), month: MESES[m[2].toLowerCase()] };
199
+ // Chinese: "26 7月" → numeric month + 月
200
+ m = block.match(/(\d{1,2})\s+(\d{1,2})月\s+\d{2}:\d{2}/);
201
+ if (m) return { day: parseInt(m[1], 10), month: parseInt(m[2], 10) };
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Core parser — works on plain text regardless of DOM or CLI mode
207
+ */
208
+ function parseSalesData(text) {
209
+ if (!text || typeof text !== "string") {
210
+ return { error: "no_content", message: "Page content is empty" };
211
+ }
212
+
213
+ const data = {
214
+ store: null,
215
+ date: new Date().toLocaleDateString('sv-SE'),
216
+ authorization_code: null,
217
+ orders: {
218
+ count: 0,
219
+ total_amount_mxn: 0,
220
+ currency: "MXN",
221
+ items: [],
222
+ },
223
+ shipping: {
224
+ today: 0,
225
+ upcoming: 0,
226
+ in_transit: 0,
227
+ },
228
+ after_sales: {
229
+ pending: 0,
230
+ },
231
+ products: [],
232
+ alerts: [],
233
+ raw_text_length: text.length,
234
+ };
235
+
236
+ // ---- Authorization code (Spanish + Chinese) ----
237
+ const authMatch = text.match(
238
+ /(?:C[oó]digo de autorizaci[oó]n para hoy|今日授权码)[::]\s*(\S+)/i
239
+ );
240
+ if (authMatch) {
241
+ data.authorization_code = authMatch[1];
242
+ } else {
243
+ data.alerts.push("auth_code_missing");
244
+ }
245
+
246
+ // ---- Order count: computed from filtered items, not the page's "X ventas" summary
247
+ // (page summary includes orders from all dates in view, e.g. "Últimos 2 meses")
248
+
249
+ // ---- Shipping summary (Spanish + Chinese) ----
250
+ const enviosHoy = text.match(/(?:Env[ií]os de hoy|今日发货)\s*(\d+)/i);
251
+ const enviosProx = text.match(/(?:Pr[oó]ximos d[ií]as|未来几天)\s*(\d+)/i);
252
+ const enviosTrans = text.match(/(?:En tr[aá]nsito|运输中)\s*(\d+)/i);
253
+ if (enviosHoy) data.shipping.today = parseInt(enviosHoy[1], 10);
254
+ if (enviosProx) data.shipping.upcoming = parseInt(enviosProx[1], 10);
255
+ if (enviosTrans) data.shipping.in_transit = parseInt(enviosTrans[1], 10);
256
+
257
+ // ---- After-sales ----
258
+ const posventaMatch = text.match(/Posventa\s*(\d+)/i);
259
+ if (posventaMatch) {
260
+ data.after_sales.pending = parseInt(posventaMatch[1], 10);
261
+ }
262
+
263
+ // ---- Individual orders: extract price + SKU + product name + status ----
264
+ // Pattern: status-text product-name quantity price SKU order-number
265
+ // Examples from real page:
266
+ // "Procesando en la bodega Llega hoy ... 1 unidad$ 219.12SKU: ZZD-MX-018 #200001..."
267
+
268
+ // Strategy: split by "SKU:" boundaries, then parse each segment
269
+ // NOTE: "SKU: ZZD-MX-XXX" appears at the END of the page card for each order.
270
+ // When we split on "SKU:", block 0 = order 1 (no SKU — it's in the delimiter),
271
+ // block i (i>=1) starts with SKU for order i (e.g. block 1 starts with "ZZD-MX-018"
272
+ // which belongs to order 2). So for order 1 we peek at block 1's start.
273
+ const orderBlocks = text.split(/SKU:\s*/i);
274
+ const startIdx = orderBlocks.length > 0 && extractOrderDate(orderBlocks[0]) ? 0 : 1;
275
+ const today = getMexicoToday();
276
+
277
+ for (let i = startIdx; i < orderBlocks.length; i++) {
278
+ const block = orderBlocks[i];
279
+
280
+ // SKU: for block 0 (order 1), peek at next block's start.
281
+ // For all other blocks, SKU is at current block's start.
282
+ let sku = null;
283
+ if (i === 0 && i + 1 < orderBlocks.length) {
284
+ const nextBlock = orderBlocks[i + 1];
285
+ const skuEnd = nextBlock.search(/\s/);
286
+ sku = skuEnd > 0 ? nextBlock.slice(0, skuEnd) : null;
287
+ } else {
288
+ const skuEnd = block.search(/\s/);
289
+ sku = skuEnd > 0 ? block.slice(0, skuEnd) : null;
290
+ }
291
+ // Skip blocks from page footer / navigation (no valid date)
292
+ if (/Copyright|Mercado Libre®|Términos y condiciones|Trabaja con nosotros/i.test(block)) continue;
293
+
294
+ // Extract order date; skip if no valid date found
295
+ const orderDate = extractOrderDate(block);
296
+ if (!orderDate) continue;
297
+
298
+ // Only include today's orders
299
+ if (orderDate.day !== today.day || orderDate.month !== today.month) continue;
300
+
301
+ const item = {};
302
+
303
+ // SKU (extracted from next block's start — see note above)
304
+ item.sku = sku || null;
305
+
306
+ // Order number (# followed by digits)
307
+ const orderMatch = block.match(/#(\d+)/);
308
+ if (orderMatch) item.order_id = orderMatch[1];
309
+
310
+ // Price: $ xxx.xx pattern
311
+ const priceMatch = block.match(/\$\s*([\d,]+\.?\d*)/);
312
+ if (priceMatch) {
313
+ item.price_mxn = parseFloat(priceMatch[1].replace(/,/g, ""));
314
+ data.orders.total_amount_mxn += item.price_mxn;
315
+ }
316
+
317
+ // Quantity (Spanish "unidad/es" or Chinese "单位")
318
+ const qtyMatch = block.match(/(\d+)\s*(?:unidad(?:es)?|单位)/i);
319
+ if (qtyMatch) item.quantity = parseInt(qtyMatch[1], 10);
320
+
321
+ // Product name: extract from the text BETWEEN status/noise keywords and price.
322
+ const nameContext = block.slice(0, Math.max(block.indexOf("$"), 0)).trim();
323
+ if (nameContext) {
324
+ let cleanName = nameContext
325
+ // Quantity
326
+ .replace(/\d+\s*(?:unidad(?:es)?|单位)/gi, "")
327
+ // Status keywords (Spanish + Chinese)
328
+ .replace(/Procesando en la bodega|En camino|Entregado|Cancelado|在路上|在仓库处理中|已送达|已取消/gi, "")
329
+ // Shipping / delivery text (Spanish + Chinese)
330
+ .replace(/Llega\s+(?:hoy|ma[ñn]ana|entre\s+el\s+\d+\s+y\s+\d+\s+de\s+\w+|el\s+\w+\s+\d+\s+de\s+\w+)/gi, "")
331
+ .replace(/明日送达|今天送达|星期\S+送达|跟踪物流/gi, "")
332
+ // "Sin factura" / "无发票"
333
+ .replace(/Sin factura(?: con RFC)?/gi, "")
334
+ .replace(/无发票/gi, "")
335
+ // "Seguir envío" / tracking
336
+ .replace(/Seguir env[ií]o/gi, "")
337
+ // Reputation text (Spanish + Chinese)
338
+ .replace(/No afecta tu reputaci[oó]n/gi, "")
339
+ .replace(/不影响您的信誉/gi, "")
340
+ // Order numbers
341
+ .replace(/#\d+/g, "")
342
+ // Customer IDs
343
+ .replace(/\b[A-Z]{2,}\d{5,}\b/g, "")
344
+ // MercadoLibre internal ref
345
+ .replace(/\bML\b\s*/g, "")
346
+ // Messages / 消息
347
+ .replace(/Mensajes/gi, "")
348
+ .replace(/消息/gi, "")
349
+ // FULL shipping notice (Spanish + Chinese)
350
+ .replace(/Por ser un env[ií]o FULL[^.]*/gi, "")
351
+ .replace(/由于采用FULL物流模式[^。]*。/gi, "")
352
+ // Questions / misc
353
+ .replace(/Recibiste \d+ pregunta[^.]*/gi, "")
354
+ // Collapse whitespace
355
+ .replace(/\s{2,}/g, " ")
356
+ .trim();
357
+ // Only use if it looks like a real product name (not just noise)
358
+ if (cleanName && cleanName.length > 3) {
359
+ item.product_name = cleanName.slice(0, 200);
360
+ }
361
+ }
362
+
363
+ // Status (Spanish + Chinese)
364
+ const statusMatch = block.match(
365
+ /(Procesando en la bodega|En camino|Entregado|Cancelado|在仓库处理中|在路上|已送达|已取消)/i
366
+ );
367
+ if (statusMatch) item.status = statusMatch[1];
368
+
369
+ if (item.sku || item.price_mxn) {
370
+ data.orders.items.push(item);
371
+ }
372
+ }
373
+
374
+ // Round total and set count from filtered items
375
+ data.orders.total_amount_mxn =
376
+ Math.round(data.orders.total_amount_mxn * 100) / 100;
377
+ data.orders.count = data.orders.items.length;
378
+
379
+ // ---- Product aggregation ----
380
+ const productMap = {};
381
+ for (const item of data.orders.items) {
382
+ if (!item.sku) continue;
383
+ if (!productMap[item.sku]) {
384
+ productMap[item.sku] = {
385
+ sku: item.sku,
386
+ name: item.product_name || null,
387
+ order_count: 0,
388
+ total_amount: 0,
389
+ };
390
+ }
391
+ productMap[item.sku].order_count += 1;
392
+ productMap[item.sku].total_amount += item.price_mxn || 0;
393
+ }
394
+ data.products = Object.values(productMap).sort(
395
+ (a, b) => b.order_count - a.order_count
396
+ );
397
+
398
+ // ---- Alerts ----
399
+ if (data.orders.count === 0) {
400
+ data.alerts.push("no_orders_found");
401
+ }
402
+ if (data.after_sales.pending > 0) {
403
+ data.alerts.push("pending_after_sales");
404
+ }
405
+ if (data.orders.items.length === 0 && data.orders.count > 0) {
406
+ data.alerts.push("order_detail_extraction_failed");
407
+ }
408
+
409
+ return data;
410
+ }
411
+
412
+ // ===================================================================
413
+ // parseReputationData — reputation page parser
414
+ // Page: https://www.mercadolibre.com.mx/reputacion?from=seller_menu
415
+ // ===================================================================
416
+
417
+ function parseReputationData(text) {
418
+ if (!text || typeof text !== "string") {
419
+ return { error: "no_content" };
420
+ }
421
+
422
+ const result = {};
423
+
424
+ // ---- MercadoLíder level ----
425
+ const levelMatch = text.match(/MercadoL[ií]der\s+(Gold|Platinum|Silver)/i);
426
+ if (levelMatch) {
427
+ result.level = levelMatch[0].trim(); // e.g. "MercadoLíder Gold"
428
+ }
429
+
430
+ // ---- Overall score (e.g. "6/7" after "Tu desempeño") ----
431
+ const scoreMatch = text.match(/Tu desempeño\s*(\d+)\s*\/\s*(\d+)/i);
432
+ if (scoreMatch) {
433
+ result.score = `${scoreMatch[1]}/${scoreMatch[2]}`;
434
+ result.score_numerator = parseInt(scoreMatch[1], 10);
435
+ result.score_denominator = parseInt(scoreMatch[2], 10);
436
+ }
437
+
438
+ // ---- Period: last-60-days sales metrics ----
439
+ // Patterns: "1,017 Ventas" / "979 Concretadas" / "$ 286,528 Facturado"
440
+ const ventasPeriod = text.match(/([\d,]+)\s*Ventas/i);
441
+ const concretadasMatch = text.match(/([\d,]+)\s*Concretadas/i);
442
+ const facturadoMatch = text.match(/\$\s*([\d,]+)\s*Facturado/i);
443
+
444
+ if (ventasPeriod || concretadasMatch || facturadoMatch) {
445
+ result.period = {};
446
+ if (ventasPeriod)
447
+ result.period.sales = parseInt(ventasPeriod[1].replace(/,/g, ""), 10);
448
+ if (concretadasMatch)
449
+ result.period.completed = parseInt(concretadasMatch[1].replace(/,/g, ""), 10);
450
+ if (facturadoMatch)
451
+ result.period.facturado_mxn = parseFloat(facturadoMatch[1].replace(/,/g, ""));
452
+ }
453
+
454
+ // ---- Complaints breakdown ----
455
+ // Page text pattern for each complaint type:
456
+ // "<desc>... X% Son N de tus ventas [con Envíos] Por debajo del Y% permitido"
457
+ // The four types appear in order: Reclamos, Mediaciones, Canceladas por ti, Envíos incorrectos
458
+
459
+ const complaints = {};
460
+ const complaintDefs = [
461
+ { key: 'reclamos_pct', countKey: 'reclamos_count', label: 'Reclamos' },
462
+ { key: 'mediaciones_pct', countKey: 'mediaciones_count', label: 'Mediaciones' },
463
+ { key: 'cancel_pct', countKey: 'cancel_count', label: 'Canceladas por ti' },
464
+ { key: 'envio_err_pct', countKey: 'envio_err_count', label: 'Env\u00edos incorrectos' },
465
+ ];
466
+
467
+ // Split text into sections by complaint type names
468
+ for (let i = 0; i < complaintDefs.length; i++) {
469
+ const def = complaintDefs[i];
470
+ // Find this section: from the complaint label to the next complaint label (or end)
471
+ const startIdx = text.indexOf(def.label);
472
+ if (startIdx === -1) continue;
473
+ const nextIdx = i + 1 < complaintDefs.length
474
+ ? text.indexOf(complaintDefs[i + 1].label, startIdx + def.label.length)
475
+ : text.length;
476
+ const section = text.slice(startIdx, nextIdx > startIdx ? nextIdx : text.length);
477
+
478
+ // Extract percentage: first occurrence of "X%" or "X.X%" in this section
479
+ const pctMatch = section.match(/(\d+(?:\.\d+)?)%/);
480
+ if (pctMatch) complaints[def.key] = pctMatch[1] + '%';
481
+
482
+ // Extract count: "Son N de tus ventas"
483
+ const countMatch = section.match(/Son\s+(\d+)\s+de\s+tus\s+ventas/i);
484
+ if (countMatch) complaints[def.countKey] = parseInt(countMatch[1], 10);
485
+ }
486
+
487
+ if (Object.keys(complaints).length > 0) {
488
+ result.complaints = complaints;
489
+ }
490
+
491
+ // ---- New questions ----
492
+ const newQMatch = text.match(/Recibiste\s+(\d+)\s+pregunta\s+nueva/i);
493
+ if (newQMatch) {
494
+ result.new_questions = parseInt(newQMatch[1], 10);
495
+ }
496
+
497
+ // ---- Problem products — table under "Productos con más problemas" ----
498
+ // Look for the section heading then capture rows
499
+ const probSection = text.match(
500
+ /Productos con m[aá]s problemas\s*([\s\S]*?)(?=\n\s*\n|$)/
501
+ );
502
+ if (probSection) {
503
+ const sectionText = probSection[1];
504
+ // Each row: product name followed by problem count
505
+ // Real format: "Product Name (3)" or "Product Name 3 problemas"
506
+ const productLines = sectionText
507
+ .split("\n")
508
+ .map((l) => l.trim())
509
+ .filter((l) => l.length > 0);
510
+
511
+ const problemProducts = [];
512
+ for (const line of productLines) {
513
+ // Pattern: "Product Name (N)" or "Product Name - N problemas"
514
+ const pMatch = line.match(/^(.+?)\s*[(-]\s*(\d+)\s*[)\s]/);
515
+ if (pMatch) {
516
+ problemProducts.push({
517
+ name: pMatch[1].trim(),
518
+ problems: parseInt(pMatch[2], 10),
519
+ });
520
+ } else {
521
+ // Fallback: any line with a number at end
522
+ const fallback = line.match(/^(.+?)\s+(\d+)\s*$/);
523
+ if (fallback) {
524
+ problemProducts.push({
525
+ name: fallback[1].trim(),
526
+ problems: parseInt(fallback[2], 10),
527
+ });
528
+ }
529
+ }
530
+ }
531
+ if (problemProducts.length > 0) {
532
+ result.problem_products = problemProducts;
533
+ }
534
+ }
535
+
536
+ return result;
537
+ }
538
+
539
+ // ===================================================================
540
+ // parseFullData — FULL inbound shipments parser
541
+ // Page: https://myaccount.mercadolibre.com.mx/shipping/inbounds
542
+ // ===================================================================
543
+
544
+ function parseFullData(text) {
545
+ if (!text || typeof text !== "string") {
546
+ return { error: "no_content" };
547
+ }
548
+
549
+ const result = {};
550
+
551
+ // ---- Pending shipments count ----
552
+ const pendingMatch = text.match(
553
+ /(\d+)\s*Env[ií]os\s+para\s+terminar\s+de\s+preparar/i
554
+ );
555
+ if (pendingMatch) {
556
+ result.pending_count = parseInt(pendingMatch[1], 10);
557
+ }
558
+
559
+ // ---- Non-apt units (U. no aptas para Full) ----
560
+ const notAptMatch = text.match(
561
+ /U\.\s*no\s+aptas\s+para\s+Full\s*[:\s]*(\d+)/i
562
+ );
563
+ if (notAptMatch) {
564
+ result.not_apt_total = parseInt(notAptMatch[1], 10);
565
+ }
566
+
567
+ // ---- Shipment rows ----
568
+ // Each row pattern from real page:
569
+ // #shipment_id | Declared/Apt | Date | Location | Status
570
+ // Look for shipment IDs prefixed with #
571
+ const shipmentBlocks = text.split(/(?=#\d{6,})/g);
572
+ const shipments = [];
573
+
574
+ for (const block of shipmentBlocks) {
575
+ const idMatch = block.match(/^#(\d{6,})/);
576
+ if (!idMatch) continue;
577
+
578
+ const shipment = { id: idMatch[1] };
579
+
580
+ // Status: one of the known values
581
+ const statusMatch = block.match(
582
+ /(En preparaci[oó]n|Procesamiento finalizado|Vencido|Finalizado)/i
583
+ );
584
+ if (statusMatch) shipment.status = statusMatch[1];
585
+
586
+ // Declared / Apt for Full
587
+ // Pattern like "Declared: 10 — Apt: 8" or "10 / 8"
588
+ const declaredMatch = block.match(
589
+ /(?:Declarados?|Declared)[:\s]*(\d+)/i
590
+ );
591
+ const aptMatch = block.match(
592
+ /(?:Aptos?|Apt)[:\s]*(\d+)/i
593
+ );
594
+ if (declaredMatch) shipment.declared = parseInt(declaredMatch[1], 10);
595
+ if (aptMatch) shipment.apt_for_full = parseInt(aptMatch[1], 10);
596
+
597
+ // Appointment date — look for date patterns
598
+ // Real: "20/07/2026" or "20-07-2026" or "lunes, 20 de julio de 2026"
599
+ const dateMatch = block.match(
600
+ /\b(\d{2}\/\d{2}\/\d{4})\b|\b(\d{2}-\d{2}-\d{4})\b/
601
+ );
602
+ if (dateMatch) {
603
+ shipment.appointment = dateMatch[1] || dateMatch[2];
604
+ } else {
605
+ // Spanish date format: "lunes, 20 de julio de 2026"
606
+ const spanDateMatch = block.match(
607
+ /(?:lunes|martes|mi[eé]rcoles|jueves|viernes|s[aá]bado|domingo)[,.]?\s+\d{1,2}\s+de\s+\w+\s+de\s+\d{4}/i
608
+ );
609
+ if (spanDateMatch) shipment.appointment = spanDateMatch[0].trim();
610
+ }
611
+
612
+ // Location / warehouse
613
+ const locationMatch = block.match(
614
+ /(?:Ubicaci[oó]n|Location|Centro|Bodega|Warehouse)[:\s]*([^\n]{3,50})/i
615
+ );
616
+ if (locationMatch) {
617
+ shipment.location = locationMatch[1].trim();
618
+ }
619
+
620
+ // Non-apt units within this shipment
621
+ const blockNotApt = block.match(
622
+ /U\.\s*no\s+aptas\s*[:\s]*(\d+)/i
623
+ );
624
+ if (blockNotApt) {
625
+ shipment.not_apt_count = parseInt(blockNotApt[1], 10);
626
+ }
627
+
628
+ // Only include if it has at least one FULL-specific field
629
+ // (filters out sales order IDs that also match #\d{6,})
630
+ // Also require reasonable shipment ID length (FULL: ~6-10 digits; sales orders: 14-16 digits)
631
+ if ((shipment.status || shipment.declared != null || shipment.apt_for_full != null
632
+ || shipment.appointment || shipment.location || shipment.not_apt_count != null)
633
+ && shipment.id.length <= 10) {
634
+ shipments.push(shipment);
635
+ }
636
+ }
637
+
638
+ if (shipments.length > 0) {
639
+ result.shipments = shipments;
640
+ }
641
+
642
+ return result;
643
+ }
644
+
645
+ // ---- Entry point ----
646
+ // Detect execution context: browser (page exec) vs Node.js CLI
647
+ // Use require.main === module to distinguish direct execution from require()
648
+ const isDirectRun = typeof document !== "undefined"
649
+ || (typeof require !== "undefined" && require.main === module);
650
+
651
+ if (typeof document !== "undefined") {
652
+ // Running in browser via page exec
653
+ const result = extractFromDOM();
654
+ return JSON.stringify(result, null, 2);
655
+ } else if (isDirectRun && typeof process !== "undefined" && process.stdin) {
656
+ // Running as Node.js CLI — read stdin
657
+ let input = "";
658
+ process.stdin.setEncoding("utf8");
659
+ process.stdin.on("data", (chunk) => {
660
+ input += chunk;
661
+ });
662
+ process.stdin.on("end", () => {
663
+ // Handle page content JSON wrapper from ziniao-cli
664
+ try {
665
+ const wrapped = JSON.parse(input);
666
+ const pageContent =
667
+ wrapped?.data?.data?.content ||
668
+ wrapped?.content ||
669
+ wrapped?.text ||
670
+ input;
671
+ const result = extractFromText(
672
+ typeof pageContent === "string" ? pageContent : JSON.stringify(pageContent)
673
+ );
674
+ console.log(JSON.stringify(result, null, 2));
675
+ } catch {
676
+ const result = extractFromText(input);
677
+ console.log(JSON.stringify(result, null, 2));
678
+ }
679
+ });
680
+ } else {
681
+ // Being required as a module — export for programmatic use
682
+ module.exports = {
683
+ parseSalesData,
684
+ parseReputationData,
685
+ parseFullData,
686
+ parseMetricsPage,
687
+ extractFromDOM,
688
+ extractFromText,
689
+ mergeAll,
690
+ };
691
+ }