lib.mjs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  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 > 自目录向上扫 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. for (let dir = REPO_ROOT; ; dir = path.dirname(dir)) {
  20. push(path.join(dir, 'server', '.env'));
  21. if (path.dirname(dir) === dir) break;
  22. }
  23. const siblings = path.dirname(REPO_ROOT);
  24. let dirNames = [];
  25. try { dirNames = fs.readdirSync(siblings); } catch { /* 忽略 */ }
  26. for (const name of dirNames.sort()) {
  27. if (/device|smartbadge/i.test(name)) {
  28. push(path.join(siblings, name, 'server', '.env'));
  29. }
  30. }
  31. const hit = candidates.find((p) => {
  32. try { return fs.statSync(p).isFile(); } catch { return false; }
  33. });
  34. return { hit, candidates };
  35. }
  36. export function loadEnv(file) {
  37. const parsed = {};
  38. try {
  39. const text = fs.readFileSync(file, 'utf8');
  40. for (const rawLine of text.split(/\r?\n/)) {
  41. const line = rawLine.trim();
  42. if (!line || line.startsWith('#') || !line.includes('=')) continue;
  43. const [key, ...rest] = line.split('=');
  44. const value = rest.join('=').trim().replace(/^['"]|['"]$/g, '');
  45. if (!process.env[key]) process.env[key] = value;
  46. parsed[key] = process.env[key];
  47. }
  48. } catch (err) {
  49. if (err.code !== 'ENOENT') throw err;
  50. }
  51. return parsed;
  52. }
  53. export function baseUrl(flagBase) {
  54. return flagBase || process.env.SMARTBADGE_API_BASE || 'https://recording.sh-lami.com';
  55. }
  56. // ---------- token ----------
  57. export function loadToken() {
  58. try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; }
  59. }
  60. export function saveToken(token) {
  61. fs.writeFileSync(TOKEN_FILE, token);
  62. }
  63. // ---------- 登录 ----------
  64. export async function login({ base, username, password }) {
  65. if (!password) fail('用法: node api.mjs login <密码>(或环境变量 SMARTBADGE_PASSWORD)', 2);
  66. const res = await fetch(`${base}/api/auth/login`, {
  67. method: 'POST',
  68. headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
  69. body: JSON.stringify({ username: username || process.env.SMARTBADGE_USERNAME || 'lami123456', password }),
  70. });
  71. const json = await res.json().catch(() => ({ raw: '非 JSON 响应' }));
  72. if (!json.success || !json.data?.sessionToken) {
  73. fail(`登录失败(${res.status}): ${JSON.stringify(json)}`, 2);
  74. }
  75. saveToken(json.data.sessionToken);
  76. return json.data.sessionToken;
  77. }
  78. // ---------- httpJson ----------
  79. // relogin=true 且 401 且环境有 SMARTBADGE_PASSWORD 时自动重新登录重试一次
  80. export async function httpJson(method, apiPath, body, { base, token, raw = false, json = true, relogin = false } = {}) {
  81. const doFetch = async () => {
  82. const tokenValue = token || loadToken();
  83. const headers = { 'X-Requested-With': 'XMLHttpRequest' };
  84. if (body !== undefined) headers['Content-Type'] = 'application/json';
  85. if (tokenValue) headers['X-Parse-Session-Token'] = tokenValue;
  86. let res;
  87. try {
  88. res = await fetch(baseUrl(base) + apiPath, {
  89. method: method.toUpperCase(),
  90. headers,
  91. body: body !== undefined ? JSON.stringify(body) : undefined,
  92. });
  93. } catch (err) {
  94. 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);
  95. }
  96. return res;
  97. };
  98. let res = await doFetch();
  99. if (res.status === 401 && relogin && process.env.SMARTBADGE_PASSWORD) {
  100. try { await login({ base: baseUrl(base), password: process.env.SMARTBADGE_PASSWORD }); } catch { /* 保留原 401 响应 */ }
  101. res = await doFetch();
  102. }
  103. const text = await res.text();
  104. if (json) {
  105. try { return { status: res.status, ok: res.ok, json: JSON.parse(text) }; } catch { return { status: res.status, ok: res.ok, json: { raw: text } }; }
  106. }
  107. return { status: res.status, ok: res.ok, json: null, text };
  108. }
  109. // ---------- 报告分页拉全 ----------
  110. export async function fetchAllReports({ base, token, type, start, end, deviceNo, pageSize = 100, relogin = false } = {}) {
  111. const all = [];
  112. let page = 1;
  113. for (;;) {
  114. const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
  115. if (type) qs.set('type', type);
  116. if (start) qs.set('start', start);
  117. if (end) qs.set('end', end);
  118. if (deviceNo) qs.set('deviceNo', deviceNo);
  119. const r = await httpJson('GET', `/api/reports?${qs}`, undefined, { base, token, raw: true, relogin });
  120. const data = r.json?.data;
  121. const items = data?.items ?? [];
  122. all.push(...items);
  123. const total = data?.total ?? 0;
  124. if (items.length === 0 || all.length >= total || items.length < 100) break;
  125. page++;
  126. }
  127. return all;
  128. }
  129. // ---------- htmlToText(正则剥标签,非完整 DOM;表格按行序文本) ----------
  130. const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
  131. export function htmlToText(html) {
  132. return String(html)
  133. .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  134. .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  135. .replace(/<(head|nav|footer)[\s\S]*?<\/\1>/gi, ' ')
  136. .replace(/<br\s*\/?>/gi, '\n')
  137. .replace(/<\/(p|li|tr|td|th|div|h[1-6])>/gi, '\n')
  138. .replace(/<li[^>]*>/gi, '- ')
  139. .replace(/<[^>]+>/g, ' ')
  140. .replace(/&([a-z]+);/gi, (m, name) => ENTITY[name.toLowerCase()] ?? m)
  141. .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(n); } catch { return m; } })
  142. .replace(/[ \t]+\n/g, '\n')
  143. .replace(/^[ \t]+/gm, '')
  144. .replace(/\n{3,}/g, '\n\n')
  145. .replace(/^\s+|\s+$/g, '')
  146. .trim();
  147. }