|
@@ -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 参数)'}`);
|