lib.mjs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  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. export async function httpJson(method, apiPath, body, { base, token, raw = false, json = true } = {}) {
  80. const tokenValue = token || loadToken();
  81. const headers = { 'X-Requested-With': 'XMLHttpRequest' };
  82. if (body !== undefined) headers['Content-Type'] = 'application/json';
  83. if (tokenValue) headers['X-Parse-Session-Token'] = tokenValue;
  84. let res;
  85. try {
  86. res = await fetch(baseUrl(base) + apiPath, {
  87. method: method.toUpperCase(),
  88. headers,
  89. body: body !== undefined ? JSON.stringify(body) : undefined,
  90. });
  91. } catch (err) {
  92. 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);
  93. }
  94. const text = await res.text();
  95. if (json) {
  96. try { return { status: res.status, ok: res.ok, json: JSON.parse(text) }; } catch { return { status: res.status, ok: res.ok, json: { raw: text } }; }
  97. }
  98. return { status: res.status, ok: res.ok, json: null, text };
  99. }
  100. // ---------- 报告分页拉全 ----------
  101. export async function fetchAllReports({ base, token, type, start, end, deviceNo, pageSize = 100 } = {}) {
  102. const all = [];
  103. let page = 1;
  104. for (;;) {
  105. const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
  106. if (type) qs.set('type', type);
  107. if (start) qs.set('start', start);
  108. if (end) qs.set('end', end);
  109. if (deviceNo) qs.set('deviceNo', deviceNo);
  110. const r = await httpJson('GET', `/api/reports?${qs}`, undefined, { base, token, raw: true });
  111. const data = r.json?.data;
  112. const items = data?.items ?? [];
  113. all.push(...items);
  114. const total = data?.total ?? 0;
  115. if (items.length === 0 || all.length >= total || items.length < 100) break;
  116. page++;
  117. }
  118. return all;
  119. }
  120. // ---------- htmlToText(正则剥标签,非完整 DOM;表格按行序文本) ----------
  121. const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
  122. export function htmlToText(html) {
  123. return String(html)
  124. .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  125. .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  126. .replace(/<(head|nav|footer)[\s\S]*?<\/\1>/gi, ' ')
  127. .replace(/<br\s*\/?>/gi, '\n')
  128. .replace(/<\/(p|li|tr|td|th|div|h[1-6])>/gi, '\n')
  129. .replace(/<li[^>]*>/gi, '- ')
  130. .replace(/<[^>]+>/g, ' ')
  131. .replace(/&([a-z]+);/gi, (m, name) => ENTITY[name.toLowerCase()] ?? m)
  132. .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(n); } catch { return m; } })
  133. .replace(/[ \t]+\n/g, '\n')
  134. .replace(/^[ \t]+/gm, '')
  135. .replace(/\n{3,}/g, '\n\n')
  136. .replace(/^\s+|\s+$/g, '')
  137. .trim();
  138. }