Parcourir la source

feat: 新增日报移动版转换器 report-mobile.mjs

- 拉取 PC 端日报 → 注入移动端增强 CSS(单列卡片流/KPI两列/表格滚动/换行保护) → 上传 OSS reports-mobile/YYYY/MM/<id>.html
- 用法: --latest(默认取最近日报) / --id <reportId> / --out 本地落盘 / --list
- OSS PUT V1 签名含 x-oss-object-acl:public-read,公开直链可读
- 已实测: 2026-09-06 日报转换+发布+在线200验证通过
ice-static il y a 1 semaine
Parent
commit
bf8ce1bd02
1 fichiers modifiés avec 164 ajouts et 0 suppressions
  1. 164 0
      scripts/report-mobile.mjs

+ 164 - 0
scripts/report-mobile.mjs

@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+/**
+ * report-mobile.mjs — 智能工牌日报移动版转换器
+ *
+ * 用途: 拉取 PC 端日报 HTML → 注入移动端增强 CSS → 上传 OSS reports-mobile/ → 输出手机版直链
+ * 用法:
+ *   node scripts/report-mobile.mjs --id <reportId>            # 转换指定报告
+ *   node scripts/report-mobile.mjs --latest                   # 自动取昨日日报(默认)
+ *   node scripts/report-mobile.mjs --latest --out /path.html  # 同时落本地文件(企微直发用)
+ *   node scripts/report-mobile.mjs --list                     # 只列最近日报
+ * 依赖: lib.mjs(env/login/reports) + oss-client-mini.mjs(OSS 上传)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+  SCRIPTS_DIR, REPO_ROOT, fail, findEnvFile, loadEnv, baseUrl, loadToken,
+  login, httpJson,
+} from './lib.mjs';
+import { ossConfig, ossEnabled, publicDomain } from './oss-client-mini.mjs';
+import crypto from 'node:crypto';
+
+const args = process.argv.slice(2);
+const flag = (name) => {
+  const i = args.indexOf(name);
+  return i >= 0 ? args[i + 1] : undefined;
+};
+const hasFlag = (name) => args.includes(name);
+const { hit: envHit } = findEnvFile({});
+if (envHit) loadEnv(envHit);
+const BASE = baseUrl(flag('--base'));
+const OUT = flag('--out');
+
+// ---------------------------------------------------------------- 报告获取
+function unwrap(r, ctx) {
+  if (r.status >= 400) fail(`${ctx} 失败: HTTP ${r.status} ${JSON.stringify(r.json).slice(0, 200)}`);
+  return r.json?.data ?? r.json;
+}
+
+async function resolveReport() {
+  const token = await ensureToken();
+  if (flag('--id')) {
+    const id = flag('--id');
+    const r = unwrap(await httpJson('GET', `/api/reports/${id}`, undefined, { base: BASE, token }), '报告详情');
+    return { id, title: r.title || `日报 ${r.periodStart}`, ossUrl: r.ossUrl, period: r.periodStart };
+  }
+  // --latest: 取最近的 day 报告
+  const list = unwrap(await httpJson('GET', '/api/reports?type=day&pageSize=1', undefined, { base: BASE, token }), '报告列表');
+  const item = list.items?.[0];
+  if (!item) fail('没有找到任何日报');
+  return { id: item.id, title: item.title, ossUrl: item.ossUrl, period: item.periodStart };
+}
+
+async function ensureToken() {
+  const t = loadToken();
+  if (t) return t;
+  await login({ base: BASE, username: ENV_SMARTBADGE_USER(), password: process.env.SMARTBADGE_PASSWORD });
+  return loadToken();
+}
+function ENV_SMARTBADGE_USER() { return process.env.SMARTBADGE_USERNAME; }
+
+async function fetchHtml(ossUrl) {
+  const res = await fetch(ossUrl, { redirect: 'follow' });
+  if (!res.ok) fail(`拉取报告 HTML 失败: HTTP ${res.status}`);
+  return await res.text();
+}
+
+// ---------------------------------------------------------------- 移动端 CSS 注入
+const MOBILE_CSS = `
+/* ===== mobile-enhance v1 (report-mobile.mjs 注入) ===== */
+:root { --m-pad: 14px; }
+html { -webkit-text-size-adjust: 100%; }
+body { padding: var(--m-pad) !important; font-size: 15px !important; line-height: 1.65 !important; }
+/* 单列卡片流 */
+.grid, [class*="grid"] { grid-template-columns: 1fr !important; }
+.kpi-grid { grid-template-columns: 1fr 1fr !important; gap: 10px !important; }
+.kpi-item { padding: 10px 12px !important; }
+.kpi-item .value, .kpi-value { font-size: 22px !important; }
+/* 卡片统一圆角阴影节奏 */
+.card, .voc-item, .risk-item, .timeline-item, [class*="card"] {
+  border-radius: 12px !important; padding: 12px !important;
+  box-shadow: 0 1px 4px rgba(0,0,0,.06) !important; margin-bottom: 10px !important;
+}
+/* 表格转卡片(每行变块) */
+table { display: block !important; width: 100% !important; overflow-x: auto !important; -webkit-overflow-scrolling: touch; }
+th, td { padding: 8px 10px !important; font-size: 13px !important; white-space: normal !important; word-break: break-word !important; }
+/* 标题层级收敛 */
+h1 { font-size: 20px !important; line-height: 1.3 !important; }
+h3 { font-size: 16px !important; margin: 14px 0 8px !important; }
+/* 时间线在窄屏留白收紧 */
+.timeline { padding-left: 18px !important; }
+.timeline-item { padding-left: 14px !important; }
+/* 长词/编号换行保护 */
+.voc-item .quote, .interpretation, .risk-desc { word-break: break-word !important; }
+/* 打印/转PDF友好 */
+@media print { body { padding: 8px !important; } }
+/* ===== mobile-enhance end ===== */
+`;
+
+function toMobile(html, meta) {
+  // 已是移动版则跳过重复注入
+  if (html.includes('mobile-enhance v1')) return html;
+  let out = html;
+  // 1) 在 </style> 前注入增强 CSS;无 style 标签则新建
+  if (out.includes('</style>')) {
+    out = out.replace('</style>', MOBILE_CSS + '\n</style>');
+  } else if (out.includes('</head>')) {
+    out = out.replace('</head>', `<style>${MOBILE_CSS}</style>\n</head>`);
+  } else {
+    fail('HTML 结构异常: 无 </style> 也无 </head>');
+  }
+  // 2) viewport 保证(已有则不动)
+  if (!/name=["']viewport["']/.test(out)) {
+    out = out.replace('<head>', '<head>\n<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">');
+  }
+  // 3) 角标: 标题后加移动版标识
+  out = out.replace(
+    /(<h1[^>]*>)([\s\S]*?)(<\/h1>)/,
+    `$1$2$3<span style="display:block;font-size:12px;color:#888;margin-top:4px;">📱 移动版 · 由骏仔(FmodeAgent)自动转换 · ${meta.period}</span>`,
+  );
+  return out;
+}
+
+// ---------------------------------------------------------------- OSS 上传(复用签名模式)
+
+async function ossPut(key, body, contentType) {
+  const cfg = ossConfig();
+  if (!ossEnabled()) fail('OSS 未启用: .env 缺 OSS_ACCESS_KEY_ID/SECRET/BUCKET/REGION');
+  const host = `${cfg.bucket}.oss-${cfg.region}.aliyuncs.com`;
+  const date = new Date().toUTCString();
+  const ct = contentType || 'text/html; charset=utf-8';
+  const acl = 'public-read';
+  const stringToSign = ['PUT', '', ct, date, `x-oss-date:${date}`, `x-oss-object-acl:${acl}`, `/${cfg.bucket}/${key}`].join('\n');
+  const sig = crypto.createHmac('sha1', cfg.accessKeySecret).update(stringToSign).digest('base64');
+  const auth = `OSS ${cfg.accessKeyId}:${sig}`;
+  const res = await fetch(`https://${host}/${key}`, {
+    method: 'PUT',
+    headers: {
+      Authorization: auth, Date: date, 'Content-Type': ct, 'x-oss-date': date,
+      'x-oss-object-acl': acl,
+    },
+    body,
+  });
+  if (!res.ok) fail(`OSS 上传失败: HTTP ${res.status} ${await res.text().catch(() => '')}`);
+  return `https://${publicDomain() || host}/${key}`;
+}
+
+// ---------------------------------------------------------------- main
+const report = await resolveReport();
+if (hasFlag('--list')) { console.log(`${report.id} | ${report.title}`); process.exit(0); }
+console.log(`目标报告: ${report.id} | ${report.title}`);
+const pcHtml = await fetchHtml(report.ossUrl);
+console.log(`PC 版已拉取: ${(pcHtml.length / 1024).toFixed(1)} KB`);
+
+const mobileHtml = toMobile(pcHtml, report);
+if (OUT) { fs.writeFileSync(OUT, mobileHtml); console.log(`本地已保存: ${OUT}`); }
+
+// 上传 OSS reports-mobile/YYYY/MM/<reportId>.html
+const m = (report.period || '').match(/^(\d{4})-(\d{2})/);
+const key = m ? `reports-mobile/${m[1]}/${m[2]}/${report.id}.html` : `reports-mobile/${report.id}.html`;
+const url = await ossPut(key, mobileHtml);
+console.log('✅ 手机版已发布:');
+console.log(url);
+console.log(`本地保留: ${OUT || '(未落盘, 加 --out 参数)'}`);