classify.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. 'use strict';
  2. /**
  3. * 把抓包流水(traffic.jsonl)归纳成接口目录(apis.json)。
  4. *
  5. * 淘宝闪购后台有个特点:**大量调用走通用 RPC 网关**,真实方法名不在 URL 路径里,
  6. * 而在 query 的 method= 或请求体的 service/method 字段里。例如:
  7. *
  8. * POST /nevermore.goods/invoke?method=FoodService2.searchRecycleBinView
  9. * body = { service:'FoodService2', method:'searchRecycleBinView', params:{...} }
  10. * POST /xtop/xtop.vas.gateway.iOneRoute.callFromNapos/1.0?service=shopService&method=queryShopAuthority
  11. * body = { metas:{...}, params:{ from,to,service,method,params:[...],reqId } }
  12. *
  13. * 不把这些拆开,122 个接口会被压成几十个无意义的同名 URL。
  14. * 本模块负责拆开、抽参数、推结构、判读写。
  15. */
  16. // ------------------------------------------------------------------ 噪声识别
  17. const NOISE_HOST_SUFFIX = [
  18. '.alicdn.com', '.elemecdn.com', '.aliapp.org', '.alipay.com', '.1688.com',
  19. '.koubei.com', '.alibaba-inc.com', '.taobao.com', '.mmstat.com',
  20. '.oss-accelerate.aliyuncs.com', '.oss-cn-zhangjiakou.aliyuncs.com',
  21. ];
  22. /** 例外:同后缀里其实是有用数据的 */
  23. const NOISE_HOST_EXCEPT = [
  24. 'lsycm.alibaba.com', // 生意参谋/经营数据
  25. 'xux-web-config.oss-accelerate.aliyuncs.com', // 前端配置
  26. ];
  27. const NOISE_PATH = [
  28. /\/arms\.\d/, /\/beacon/, /\/report\b/, /\/track\b/, /\.gif$/, /\.png$/, /\.jpg$/,
  29. /\/mtb\//, /\/ynuf/, /\/mdap/, /\/druid/, /\.js$/, /\.css$/, /\.woff2?$/,
  30. ];
  31. function isNoise(url) {
  32. let u;
  33. try { u = new URL(url); } catch { return true; }
  34. const host = u.hostname;
  35. const excepted = NOISE_HOST_EXCEPT.some((h) => host === h || host.endsWith('.' + h));
  36. if (!excepted) {
  37. const base = NOISE_HOST_SUFFIX.find((s) => host.endsWith(s));
  38. // .taobao.com 下的 gw/h5api 是真业务网关(agw.alsc.taobao.com)
  39. if (base === '.taobao.com') {
  40. if (!/\/gw\//.test(u.pathname)) return true;
  41. } else if (base) return true;
  42. }
  43. return NOISE_PATH.some((r) => r.test(u.pathname));
  44. }
  45. // ------------------------------------------------------------------ 路径模板
  46. function templatePath(pathname) {
  47. return pathname
  48. .split('/')
  49. .map((seg) => {
  50. if (!seg) return seg;
  51. if (/^\d{4}-\d{2}-\d{2}/.test(seg)) return '{date}';
  52. if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return '{uuid}';
  53. if (/^[0-9a-f]{16,}$/i.test(seg)) return '{hash}';
  54. if (/^\d{4,}$/.test(seg)) return '{id}';
  55. return seg;
  56. })
  57. .join('/');
  58. }
  59. // ------------------------------------------------------------------ 类型草图
  60. function typeOf(v) {
  61. if (v === null) return 'null';
  62. if (Array.isArray(v)) return 'array';
  63. return typeof v;
  64. }
  65. function shape(v, depth = 0) {
  66. if (depth >= 4) return typeOf(v);
  67. if (v === null || v === undefined) return 'null';
  68. if (Array.isArray(v)) return v.length ? [shape(v[0], depth + 1)] : [];
  69. if (typeof v === 'object') {
  70. const o = {};
  71. for (const k of Object.keys(v).slice(0, 40)) o[k] = shape(v[k], depth + 1);
  72. return o;
  73. }
  74. return typeof v;
  75. }
  76. function tryParseJson(s) {
  77. if (typeof s !== 'string') return undefined;
  78. const t = s.trim();
  79. if (!t || (t[0] !== '{' && t[0] !== '[')) return undefined;
  80. try { return JSON.parse(t); } catch { return undefined; }
  81. }
  82. /** 收集对象里所有叶子字段路径(深度受限),用于"这个接口要传什么" */
  83. function leafKeys(v, prefix = '', out = [], depth = 0) {
  84. if (depth > 3 || v === null || v === undefined) return out;
  85. if (Array.isArray(v)) {
  86. if (v.length) leafKeys(v[0], prefix + '[]', out, depth + 1);
  87. return out;
  88. }
  89. if (typeof v === 'object') {
  90. for (const k of Object.keys(v)) leafKeys(v[k], prefix ? `${prefix}.${k}` : k, out, depth + 1);
  91. return out;
  92. }
  93. if (prefix) out.push(prefix);
  94. return out;
  95. }
  96. // ------------------------------------------------------------------ 读写判定
  97. // 注意:这两张词表被 guard.js 复用,改这里等于改写操作防护策略。
  98. //
  99. // 用**词元精确匹配**而不是正则子串匹配,因为子串会大面积撞词:
  100. // marketingActivity… 里的 mark → 误判成"标记"
  101. // shopSettings… 里的 set → 误判成"设置"
  102. // getTaskCompleteRate 里的 complete → 误判成"完成操作"
  103. // 切成 camelCase 词元后,"marketing" ≠ "mark"、"settings" ≠ "set"、"completeRate" 里的
  104. // "Complete" 虽然等于 "complete",但它前面已经先命中了 "get",按**先出现者为准**判为读。
  105. //
  106. // 为什么"先出现者为准":这类接口的动作动词几乎都在方法名最前面
  107. // (getXxx / queryXxx / createXxx / saveXxx…),后面的多是被操作的业务名词。
  108. const READ_WORDS = new Set([
  109. 'get', 'query', 'list', 'find', 'search', 'page', 'count', 'stat', 'stats', 'statistics',
  110. 'is', 'has', 'exists', 'exist', 'check', 'valid', 'detail', 'details', 'info',
  111. 'view', 'overview', 'gray', 'tree', 'fetch', 'load', 'read', 'preview',
  112. 'export', 'download', 'calc', 'calculate', 'estimate', 'summary', 'insight',
  113. 'trend', 'rank', 'trace', 'config', 'report', 'analysis', 'analyze', 'analyse',
  114. 'dashboard', 'filter',
  115. ]);
  116. // 刻意不收 'permission' / 'authority' / 'total':
  117. // userPermissionChange 这类名字里,动作词在后面,把名词当读词会导致**漏拦写操作**。
  118. // 而带这些名词的读接口(getBrandVersionPermission、queryShopAuthority)本来就有 get/query 打头。
  119. const WRITE_WORDS = new Set([
  120. 'create', 'update', 'delete', 'remove', 'save', 'submit', 'set', 'modify', 'add', 'edit',
  121. 'cancel', 'confirm', 'publish', 'unbind', 'bind', 'enable', 'disable', 'close', 'open',
  122. 'import', 'upload', 'agree', 'reject', 'refund', 'audit', 'apply', 'batch',
  123. 'start', 'stop', 'push', 'send', 'reply', 'mark', 'offline', 'online',
  124. 'transfer', 'revoke', 'grant', 'incr', 'decr', 'deduct', 'pay', 'sign',
  125. 'clear', 'reset', 'finish', 'withdraw', 'freeze', 'sync', 'move', 'sort', 'arrange',
  126. 'copy', 'clone', 'invalid', 'expire', 'lock', 'unlock', 'change', 'switch', 'join', 'quit',
  127. ]);
  128. /**
  129. * 把标识符切成小写词元。
  130. * saveShopInfo → ['save','shop','info']
  131. * marketingActivityEffect → ['marketing','activity','effect']
  132. * IGwShopSummary → ['i','gw','shop','summary']
  133. * mtop….user.set → [...,'user','set']
  134. * h5api → ['h','5api']
  135. */
  136. function wordsOf(text) {
  137. const out = [];
  138. for (const part of String(text || '').split(/[._\-\/\\\s]+/)) {
  139. if (!part) continue;
  140. for (const w of part.split(/(?=[A-Z])|(?<=[a-z])(?=\d)/)) {
  141. if (w) out.push(w.toLowerCase());
  142. }
  143. }
  144. return out;
  145. }
  146. /** 按"先出现者为准"判定;读词与写词都有可能命中时,取位置靠前的那个 */
  147. function detectKind(text) {
  148. for (const w of wordsOf(text)) {
  149. if (READ_WORDS.has(w)) return { kind: 'read', word: w };
  150. if (WRITE_WORDS.has(w)) return { kind: 'write', word: w };
  151. }
  152. return null;
  153. }
  154. /** 取方法名最后一段,例如 ShopSummaryQueryFacade.queryChainSettleListByPage → queryChainSettleListByPage */
  155. function lastSegment(name) {
  156. if (!name) return '';
  157. const parts = String(name).split('.');
  158. return parts[parts.length - 1];
  159. }
  160. /**
  161. * 从 URL 路径里挖方法名。
  162. *
  163. * 大量接口的方法名直接写在路径里,形如:
  164. * /xtop/xtop.napos.keeper.shop.queryShopTree/1.0
  165. * /gw/h5api/alsc-ad-account.IGwShopSummaryCouponService.offlineExistsCouponActivity
  166. * 最后一段往往是版本号(1.0),所以不能只看 path 末尾。
  167. */
  168. function methodFromPath(pathTemplate) {
  169. for (const s of String(pathTemplate || '').split('/').filter(Boolean)) {
  170. if (!s.includes('.')) continue;
  171. if (/^\d+(\.\d+)*$/.test(s)) continue; // 版本号段 1.0 / 2.1
  172. const cand = s.split('.').pop();
  173. if (cand) return cand;
  174. }
  175. return '';
  176. }
  177. /**
  178. * 判定接口是读还是写。
  179. * @returns {{kind:'read'|'write'|'unknown', source:string}}
  180. * unknown = 拿不到任何动作词且不是 GET,无法确认 → 由防护层保守拦截
  181. */
  182. function inferKind(httpMethod, apiMethod, pathTemplate) {
  183. // 1) body / query 里的真实方法名,最可信
  184. if (apiMethod) {
  185. const d = detectKind(lastSegment(apiMethod));
  186. if (d) return { kind: d.kind, source: 'name' };
  187. }
  188. // 2) 路径里自带的方法名(xtop / gw 风格)
  189. const pm = methodFromPath(pathTemplate);
  190. if (pm) {
  191. const d = detectKind(pm);
  192. if (d) return { kind: d.kind, source: 'pathMethod' };
  193. }
  194. // 3) 整个路径的词元(REST 纯名词路径,如 /api/customer/chain/general/chainPrvtOverview)
  195. const d = detectKind(pathTemplate);
  196. if (d) return { kind: d.kind, source: 'path' };
  197. return { kind: httpMethod === 'GET' ? 'read' : 'unknown', source: 'http' };
  198. }
  199. // ------------------------------------------------------------------ 概述
  200. const GENERIC_INVOKER = /\/invoke\/?$|invoke\/?$|iOneRoute|h5api|callFromNapos|nevermore|xtop\//i;
  201. function buildCatalog(records) {
  202. const map = new Map();
  203. const hosts = {};
  204. for (const r of records) {
  205. if (!r || !r.url) continue;
  206. let u;
  207. try { u = new URL(r.url); } catch { continue; }
  208. if (!/^https?:/.test(u.protocol)) continue;
  209. const host = u.hostname;
  210. hosts[host] = (hosts[host] || 0) + 1;
  211. const pt = templatePath(u.pathname);
  212. const body = tryParseJson(r.postData);
  213. // ---- 拆出真实 RPC 方法名
  214. let apiService = '';
  215. let apiMethod = '';
  216. if (body && typeof body === 'object' && !Array.isArray(body)) {
  217. if (typeof body.service === 'string') apiService = body.service;
  218. if (typeof body.method === 'string') apiMethod = body.method;
  219. // iOneRoute:真名在 body.params 里
  220. const inner = body.params && !Array.isArray(body.params) ? body.params : null;
  221. if (inner && typeof inner.method === 'string') {
  222. apiMethod = inner.method;
  223. if (typeof inner.service === 'string' && !apiService) apiService = inner.service;
  224. }
  225. }
  226. if (!apiMethod) {
  227. const qm = u.searchParams.get('method');
  228. if (qm) {
  229. if (qm.includes('.') && !apiService) {
  230. const i = qm.lastIndexOf('.');
  231. apiService = qm.slice(0, i);
  232. apiMethod = qm.slice(i + 1);
  233. } else apiMethod = qm;
  234. }
  235. }
  236. if (!apiService) {
  237. const qs = u.searchParams.get('service');
  238. if (qs) apiService = qs;
  239. }
  240. const isGeneric = GENERIC_INVOKER.test(u.pathname);
  241. const qualified = apiMethod ? (apiService ? `${apiService}.${apiMethod}` : apiMethod) : '';
  242. // 只有通用网关才把方法名并进 key;普通 REST 路径本身就是唯一标识
  243. const key = qualified && isGeneric
  244. ? `${r.method || 'GET'} ${host} ${pt} :: ${qualified}`
  245. : `${r.method || 'GET'} ${host} ${pt}`;
  246. let e = map.get(key);
  247. if (!e) {
  248. e = {
  249. key,
  250. method: r.method || 'GET',
  251. host,
  252. pathTemplate: pt,
  253. scheme: u.protocol.replace(':', ''),
  254. genericInvoker: isGeneric || undefined,
  255. service: apiService || undefined,
  256. rpcMethod: apiMethod || undefined,
  257. count: 0,
  258. statuses: {},
  259. queryKeys: [],
  260. bodyKeys: [],
  261. paramKeys: [],
  262. paramShape: undefined,
  263. shopIds: [],
  264. mimeTypes: [],
  265. pages: [],
  266. noise: isNoise(r.url),
  267. sampleUrl: r.url,
  268. // 请求体样本(截断保存):写操作防护需要它来提取通用网关的真实方法名
  269. sampleBody: r.postData ? String(r.postData).slice(0, 2000) : undefined,
  270. };
  271. map.set(key, e);
  272. }
  273. e.count++;
  274. if (r.status != null) e.statuses[r.status] = (e.statuses[r.status] || 0) + 1;
  275. if (r.mimeType && !e.mimeTypes.includes(r.mimeType)) e.mimeTypes.push(r.mimeType);
  276. if (r.page && !e.pages.includes(r.page)) e.pages.push(r.page);
  277. for (const k of u.searchParams.keys()) {
  278. if (k === '_' || k === 't') continue;
  279. if (!e.queryKeys.includes(k)) e.queryKeys.push(k);
  280. }
  281. if (body && typeof body === 'object' && !Array.isArray(body)) {
  282. for (const k of Object.keys(body)) if (!e.bodyKeys.includes(k)) e.bodyKeys.push(k);
  283. const p = body.params;
  284. if (p !== undefined) {
  285. if (!e.paramShape) e.paramShape = shape(p);
  286. for (const leaf of leafKeys(p)) {
  287. if (!e.paramKeys.includes(leaf) && e.paramKeys.length < 40) e.paramKeys.push(leaf);
  288. }
  289. }
  290. for (const sid of [body.metas && body.metas.shopId, body.shopId].filter((x) => x != null)) {
  291. if (!e.shopIds.includes(sid)) e.shopIds.push(sid);
  292. }
  293. } else if (r.postData && !e.bodyKeys.length) {
  294. e.bodyKeys.push('<form-or-raw>');
  295. }
  296. if (!e.responseShape || e.responseShape === undefined) {
  297. if (r.body) {
  298. const bj = tryParseJson(r.body);
  299. e.responseShape = bj !== undefined ? shape(bj) : `<${r.mimeType || 'raw'} ${String(r.body).length}B>`;
  300. e.responseSample = String(r.body).slice(0, 1500);
  301. }
  302. }
  303. }
  304. const endpoints = [...map.values()];
  305. for (const e of endpoints) {
  306. const k = inferKind(e.method, e.rpcMethod, e.pathTemplate);
  307. e.kind = k.kind;
  308. e.kindSource = k.source;
  309. e.statuses = Object.keys(e.statuses).map(Number).sort();
  310. e.business = !e.noise;
  311. }
  312. endpoints.sort((a, b) => {
  313. if (a.business !== b.business) return a.business ? -1 : 1;
  314. if (a.host !== b.host) return a.host.localeCompare(b.host);
  315. return b.count - a.count;
  316. });
  317. const biz = endpoints.filter((e) => e.business);
  318. // 按业务域归组,方便看"这个后台有哪些能力"
  319. const groups = {};
  320. const GROUP_RULES = [
  321. [/order|weborder|fulfill|delivery|trace|afterSale/i, '订单履约'],
  322. [/goods|item|FoodService|menu|MenuService|spu|sku|deco/i, '商品/门店装修'],
  323. [/shop|keeper|unit|chain|brand|ShopService|ShopSetting|authority/i, '店铺/账号'],
  324. [/marketing|activity|coupon|promotion|ad-|advert|balance|bill|fin|settle|account/i, '营销/财务'],
  325. [/stat|data|report|dashboard|analy|ycm|biz|metric/i, '数据/报表'],
  326. [/customer|user|member|comment|review|rating/i, '顾客/评价'],
  327. [/gray|config|permission|isInGray|framework/i, '灰度/配置'],
  328. ];
  329. for (const e of biz) {
  330. const hay = `${e.host} ${e.pathTemplate} ${e.rpcMethod || ''} ${e.service || ''}`;
  331. let g = '其他';
  332. for (const [re, name] of GROUP_RULES) { if (re.test(hay)) { g = name; break; } }
  333. (groups[g] = groups[g] || []).push(e.key);
  334. }
  335. return {
  336. generatedAt: new Date().toISOString(),
  337. totals: {
  338. records: records.length,
  339. endpoints: endpoints.length,
  340. businessEndpoints: biz.length,
  341. read: biz.filter((e) => e.kind === 'read').length,
  342. write: biz.filter((e) => e.kind === 'write').length,
  343. unknown: biz.filter((e) => e.kind === 'unknown').length,
  344. hosts: Object.keys(hosts).length,
  345. },
  346. hosts,
  347. groups,
  348. endpoints,
  349. };
  350. }
  351. module.exports = {
  352. buildCatalog, templatePath, shape, isNoise, tryParseJson, leafKeys,
  353. inferKind, methodFromPath, lastSegment, wordsOf, detectKind, READ_WORDS, WRITE_WORDS,
  354. };