harvest.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. 'use strict';
  2. /**
  3. * 定向流量采集(harvest)
  4. * ============================================================================
  5. * 背景:napos/data/apis.json 是当年在客户端里抓 5 分钟流量归纳的,页面覆盖不全
  6. * (订单页只抓到实时接口、没人点过「历史订单」)。本模块补上「定向补抓」能力:
  7. * 同事在客户端里操作目标页面(翻历史订单、看营业时间设置),本模块通过 CDP
  8. * Network 域把这段时间内所有业务请求录下来,输出「接口清单」(method/路径/
  9. * 请求体采样/状态码),供开发侧定位目录外接口。
  10. *
  11. * 边界(与运营套件手册一致):
  12. * · 只录不改 —— 不重放、不模拟点击、不读响应体、不碰任何写接口;
  13. * · 只录业务域 —— CDN 静态资源/埋点/风控探针一律丢弃。
  14. */
  15. const cdp = require('./cdp');
  16. /** 只关心这些业务域(后缀匹配) */
  17. const BIZ_HOST_SUFFIXES = ['ele.me', 'elemecdn.com', 'alibaba.com', 'taobao.com', 'koubei.com'];
  18. /** 静态资源/埋点/风控探针 —— 即使在业务域下也丢 */
  19. const NOISE_HOST_RE = /(^|\.)g\.alicdn\.com$|(^|\.)img\.alicdn\.com$|(^|\.)assets\.elemecdn\.com$|(^|\.)alidt\.alicdn\.com$|mdap\.alipay\.com|ynuf\.aliapp\.org|(^|\.)render\.alipay\.com$|(^|\.)gw\.alipayobjects\.com$|xux-web-config\.oss/i;
  20. const STATIC_EXT_RE = /\.(js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|mp4|map|html?)(\?|$)/i;
  21. const state = { running: false, startedAt: null, seconds: 0, stopRequested: false };
  22. let lastResult = null;
  23. function hostAllowed(hostname) {
  24. const h = String(hostname || '').toLowerCase();
  25. if (NOISE_HOST_RE.test(h)) return false;
  26. return BIZ_HOST_SUFFIXES.some((s) => h === s || h.endsWith('.' + s));
  27. }
  28. function status() {
  29. return {
  30. running: state.running,
  31. startedAt: state.startedAt,
  32. seconds: state.seconds,
  33. stopRequested: state.stopRequested,
  34. };
  35. }
  36. /** 请求体/URL 里常带登录态(ksid/secretkey):落清单前一律打码,报告外发不泄露凭证 */
  37. function redact(s) {
  38. return String(s).replace(/(ksid|secretkey)(["']?\s*[:=]\s*"?)([^"'&,;\s}\]]+)/gi, '$1$2***');
  39. }
  40. async function runHarvest({ port = cdp.DEFAULT_PORT, seconds = 120 } = {}) {
  41. if (state.running) {
  42. const e = new Error('已有定向采集在进行中');
  43. e.code = 'HARVEST_BUSY';
  44. throw e;
  45. }
  46. const p = await cdp.probe(port);
  47. if (!p.ok) {
  48. const e = new Error(`客户端不在线(调试端口 ${port} 不可用)`);
  49. e.code = 'HARVEST_NO_PORT';
  50. throw e;
  51. }
  52. const secs = Math.max(10, Math.min(600, Number(seconds) || 120));
  53. state.running = true;
  54. state.stopRequested = false;
  55. state.startedAt = new Date().toISOString();
  56. state.seconds = secs;
  57. lastResult = null; // 新一轮先清旧结果:失败/中断时绝不把上一次的清单当本次的
  58. const map = new Map(); // key -> endpoint 条目(statuses 用 Set,最后转数组)
  59. const sessions = [];
  60. const record = (pa) => {
  61. try {
  62. const req = pa && pa.request;
  63. if (!req || !/^https?:/i.test(req.url)) return;
  64. let u;
  65. try { u = new URL(req.url); } catch { return; }
  66. if (!hostAllowed(u.hostname)) return;
  67. if (STATIC_EXT_RE.test(u.pathname)) return;
  68. const method = String(req.method || 'GET').toUpperCase();
  69. const key = `${method} ${u.host}${u.pathname}`;
  70. let e = map.get(key);
  71. if (!e) {
  72. e = { key, method, host: u.host, path: u.pathname, count: 0,
  73. sampleUrl: redact(req.url).slice(0, 800), sampleBody: '', _statuses: new Set() };
  74. map.set(key, e);
  75. }
  76. e.count += 1;
  77. if (!e.sampleBody && req.postData) e.sampleBody = redact(String(req.postData)).slice(0, 2000);
  78. return key;
  79. } catch { return null; }
  80. };
  81. try {
  82. // 逐 target 建会话;单个 target 失败不阻塞(iframe 进程可能拒绝附加)
  83. const targets = await cdp.listTargets(port).catch(() => []);
  84. for (const t of targets) {
  85. if (!t.webSocketDebuggerUrl || !['page', 'iframe', 'webview'].includes(t.type)) continue;
  86. try {
  87. const c = new cdp.CDP(t.webSocketDebuggerUrl);
  88. await c.open(8000);
  89. const sid = await c.attachTo(t.id);
  90. const ridKey = new Map(); // requestId -> endpoint key
  91. c.on('Network.requestWillBeSent', (pa) => {
  92. const key = record(pa);
  93. if (key && pa.requestId) ridKey.set(pa.requestId, key);
  94. });
  95. c.on('Network.responseReceived', (pa) => {
  96. try {
  97. const key = pa && pa.requestId && ridKey.get(pa.requestId);
  98. const e = key && map.get(key);
  99. const st = pa && pa.response && pa.response.status;
  100. if (e && st) e._statuses.add(st);
  101. } catch { /* ignore */ }
  102. });
  103. await c.send('Network.enable', {}, sid);
  104. sessions.push({ c, sid });
  105. } catch { /* ignore this target */ }
  106. }
  107. const startedAt = Date.now();
  108. const deadline = startedAt + secs * 1000;
  109. while (Date.now() < deadline && !state.stopRequested) await cdp.sleep(500);
  110. const actualSeconds = Math.round((Date.now() - startedAt) / 1000);
  111. const endpoints = [...map.values()]
  112. .map(({ _statuses, ...e }) => ({ ...e, statuses: [..._statuses].sort((a, b) => a - b) }))
  113. .sort((a, b) => b.count - a.count);
  114. const result = {
  115. harvestedAt: new Date().toISOString(),
  116. seconds: actualSeconds,
  117. stoppedEarly: state.stopRequested,
  118. targetCount: targets.length,
  119. sessionCount: sessions.length,
  120. endpoints,
  121. };
  122. lastResult = result;
  123. return result;
  124. } finally {
  125. // 无论正常结束、提前停止还是半路抛异常:会话必关、状态必清,绝不卡死下次采集
  126. for (const s of sessions) {
  127. try { await s.c.detach(s.sid); } catch { /* ignore */ }
  128. s.c.close();
  129. }
  130. state.running = false;
  131. state.stopRequested = false;
  132. }
  133. }
  134. /** 提前收工:把剩余等待截断,正常走收尾(会话已录到的请求不丢) */
  135. function stopHarvest() {
  136. if (!state.running) return status();
  137. state.stopRequested = true;
  138. return status();
  139. }
  140. function getResult() { return lastResult; }
  141. module.exports = { runHarvest, stopHarvest, status, getResult, hostAllowed };