// 共享库:env 回退链 / token 读写 / login / httpJson / fetchAllReports / htmlToText(零依赖, Node >=18) import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; export const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url)); export const REPO_ROOT = path.dirname(SCRIPTS_DIR); export const TOKEN_FILE = path.join(SCRIPTS_DIR, '.token'); export function fail(msg, code = 1) { console.error(msg); process.exit(code); } // ---------- env 回退链 ---------- // --env > SMARTBADGE_ENV_FILE > 技能仓库根 .env(clone 后 cp .env.example) > 自目录向上扫 server/.env > 兄弟目录自动发现(名称含 device|smartbadge 的仓库) export function findEnvFile({ envPath, envFileEnv = process.env.SMARTBADGE_ENV_FILE } = {}) { const candidates = []; const push = (p) => candidates.push(p); if (envPath) push(path.resolve(envPath)); if (envFileEnv) push(path.resolve(envFileEnv)); push(path.join(REPO_ROOT, '.env')); for (let dir = REPO_ROOT; ; dir = path.dirname(dir)) { push(path.join(dir, 'server', '.env')); if (path.dirname(dir) === dir) break; } const siblings = path.dirname(REPO_ROOT); let dirNames = []; try { dirNames = fs.readdirSync(siblings); } catch { /* 忽略 */ } for (const name of dirNames.sort()) { if (/device|smartbadge/i.test(name)) { push(path.join(siblings, name, 'server', '.env')); } } const hit = candidates.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }); return { hit, candidates }; } export function loadEnv(file) { const parsed = {}; try { const text = fs.readFileSync(file, 'utf8'); for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#') || !line.includes('=')) continue; const [key, ...rest] = line.split('='); const value = rest.join('=').trim().replace(/^['"]|['"]$/g, ''); if (!process.env[key]) process.env[key] = value; parsed[key] = process.env[key]; } } catch (err) { if (err.code !== 'ENOENT') throw err; } return parsed; } export function baseUrl(flagBase) { return flagBase || process.env.SMARTBADGE_API_BASE || 'https://recording.sh-lami.com'; } // ---------- token ---------- export function loadToken() { try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; } } export function saveToken(token) { fs.writeFileSync(TOKEN_FILE, token); } // ---------- 登录 ---------- // 凭据只从参数/环境变量取值(无内置默认值——本仓库不含任何密钥,含 Docker/裸 Linux 环境) export async function login({ base, username, password }) { const user = username || process.env.SMARTBADGE_USERNAME; if (!user) fail('缺少用户名: 设置环境变量 SMARTBADGE_USERNAME(或 .env 中填写;查看 .env.example 模板)', 2); if (!password) fail('缺少密码: 设置环境变量 SMARTBADGE_PASSWORD(或 .env 中填写;或作为参数: node api.mjs login <密码>)', 2); const res = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ username: user, password }), }); const json = await res.json().catch(() => ({ raw: '非 JSON 响应' })); if (!json.success || !json.data?.sessionToken) { fail(`登录失败(${res.status}): ${JSON.stringify(json)}`, 2); } saveToken(json.data.sessionToken); return json.data.sessionToken; } // ---------- httpJson ---------- // relogin=true 且 401 且环境有 SMARTBADGE_PASSWORD 时自动重新登录重试一次 export async function httpJson(method, apiPath, body, { base, token, raw = false, json = true, relogin = false } = {}) { const doFetch = async () => { const tokenValue = token || loadToken(); const headers = { 'X-Requested-With': 'XMLHttpRequest' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (tokenValue) headers['X-Parse-Session-Token'] = tokenValue; let res; try { res = await fetch(baseUrl(base) + apiPath, { method: method.toUpperCase(), headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); } catch (err) { fail(`无法连接服务器(${err.message})\n - 检查 SMARTBADGE_API_BASE / --base(默认线上 https://recording.sh-lami.com;本地 http://localhost:3002)\n - 以 / 开头的参数在 Git Bash 需先 export MSYS_NO_PATHCONV=1\n - 服务器是否已启动`, 3); } return res; }; let res = await doFetch(); if (res.status === 401 && relogin && process.env.SMARTBADGE_PASSWORD) { try { await login({ base: baseUrl(base), password: process.env.SMARTBADGE_PASSWORD }); } catch { /* 保留原 401 响应 */ } res = await doFetch(); } const text = await res.text(); if (json) { try { return { status: res.status, ok: res.ok, json: JSON.parse(text) }; } catch { return { status: res.status, ok: res.ok, json: { raw: text } }; } } return { status: res.status, ok: res.ok, json: null, text }; } // ---------- 报告分页拉全 ---------- export async function fetchAllReports({ base, token, type, start, end, deviceNo, pageSize = 100, relogin = false } = {}) { const all = []; let page = 1; for (;;) { const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (type) qs.set('type', type); if (start) qs.set('start', start); if (end) qs.set('end', end); if (deviceNo) qs.set('deviceNo', deviceNo); const r = await httpJson('GET', `/api/reports?${qs}`, undefined, { base, token, raw: true, relogin }); const data = r.json?.data; const items = data?.items ?? []; all.push(...items); const total = data?.total ?? 0; if (items.length === 0 || all.length >= total || items.length < 100) break; page++; } return all; } // ---------- htmlToText(正则剥标签,非完整 DOM;表格按行序文本) ---------- const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; export function htmlToText(html) { return String(html) .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<(head|nav|footer)[\s\S]*?<\/\1>/gi, ' ') .replace(//gi, '\n') .replace(/<\/(p|li|tr|td|th|div|h[1-6])>/gi, '\n') .replace(/]*>/gi, '- ') .replace(/<[^>]+>/g, ' ') .replace(/&([a-z]+);/gi, (m, name) => ENTITY[name.toLowerCase()] ?? m) .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(n); } catch { return m; } }) .replace(/[ \t]+\n/g, '\n') .replace(/^[ \t]+/gm, '') .replace(/\n{3,}/g, '\n\n') .replace(/^\s+|\s+$/g, '') .trim(); }