lib.mjs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. // 共享库:env 回退链 / token 读写 / login / httpJson / fetchAllReports / htmlToText(零依赖, Node >=18)
  2. import fs from 'fs';
  3. import path from 'path';
  4. import { fileURLToPath } from 'url';
  5. export const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
  6. export const REPO_ROOT = path.dirname(SCRIPTS_DIR);
  7. export const TOKEN_FILE = path.join(SCRIPTS_DIR, '.token');
  8. export function fail(msg, code = 1) {
  9. console.error(msg);
  10. process.exit(code);
  11. }
  12. // ---------- env 回退链 ----------
  13. // --env > SMARTBADGE_ENV_FILE > 技能仓库根 .env(clone 后 cp .env.example) > 自目录向上扫 server/.env > 兄弟目录自动发现(名称含 device|smartbadge 的仓库)
  14. export function findEnvFile({ envPath, envFileEnv = process.env.SMARTBADGE_ENV_FILE } = {}) {
  15. const candidates = [];
  16. const push = (p) => candidates.push(p);
  17. if (envPath) push(path.resolve(envPath));
  18. if (envFileEnv) push(path.resolve(envFileEnv));
  19. push(path.join(REPO_ROOT, '.env'));
  20. for (let dir = REPO_ROOT; ; dir = path.dirname(dir)) {
  21. push(path.join(dir, 'server', '.env'));
  22. if (path.dirname(dir) === dir) break;
  23. }
  24. const siblings = path.dirname(REPO_ROOT);
  25. let dirNames = [];
  26. try { dirNames = fs.readdirSync(siblings); } catch { /* 忽略 */ }
  27. for (const name of dirNames.sort()) {
  28. if (/device|smartbadge/i.test(name)) {
  29. push(path.join(siblings, name, 'server', '.env'));
  30. }
  31. }
  32. const hit = candidates.find((p) => {
  33. try { return fs.statSync(p).isFile(); } catch { return false; }
  34. });
  35. return { hit, candidates };
  36. }
  37. export function loadEnv(file) {
  38. const parsed = {};
  39. try {
  40. const text = fs.readFileSync(file, 'utf8');
  41. for (const rawLine of text.split(/\r?\n/)) {
  42. const line = rawLine.trim();
  43. if (!line || line.startsWith('#') || !line.includes('=')) continue;
  44. const [key, ...rest] = line.split('=');
  45. const value = rest.join('=').trim().replace(/^['"]|['"]$/g, '');
  46. if (!process.env[key]) process.env[key] = value;
  47. parsed[key] = process.env[key];
  48. }
  49. } catch (err) {
  50. if (err.code !== 'ENOENT') throw err;
  51. }
  52. return parsed;
  53. }
  54. export function baseUrl(flagBase) {
  55. return flagBase || process.env.SMARTBADGE_API_BASE || 'https://recording.sh-lami.com';
  56. }
  57. // ---------- token ----------
  58. export function loadToken() {
  59. try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; }
  60. }
  61. export function saveToken(token) {
  62. fs.writeFileSync(TOKEN_FILE, token);
  63. }
  64. // ---------- 登录 ----------
  65. // 凭据只从参数/环境变量取值(无内置默认值——本仓库不含任何密钥,含 Docker/裸 Linux 环境)
  66. export async function login({ base, username, password }) {
  67. const user = username || process.env.SMARTBADGE_USERNAME;
  68. if (!user) fail('缺少用户名: 设置环境变量 SMARTBADGE_USERNAME(或 .env 中填写;查看 .env.example 模板)', 2);
  69. if (!password) fail('缺少密码: 设置环境变量 SMARTBADGE_PASSWORD(或 .env 中填写;或作为参数: node api.mjs login <密码>)', 2);
  70. const res = await fetch(`${base}/api/auth/login`, {
  71. method: 'POST',
  72. headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
  73. body: JSON.stringify({ username: user, password }),
  74. });
  75. const json = await res.json().catch(() => ({ raw: '非 JSON 响应' }));
  76. if (!json.success || !json.data?.sessionToken) {
  77. fail(`登录失败(${res.status}): ${JSON.stringify(json)}`, 2);
  78. }
  79. saveToken(json.data.sessionToken);
  80. return json.data.sessionToken;
  81. }
  82. // ---------- httpJson ----------
  83. // relogin=true 且 401 且环境有 SMARTBADGE_PASSWORD 时自动重新登录重试一次
  84. export async function httpJson(method, apiPath, body, { base, token, raw = false, json = true, relogin = false } = {}) {
  85. const doFetch = async () => {
  86. const tokenValue = token || loadToken();
  87. const headers = { 'X-Requested-With': 'XMLHttpRequest' };
  88. if (body !== undefined) headers['Content-Type'] = 'application/json';
  89. if (tokenValue) headers['X-Parse-Session-Token'] = tokenValue;
  90. let res;
  91. try {
  92. res = await fetch(baseUrl(base) + apiPath, {
  93. method: method.toUpperCase(),
  94. headers,
  95. body: body !== undefined ? JSON.stringify(body) : undefined,
  96. });
  97. } catch (err) {
  98. 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);
  99. }
  100. return res;
  101. };
  102. let res = await doFetch();
  103. if (res.status === 401 && relogin && process.env.SMARTBADGE_PASSWORD) {
  104. try { await login({ base: baseUrl(base), password: process.env.SMARTBADGE_PASSWORD }); } catch { /* 保留原 401 响应 */ }
  105. res = await doFetch();
  106. }
  107. const text = await res.text();
  108. if (json) {
  109. try { return { status: res.status, ok: res.ok, json: JSON.parse(text) }; } catch { return { status: res.status, ok: res.ok, json: { raw: text } }; }
  110. }
  111. return { status: res.status, ok: res.ok, json: null, text };
  112. }
  113. // ---------- 报告分页拉全 ----------
  114. export async function fetchAllReports({ base, token, type, start, end, deviceNo, pageSize = 100, relogin = false } = {}) {
  115. const all = [];
  116. let page = 1;
  117. for (;;) {
  118. const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
  119. if (type) qs.set('type', type);
  120. if (start) qs.set('start', start);
  121. if (end) qs.set('end', end);
  122. if (deviceNo) qs.set('deviceNo', deviceNo);
  123. const r = await httpJson('GET', `/api/reports?${qs}`, undefined, { base, token, raw: true, relogin });
  124. const data = r.json?.data;
  125. const items = data?.items ?? [];
  126. all.push(...items);
  127. const total = data?.total ?? 0;
  128. if (items.length === 0 || all.length >= total || items.length < 100) break;
  129. page++;
  130. }
  131. return all;
  132. }
  133. // ---------- htmlToText(正则剥标签,非完整 DOM;表格按行序文本) ----------
  134. const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
  135. export function htmlToText(html) {
  136. return String(html)
  137. .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  138. .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  139. .replace(/<(head|nav|footer)[\s\S]*?<\/\1>/gi, ' ')
  140. .replace(/<br\s*\/?>/gi, '\n')
  141. .replace(/<\/(p|li|tr|td|th|div|h[1-6])>/gi, '\n')
  142. .replace(/<li[^>]*>/gi, '- ')
  143. .replace(/<[^>]+>/g, ' ')
  144. .replace(/&([a-z]+);/gi, (m, name) => ENTITY[name.toLowerCase()] ?? m)
  145. .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(n); } catch { return m; } })
  146. .replace(/[ \t]+\n/g, '\n')
  147. .replace(/^[ \t]+/gm, '')
  148. .replace(/\n{3,}/g, '\n\n')
  149. .replace(/^\s+|\s+$/g, '')
  150. .trim();
  151. }