@whyshu/argent-engine 0.7.3 → 0.7.4

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 (45) hide show
  1. package/MANIFEST.json +39 -28
  2. package/engine/VERSION +1 -1
  3. package/engine/scripts/appointment-create.mjs +34 -0
  4. package/engine/scripts/appointment-stop.mjs +18 -0
  5. package/engine/scripts/appointment-worker.mjs +96 -0
  6. package/engine/scripts/appointment_grab.py +327 -0
  7. package/engine/scripts/argent-runner.mjs +223 -0
  8. package/engine/scripts/argent-setup-check.mjs +3 -3
  9. package/engine/scripts/capability-packs.lock.json +33 -39
  10. package/engine/scripts/check-agent-catalog.mjs +1 -1
  11. package/engine/scripts/check-config-catalog.mjs +0 -1
  12. package/engine/scripts/collect-evidence.mjs +2 -5
  13. package/engine/scripts/daily-report.mjs +315 -12
  14. package/engine/scripts/dsh-upgrade.mjs +18 -5
  15. package/engine/scripts/flow-requirements.json +5 -7
  16. package/engine/scripts/flow-timers.sh +1 -1
  17. package/engine/scripts/flow-trigger.sh +1 -1
  18. package/engine/scripts/gen-preset-rows.mjs +20 -0
  19. package/engine/scripts/invoice_stageb.py +20 -1
  20. package/engine/scripts/lib/agent-install.mjs +10 -1
  21. package/engine/scripts/lib/daily-report-core.mjs +124 -16
  22. package/engine/scripts/lib/dsh-versions.mjs +17 -1
  23. package/engine/scripts/lib/ml-ads-core.mjs +204 -0
  24. package/engine/scripts/lib/plugin-packages.mjs +1 -0
  25. package/engine/scripts/ml-ads.mjs +181 -0
  26. package/engine/scripts/ml-oauth-setup.mjs +1 -1
  27. package/engine/scripts/p2-report-run.mjs +102 -0
  28. package/engine/scripts/patches/dsh-session--append-message-shape.patch +34 -9
  29. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +44 -0
  30. package/engine/scripts/patches/verify-hardening.mjs +4 -1
  31. package/engine/scripts/patches/verify-turn-outside-replace.mjs +64 -0
  32. package/engine/scripts/poll-fulfillment.sh +1 -1
  33. package/engine/scripts/profile-doctor.mjs +1 -1
  34. package/engine/scripts/python-runtime.sh +16 -4
  35. package/engine/scripts/release-argent.sh +8 -7
  36. package/engine/scripts/session-load-check.mjs +105 -0
  37. package/engine/scripts/sync-scripts.sh +1 -1
  38. package/engine/scripts/xzh-browser.sh +37 -0
  39. package/engine/scripts/xzh_browser.py +49 -0
  40. package/engine/skills/ml-full-appointment/SKILL.md +98 -0
  41. package/engine/version.mjs +1 -1
  42. package/package.json +1 -1
  43. package/engine/scripts/drill-clean-machine.sh +0 -315
  44. package/engine/scripts/install-internal-role.sh +0 -190
  45. package/engine/scripts/install-one-shot.mjs +0 -665
@@ -37,7 +37,7 @@ pick_py() {
37
37
  [ -x "$c" ] || continue
38
38
  "$c" -c 'import openpyxl, yaml' 2>/dev/null || continue # 硬要求:配置/发票生成必需
39
39
  "$c" -c 'import PIL, playwright' 2>/dev/null || \
40
- echo "[$(date '+%F %T')] ⚠️ 解释器 $c 缺 pillow/playwright ⇒ 浏览器流程会失败(修:node install-argent.mjs --venv-only)" >&2
40
+ echo "[$(date '+%F %T')] ⚠️ 解释器 $c 缺 pillow/playwright ⇒ 浏览器流程会失败(修:bash scripts/python-runtime.sh ensure)" >&2
41
41
  printf '%s' "$c"; return 0
42
42
  done
43
43
  return 1
@@ -106,8 +106,28 @@ export function indentComposition(text) {
106
106
  return text.replace(/\n+$/, '\n').split('\n').map((l) => (l.trim() === '' ? '' : INDENT + l)).join('\n')
107
107
  }
108
108
 
109
+ /**
110
+ * 0.2.x 只注册 `provider`/`model` 两个提示词变量;`cwd` 已移除(工作目录改由
111
+ * `dsh-working-directory` 作为**原生上下文**注入)。预设里若再写 `{{cwd}}`,
112
+ * 会话首轮就会 `unknown prompt variable` 直接失败 ⇒ 生成期就拦下(2026-10-18 P2 试点实测)。
113
+ */
114
+ export const ALLOWED_PROMPT_VARS = ['provider', 'model']
115
+
116
+ function assertPromptVars(id, text) {
117
+ const refs = [...text.matchAll(/\{\{([^{}]*)\}\}/g)].map((m) => m[1].trim())
118
+ const bad = [...new Set(refs.filter((v) => !ALLOWED_PROMPT_VARS.includes(v)))]
119
+ if (bad.length > 0) {
120
+ throw new Error(
121
+ `preset ${id} 引用了宿主未注册的提示词变量 ${bad.map((v) => `{{${v}}}`).join(', ')};`
122
+ + `0.2.x 允许的变量:${ALLOWED_PROMPT_VARS.map((v) => `{{${v}}}`).join(', ')}`
123
+ + '(工作目录由 dsh-working-directory 上下文原生提供,不要写 {{cwd}})',
124
+ )
125
+ }
126
+ }
127
+
109
128
  /** 生成单个角色的 patch 文件内容(含跨版本行改写;改写记录写进文件头便于审计)。 */
110
129
  export function renderPatch({ id, meta, composition, order }) {
130
+ assertPromptVars(id, composition)
111
131
  const compat = applyRowCompat(composition)
112
132
  const name = (meta.name || id).replace(/'/g, "''")
113
133
  const desc = (meta.description || '').replace(/'/g, "''")
@@ -18,6 +18,7 @@ from argent_datadir import resolve_data_dir # noqa: E402 —— 勿在此复
18
18
  DATA = Path(resolve_data_dir()['dir'])
19
19
  CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
20
20
  PROFILE = str(DATA / "chrome-profile-xinhang")
21
+ XZH_CDP_URL = os.environ.get("XZH_CDP_URL", "http://127.0.0.1:9223")
21
22
  TIMEOUT = 15000
22
23
 
23
24
 
@@ -27,9 +28,24 @@ def load_ctx():
27
28
  return m, c
28
29
 
29
30
 
31
+ def _try_shared(p):
32
+ """优先连接常驻鑫中航 Chrome(CDP);连不上返回 None,回退旧的一次性浏览器。"""
33
+ try:
34
+ browser = p.chromium.connect_over_cdp(XZH_CDP_URL, timeout=3000)
35
+ contexts = browser.contexts
36
+ if not contexts:
37
+ return None
38
+ return browser, contexts[0]
39
+ except Exception:
40
+ return None
41
+
42
+
30
43
  def browser():
31
44
  from playwright.sync_api import sync_playwright
32
45
  p = sync_playwright().start()
46
+ shared = _try_shared(p)
47
+ if shared:
48
+ return p, shared[1]
33
49
  ctx = p.chromium.launch_persistent_context(
34
50
  user_data_dir=PROFILE,
35
51
  executable_path=CHROME,
@@ -159,9 +175,12 @@ LABELS_DIR.mkdir(parents=True, exist_ok=True)
159
175
 
160
176
 
161
177
  def browser_ac():
162
- """带 accept_downloads 的持久化 context(登录态复用;下载标签必需)。"""
178
+ """带 accept_downloads 的 context;优先常驻 Chrome,否则一次性 profile。"""
163
179
  from playwright.sync_api import sync_playwright
164
180
  p = sync_playwright().start()
181
+ shared = _try_shared(p)
182
+ if shared:
183
+ return p, shared[1]
165
184
  ctx = p.chromium.launch_persistent_context(
166
185
  user_data_dir=PROFILE,
167
186
  executable_path=CHROME,
@@ -457,7 +457,16 @@ export async function runAgentInstall({
457
457
  }
458
458
  // ③ 快照**本次落盘面**(跨进程事务;失败即逐字节回滚 —— "⛔ 不写半截依赖图")
459
459
  const surface = snapshotAgentSurface({ repo, dshHome, profile, id })
460
- // ④ 调用**安装器本体**(唯一安装链)
460
+ // ④ **旧安装器已退役(2026-10-18)**:不再 spawn `install-argent.mjs`。
461
+ // 新链路 = @whyshu/argent 启动器(待发布);此处 fail-fast,避免"进程启动失败"式的模糊报错。
462
+ if (!existsSync(join(repo, 'install-argent.mjs'))) {
463
+ return {
464
+ ok: false, step: 'installer-retired', argv: [], exitCode: null, stdout: '', stderr: '', out: '',
465
+ fix: '旧一句话安装器已退役(2026-10-18)。智能体安装/升级改由 @whyshu/argent 启动器承担(待发布);本次没有写任何东西。',
466
+ logs: ['install-argent.mjs 已退役 ⇒ 本次不装 agent bundle(⛔ 不写半截依赖图)'], wrote: false,
467
+ }
468
+ }
469
+ // ⑤ 调用安装器本体(历史路径;仅当仓库里仍有安装器时)
461
470
  const argv = agentInstallArgv({ repo, dshHome, id })
462
471
  const child = spawn(process.execPath, argv, { env, cwd: cwd || repo, stdio: ['ignore', 'pipe', 'pipe'] })
463
472
  const logs = []
@@ -426,26 +426,134 @@ export async function apiGet(path, token, opts = {}) {
426
426
  }
427
427
  }
428
428
 
429
- /** 拉窗口内订单(分页 + 节流)。只读。 */
430
- export async function fetchOrders(token, sellerId, fromISO, toISO, opts = {}) {
431
- const all = []
429
+ // ── 订单窗口取全量(P0,2026-10-18):超限不再硬停,按整点二分 ──
430
+ //
431
+ // 事故:旧实现有 `all.length >= 5000` 的静默硬停;订单接口按 date_created 升序 ⇒
432
+ // 截断丢的是**窗口尾部(最新)**,导致 2 店「昨日」被砍 40~58%(见 docs/日报数据出入-5000截断…)。
433
+ // 现口径:
434
+ // ① 窗口先取一页(limit=50)读 paging.total;
435
+ // ② total ≤ 阈值(默认 4000) 或窗口 ≤ 2h ⇒ 直接分页拉全;
436
+ // ③ 否则在**整点边界**二分递归;合并后按 `order.id` 去重(防 `to` 整点扩展造成的边界重叠);
437
+ // ④ 合并条数必须 == 根窗口 total;不等 ⇒ `complete=false`(**不假装成功**,由调用方告警);
438
+ // ⑤ 总上限保护(默认 20000)⇒ `complete=false` + 告警,不硬撑。
439
+ // 兼容:`fetchOrders()` 仍返回数组(内部走 WithMeta);日报/对账不用大改。
440
+ const ORDER_PAGE_LIMIT = 50
441
+ const HOUR_MS = 3600 * 1000
442
+ export const DEFAULT_ORDERS_SPLIT_THRESHOLD = 4000
443
+ export const DEFAULT_ORDERS_MAX_TOTAL = 20000
444
+
445
+ const envPositiveInt = (name, fallback) => {
446
+ const n = Number(process.env[name])
447
+ return Number.isFinite(n) && n > 0 ? n : fallback
448
+ }
449
+
450
+ function orderSearchPath(sellerId, fromISO, toISO, offset) {
451
+ const p = new URLSearchParams({
452
+ seller: String(sellerId),
453
+ 'order.date_created.from': fromISO,
454
+ 'order.date_created.to': toISO,
455
+ limit: String(ORDER_PAGE_LIMIT),
456
+ offset: String(offset),
457
+ })
458
+ return `/orders/search?${p}`
459
+ }
460
+
461
+ /** 一个窗口内分页拉全(不再有 5000 硬停;`preloaded` = 已取到的第一页,避免重复请求)。 */
462
+ async function fetchOrderWindow(token, sellerId, fromISO, toISO, opts = {}, preloaded = null) {
463
+ const orders = []
432
464
  let offset = 0
465
+ let total = null
466
+ let page = preloaded
433
467
  for (;;) {
434
- const p = new URLSearchParams({
435
- seller: String(sellerId),
436
- 'order.date_created.from': fromISO,
437
- 'order.date_created.to': toISO,
438
- limit: '50',
439
- offset: String(offset),
440
- })
441
- const d = await apiGet(`/orders/search?${p}`, token, { base: opts.base })
442
- all.push(...(d.results || []))
443
- const total = (d.paging && d.paging.total) || all.length
444
- if (all.length >= total || all.length >= 5000 || !(d.results || []).length) break
445
- offset += 50
468
+ const d = page || await apiGet(orderSearchPath(sellerId, fromISO, toISO, offset), token, { base: opts.base })
469
+ page = null
470
+ orders.push(...(d.results || []))
471
+ if (total === null) {
472
+ const n = Number(d.paging && d.paging.total)
473
+ total = Number.isFinite(n) ? n : orders.length
474
+ }
475
+ if (orders.length >= total || !(d.results || []).length) break
476
+ offset += ORDER_PAGE_LIMIT
446
477
  await sleep(Number(process.env.ML_PAGE_GAP_MS || 60)) // 翻页节流:降低被限流概率
447
478
  }
448
- return all
479
+ return { orders, total, got: orders.length }
480
+ }
481
+
482
+ /**
483
+ * 拉窗口内订单并返回完整度元信息。只读。
484
+ * @returns {{orders:object[], total:number, got:number, complete:boolean, windows:object[], reason:string}}
485
+ */
486
+ export async function fetchOrdersWithMeta(token, sellerId, fromISO, toISO, opts = {}) {
487
+ const threshold = Number(opts.splitThreshold) > 0
488
+ ? Number(opts.splitThreshold)
489
+ : envPositiveInt('ML_ORDERS_SPLIT_THRESHOLD', DEFAULT_ORDERS_SPLIT_THRESHOLD)
490
+ const maxTotal = Number(opts.maxTotal) > 0
491
+ ? Number(opts.maxTotal)
492
+ : envPositiveInt('ML_ORDERS_MAX_TOTAL', DEFAULT_ORDERS_MAX_TOTAL)
493
+
494
+ const rootFirst = await apiGet(orderSearchPath(sellerId, fromISO, toISO, 0), token, { base: opts.base })
495
+ const rootTotalRaw = Number(rootFirst && rootFirst.paging && rootFirst.paging.total)
496
+ const rootTotal = Number.isFinite(rootTotalRaw) ? rootTotalRaw : (rootFirst.results || []).length
497
+
498
+ if (rootTotal > maxTotal) {
499
+ return { orders: [], total: rootTotal, got: 0, complete: false, windows: [],
500
+ reason: `max-total(>${maxTotal})` }
501
+ }
502
+
503
+ const windows = []
504
+ let failed = false
505
+
506
+ async function collect(from, to, preloaded) {
507
+ const fromMs = Date.parse(from)
508
+ const toMs = Date.parse(to)
509
+ const first = preloaded || await apiGet(orderSearchPath(sellerId, from, to, 0), token, { base: opts.base })
510
+ const t = Number(first && first.paging && first.paging.total)
511
+ const total = Number.isFinite(t) ? t : (first.results || []).length
512
+ // 小窗口/极窄窗口:单窗口分页拉全
513
+ if (total <= threshold || !(toMs - fromMs > 2 * HOUR_MS)) {
514
+ const w = await fetchOrderWindow(token, sellerId, from, to, opts, first)
515
+ windows.push({ from, to, total: w.total, got: w.got })
516
+ return w.orders
517
+ }
518
+ // 整点边界二分;取不到合法中点(窗口过窄)⇒ 退化为单窗口拉全
519
+ const midMs = Math.floor(((fromMs + toMs) / 2) / HOUR_MS) * HOUR_MS
520
+ if (!(midMs > fromMs && midMs < toMs)) {
521
+ const w = await fetchOrderWindow(token, sellerId, from, to, opts, first)
522
+ windows.push({ from, to, total: w.total, got: w.got })
523
+ return w.orders
524
+ }
525
+ const mid = new Date(midMs).toISOString()
526
+ const left = await collect(from, mid, null)
527
+ const right = await collect(mid, to, null)
528
+ return [...left, ...right]
529
+ }
530
+
531
+ async function safeCollect(from, to, preloaded) {
532
+ try { return await collect(from, to, preloaded) } catch (error) {
533
+ failed = true
534
+ windows.push({ from, to, error: String((error && error.message) || error).slice(0, 200) })
535
+ return []
536
+ }
537
+ }
538
+
539
+ const all = await safeCollect(fromISO, toISO, rootFirst)
540
+ const seen = new Map()
541
+ for (const o of all) {
542
+ if (o && o.id != null) seen.set(String(o.id), o)
543
+ else if (o) seen.set(`__anon_${seen.size}`, o)
544
+ }
545
+ const orders = [...seen.values()]
546
+ const complete = !failed && orders.length === rootTotal
547
+ return {
548
+ orders, total: rootTotal, got: orders.length, complete, windows,
549
+ reason: complete ? 'ok' : failed ? 'window-error' : `short(${orders.length}/${rootTotal})`,
550
+ }
551
+ }
552
+
553
+ /** 兼容入口:返回订单数组(日报/对账既有调用方签名不变)。 */
554
+ export async function fetchOrders(token, sellerId, fromISO, toISO, opts = {}) {
555
+ const meta = await fetchOrdersWithMeta(token, sellerId, fromISO, toISO, opts)
556
+ return meta.orders
449
557
  }
450
558
 
451
559
  // ── OAuth token(刷新 + 轮换回写交给调用方)──────────────────
@@ -21,7 +21,23 @@
21
21
  * ⇒ 客户看到一条毫无意义的告警)。防漂移门禁 = `verifiedListGaps()`(回归
22
22
  * `scripts/tests/dsh-verified-list.test.mjs` 钉住"缺项即红")。
23
23
  */
24
- export const DSH_VERIFIED_FALLBACK = ['0.1.0-rc.6', '0.1.0-rc.7', '0.1.1-rc.1', '0.1.5-rc.1', '0.1.7-rc.2']
24
+ export const DSH_VERIFIED_FALLBACK = ['0.1.0-rc.6', '0.1.0-rc.7', '0.1.1-rc.1', '0.1.5-rc.1', '0.1.7-rc.2', '0.2.0-rc.2', '0.2.1-rc.2']
25
+
26
+ /**
27
+ * **最小支持宿主版本**(唯一真源,2026-10-18 用户决定:「以后机器都要求升级到与本机一致才能用」)。
28
+ *
29
+ * 为什么是硬下限而不是"窗口内任选":0.2.x 改动会让旧宿主上的 argent 预设/插件直接失败
30
+ * (典型:0.2.x 移除 `{{cwd}}` 提示词变量 ⇒ 所有旧预设首轮 `unknown prompt variable` 失败;
31
+ * 另有 patch 面/peer 门禁变化)。旧宿主上"装得上"≠"能跑",所以窗口统一收敛到 ≥ 0.2.1-rc.2。
32
+ * 逃生阀仍是 `--allow-unsupported-host`(内部/验证用,带告警+审计)。
33
+ */
34
+ export const DSH_MIN_SUPPORTED = '0.2.1-rc.2'
35
+
36
+ /** 判断某个 dsh 版本是否满足最小支持版本(纯函数;`rc` 版本按 cmpVer 比较)。 */
37
+ export function isSupportedHostVersion(version, min = DSH_MIN_SUPPORTED) {
38
+ if (!version) return false
39
+ return cmpVer(String(version).trim(), min) >= 0
40
+ }
25
41
 
26
42
  /**
27
43
  * **防漂移门禁**:`verified`(安装器实际会用的清单)是否覆盖 `lockDir` 里**已晋级**的每个版本。
@@ -0,0 +1,204 @@
1
+ /**
2
+ * ml-ads-core.mjs —— Mercado Ads(Product Ads)只读取数 + 聚合 + 告警(L1 执行内核,纯逻辑)。
3
+ *
4
+ * ## 为什么单独一份
5
+ * 「广告」这块要同时服务两个消费方:
6
+ * · `scripts/ml-ads.mjs`(只读 CLI:人工/角色对话按需查广告效果);
7
+ * · `scripts/daily-report.mjs`(日报卡片里的「广告」分区)。
8
+ * 两边**必须同一份口径**(花费、ROAS、ACoS、单量),所以取数/聚合/告警全放这里,
9
+ * ⛔ 不在两个入口各写一份(口径一漂两边各自"正确",历史已踩过:日报 vs 平台对账)。
10
+ *
11
+ * ## 纪律(与 daily-report-core.mjs 一致)
12
+ * · **纯 L1**:只 import `node:` 内建;⛔ 不 import 宿主模块(`scripts/tests/kernel-purity.test.mjs` 扫);
13
+ * · **零平台域名**:HTTP 入口由调用方注入(`apiGet`),本文件不认任何域名;
14
+ * · **零写盘**:本文件不出现在任何写路径上(token 轮换写回由调用方负责)。
15
+ *
16
+ * ## 口径(2026-10 实测,MELI 现行 API)
17
+ * · 广告主:`GET /advertising/advertisers?product_id=PADS` → `{advertisers:[{advertiser_id, site_id, ...}]}`
18
+ * · 活动:`GET /advertising/{site}/advertisers/{id}/product_ads/campaigns/search`
19
+ * (`date_from`/`date_to` + `metrics`;**必须逐页累加**——`metrics_summary=true` 只汇总返回的那一条,
20
+ * ⛔ 不能当账户总额用,2026-10-17 实测踩过)
21
+ * · 旧 `product_ads/ads/*` 已于 2026-05/06 下线 ⇒ 统一走 campaign 维度(单品下钻用 ad_groups)。
22
+ * · 指标可回溯 90 天;数据每天 10:00 (GMT-3) 更新。
23
+ */
24
+
25
+ /** 请求的活动指标(逗号串直接拼进 query)。 */
26
+ export const ADS_METRICS = [
27
+ 'clicks', 'prints', 'ctr', 'cost', 'cpc', 'acos', 'roas',
28
+ 'units_quantity', 'total_amount', 'direct_amount', 'indirect_amount',
29
+ 'advertising_items_quantity',
30
+ ].join(',')
31
+
32
+ /** 默认告警阈值(可按店铺/全局覆盖)。 */
33
+ export const ADS_ALERT_DEFAULTS = {
34
+ acosWarn: 25, // %:ACoS 超过 ⇒ 🟡
35
+ acosBad: 35, // %:ACoS 超过 ⇒ 🔴
36
+ minSpend: 100, // 本币:花费低于此值不告警(避免小样本噪声)
37
+ minRoas: 2, // ROAS 低于此值且花费达标 ⇒ 🔴
38
+ }
39
+
40
+ const num = (x) => Number(x || 0)
41
+
42
+ /**
43
+ * 纯函数:把一批 campaign(含 `metrics`)聚合成总额 + 派生指标。
44
+ * @param {Array<{id?:any,name?:string,status?:string,metrics?:object}>} campaigns
45
+ */
46
+ export function deriveAdsTotals(campaigns = []) {
47
+ const t = { clicks: 0, prints: 0, cost: 0, units: 0, amount: 0, directAmount: 0, indirectAmount: 0, campaigns: campaigns.length, activeCampaigns: 0 }
48
+ for (const c of campaigns) {
49
+ const m = (c && c.metrics) || {}
50
+ t.clicks += num(m.clicks)
51
+ t.prints += num(m.prints)
52
+ t.cost += num(m.cost)
53
+ t.units += num(m.units_quantity)
54
+ t.amount += num(m.total_amount)
55
+ t.directAmount += num(m.direct_amount)
56
+ t.indirectAmount += num(m.indirect_amount)
57
+ if (String(c && c.status || '').toLowerCase() === 'active') t.activeCampaigns++
58
+ }
59
+ t.cost = round2(t.cost)
60
+ t.amount = round2(t.amount)
61
+ t.directAmount = round2(t.directAmount)
62
+ t.indirectAmount = round2(t.indirectAmount)
63
+ t.roas = t.cost > 0 ? round2(t.amount / t.cost) : 0
64
+ t.acos = t.amount > 0 ? round2((t.cost / t.amount) * 100) : 0
65
+ t.cpc = t.clicks > 0 ? round2(t.cost / t.clicks) : 0
66
+ t.ctr = t.prints > 0 ? round2((t.clicks / t.prints) * 100) : 0
67
+ t.cvr = t.clicks > 0 ? round2((t.units / t.clicks) * 100) : 0
68
+ return t
69
+ }
70
+
71
+ /** 纯函数:告警分级(返回 `[{level:'red'|'yellow', code, text}]`,text 为中文可读句)。 */
72
+ export function classifyAdsAlerts(totals, opts = {}) {
73
+ const o = { ...ADS_ALERT_DEFAULTS, ...opts }
74
+ const alerts = []
75
+ const t = totals || {}
76
+ const spend = num(t.cost)
77
+ if (spend <= 0) return alerts
78
+ const amount = num(t.amount)
79
+ const big = spend >= num(o.minSpend)
80
+ if (amount <= 0) {
81
+ // 花钱但零广告销售额:**不管花多少都要报**(小钱也是白烧)
82
+ alerts.push({ level: 'red', code: 'zero_conversion', text: `花费 ${fmtMoney(spend)} 但广告销售额 0(烧钱零转化)` })
83
+ return alerts
84
+ }
85
+ // 容错:调用方可能只给 cost/amount(没预算 acos/roas)⇒ 这里自己算,避免"没告警=真健康"的假绿
86
+ const acos = t.acos != null ? num(t.acos) : round2((spend / amount) * 100)
87
+ const roas = t.roas != null ? num(t.roas) : (spend > 0 ? round2(amount / spend) : 0)
88
+ // ACoS / ROAS 告警只在「花费达到 minSpend」时给:小钱的比例噪声大,报了会训练人忽略告警
89
+ if (big) {
90
+ if (acos > num(o.acosBad)) alerts.push({ level: 'red', code: 'acos_bad', text: `ACoS ${acos}%(阈值 ${o.acosBad}%)偏高` })
91
+ else if (acos > num(o.acosWarn)) alerts.push({ level: 'yellow', code: 'acos_warn', text: `ACoS ${acos}%(阈值 ${o.acosWarn}%)偏高` })
92
+ if (roas > 0 && roas < num(o.minRoas)) {
93
+ alerts.push({ level: 'red', code: 'roas_low', text: `ROAS ${roas} 低于 ${o.minRoas}` })
94
+ }
95
+ }
96
+ return alerts
97
+ }
98
+
99
+ /**
100
+ * 只读取数:一个店铺 → 广告主列表 + 每个广告主的活动聚合 + 总计。
101
+ * @param {{apiGet:Function, token:string, site:string, dateFrom:string, dateTo:string, pageSize?:number, maxPages?:number, metrics?:string}} p
102
+ */
103
+ export async function fetchAdsReport({ apiGet, token, site, dateFrom, dateTo, pageSize = 50, maxPages = 40, metrics = ADS_METRICS }) {
104
+ if (typeof apiGet !== 'function') throw new Error('fetchAdsReport 需要注入 apiGet')
105
+ const advRes = await apiGet('/advertising/advertisers?product_id=PADS', token)
106
+ const advertisers = (advRes && advRes.advertisers) || []
107
+ const perAdvertiser = []
108
+ for (const a of advertisers) {
109
+ const advSite = a.site_id || site
110
+ let offset = 0, total = null
111
+ const campaigns = []
112
+ for (let page = 0; page < maxPages; page++) {
113
+ const q = new URLSearchParams({
114
+ limit: String(pageSize), offset: String(offset),
115
+ date_from: dateFrom, date_to: dateTo, metrics,
116
+ })
117
+ const r = await apiGet(`/advertising/${advSite}/advertisers/${a.advertiser_id}/product_ads/campaigns/search?${q}`, token)
118
+ const rows = (r && r.results) || []
119
+ campaigns.push(...rows)
120
+ total = (r && r.paging && r.paging.total) ?? campaigns.length
121
+ offset += pageSize
122
+ if (!rows.length || campaigns.length >= total || offset >= 10000) break
123
+ }
124
+ const totals = deriveAdsTotals(campaigns)
125
+ perAdvertiser.push({
126
+ advertiserId: a.advertiser_id, site: advSite, name: a.advertiser_name || '', account: a.account_name || '',
127
+ campaignTotal: total ?? campaigns.length, totals,
128
+ topCampaigns: pickCampaigns(campaigns),
129
+ })
130
+ }
131
+ const combined = combineTotals(perAdvertiser.map((x) => x.totals))
132
+ return { site, dateFrom, dateTo, advertisers: perAdvertiser, totals: combined }
133
+ }
134
+
135
+ /** 纯函数:多广告主合计(同一店铺可能多个 advertiser)。 */
136
+ export function combineTotals(list = []) {
137
+ const sum = list.reduce((a, t) => {
138
+ a.clicks += num(t.clicks); a.prints += num(t.prints); a.cost += num(t.cost)
139
+ a.units += num(t.units); a.amount += num(t.amount); a.campaigns += num(t.campaigns)
140
+ return a
141
+ }, { clicks: 0, prints: 0, cost: 0, units: 0, amount: 0, campaigns: 0 })
142
+ sum.cost = round2(sum.cost); sum.amount = round2(sum.amount)
143
+ sum.roas = sum.cost > 0 ? round2(sum.amount / sum.cost) : 0
144
+ sum.acos = sum.amount > 0 ? round2((sum.cost / sum.amount) * 100) : 0
145
+ sum.cpc = sum.clicks > 0 ? round2(sum.cost / sum.clicks) : 0
146
+ sum.ctr = sum.prints > 0 ? round2((sum.clicks / sum.prints) * 100) : 0
147
+ sum.cvr = sum.clicks > 0 ? round2((sum.units / sum.clicks) * 100) : 0
148
+ return sum
149
+ }
150
+
151
+ /** 纯函数:挑出「花钱最多 / ACoS 超标」的活动(用于下钻展示)。 */
152
+ export function pickCampaigns(campaigns = [], { topSpend = 3, badAcos = ADS_ALERT_DEFAULTS.acosBad, minSpend = 50 } = {}) {
153
+ const rows = campaigns.map((c) => ({
154
+ id: c.id, name: c.name || String(c.id || ''), status: String(c.status || ''),
155
+ cost: round2(num(c.metrics && c.metrics.cost)),
156
+ amount: round2(num(c.metrics && c.metrics.total_amount)),
157
+ clicks: num(c.metrics && c.metrics.clicks),
158
+ units: num(c.metrics && c.metrics.units_quantity),
159
+ roas: round2(num(c.metrics && c.metrics.roas)),
160
+ acos: round2(num(c.metrics && c.metrics.acos)),
161
+ })).filter((x) => x.cost > 0)
162
+ const top = [...rows].sort((a, b) => b.cost - a.cost).slice(0, topSpend)
163
+ const bad = rows.filter((x) => x.cost >= minSpend && (x.acos > badAcos || x.amount <= 0))
164
+ .sort((a, b) => b.cost - a.cost).slice(0, 3)
165
+ return { top, bad }
166
+ }
167
+
168
+ /** 纯函数:单行金额(只做千分位,不带币种——币种由调用方按店铺站点加)。 */
169
+ export function fmtMoney(n) {
170
+ const v = num(n)
171
+ return v.toLocaleString('en-US', { maximumFractionDigits: 2 })
172
+ }
173
+
174
+ /**
175
+ * 纯函数:把结果渲染成中文卡片行(日报分区 / CLI 共用)。
176
+ * @param {object} report fetchAdsReport 的返回
177
+ * @param {{currency?:string, alertOpts?:object, title?:string}} opts
178
+ */
179
+ export function adsCardLines(report, opts = {}) {
180
+ const cur = opts.currency || ''
181
+ const m = (n) => `${cur ? cur + ' ' : ''}${fmtMoney(n)}`
182
+ const out = []
183
+ if (!report || !report.advertisers || !report.advertisers.length) {
184
+ out.push('**📣 广告**:未找到广告主(该店未开通 Mercado Ads 或没有广告账户)')
185
+ return out
186
+ }
187
+ const t = report.totals || {}
188
+ out.push(`**📣 广告**(${report.dateFrom} ~ ${report.dateTo},Product Ads)`)
189
+ out.push(`花费 ${m(t.cost)} · 广告销售额 ${m(t.amount)} · **ROAS ${t.roas}** · ACoS ${t.acos}% · 点击 ${t.clicks} · 广告单量 ${t.units} · ${t.campaigns} 个活动`)
190
+ for (const a of report.advertisers) {
191
+ const at = a.totals || {}
192
+ const alerts = classifyAdsAlerts(at, opts.alertOpts)
193
+ const flag = alerts.some((x) => x.level === 'red') ? '🔴' : alerts.length ? '🟡' : '✅'
194
+ out.push(`${flag} 广告主 ${a.advertiserId}:花费 ${m(at.cost)} / ROAS ${at.roas} / ACoS ${at.acos}% / 单量 ${at.units}(${a.campaignTotal} 个活动)`)
195
+ for (const al of alerts) out.push(` └ ${al.level === 'red' ? '🔴' : '🟡'} ${al.text}`)
196
+ const bad = (a.topCampaigns && a.topCampaigns.bad) || []
197
+ for (const c of bad.slice(0, 3)) {
198
+ out.push(` └ ${c.amount <= 0 ? '🔴 零转化' : '🟡 ACoS 高'}「${c.name}」花费 ${m(c.cost)} / ROAS ${c.roas} / ACoS ${c.acos}%`)
199
+ }
200
+ }
201
+ return out
202
+ }
203
+
204
+ const round2 = (x) => Math.round(Number(x || 0) * 100) / 100
@@ -44,6 +44,7 @@ export const NPM_SCOPE = '@whyshu'
44
44
  * `npm` = npm 包名(`package.json#name`、`dependencies` 键、组合行 `name:`)。
45
45
  */
46
46
  export const PLUGIN_PACKAGES = [
47
+ { id: 'argent-dsh', npm: `${NPM_SCOPE}/argent-dsh` },
47
48
  { id: 'argent-core', npm: `${NPM_SCOPE}/argent-core` },
48
49
  { id: 'argent-skin', npm: `${NPM_SCOPE}/argent-skin` },
49
50
  { id: 'argent-presets', npm: `${NPM_SCOPE}/argent-presets` },