| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164 |
- #!/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 参数)'}`);
|