#!/usr/bin/env node /** * report-mobile.mjs — 智能工牌日报移动版转换器 * * 用途: 拉取 PC 端日报 HTML → 注入移动端增强 CSS → 上传 OSS reports-mobile/ → 输出手机版直链 * 用法: * node scripts/report-mobile.mjs --id # 转换指定报告 * 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) 在 前注入增强 CSS;无 style 标签则新建 if (out.includes('')) { out = out.replace('', MOBILE_CSS + '\n'); } else if (out.includes('')) { out = out.replace('', `\n`); } else { fail('HTML 结构异常: 无 也无 '); } // 2) viewport 保证(已有则不动) if (!/name=["']viewport["']/.test(out)) { out = out.replace('', '\n'); } // 3) 角标: 标题后加移动版标识 out = out.replace( /(]*>)([\s\S]*?)(<\/h1>)/, `$1$2$3📱 移动版 · 由骏仔(FmodeAgent)自动转换 · ${meta.period}`, ); 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/.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 参数)'}`);