cardbuddy 0.20.0 → 0.21.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.
@@ -80,6 +80,9 @@ const COVER_FETCH_TIMEOUT_MS = Number(process.env.CARDBUDDY_COVER_TIMEOUT_MS ||
80
80
  // FinMall 交易请求超时。IDC10001/IDC10012 偶发挂起(后端不响应),同样没有任何默认超时,
81
81
  // 一张卡挂住就会拖垮整批同步——所以和预览图一样,内层 abort + 外层 race 双重设防。
82
82
  const FINMALL_POST_TIMEOUT_MS = Number(process.env.CARDBUDDY_POST_TIMEOUT_MS || 30000);
83
+ // 卡片预览图上传(IDC10014.do)超时。与切图上传同款设防:内层 AbortController +
84
+ // 外层 Promise.race 兜住 page.evaluate 自身不返回(evaluate 无默认超时)。
85
+ const PREVIEW_UPLOAD_TIMEOUT_MS = Number(process.env.CARDBUDDY_PREVIEW_UPLOAD_TIMEOUT_MS || 30000);
83
86
 
84
87
  function withTimeout(promise, ms, label) {
85
88
  let timer;
@@ -417,6 +420,175 @@ export async function saveCardToRemote(schema, { skeletonScreen = '', verify = t
417
420
  }
418
421
  }
419
422
 
423
+ /**
424
+ * 在已认证页面上下文内以 multipart/form-data 上传卡片预览图(IDC10014.do)。
425
+ *
426
+ * 请求形态取自线上编辑器抓包(`/ubomp-web/IDC10014.do`),与切图上传(CC0000.do)的差异:
427
+ * - 表单字段是 `cardId`(纯文本数字 cardId)+ `file`(二进制,filename 固定 `<cardId>.png`,
428
+ * 抓包里 Content-Type 为 application/octet-stream —— Blob 不带 type 时浏览器就是这个值,
429
+ * 这里照抄抓包,不做「更正确」的覆盖);
430
+ * - `sassAppId` / `sassWorkspaceId` 走**表单字段**(CC0000.do 是查询串);
431
+ * - 没有 `fileName` 纯文本字段;
432
+ * - 鉴权头只有 `token`。
433
+ *
434
+ * token 仅在页面上下文内读取并直接用于请求,不回传 Node 侧、不打印、不落盘(与 finmallPost 同纪律)。
435
+ *
436
+ * @returns {Promise<string>} 原始响应文本(交由 parsePreviewUploadResponse 解析)
437
+ */
438
+ async function postPreviewUpload(page, { cardId, fileName, base64, appId, workspaceId }) {
439
+ return withTimeout(
440
+ page.evaluate(
441
+ async ({ cardId, fileName, base64, appId, workspaceId, timeoutMs }) => {
442
+ const controller = new AbortController();
443
+ setTimeout(() => controller.abort(), timeoutMs);
444
+ const readSessionToken = () => {
445
+ try {
446
+ const raw = localStorage.getItem('_sessionId');
447
+ if (!raw) return '';
448
+ let value = JSON.parse(raw);
449
+ if (value && typeof value === 'object' && 'value' in value) value = value.value;
450
+ if (typeof value === 'string' && value.startsWith('"')) {
451
+ try {
452
+ value = JSON.parse(value);
453
+ } catch {
454
+ /* 保留原字符串 */
455
+ }
456
+ }
457
+ return typeof value === 'string' ? value : '';
458
+ } catch {
459
+ return '';
460
+ }
461
+ };
462
+ const sessionToken = readSessionToken();
463
+ if (!sessionToken) {
464
+ throw new Error('页面缺少会话凭据(localStorage._sessionId 为空),请先运行 finmall-session-navigator 登录。');
465
+ }
466
+ const binary = atob(base64);
467
+ const bytes = new Uint8Array(binary.length);
468
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
469
+ const form = new FormData();
470
+ form.append('cardId', cardId);
471
+ // 抓包里该 part 的 Content-Type 是 application/octet-stream,filename 为 <cardId>.png
472
+ form.append('file', new Blob([bytes]), fileName);
473
+ form.append('sassAppId', String(appId));
474
+ form.append('sassWorkspaceId', String(workspaceId));
475
+ const response = await fetch(new URL('/ubomp-web/IDC10014.do', location.origin).href, {
476
+ method: 'POST',
477
+ headers: { token: sessionToken, Accept: '*/*' },
478
+ credentials: 'include',
479
+ body: form,
480
+ signal: controller.signal,
481
+ });
482
+ const text = await response.text();
483
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
484
+ return text;
485
+ },
486
+ { cardId, fileName, base64, appId, workspaceId, timeoutMs: PREVIEW_UPLOAD_TIMEOUT_MS },
487
+ ),
488
+ PREVIEW_UPLOAD_TIMEOUT_MS + 2000,
489
+ `上传卡片预览图 ${fileName}`,
490
+ );
491
+ }
492
+
493
+ /**
494
+ * 解析 IDC10014.do 响应(导出仅为单测可直接断言;正常路径走 uploadCardPreview)。
495
+ *
496
+ * 该接口的响应体形态没有可比对的既有文档,故按 FinMall 交易接口的通用信封
497
+ * (`{header:{errorCode,errorMsg}, body:{...}}`)做**宽松**判定:
498
+ * - 空响应体 → 视为成功(HTTP 2xx 已由调用方保证,接口可能不返回业务体);
499
+ * - 非 JSON 非空 → 直接报错(多半是网关 HTML 错误页,不能当成功);
500
+ * - errorCode 存在且非 '0' → 抛错(BLEC0006 等会话失效特征会被 uploadCardPreview 捕获并重登重试);
501
+ * - 成功时尽力从 body 里捞预览图地址(字段名依 ubomp 同类接口习惯多键兜底),
502
+ * 捞不到不影响「上传成功」的结论。
503
+ *
504
+ * @param {string} text 原始响应文本
505
+ * @param {string} cardId 请求用的数字型 cardId(用于回显校验 / 日志)
506
+ * @returns {{ ok: boolean, url: string, cardId: string }}
507
+ */
508
+ export function parsePreviewUploadResponse(text, cardId) {
509
+ const raw = String(text == null ? '' : text).trim();
510
+ if (!raw) return { ok: true, url: '', cardId: String(cardId == null ? '' : cardId) };
511
+ let parsed;
512
+ try {
513
+ parsed = JSON.parse(raw);
514
+ } catch {
515
+ throw new Error(`预览图上传响应不是 JSON:${raw.slice(0, 300)}`);
516
+ }
517
+ const header = (parsed && parsed.header) || {};
518
+ const body = (parsed && parsed.body) || {};
519
+ // header 与 body 各自可能带 errorCode:**逐个**看,不能用 `a || b` 短路——
520
+ // header.errorCode='0' 是真值,会把 body 里的业务错误(如 PEO000003)整段盖掉。
521
+ const codes = [header.errorCode, body.errorCode].filter((c) => c != null && String(c).trim() !== '');
522
+ const bad = codes.find((c) => String(c) !== '0');
523
+ if (bad != null) {
524
+ throw new Error(
525
+ `FinMall 预览图上传失败 [errorCode=${bad}] ${header.errorMsg || body.errorMsg || ''} | ${raw.slice(0, 300)}`,
526
+ );
527
+ }
528
+ // 地址字段多键兜底:接口未回地址属正常(上传即生效),所以这里只做尽力提取。
529
+ const url =
530
+ body.picUrl || body.previewUrl || body.imageUrl || body.filePath || body.url ||
531
+ parsed.picUrl || '';
532
+ return {
533
+ ok: true,
534
+ url: typeof url === 'string' ? url : '',
535
+ cardId: String(body.cardId || cardId || ''),
536
+ };
537
+ }
538
+
539
+ /**
540
+ * 上传卡片预览图到远端(IDC10014.do)。供「推送」成功后由 server/core/cardSave.js 调用:
541
+ * 本地无头渲染出透明底 PNG → 交给已登录的 FinMall 后台会话上传,卡片列表缩略图随之更新。
542
+ *
543
+ * 与 saveCardToRemote 的会话纪律一致:saveCardToRemote 末尾会 disconnect,故本函数自行
544
+ * ensureFinMallSession 重新取一次连接(Chrome 已常驻时这一步是廉价的 attach)。
545
+ * 会话/权限失效命中 SESSION_INVALID_RE 时自动清 session 重登并重试一次。
546
+ *
547
+ * @param {{ cardId: string|number, pngBase64: string, appId?: string, workspaceId?: string }} opts
548
+ * @returns {Promise<{ ok: true, url: string, cardId: string, bytes: number }>}
549
+ */
550
+ export async function uploadCardPreview({ cardId, pngBase64, appId, workspaceId } = {}) {
551
+ const id = String(cardId == null ? '' : cardId).trim();
552
+ if (!/^\d+$/.test(id)) {
553
+ throw new Error(`上传卡片预览图需要数字型 cardId(IDC10014.do 主键),收到:${JSON.stringify(cardId)}`);
554
+ }
555
+ const base64 = String(pngBase64 == null ? '' : pngBase64);
556
+ if (!base64) throw new Error('上传卡片预览图缺少 PNG 内容(pngBase64 为空)。');
557
+ const appIdv = appId || finmallConfig.appId;
558
+ const workspaceIdv = workspaceId || finmallConfig.workspaceId;
559
+ const fileName = `${id}.png`;
560
+
561
+ const session = await ensureFinMallSession();
562
+ const browser = session.browser;
563
+ try {
564
+ const context = browser.contexts()[0];
565
+ const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
566
+ if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发同步。');
567
+ await page.bringToFront();
568
+
569
+ const payload = { cardId: id, fileName, base64, appId: appIdv, workspaceId: workspaceIdv };
570
+ let parsed;
571
+ try {
572
+ parsed = parsePreviewUploadResponse(await postPreviewUpload(page, payload), id);
573
+ } catch (error) {
574
+ if (SESSION_INVALID_RE.test(String(error && error.message))) {
575
+ log.warn('会话/权限失效,自动重新登录后重试预览图上传', {
576
+ cardId: id,
577
+ reason: String(error && error.message).slice(0, 40),
578
+ });
579
+ await relogin(page);
580
+ parsed = parsePreviewUploadResponse(await postPreviewUpload(page, payload), id);
581
+ } else {
582
+ throw error;
583
+ }
584
+ }
585
+ log.ok('卡片预览图已上传', { cardId: id, fileName, url: parsed.url || '(接口未回地址)' });
586
+ return { ...parsed, bytes: Math.round((base64.length * 3) / 4) };
587
+ } finally {
588
+ try { await browser.disconnect() } catch { /* 后台 Chrome 常驻,仅断开连接 */ }
589
+ }
590
+ }
591
+
420
592
  /**
421
593
  * 远端预览:复刻 ubomp-h5-sub PreviewPopover.vue 的 onPopoverShow 全链路。
422
594
  * 1) prepreview:先把当前编辑的卡片保存到远端(IDC10010.do,对齐 ubomp 的 prepreview 钩子)
@@ -520,11 +692,40 @@ export async function previewCardRemote(schema, { skeletonScreen = '', publish =
520
692
  }
521
693
 
522
694
  /**
523
- * 远端发布:取当前卡片远端最大版本号 +1,发布上线为新版本。
695
+ * FinMall 版本号格式:8 位「年份 + 4 位序号」,如 20260025。
696
+ * 该格式的最小值约 20200001,故任何 < 10000000 的纯数字都不符合格式
697
+ * (含旧实现首发时误写出的 1 / 2 / 3…)。
698
+ */
699
+ const MIN_YEAR_SEQ_VERSION = 10000000;
700
+
701
+ /**
702
+ * 由远端最大版本号推导「下一个待发布版本号」。
703
+ * - 远端从未发布(maxVersion 为 null / 空 / '0')→ `${当前年份}0001`,而不是裸 '1';
704
+ * - 已是「年份+4 位序号」→ 数值 +1。注意这里**不按年份重置**:跨年后沿用上一年的序号递增
705
+ * (如 20250028 → 20250029),保证版本号单调递增,避免回退到比线上更小的值;
706
+ * - 历史遗留的旧格式小版本(旧 bug 写出的 '1' 等)→ 视同首发,规范化为 `${当前年份}0001`,
707
+ * 数值上 20260001 > 2,不会造成版本回退,可自愈已受该 bug 影响的卡片。
708
+ * @param {string|number|null|undefined} maxVersion IDC10004 返回的 maxVersion
709
+ * @returns {string} 8 位版本号
710
+ * @example nextPublishVersion(null) // '20260001'
711
+ * @example nextPublishVersion('20260025') // '20260026'
712
+ * @example nextPublishVersion('1') // '20260001'
713
+ */
714
+ export function nextPublishVersion(maxVersion) {
715
+ const raw = String(maxVersion == null ? '' : maxVersion).trim();
716
+ const num = Number(raw);
717
+ const valid = /^\d+$/.test(raw) && Number.isFinite(num) && num >= MIN_YEAR_SEQ_VERSION;
718
+ if (!valid) return `${new Date().getFullYear()}0001`;
719
+ return String(num + 1);
720
+ }
721
+
722
+ /**
723
+ * 远端发布:取当前卡片远端最大版本号,推导下一个版本号并发布上线。
524
724
  * 链路(对齐 ubomp 发布流程,仅两步):
525
725
  * 1) IDC10004.do 取当前卡片最大版本号 maxVersion;
526
- * 2) IDC10016.do 以 maxVersion+1 作为新版本号发布(发布的是远端「当前已保存」的卡片内容——
726
+ * 2) IDC10016.do 以 nextPublishVersion(maxVersion) 作为新版本号发布(发布的是远端「当前已保存」的卡片内容——
527
727
  * 故发布前应先用「推送当前卡」把本地内容保存到远端,否则会发布陈旧内容)。
728
+ * 版本号格式为 8 位「年份 + 4 位序号」:远端从未发布时取 `${当前年份}0001`(不是裸 1)。
528
729
  * 会话由 ensureFinMallSession 自动保证;与 saveCardToRemote / syncCards / previewCardRemote
529
730
  * 共用同一套已登录后台 Chrome,命中会话/权限失效(BLEC0006 等)时自动清 session 重登重试一次。
530
731
  *
@@ -554,8 +755,7 @@ export async function publishCard(schema) {
554
755
  'IDC10004.do',
555
756
  )
556
757
  const maxVersion = String(maxResp && maxResp.maxVersion != null ? maxResp.maxVersion : '0')
557
- const maxNum = Number(maxVersion)
558
- const nextVersion = String(Number.isFinite(maxNum) ? maxNum + 1 : 1)
758
+ const nextVersion = nextPublishVersion(maxVersion)
559
759
  log.info('发布前取最大版本', { cardId, maxVersion, nextVersion })
560
760
 
561
761
  // 2) 以 maxVersion+1 发布(发布类型/平台等参数对齐 ubomp 发布弹窗的默认值)
@@ -620,6 +820,90 @@ export async function fetchCardLatestVersion(cardId) {
620
820
  }
621
821
  }
622
822
 
823
+ /**
824
+ * 取远端「发布版本记录」分页列表:IDC10004.do 的 versionList(新→旧,最多一页 pageSize 条)。
825
+ * 供调试面板「导入信息」tab 的「发布版本记录」弹框渲染(版本号 / 平台 / 状态 / 发布时间)。
826
+ * 只读;与 fetchCardLatestVersion / fetchCardHistory 共用同一后台 FinMall 会话
827
+ * (ensureFinMallSession + finmallPostWithHeal,命中会话失效自动清 session 重登重试一次)。
828
+ *
829
+ * 响应体(逆向 innermanage 前端 inner_flamePublish 页面):
830
+ * body.versionList[] = { versionId, version, platformStatus(JSON 字符串,如 '["iOS","Android"]'),
831
+ * publishStatus, createTime }
832
+ * body.maxVersion / pageNo / pageSize / totalNums / totalPages
833
+ *
834
+ * publishStatus 枚举(逆向页面 statusEnum,见 34.*.js:{ UNPUB:'0', PUB:'1', FINISH:'2', STOP:'3' };
835
+ * 页面渲染仅对 0/1 出标签「未发布 / 发布中」,2/3 依枚举名与「停止发布」动作(publishStatus:'3')释义):
836
+ * 0=未发布 1=发布中 2=已完成 3=已停止
837
+ *
838
+ * @param {string|number} cardId
839
+ * @param {{ pageNo?: number, pageSize?: number }} [opts]
840
+ * @returns {Promise<{ list: Array<object>, maxVersion: string, totalNums: number, totalPages: number }>}
841
+ */
842
+ export async function fetchCardPublishVersions(cardId, { pageNo = 1, pageSize = 10 } = {}) {
843
+ if (cardId == null) {
844
+ throw new Error('缺少 cardId,无法查询发布版本记录(IDC10004.do 需要 cardId)。');
845
+ }
846
+ const appId = finmallConfig.appId;
847
+ const workspaceId = finmallConfig.workspaceId;
848
+ const id = String(cardId);
849
+ const session = await ensureFinMallSession();
850
+ const browser = session.browser;
851
+ try {
852
+ const context = browser.contexts()[0];
853
+ const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
854
+ if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。');
855
+ await page.bringToFront();
856
+ const resp = await finmallPostWithHeal(
857
+ page,
858
+ `/ubomp-web/IDC10004.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
859
+ {
860
+ channel: '',
861
+ pageNo,
862
+ pageSize,
863
+ publishStatus: '',
864
+ version: '',
865
+ cardId: id,
866
+ sassAppId: appId,
867
+ sassWorkspaceId: workspaceId,
868
+ },
869
+ 'IDC10004.do',
870
+ );
871
+ const rawList = Array.isArray(resp && resp.versionList) ? resp.versionList : [];
872
+ const list = rawList.map((raw) => {
873
+ let platforms = [];
874
+ try {
875
+ const parsed = JSON.parse(raw.platformStatus || '[]');
876
+ if (Array.isArray(parsed)) platforms = parsed.map((p) => String(p));
877
+ } catch {
878
+ /* platformStatus 损坏时按空平台处理,不阻断列表 */
879
+ }
880
+ const status = String(raw.publishStatus == null ? '' : raw.publishStatus);
881
+ return {
882
+ versionId: String(raw.versionId == null ? '' : raw.versionId),
883
+ version: String(raw.version == null ? '' : raw.version),
884
+ platforms,
885
+ platformStr: platforms.join('、'),
886
+ publishStatus: status,
887
+ publishTime: String(raw.createTime == null ? '' : raw.createTime),
888
+ };
889
+ });
890
+ log.info('查询远端发布版本记录', {
891
+ cardId: id,
892
+ pageNo,
893
+ count: list.length,
894
+ totalNums: resp && resp.totalNums,
895
+ });
896
+ return {
897
+ list,
898
+ maxVersion: String(resp && resp.maxVersion != null ? resp.maxVersion : ''),
899
+ totalNums: Number(resp && resp.totalNums != null ? resp.totalNums : list.length) || list.length,
900
+ totalPages: Number(resp && resp.totalPages != null ? resp.totalPages : 1) || 1,
901
+ };
902
+ } finally {
903
+ try { await browser.disconnect(); } catch { /* ignore */ }
904
+ }
905
+ }
906
+
623
907
  /**
624
908
  * 取远端「卡片历史版本时间线」:IDC10013 按 cardId 分页返回 schemaValueList(新→旧)。
625
909
  * 供编辑器「卡片历史记录」tab 渲染 git 式时间线。只读;与 fetchCardLatestVersion 共用
@@ -979,15 +1263,17 @@ async function fetchCardSchema(page, card, { appId, workspaceId }) {
979
1263
  /** 清空过期会话并以保存的凭据重新登录(复用导航器的登录逻辑,全程在页面上下文内完成)。 */
980
1264
  async function relogin(page) {
981
1265
  await clearTimeoutPrompts(page);
982
- await page
983
- .evaluate(() => {
1266
+ await withTimeout(
1267
+ page.evaluate(() => {
984
1268
  try {
985
1269
  localStorage.clear();
986
1270
  } catch {
987
1271
  /* ignore */
988
1272
  }
989
- })
990
- .catch(() => {});
1273
+ }),
1274
+ FINMALL_POST_TIMEOUT_MS,
1275
+ 'page.evaluate(localStorage.clear)',
1276
+ ).catch(() => {});
991
1277
  await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
992
1278
  await sleep(1500);
993
1279
  const credentials = await loadCredentials();
@@ -1302,6 +1588,7 @@ async function findExistingCover(cardDir, id) {
1302
1588
  async function writeImportSidecar(cardDir, { appId, workspaceId, card, files, importMeta }) {
1303
1589
  try {
1304
1590
  const mod = importMeta || (await import(pathToFileURL(path.resolve(workspace, 'server', 'core', 'cardImportMeta.js')).href));
1591
+ const raw = card.raw || {};
1305
1592
  const meta = mod.buildImportMeta({
1306
1593
  baseUrl: finmallConfig.baseUrl,
1307
1594
  appId,
@@ -1310,6 +1597,14 @@ async function writeImportSidecar(cardDir, { appId, workspaceId, card, files, im
1310
1597
  cardCode: card.id,
1311
1598
  cardName: card.title,
1312
1599
  files,
1600
+ // 远端列表元数据原样快照:导入包(cards.json)的 picUrl / cardClassId / applyScene
1601
+ // 只能从这里取真值,本地无处可推。缺了它导出侧只能伪造或留空,而留空会让
1602
+ // 导入侧按空串推封面文件名、退化成卡目录路径并抛异常(2026-10-22 事故)。
1603
+ remote: {
1604
+ picUrl: typeof raw.picUrl === 'string' ? raw.picUrl : '',
1605
+ cardClassId: raw.cardClassId == null ? '' : String(raw.cardClassId),
1606
+ applyScene: raw.applyScene == null ? '' : String(raw.applyScene),
1607
+ },
1313
1608
  });
1314
1609
  mod.writeImportMeta(cardDir, meta);
1315
1610
  } catch (e) {
@@ -54,6 +54,24 @@ const CHROME_CANDIDATES = [
54
54
 
55
55
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
56
 
57
+ // page.evaluate 没有默认超时:FinMall 页面主线程被占 / 页面内 fetch 挂起(实测该环境确有
58
+ // responseStatus=0 的常驻请求)时,裸 evaluate 永远不返回,连带 ensureFinMallSession 与
59
+ // 整个同步任务永久挂起——同步中间件是单飞制,一个挂死的任务会让之后每次「远端同步」都拿到
60
+ // 409「已有同步任务正在进行」,用户侧就是「点了同步、服务端再也不响应」。
61
+ // 这里给每次 evaluate 套硬超时:Node 侧停止等待并抛明确错误(页面里仍在跑的那个 evaluate
62
+ // 无法真正取消,但至少不会把同步任务与单飞锁一起拖死)。
63
+ const EVALUATE_TIMEOUT_MS = Number(process.env.CARDBUDDY_EVALUATE_TIMEOUT_MS || 30000);
64
+
65
+ function withTimeout(promise, ms, label) {
66
+ let timer;
67
+ return Promise.race([
68
+ promise,
69
+ new Promise((_, reject) => {
70
+ timer = setTimeout(() => reject(new Error(`${label}超时(>${ms}ms),已放弃`)), ms);
71
+ }),
72
+ ]).finally(() => clearTimeout(timer));
73
+ }
74
+
57
75
  // ---------------------------------------------------------------------------
58
76
  // 凭据与请求体
59
77
  // ---------------------------------------------------------------------------
@@ -329,12 +347,17 @@ async function evaluateWithRetry(page, fn, arg, maxRetries = 5) {
329
347
  let lastError;
330
348
  for (let i = 0; i < maxRetries; i++) {
331
349
  try {
332
- return await page.evaluate(fn, arg);
350
+ // 硬超时包裹:页面主线程被占 / 页面内 fetch 挂起时不让同步任务永久挂起
351
+ return await withTimeout(
352
+ page.evaluate(fn, arg),
353
+ EVALUATE_TIMEOUT_MS,
354
+ 'page.evaluate',
355
+ );
333
356
  } catch (error) {
334
357
  lastError = error;
335
358
  const msg = String(error?.message || error);
336
359
  if (
337
- /Execution context was destroyed|Target page.*has been closed|Frame was detached|Cannot find context/i.test(
360
+ /Execution context was destroyed|Target page.*has been closed|Frame was detached|Cannot find context|page\.evaluate\s*超时/i.test(
338
361
  msg,
339
362
  )
340
363
  ) {
@@ -444,9 +467,16 @@ export async function performLogin(page, credentials) {
444
467
  return evaluateWithRetry(
445
468
  page,
446
469
  async (input) => {
470
+ // 页面内 fetch 也必须自带超时:IM01001 / IM01002 挂起时(该环境实测有常驻不返回的请求),
471
+ // 没有 AbortController 就会把 performLogin → runFinmallLogin → ensureFinMallSession
472
+ // 整条链永久挂住。这里内层 abort + 外层 evaluateWithRetry 的硬超时双重设防。
473
+ const ctl = new AbortController();
474
+ const timer = setTimeout(() => ctl.abort(), input.timeoutMs);
475
+ try {
447
476
  const response = await fetch(input.endpoint, {
448
477
  method: "POST",
449
478
  credentials: "include",
479
+ signal: ctl.signal,
450
480
  headers: {
451
481
  Accept: "application/json, text/plain, */*",
452
482
  "Content-Type": "application/json;charset=UTF-8",
@@ -533,6 +563,7 @@ export async function performLogin(page, credentials) {
533
563
  {
534
564
  method: "POST",
535
565
  credentials: "include",
566
+ signal: ctl.signal,
536
567
  headers: {
537
568
  Accept: "application/json, text/plain, */*",
538
569
  "Content-Type": "application/json;charset=UTF-8",
@@ -603,8 +634,16 @@ export async function performLogin(page, credentials) {
603
634
  businessFailure: businessFailure || !loginSucceeded,
604
635
  sessionCreated: loginSucceeded,
605
636
  };
637
+ } finally {
638
+ clearTimeout(timer);
639
+ }
640
+ },
641
+ {
642
+ endpoint: LOGIN_ENDPOINT,
643
+ menuEndpoint: MENU_ENDPOINT,
644
+ payload,
645
+ timeoutMs: EVALUATE_TIMEOUT_MS,
606
646
  },
607
- { endpoint: LOGIN_ENDPOINT, menuEndpoint: MENU_ENDPOINT, payload },
608
647
  );
609
648
  }
610
649
 
@@ -22,7 +22,16 @@ if (!input) {
22
22
  }
23
23
  const output = positional[1] || input.replace(/\.html$/i, '') + '.png';
24
24
 
25
- const browser = await chromium.launch();
25
+ // 优先用 Playwright 自带浏览器;未下载(ms-playwright 缓存为空)时回退系统 Chrome,
26
+ // 与仓库「Playwright + 系统 Chrome 验证」约定一致,避免强依赖 npx playwright install。
27
+ let browser;
28
+ try {
29
+ browser = await chromium.launch();
30
+ } catch (err) {
31
+ if (!/Executable doesn't exist/.test(String(err?.message ?? err))) throw err;
32
+ console.error('[screenshot] 未找到 Playwright 内置浏览器,回退系统 Chrome');
33
+ browser = await chromium.launch({ channel: 'chrome' });
34
+ }
26
35
  const page = await browser.newPage({
27
36
  viewport: { width: 375, height: 812 },
28
37
  deviceScaleFactor: 2, // @2x → 750 宽输出,与设计稿一致
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
5
5
  "type": "module",
6
6
  "bin": {