#!/usr/bin/env node // 报告正文拉取: --id 按 reportId 定位(经 /api/reports/:id)或 --url 直链,下载 HTML → 剥标签存 out/ 并打印 // 用法: // node fetch-report-text.mjs --id # 按报告 id(当前窗口内最佳) // node fetch-report-text.mjs --url # 直链(自动跟随 302) // node fetch-report-text.mjs --id xxxx --preview # 只输出标题+前 28 行摘要 // node fetch-report-text.mjs --id xxxx --json # 输出 {id,title,chars,file} // node fetch-report-text.mjs --id xxxx --out 报告.txt # 指定输出文件 // 密码: SMARTBADGE_PASSWORD(无则复用 scripts/.token) import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { baseUrl, fail, loadToken, login, httpJson, htmlToText, fetchAllReports } from './lib.mjs'; const args = process.argv.slice(2); const argVal = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; const id = argVal('--id'); const url = argVal('--url'); const outPath = argVal('--out'); const preview = args.includes('--preview'); const jsonOut = args.includes('--json'); const base = baseUrl(); if (!id && !url) fail('用法: node fetch-report-text.mjs --id | --url [--preview] [--json] [--out 路径]', 3); if (url && !/^https?:\/\//i.test(url)) fail(`--url 仅接受 http(s) 协议(拒绝 file:// 等本地协议),收到: ${url}`, 4); async function getRedirectTarget() { const token = loadToken(); let r = await httpJson('GET', `/api/reports/${id}`, undefined, { base, token, raw: true }); if (!r.ok || !r.json?.data) { if (r.status === 401 && process.env.SMARTBADGE_PASSWORD) { await login({ base, password: process.env.SMARTBADGE_PASSWORD }); r = await httpJson('GET', `/api/reports/${id}`, undefined, { base, token: loadToken(), raw: true }); } if (!r.ok || !r.json?.data) { const recent = await fetchAllReports({ base, token, pageSize: 3 }).then((rs) => rs.map((x) => `${x.id}(${x.type}, ${x.periodStart})`).join('、')); fail(`报告 id 未找到(${id}). 近期报告参考: ${recent || '无'}`, 5); } } const d = r.json.data; const target = d.ossUrl || d.downloadUrl; if (!target) fail(`报告 ${id} 无 ossUrl/downloadUrl,请在服务器侧检查 OSS 配置`, 6); return { id: String(d.id), target, title: d.title, type: d.type, period: `${d.periodStart} ~ ${d.periodEnd}` }; } async function fetchHtml(target) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); try { const res = await fetch(target, { signal: controller.signal }); if (!res.ok) fail(`下载失败 HTTP ${res.status}: ${target}`, 6); return await res.text(); } catch (err) { fail(`下载失败(超时 30s 或网络异常): ${err.message}\n 直链: ${target}`, 7); } finally { clearTimeout(timer); } } const meta = id ? await getRedirectTarget() : { id: `url-${Date.now()}`, target: url, title: path.basename(new URL(url).pathname) || 'report', type: '', period: url }; const html = await fetchHtml(meta.target); const text = htmlToText(html); const safeName = String(meta.id).replace(/[^a-zA-Z0-9._-]/g, '_'); const file = path.resolve(outPath || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'out', `report-${safeName}.txt`)); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); if (jsonOut) { console.log(JSON.stringify({ id: meta.id, title: meta.title, type: meta.type, period: meta.period, chars: text.length, file })); process.exit(0); } if (preview) { const lines = text.split('\n').filter(Boolean); const first = lines.slice(0, 28).join('\n'); console.log(`标题: ${meta.title || '(无)'}\n${first}\n...[共 ${text.length} 字符, 全文见 ${file}]`); } else { console.log(text); console.error(`\n[全文已另存: ${file}]`); }