report-mobile.mjs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. #!/usr/bin/env node
  2. /**
  3. * report-mobile.mjs — 智能工牌日报移动版转换器
  4. *
  5. * 用途: 拉取 PC 端日报 HTML → 注入移动端增强 CSS → 上传 OSS reports-mobile/ → 输出手机版直链
  6. * 用法:
  7. * node scripts/report-mobile.mjs --id <reportId> # 转换指定报告
  8. * node scripts/report-mobile.mjs --latest # 自动取昨日日报(默认)
  9. * node scripts/report-mobile.mjs --latest --out /path.html # 同时落本地文件(企微直发用)
  10. * node scripts/report-mobile.mjs --list # 只列最近日报
  11. * 依赖: lib.mjs(env/login/reports) + oss-client-mini.mjs(OSS 上传)
  12. */
  13. import fs from 'node:fs';
  14. import path from 'node:path';
  15. import { fileURLToPath } from 'node:url';
  16. import {
  17. SCRIPTS_DIR, REPO_ROOT, fail, findEnvFile, loadEnv, baseUrl, loadToken,
  18. login, httpJson,
  19. } from './lib.mjs';
  20. import { ossConfig, ossEnabled, publicDomain } from './oss-client-mini.mjs';
  21. import crypto from 'node:crypto';
  22. const args = process.argv.slice(2);
  23. const flag = (name) => {
  24. const i = args.indexOf(name);
  25. return i >= 0 ? args[i + 1] : undefined;
  26. };
  27. const hasFlag = (name) => args.includes(name);
  28. const { hit: envHit } = findEnvFile({});
  29. if (envHit) loadEnv(envHit);
  30. const BASE = baseUrl(flag('--base'));
  31. const OUT = flag('--out');
  32. // ---------------------------------------------------------------- 报告获取
  33. function unwrap(r, ctx) {
  34. if (r.status >= 400) fail(`${ctx} 失败: HTTP ${r.status} ${JSON.stringify(r.json).slice(0, 200)}`);
  35. return r.json?.data ?? r.json;
  36. }
  37. async function resolveReport() {
  38. const token = await ensureToken();
  39. if (flag('--id')) {
  40. const id = flag('--id');
  41. const r = unwrap(await httpJson('GET', `/api/reports/${id}`, undefined, { base: BASE, token }), '报告详情');
  42. return { id, title: r.title || `日报 ${r.periodStart}`, ossUrl: r.ossUrl, period: r.periodStart };
  43. }
  44. // --latest: 取最近的 day 报告
  45. const list = unwrap(await httpJson('GET', '/api/reports?type=day&pageSize=1', undefined, { base: BASE, token }), '报告列表');
  46. const item = list.items?.[0];
  47. if (!item) fail('没有找到任何日报');
  48. return { id: item.id, title: item.title, ossUrl: item.ossUrl, period: item.periodStart };
  49. }
  50. async function ensureToken() {
  51. const t = loadToken();
  52. if (t) return t;
  53. await login({ base: BASE, username: ENV_SMARTBADGE_USER(), password: process.env.SMARTBADGE_PASSWORD });
  54. return loadToken();
  55. }
  56. function ENV_SMARTBADGE_USER() { return process.env.SMARTBADGE_USERNAME; }
  57. async function fetchHtml(ossUrl) {
  58. const res = await fetch(ossUrl, { redirect: 'follow' });
  59. if (!res.ok) fail(`拉取报告 HTML 失败: HTTP ${res.status}`);
  60. return await res.text();
  61. }
  62. // ---------------------------------------------------------------- 移动端 CSS 注入
  63. const MOBILE_CSS = `
  64. /* ===== mobile-enhance v1 (report-mobile.mjs 注入) ===== */
  65. :root { --m-pad: 14px; }
  66. html { -webkit-text-size-adjust: 100%; }
  67. body { padding: var(--m-pad) !important; font-size: 15px !important; line-height: 1.65 !important; }
  68. /* 单列卡片流 */
  69. .grid, [class*="grid"] { grid-template-columns: 1fr !important; }
  70. .kpi-grid { grid-template-columns: 1fr 1fr !important; gap: 10px !important; }
  71. .kpi-item { padding: 10px 12px !important; }
  72. .kpi-item .value, .kpi-value { font-size: 22px !important; }
  73. /* 卡片统一圆角阴影节奏 */
  74. .card, .voc-item, .risk-item, .timeline-item, [class*="card"] {
  75. border-radius: 12px !important; padding: 12px !important;
  76. box-shadow: 0 1px 4px rgba(0,0,0,.06) !important; margin-bottom: 10px !important;
  77. }
  78. /* 表格转卡片(每行变块) */
  79. table { display: block !important; width: 100% !important; overflow-x: auto !important; -webkit-overflow-scrolling: touch; }
  80. th, td { padding: 8px 10px !important; font-size: 13px !important; white-space: normal !important; word-break: break-word !important; }
  81. /* 标题层级收敛 */
  82. h1 { font-size: 20px !important; line-height: 1.3 !important; }
  83. h3 { font-size: 16px !important; margin: 14px 0 8px !important; }
  84. /* 时间线在窄屏留白收紧 */
  85. .timeline { padding-left: 18px !important; }
  86. .timeline-item { padding-left: 14px !important; }
  87. /* 长词/编号换行保护 */
  88. .voc-item .quote, .interpretation, .risk-desc { word-break: break-word !important; }
  89. /* 打印/转PDF友好 */
  90. @media print { body { padding: 8px !important; } }
  91. /* ===== mobile-enhance end ===== */
  92. `;
  93. function toMobile(html, meta) {
  94. // 已是移动版则跳过重复注入
  95. if (html.includes('mobile-enhance v1')) return html;
  96. let out = html;
  97. // 1) 在 </style> 前注入增强 CSS;无 style 标签则新建
  98. if (out.includes('</style>')) {
  99. out = out.replace('</style>', MOBILE_CSS + '\n</style>');
  100. } else if (out.includes('</head>')) {
  101. out = out.replace('</head>', `<style>${MOBILE_CSS}</style>\n</head>`);
  102. } else {
  103. fail('HTML 结构异常: 无 </style> 也无 </head>');
  104. }
  105. // 2) viewport 保证(已有则不动)
  106. if (!/name=["']viewport["']/.test(out)) {
  107. out = out.replace('<head>', '<head>\n<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">');
  108. }
  109. // 3) 角标: 标题后加移动版标识
  110. out = out.replace(
  111. /(<h1[^>]*>)([\s\S]*?)(<\/h1>)/,
  112. `$1$2$3<span style="display:block;font-size:12px;color:#888;margin-top:4px;">📱 移动版 · 由骏仔(FmodeAgent)自动转换 · ${meta.period}</span>`,
  113. );
  114. return out;
  115. }
  116. // ---------------------------------------------------------------- OSS 上传(复用签名模式)
  117. async function ossPut(key, body, contentType) {
  118. const cfg = ossConfig();
  119. if (!ossEnabled()) fail('OSS 未启用: .env 缺 OSS_ACCESS_KEY_ID/SECRET/BUCKET/REGION');
  120. const host = `${cfg.bucket}.oss-${cfg.region}.aliyuncs.com`;
  121. const date = new Date().toUTCString();
  122. const ct = contentType || 'text/html; charset=utf-8';
  123. const acl = 'public-read';
  124. const stringToSign = ['PUT', '', ct, date, `x-oss-date:${date}`, `x-oss-object-acl:${acl}`, `/${cfg.bucket}/${key}`].join('\n');
  125. const sig = crypto.createHmac('sha1', cfg.accessKeySecret).update(stringToSign).digest('base64');
  126. const auth = `OSS ${cfg.accessKeyId}:${sig}`;
  127. const res = await fetch(`https://${host}/${key}`, {
  128. method: 'PUT',
  129. headers: {
  130. Authorization: auth, Date: date, 'Content-Type': ct, 'x-oss-date': date,
  131. 'x-oss-object-acl': acl,
  132. },
  133. body,
  134. });
  135. if (!res.ok) fail(`OSS 上传失败: HTTP ${res.status} ${await res.text().catch(() => '')}`);
  136. return `https://${publicDomain() || host}/${key}`;
  137. }
  138. // ---------------------------------------------------------------- main
  139. const report = await resolveReport();
  140. if (hasFlag('--list')) { console.log(`${report.id} | ${report.title}`); process.exit(0); }
  141. console.log(`目标报告: ${report.id} | ${report.title}`);
  142. const pcHtml = await fetchHtml(report.ossUrl);
  143. console.log(`PC 版已拉取: ${(pcHtml.length / 1024).toFixed(1)} KB`);
  144. const mobileHtml = toMobile(pcHtml, report);
  145. if (OUT) { fs.writeFileSync(OUT, mobileHtml); console.log(`本地已保存: ${OUT}`); }
  146. // 上传 OSS reports-mobile/YYYY/MM/<reportId>.html
  147. const m = (report.period || '').match(/^(\d{4})-(\d{2})/);
  148. const key = m ? `reports-mobile/${m[1]}/${m[2]}/${report.id}.html` : `reports-mobile/${report.id}.html`;
  149. const url = await ossPut(key, mobileHtml);
  150. console.log('✅ 手机版已发布:');
  151. console.log(url);
  152. console.log(`本地保留: ${OUT || '(未落盘, 加 --out 参数)'}`);