cdp.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954
  1. 'use strict';
  2. /**
  3. * CDP(Chrome DevTools Protocol)客户端 + 淘宝闪购客户端发现工具。
  4. *
  5. * 连的是 eleme-napos 的 remote-debugging-port(默认 9669)。
  6. *
  7. * ⚠️ 核心设计(v0.2 修正):客户端里同时存在多个「子站」页面,各自属于不同的域:
  8. *
  9. * · 商家后台 melody.shop.ele.me —— 订单 / 商品 / 门店 / 营销
  10. * · 数据中心 melody-stats-next.ele.me —— 生意参谋:流量 / 转化 / 报表
  11. * · 单店看板 napos-dashboard-pc.faas.ele.me
  12. *
  13. * 一个请求必须「由拥有该域的那个页面」发出。
  14. * 若从错误的页面(跨站)发出,服务端会把它判为异常来源 ——
  15. * 现象就是**客户端弹鉴权/验证码,接口回「用户未登录」(RGV587)**。
  16. *
  17. * 所以下面用 APPS 表把「请求域名」映射到「应该由哪个页面发出」,
  18. * 并在拿不到正确页面时 **拒发**(抛 NAPOS_WRONG_ORIGIN),而不是硬发。
  19. */
  20. const { RawWebSocket } = require('./ws');
  21. const guard = require('./guard');
  22. const DEFAULT_PORT = 9669;
  23. const BIZ_HOST_HINT = ['melody.shop.ele.me', 'melody-plus', 'melody-electron'];
  24. /**
  25. * 请求子站路由表(顺序敏感:更具体的域放前面)。
  26. * hosts 该子站「拥有」的请求域名(正则,作用于 hostname)
  27. * pageHints 该子站页面 URL 里会出现的特征串,用于在 CDP target 列表里认人
  28. * openUrl 该子站页面的地址;null = 客户端默认页,无需自己开
  29. * authCarrierHosts 需要额外补鉴权头(如 lsycm 的 token)的域名
  30. */
  31. const APPS = [
  32. {
  33. // ★ 渠道 / 联盟 / 推广 / 金融保险(实测 2026-10-11)
  34. // 三个菜单全部是 r.ele.me 下的跨域 iframe:
  35. // 闪购联盟 → https://r.ele.me/bs-taoke-pc/ 路由 /app/unit/zw-promotion__home
  36. // 店铺推广 → https://r.ele.me/doujin-isv-manage/... 路由 /app/unit/vas__dashboard
  37. // 金融保险 → https://r.ele.me/alsc-enjoy-before-pay-pc/home?…
  38. // ★ 三类鉴权各不相同,且都**不需要**专属 H5 上下文(这点与账单域完全相反):
  39. // · callFromNapos(app-api.shop.ele.me/…/iOneRoute)→ ksid 在 **body.metas.ksid**
  40. // · 推广余额(agw.alsc.taobao.com/gw/h5api/alsc-ad-account.*)→ ksid 请求头
  41. // · 金融(httpizza.ele.me)→ 仅靠 Cookie(同一浏览器会话)
  42. // 实测从商家后台主页面(melody.shop.ele.me)直发即可成功,故 ctxHosts 两个都收。
  43. // ★ 必须排在 shop 之前 —— 否则 shop 的 /ele\.me$/ 会吞掉 r.ele.me。
  44. id: 'channel',
  45. label: '闪购联盟 / 店铺推广 / 金融保险',
  46. hosts: [/(^|\.)r\.ele\.me$/i, /(^|\.)httpizza\.ele\.me$/i],
  47. urlHosts: [
  48. /iOneRoute\.callFromNapos/i,
  49. /alsc-ad-account\./i,
  50. /fn-alsc-fin-ops\./i,
  51. ],
  52. pageHints: ['r.ele.me'],
  53. // ★ 顺序敏感:优先用商家后台主页面上下文 —— 实测 r.ele.me 是 sandbox iframe,
  54. // 其 window 缺 AbortController,且主页面(有完整 localStorage/cookie)发同样成功。
  55. ctxHosts: ['melody.shop.ele.me', 'r.ele.me'],
  56. openUrl: null,
  57. authStyle: 'napos',
  58. },
  59. {
  60. id: 'stats',
  61. label: '数据中心 / 生意参谋',
  62. hosts: [
  63. /(^|\.)lsycm\.alibaba\.com$/i,
  64. /(^|\.)melody-stats-next\.ele\.me$/i,
  65. /(^|\.)napos-dashboard-pc\.faas\.ele\.me$/i,
  66. /(^|\.)sycm\.taobao\.com$/i,
  67. ],
  68. pageHints: ['melody-stats-next.ele.me', 'napos-dashboard-pc.faas.ele.me', 'sycm.taobao.com'],
  69. // 该子站页面/iframe 自己的 origin(精确后缀,用于挑执行上下文)
  70. ctxHosts: ['melody-stats-next.ele.me', 'napos-dashboard-pc.faas.ele.me', 'sycm.taobao.com'],
  71. openUrl: 'https://melody-stats-next.ele.me/new/',
  72. // 实测:数据中心是嵌在商家后台页里的跨域 iframe,挂在 /app/unit/dashboard 这类路由上。
  73. // 子站跑丢时可以把主页面导航回这个路由把它带回来(同源导航,不会触发鉴权)。
  74. openPath: process.env.NAPOS_STATS_PATH || '/app/unit/dashboard',
  75. authCarrierHosts: [/(^|\.)lsycm\.alibaba\.com$/i],
  76. },
  77. {
  78. // ★ 财务管理(账单 / 结算 / 余额)
  79. // 请求打到 agw.alsc.taobao.com(阿里本地生活网关),但它**必须由财务 H5 自己的
  80. // 执行上下文发出** —— 从商家后台页跨站发出会被网关判为未登录(111006 UNLOGIN)。
  81. // 实测(2026-10-11):宿主页 = https://napos-bill-pc.faas.ele.me(商家后台内的 iframe),
  82. // 对应路由 /app/unit/billDetail#app.unit.billDetail。
  83. // 鉴权头(实测抓包):ksid / current-shop-id / X-AGW-BIZ-CONTEXT: currentShopId=<unitId>
  84. id: 'bill',
  85. label: '财务管理(账单 / 结算)',
  86. hosts: [
  87. /(^|\.)agw\.alsc\.taobao\.com$/i,
  88. /(^|\.)napos-bill-pc\.faas\.ele\.me$/i,
  89. ],
  90. pageHints: ['napos-bill-pc.faas.ele.me'],
  91. ctxHosts: ['napos-bill-pc.faas.ele.me'],
  92. openUrl: null,
  93. openPath: process.env.NAPOS_BILL_PATH || '/app/unit/billDetail',
  94. authStyle: 'agw',
  95. agw: { currentShopId: '-1', contentType: 'application/json' },
  96. },
  97. {
  98. // ★ 成长中心(产能分层 / 诊断 / 周报 / 任务)
  99. // 请求打到 agw.ele.me,宿主页 = https://napos-shop-operation-pc.faas.ele.me,
  100. // 对应路由 /app/unit/business-task-new#app.unit.business-task-new。
  101. // 实测:成长 H5 的 ksid 存在 cookie(localStorage 里没有),故凭证探测要多来源。
  102. id: 'growth',
  103. label: '成长中心',
  104. hosts: [
  105. /(^|\.)agw\.ele\.me$/i,
  106. /(^|\.)napos-shop-operation-pc\.faas\.ele\.me$/i,
  107. ],
  108. pageHints: ['napos-shop-operation-pc.faas.ele.me'],
  109. ctxHosts: ['napos-shop-operation-pc.faas.ele.me'],
  110. openUrl: null,
  111. openPath: process.env.NAPOS_GROWTH_PATH || '/app/unit/business-task-new',
  112. authStyle: 'agw',
  113. agw: { currentShopId: '', contentType: 'application/json;charset=UTF-8' },
  114. },
  115. {
  116. id: 'shop',
  117. label: '商家后台',
  118. hosts: [
  119. /(^|\.)melody\.shop\.ele\.me$/i,
  120. /(^|\.)app-api\.shop\.ele\.me$/i,
  121. /(^|\.)napos\.ele\.me$/i,
  122. /melody-plus/i,
  123. /melody-electron/i,
  124. /(^|\.)ele\.me$/i,
  125. ],
  126. pageHints: ['melody.shop.ele.me', 'melody-plus', 'melody-electron'],
  127. ctxHosts: ['melody.shop.ele.me', 'melody-plus', 'melody-electron'],
  128. openUrl: null,
  129. authCarrierHosts: [],
  130. },
  131. ];
  132. const SHOP_APP = APPS.find((a) => a.id === 'shop');
  133. /** fetch 禁止设置的请求头,重放时一律剔除(由浏览器自己填) */
  134. const FORBIDDEN_REQ_HEADERS = new Set([
  135. 'host', 'content-length', 'connection', 'origin', 'referer', 'user-agent',
  136. 'accept-encoding', 'cookie', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-dest',
  137. 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'accept-language',
  138. ]);
  139. /** 风控 / 未登录的响应特征 —— 命中即说明这次访问"触发了鉴权" */
  140. const RISK_RE = /RGV587|FAIL_SYS_USER_VALIDATE|FAIL_SYS_ILLEGAL|punish|captcha|slide/i;
  141. const AUTH_RE = /用户未登录|未登录|NOT_LOGIN|not.?logined|please.?login|sessions?expired/i;
  142. // ------------------------------------------------------------------ 基础
  143. function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
  144. async function httpJson(url, { method = 'GET', timeout = 5000 } = {}) {
  145. const ac = new AbortController();
  146. const t = setTimeout(() => ac.abort(), timeout);
  147. try {
  148. const r = await fetch(url, { method, signal: ac.signal });
  149. if (!r.ok) throw new Error(`HTTP ${r.status}`);
  150. return await r.json();
  151. } finally {
  152. clearTimeout(t);
  153. }
  154. }
  155. /** 探测调试端口是否可用 */
  156. async function probe(port = DEFAULT_PORT) {
  157. try {
  158. const v = await httpJson(`http://127.0.0.1:${port}/json/version`);
  159. return { ok: true, port, version: v };
  160. } catch (e) {
  161. return { ok: false, port, error: e.message };
  162. }
  163. }
  164. async function listTargets(port = DEFAULT_PORT) {
  165. return httpJson(`http://127.0.0.1:${port}/json/list`);
  166. }
  167. // ------------------------------------------------------------------ 子站路由
  168. /** 从 URL 取 hostname(兼容相对路径) */
  169. function hostOf(url) {
  170. try { return new URL(String(url)).hostname; } catch { /* 相对路径 */ }
  171. return String(url).replace(/^\/\//, '').split('/')[0].split(':')[0];
  172. }
  173. /**
  174. * 这个请求地址属于哪个子站?未知返回 null
  175. * ① 先按 hostname 匹配(hosts)
  176. * ② 再按**完整 URL** 匹配(urlHosts)—— 同一个域名下不同业务线走不同宿主/鉴权的场景
  177. * (例:agw.alsc.taobao.com 的「账单」与「推广余额」是两套)
  178. */
  179. function appForUrl(url) {
  180. const h = hostOf(url);
  181. if (!h) return null;
  182. const full = String(url || '');
  183. for (const a of APPS) {
  184. if (a.hosts.some((re) => re.test(h))) return a;
  185. if (a.urlHosts && a.urlHosts.some((re) => re.test(full))) return a;
  186. }
  187. return null;
  188. }
  189. /** 把 'stats' / 一个 URL / null 统一解析成 app 对象(未知回退到商家后台) */
  190. function resolveApp(x) {
  191. if (!x) return SHOP_APP;
  192. if (typeof x === 'object' && x.id) return x;
  193. const s = String(x);
  194. const byId = APPS.find((a) => a.id === s);
  195. if (byId) return byId;
  196. return appForUrl(s) || SHOP_APP;
  197. }
  198. function rankTarget(t) {
  199. const url = t.url || '';
  200. let score = 0;
  201. if (t.type === 'page') score += 10;
  202. if (BIZ_HOST_HINT.some((h) => url.includes(h))) score += 100;
  203. if (/^(https?):/.test(url)) score += 5;
  204. if (url.startsWith('file://')) score -= 50;
  205. if (url.startsWith('devtools://')) score -= 100;
  206. return score;
  207. }
  208. /**
  209. * 只有真正的 document 页面才能承载 fetch。
  210. * service_worker / iframe / 后台页里没有 window,也没有站点的登录态,
  211. * 从那里发请求一律「用户未登录」——必须排除,否则会误判"来源页已就位"。
  212. */
  213. function isPageTarget(t) {
  214. return !!t && t.type === 'page';
  215. }
  216. function targetMatchesApp(t, app) {
  217. return isPageTarget(t) && app.pageHints.some((h) => (t.url || '').includes(h));
  218. }
  219. function rankTargetForApp(t, app) {
  220. const url = t.url || '';
  221. let score = 0;
  222. if (t.type === 'page') score += 10;
  223. if (targetMatchesApp(t, app)) score += 1000;
  224. else if (app === SHOP_APP && BIZ_HOST_HINT.some((h) => url.includes(h))) score += 100;
  225. if (/^(https?):/.test(url)) score += 5;
  226. if (url.startsWith('file://')) score -= 50;
  227. if (url.startsWith('devtools://')) score -= 500;
  228. return score;
  229. }
  230. /** 找到业务页面 target(= 商家后台页,向后兼容) */
  231. async function findBusinessTarget(port = DEFAULT_PORT) {
  232. const all = await listTargets(port);
  233. const usable = all.filter((t) => t.webSocketDebuggerUrl);
  234. if (!usable.length) return null;
  235. usable.sort((a, b) => rankTarget(b) - rankTarget(a));
  236. return usable[0];
  237. }
  238. /**
  239. * 找到「属于某个子站」的 target。
  240. * @returns {{target:object, matched:boolean}|null} matched=false 表示没找到匹配页,返回的是兜底页
  241. */
  242. async function findTargetForApp(port, app) {
  243. const all = (await listTargets(port)).filter((t) => t.webSocketDebuggerUrl && isPageTarget(t));
  244. if (!all.length) return null;
  245. const matched = all.filter((t) => targetMatchesApp(t, app));
  246. if (matched.length) {
  247. matched.sort((a, b) => rankTargetForApp(b, app) - rankTargetForApp(a, app));
  248. return { target: matched[0], matched: true };
  249. }
  250. const pool = all.slice().sort((a, b) => rankTargetForApp(b, app) - rankTargetForApp(a, app));
  251. return { target: pool[0], matched: false };
  252. }
  253. // ------------------------------------------------------------------ CDP
  254. class CDP {
  255. constructor(wsUrl) {
  256. this.wsUrl = wsUrl;
  257. this.ws = null;
  258. this.seq = 0;
  259. this.pending = new Map();
  260. this.handlers = new Map();
  261. this.attached = new Set();
  262. this.dead = false;
  263. }
  264. async open(timeout = 10000) {
  265. this.ws = new RawWebSocket(this.wsUrl);
  266. this.ws.on('message', (m) => this._onMessage(m));
  267. // 必须接住 error/close:底层 socket 出错时 EventEmitter 无人监听 'error'
  268. // 会直接崩掉整个进程;断连后 pending 也要立刻兑现而不是干等超时。
  269. this.ws.on('error', (e) => this._onDead(e instanceof Error ? e : new Error(String(e || 'WebSocket 连接错误'))));
  270. this.ws.on('close', () => this._onDead(new Error('WebSocket 连接已关闭')));
  271. await this.ws.connect(timeout);
  272. return this;
  273. }
  274. /** 连接死亡:把所有在途请求立即以该错误兑现,后续 send 也会快速失败 */
  275. _onDead(err) {
  276. if (this.dead) return;
  277. this.dead = true;
  278. const waiting = [...this.pending.values()];
  279. this.pending.clear();
  280. for (const { reject, timer } of waiting) {
  281. clearTimeout(timer);
  282. reject(err);
  283. }
  284. }
  285. _onMessage(raw) {
  286. let msg;
  287. try { msg = JSON.parse(raw); } catch { return; }
  288. if (msg.id != null && this.pending.has(msg.id)) {
  289. const { resolve, reject, timer } = this.pending.get(msg.id);
  290. clearTimeout(timer);
  291. this.pending.delete(msg.id);
  292. if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`));
  293. else resolve(msg.result);
  294. return;
  295. }
  296. if (msg.method) {
  297. const list = this.handlers.get(msg.method);
  298. if (list) for (const h of list) { try { h(msg.params, msg.sessionId); } catch { /* ignore */ } }
  299. }
  300. }
  301. send(method, params = {}, sessionId) {
  302. return new Promise((resolve, reject) => {
  303. if (this.dead || !this.ws || this.ws.readyState !== 1) {
  304. return reject(new Error(`CDP 连接不可用: ${method}`));
  305. }
  306. const id = ++this.seq;
  307. const payload = { id, method, params };
  308. if (sessionId) payload.sessionId = sessionId;
  309. const timer = setTimeout(() => {
  310. this.pending.delete(id);
  311. reject(new Error(`CDP 调用超时: ${method}`));
  312. }, 90000);
  313. this.pending.set(id, { resolve, reject, timer });
  314. this.ws.send(JSON.stringify(payload));
  315. });
  316. }
  317. on(method, cb) {
  318. if (!this.handlers.has(method)) this.handlers.set(method, []);
  319. this.handlers.get(method).push(cb);
  320. }
  321. async attachTo(targetId) {
  322. const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
  323. this.attached.add(sessionId);
  324. return sessionId;
  325. }
  326. async detach(sessionId) {
  327. try { await this.send('Target.detachFromTarget', { sessionId }); } catch { /* ignore */ }
  328. this.attached.delete(sessionId);
  329. }
  330. close() {
  331. this._onDead(new Error('CDP 连接已关闭'));
  332. try { this.ws && this.ws.close(); } catch { /* ignore */ }
  333. }
  334. }
  335. /**
  336. * 真的把子站页面开出来(新建一个 target,不动用户正在看的那个页面)。
  337. * 这是"正确来源页不存在时就地创建"的能力,用于一次性把数据中心页拉起。
  338. */
  339. async function openAppTarget(port, app) {
  340. if (!app || !app.openUrl) return null;
  341. const p = await probe(port);
  342. if (!p.ok) return null;
  343. const cdp = new CDP(p.version.webSocketDebuggerUrl);
  344. try {
  345. await cdp.open(12000);
  346. } catch {
  347. return null; // 连接没建起来,cdp.close() 里已无 socket 可关
  348. }
  349. try {
  350. let res = null;
  351. for (const opts of [
  352. { url: app.openUrl, newWindow: false, background: true },
  353. { url: app.openUrl, newWindow: true },
  354. { url: app.openUrl },
  355. ]) {
  356. try { res = await cdp.send('Target.createTarget', opts); break; } catch { /* 换下一种开法 */ }
  357. }
  358. return res ? res.targetId : null;
  359. } catch {
  360. return null;
  361. } finally {
  362. cdp.close();
  363. }
  364. }
  365. /** 保证拿到「属于该子站」的页面;没有就尝试开一个并等它就绪 */
  366. async function ensureAppTarget(port, app, { waitMs = 12000 } = {}) {
  367. let found = await findTargetForApp(port, app);
  368. if (found && found.matched) return found;
  369. await openAppTarget(port, app);
  370. const deadline = Date.now() + waitMs;
  371. while (Date.now() < deadline) {
  372. await sleep(700);
  373. const f = await findTargetForApp(port, app);
  374. if (f && f.matched) return f;
  375. }
  376. return found;
  377. }
  378. // ------------------------------------------------------------------ 执行上下文
  379. /**
  380. * ★ 核心:子站不一定是一个独立窗口。
  381. * 实测「数据中心」就是嵌在商家后台页面里的**跨域 iframe** ——
  382. * 在 CDP 里它不产生新 target,只产生一个新的 executionContext。
  383. *
  384. * 于是"从正确的来源发请求" = 在**域匹配的那个执行上下文里**跑 fetch。
  385. * 从外层页面(melody.shop.ele.me)发出去就是跨站请求:服务端判为异常来源 →
  386. * 客户端弹鉴权/验证码,或请求直接挂死(既不返回也不报错)。
  387. */
  388. async function collectContexts(cdp, sessionId, ms = 800) {
  389. const map = new Map();
  390. cdp.on('Runtime.executionContextCreated', (pa) => {
  391. const ctx = pa && pa.context;
  392. if (!ctx || !ctx.origin || !/^https?:/i.test(ctx.origin)) return;
  393. const aux = ctx.auxData || {};
  394. if (aux.isDefault === false || aux.type === 'isolated') return; // 跳过 Electron 隔离上下文
  395. if (!map.has(ctx.origin)) map.set(ctx.origin, ctx);
  396. });
  397. try { await cdp.send('Runtime.enable', {}, sessionId); } catch { /* ignore */ }
  398. await sleep(ms);
  399. return map;
  400. }
  401. /** 找一个"域属于该请求"的执行上下文(按 ctxHosts 声明顺序,越靠前越优先) */
  402. function contextForUrl(ctxMap, url) {
  403. const app = appForUrl(url);
  404. if (!app || !ctxMap) return null;
  405. const hints = app.ctxHosts || app.pageHints || [];
  406. for (const hint of hints) {
  407. for (const [origin, ctx] of ctxMap) {
  408. let h;
  409. try { h = new URL(origin).hostname; } catch { continue; }
  410. if (h === hint || h.endsWith('.' + hint)) return ctx;
  411. }
  412. }
  413. return null;
  414. }
  415. /**
  416. * 某个执行上下文的 origin 是不是"这个子站自己的页面"。
  417. * 用精确的 ctxHosts 后缀匹配 —— 不能用 APPS.hosts 那套宽泛正则,
  418. * 否则 shop 的 `ele\.me$` 会把 napos-dashboard-pc.faas.ele.me 也吞进去。
  419. */
  420. function ctxMatchesApp(origin, app) {
  421. let h;
  422. try { h = new URL(origin).hostname; } catch { return false; }
  423. const hints = app.ctxHosts || app.pageHints || [];
  424. return hints.some((s) => h === s || h.endsWith('.' + s));
  425. }
  426. /** 该子站现在能不能发请求?(独立页面 target,或页面里存在域匹配的执行上下文) */
  427. function appUsable(app, found, ctxMap) {
  428. if (found && found.matched) return true;
  429. for (const origin of (ctxMap ? ctxMap.keys() : [])) {
  430. if (ctxMatchesApp(origin, app)) return true;
  431. }
  432. return false;
  433. }
  434. function wrongOriginError(app, found, ctxMap) {
  435. const ctxs = [...(ctxMap ? ctxMap.keys() : [])];
  436. const err = new Error(
  437. `🛑 拒绝从错误的来源发请求 —— 这正是"每次调用都触发客户端鉴权"的原因。\n` +
  438. ` 该请求属于「${app.label}」,必须由这个域自己的页面/上下文发出。\n` +
  439. ` 当前页面:${(found && found.target && found.target.url) || '-'}\n` +
  440. ` 可用执行上下文:${ctxs.length ? ctxs.join(' , ') : '(无)'}\n` +
  441. ` 修复:在客户端打开「${app.label}」页面` +
  442. (app.openPath ? `,或让本工具导航过去(napos_call 传 autoOpen=true)` : '') + '。'
  443. );
  444. err.code = 'NAPOS_WRONG_ORIGIN';
  445. err.app = app.id;
  446. err.availableContexts = ctxs;
  447. return err;
  448. }
  449. /**
  450. * 就地打开子站。
  451. * ① 优先新开一个 target(标准 Chromium 支持;本机 Electron 94 实测返回 "Not supported")
  452. * ② 退回:把商家后台页导航到子站内嵌路由(同源导航,不会触发鉴权)
  453. */
  454. async function tryOpenApp(port, app, fallbackTarget) {
  455. const p = await probe(port);
  456. if (!p.ok) return false;
  457. if (app.openUrl) {
  458. const id = await openAppTarget(port, app);
  459. if (id) return true;
  460. }
  461. if (!app.openPath || !fallbackTarget) return false;
  462. const cdp = new CDP(p.version.webSocketDebuggerUrl);
  463. try {
  464. await cdp.open(12000);
  465. const sessionId = await cdp.attachTo(fallbackTarget.id);
  466. const url = `https://melody.shop.ele.me${app.openPath}`;
  467. await cdp.send('Runtime.evaluate', {
  468. expression: `location.href = ${JSON.stringify(url)}`,
  469. userGesture: true,
  470. }, sessionId);
  471. await cdp.detach(sessionId).catch(() => {});
  472. return true;
  473. } catch {
  474. return false;
  475. } finally {
  476. cdp.close();
  477. }
  478. }
  479. /**
  480. * 连接「某个子站的页面」并执行 fn(api)。
  481. * api = { cdp, sessionId, targetId, target, app, contexts, contextFor, send, on, evaluate }
  482. *
  483. * @param {*} urlOrApp URL(按域名自动选)或 app id('stats' / 'shop')
  484. * @param {{needExact?:boolean, autoOpen?:boolean, timeout?:number}} opts
  485. * needExact=true 时,若该子站没有可用的页面/上下文 → 抛 NAPOS_WRONG_ORIGIN(拒绝硬发)
  486. */
  487. async function withPageFor(port, urlOrApp, fn, { needExact = false, autoOpen = true, timeout = 15000 } = {}) {
  488. const p = await probe(port);
  489. if (!p.ok) {
  490. throw new Error(
  491. `调试端口 ${port} 不可用(${p.error})。\n` +
  492. `请先完全退出淘宝闪购客户端,再用 --remote-debugging-port=${port} 重新启动。`
  493. );
  494. }
  495. const app = resolveApp(urlOrApp);
  496. let lastFound = null;
  497. let lastCtx = new Map();
  498. for (let attempt = 1; attempt <= 3; attempt++) {
  499. const found = await findTargetForApp(port, app);
  500. if (!found || !found.target) {
  501. throw new Error('没有找到可用的页面 target(客户端可能还没加载完业务页面)');
  502. }
  503. const cdp = new CDP(p.version.webSocketDebuggerUrl);
  504. let sessionId;
  505. try {
  506. await cdp.open(timeout);
  507. sessionId = await cdp.attachTo(found.target.id);
  508. } catch (e) {
  509. // 建连/附加失败必须关掉半开连接,否则 socket 泄漏拖住进程退不出
  510. cdp.close();
  511. throw new Error(`CDP 连接失败(第 ${attempt} 次尝试):${(e && e.message) || e}`);
  512. }
  513. const ctxMap = await collectContexts(cdp, sessionId, attempt === 1 ? 800 : 1500);
  514. lastFound = found;
  515. lastCtx = ctxMap;
  516. const usable = appUsable(app, found, ctxMap);
  517. if (usable || !needExact) {
  518. const s = (m, params) => cdp.send(m, params, sessionId);
  519. const api = {
  520. cdp, sessionId, targetId: found.target.id, target: found.target, app,
  521. exact: found.matched, contexts: ctxMap,
  522. contextFor: (u) => contextForUrl(ctxMap, u),
  523. send: s,
  524. on: (m, cb) => cdp.on(m, cb),
  525. async evaluate(expression, { awaitPromise = true, returnByValue = true, contextId } = {}) {
  526. const params = { expression, awaitPromise, returnByValue, userGesture: true, includeCommandLineAPI: true };
  527. if (contextId) params.contextId = contextId;
  528. const r = await s('Runtime.evaluate', params);
  529. if (r.exceptionDetails) {
  530. const d = r.exceptionDetails;
  531. throw new Error(`页面内执行出错: ${d.exception?.description || d.text}`);
  532. }
  533. return r.result?.value;
  534. },
  535. };
  536. try {
  537. return await fn(api);
  538. } finally {
  539. await cdp.detach(sessionId).catch(() => {});
  540. cdp.close();
  541. }
  542. }
  543. // ❌ 来源不对 → 断开、尝试就地打开、重试
  544. await cdp.detach(sessionId).catch(() => {});
  545. cdp.close();
  546. if (!autoOpen || attempt === 3) break;
  547. const opened = await tryOpenApp(port, app, found.target);
  548. if (!opened) break;
  549. await sleep(2500);
  550. }
  551. throw wrongOriginError(app, lastFound, lastCtx);
  552. }
  553. /** 向后兼容:等价于「连商家后台页」 */
  554. async function withBusinessPage(port, fn, opts = {}) {
  555. return withPageFor(port, SHOP_APP, fn, { ...opts, needExact: false });
  556. }
  557. // ------------------------------------------------------------------ 鉴权头
  558. /**
  559. * 在页面里装一个「请求头采集器」:把该页面对目标域名发出的真实请求头记下来。
  560. * 页面自己发出的请求一定带齐了服务端要的鉴权(token / bx-et …),
  561. * 我们照抄即可 —— 比猜参数可靠得多。
  562. */
  563. function captureInstallCode(host) {
  564. return `(() => {
  565. try {
  566. if (!window.__naposCap || !window.__naposCap.on) {
  567. const cap = { on: true, seen: {} };
  568. window.__naposCap = cap;
  569. const TARGET = ${JSON.stringify(String(host))};
  570. const isT = (u) => { try { return String(u || '').indexOf(TARGET) !== -1; } catch (e) { return false; } };
  571. const norm = (h) => {
  572. const o = {};
  573. try {
  574. if (h && typeof h.forEach === 'function') h.forEach((v, k) => { o[String(k)] = String(v); });
  575. else if (h && typeof h === 'object') Object.keys(h).forEach((k) => { o[String(k)] = String(h[k]); });
  576. } catch (e) {}
  577. return o;
  578. };
  579. const rec = (u, h) => {
  580. try {
  581. if (!isT(u)) return;
  582. const key = String(u).split('?')[0];
  583. if (!cap.seen[key]) cap.seen[key] = { h: norm(h), url: String(u), t: Date.now() };
  584. } catch (e) {}
  585. };
  586. const of = window.fetch;
  587. if (of) {
  588. window.fetch = function (input, init) {
  589. try { rec(typeof input === 'string' ? input : (input && input.url), (init && init.headers) || (input && input.headers)); } catch (e) {}
  590. return of.apply(this, arguments);
  591. };
  592. }
  593. const XHR = window.XMLHttpRequest;
  594. if (XHR && XHR.prototype) {
  595. const ox = XHR.prototype.open, os = XHR.prototype.send, oh = XHR.prototype.setRequestHeader;
  596. XHR.prototype.setRequestHeader = function (k, v) {
  597. try { this.__nh = this.__nh || {}; this.__nh[k] = v; } catch (e) {}
  598. return oh.apply(this, arguments);
  599. };
  600. XHR.prototype.open = function (m, u) { try { this.__nu = u; } catch (e) {} return ox.apply(this, arguments); };
  601. XHR.prototype.send = function () {
  602. try { rec(this.__nu, this.__nh); } catch (e) {}
  603. return os.apply(this, arguments);
  604. };
  605. }
  606. }
  607. return Object.keys(window.__naposCap.seen || {});
  608. } catch (e) { return []; }
  609. })()`;
  610. }
  611. function captureReadCode() {
  612. return `(() => {
  613. try {
  614. const cap = window.__naposCap;
  615. if (!cap || !cap.seen) return null;
  616. const keys = Object.keys(cap.seen);
  617. if (!keys.length) return null;
  618. const merged = {};
  619. for (const k of keys) Object.assign(merged, (cap.seen[k] || {}).h || {});
  620. return merged;
  621. } catch (e) { return null; }
  622. })()`;
  623. }
  624. /**
  625. * 兜底:找 ksid。
  626. * ⚠️ 关键:ksid 往往只存在于**外层页面**的 localStorage / Cookie,
  627. * 子站 iframe 自己那份可能没有 —— 所以要在所有上下文里挨个找。
  628. * 抓包实证:真实请求带的鉴权头就是 `token: <ksid>=-1`。
  629. */
  630. const KSID_PROBE = `(() => {
  631. const out = { ksid: null, source: null };
  632. const g = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
  633. const s = (k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } };
  634. for (const k of ['ksid','KSID','ksid_chain','napos_ksid','chainKsid','_ksid']) {
  635. const v = g(k) || s(k);
  636. if (v && String(v).length > 8) { out.ksid = String(v); out.source = 'storage:' + k; return out; }
  637. }
  638. try {
  639. const m = (document.cookie || '').match(/(?:^|;\\s*)ksid=([^;]+)/);
  640. if (m && m[1] && m[1].length > 8) { out.ksid = decodeURIComponent(m[1]); out.source = 'cookie'; return out; }
  641. } catch (e) {}
  642. return out;
  643. })()`;
  644. /**
  645. * ★ agw 网关(财务 / 成长)的凭证探测。
  646. * 实测:两个 H5 子站的登录态都是同一枚 ksid,但**存放位置不同**
  647. * (财务在 localStorage.ksid,成长只在 cookie.ksid),
  648. * 所以这里按 localStorage → sessionStorage → cookie 依次兜底。
  649. * 同时取 unitId 用于拼业务上下文头 `X-AGW-BIZ-CONTEXT: currentShopId=<unitId>`。
  650. */
  651. const AGW_CRED_PROBE = `(() => {
  652. try {
  653. const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } };
  654. const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } };
  655. const ksid = g(localStorage, 'ksid') || g(sessionStorage, 'ksid') || ck('ksid');
  656. const unitId = g(localStorage, 'unitId') || g(sessionStorage, 'unitId') || ck('unitId');
  657. const shopId = g(localStorage, 'shopId') || g(sessionStorage, 'shopId') || ck('shopId');
  658. return { ksid: ksid ? String(ksid) : null, unitId: unitId || null, shopId: shopId || null };
  659. } catch (e) { return null; }
  660. })()`;
  661. const CHANNEL_ID_PROBE = `(() => {
  662. try {
  663. const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } };
  664. const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } };
  665. const one = (k) => g(localStorage, k) || g(sessionStorage, k) || ck(k);
  666. return {
  667. ksid: one('ksid') || null,
  668. unitId: one('unitId') || null,
  669. keeperId: one('keeperId') || null,
  670. loginShopId: one('loginShopId') || null,
  671. shopName: g(localStorage, 'shopName') || null,
  672. };
  673. } catch (e) { return null; }
  674. })()`;
  675. /** 在所有可用的执行上下文(含外层页面)里找 ksid */
  676. async function findKsid(api) {
  677. const ids = [];
  678. if (api && api.contexts && api.contexts.size) for (const c of api.contexts.values()) ids.push(c.id);
  679. ids.push(undefined); // 再加一次默认上下文兜底
  680. for (const contextId of ids) {
  681. try {
  682. const v = await api.evaluate(KSID_PROBE, contextId ? { contextId } : undefined);
  683. if (v && v.ksid) return v.ksid;
  684. } catch { /* 换下一个上下文 */ }
  685. }
  686. return null;
  687. }
  688. /**
  689. * 为一个跨域数据接口凑齐鉴权头。
  690. * ① 照抄「该子站自己发出的真实请求头」—— 最准
  691. * ② 兜底:跨上下文找 ksid,按 `token: <ksid>=-1` 拼出来(格式来自真实抓包)
  692. */
  693. async function buildAuthHeaders(api, url) {
  694. const app = appForUrl(url);
  695. if (!app) return {};
  696. // ★ 渠道风格(闪购联盟 / 店铺推广 / 金融保险)
  697. // 鉴权分两种,**按域名区分**(实测血泪):
  698. // · agw 网关(推广余额)→ ksid 请求头
  699. // · app-api.shop.ele.me 的 callFromNapos → **不能带 ksid 头**!它只允许 content-type
  700. // (CORS 白名单里没有 ksid),ksid 必须放进 body.metas(由 kit 层注入);
  701. // · httpizza.ele.me(金融)→ 只靠 Cookie,无需任何自定义头。
  702. if (app.authStyle === 'napos') {
  703. const host = hostOf(url);
  704. const out = { Accept: 'application/json, text/plain, */*' };
  705. if (/(^|\.)agw\./i.test(host)) {
  706. const ksid = await findKsid(api);
  707. if (ksid) out.ksid = String(ksid);
  708. }
  709. return out;
  710. }
  711. // ★ agw 风格(财务管理 / 成长中心)
  712. // 与 lsycm 的 `token: <ksid>=-1` 完全不同 —— 它要的是
  713. // ksid: <H5 自己的 ksid>
  714. // current-shop-id: <-1 | ''>
  715. // X-AGW-BIZ-CONTEXT: currentShopId=<unitId>
  716. // 凭证一律「在该子站自己的执行上下文里」运行时读取,不硬编码。
  717. if (app.authStyle === 'agw') {
  718. const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null;
  719. const evalOpt = ctx ? { contextId: ctx.id } : undefined;
  720. let cred = null;
  721. try { cred = await api.evaluate(AGW_CRED_PROBE, evalOpt); } catch { /* ignore */ }
  722. if (!cred || !cred.ksid) return {};
  723. const out = {
  724. ksid: String(cred.ksid),
  725. 'current-shop-id': (app.agw && app.agw.currentShopId != null) ? String(app.agw.currentShopId) : '',
  726. };
  727. const unit = cred.unitId || cred.shopId;
  728. if (unit) out['X-AGW-BIZ-CONTEXT'] = 'currentShopId=' + unit;
  729. if (app.agw && app.agw.contentType) out['Content-Type'] = app.agw.contentType;
  730. out.Accept = 'application/json, text/plain, */*';
  731. return out;
  732. }
  733. if (!app.authCarrierHosts || !app.authCarrierHosts.length) return {};
  734. const host = hostOf(url);
  735. if (!app.authCarrierHosts.some((re) => re.test(host))) return {};
  736. // ★ 必须在「属于该域的上下文」里采集 —— 外层页面的请求头跟子站不是一套
  737. const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null;
  738. const evalOpt = ctx ? { contextId: ctx.id } : undefined;
  739. let captured = null;
  740. try { await api.evaluate(captureInstallCode(host), evalOpt); } catch { /* 忽略 */ }
  741. try { captured = await api.evaluate(captureReadCode(), evalOpt); } catch { /* 忽略 */ }
  742. const out = {};
  743. if (captured && typeof captured === 'object') {
  744. for (const [k, v] of Object.entries(captured)) {
  745. const lk = String(k).toLowerCase();
  746. if (FORBIDDEN_REQ_HEADERS.has(lk)) continue;
  747. if (lk === 'content-type' || lk === 'accept') continue; // 由调用方决定
  748. out[k] = String(v);
  749. }
  750. }
  751. const hasToken = Object.keys(out).some((k) => k.toLowerCase() === 'token');
  752. if (!hasToken) {
  753. const ksid = await findKsid(api);
  754. if (ksid) out.token = `${ksid}=-1`;
  755. }
  756. return out;
  757. }
  758. // ------------------------------------------------------------------ 请求节流
  759. let _chain = Promise.resolve();
  760. let _lastReqAt = 0;
  761. const MIN_GAP_MS = Number(process.env.NAPOS_MIN_GAP_MS || 300);
  762. /** 串行 + 最小间隔:短时间高频请求本身就是触发风控的主因之一 */
  763. function throttle() {
  764. const p = _chain.then(async () => {
  765. const wait = _lastReqAt + MIN_GAP_MS - Date.now();
  766. if (wait > 0) await sleep(wait);
  767. _lastReqAt = Date.now();
  768. });
  769. _chain = p.catch(() => {});
  770. return p;
  771. }
  772. // ------------------------------------------------------------------ 发请求
  773. /**
  774. * 刷新请求体里的一次性 id。
  775. *
  776. * 后台的 RPC 调用普遍带 `"id": "<32位hex>|<毫秒时间戳>"`,
  777. * 服务端会校验/去重,直接重放旧值可能返回 SERVER_ERROR。
  778. * 这里把符合该模式的 id 重新生成。
  779. */
  780. function refreshRequestId(body) {
  781. if (typeof body !== 'string') return body;
  782. const t = body.trim();
  783. if (!t || t[0] !== '{') return body;
  784. let obj;
  785. try { obj = JSON.parse(t); } catch { return body; }
  786. let changed = false;
  787. const REGEN = /^[0-9a-f]{32}\|\d+$/i;
  788. const walk = (v) => {
  789. if (Array.isArray(v)) { v.forEach(walk); return; }
  790. if (!v || typeof v !== 'object') return;
  791. for (const k of Object.keys(v)) {
  792. if (k === 'id' && typeof v[k] === 'string' && REGEN.test(v[k])) {
  793. const hex = Array.from({ length: 32 }, () => '0123456789ABCDEF'[Math.floor(Math.random() * 16)]).join('');
  794. v[k] = `${hex}|${Date.now()}`;
  795. changed = true;
  796. } else walk(v[k]);
  797. }
  798. };
  799. walk(obj);
  800. return changed ? JSON.stringify(obj) : body;
  801. }
  802. /**
  803. * 用业务页面的身份发一次请求。
  804. *
  805. * v0.2 修正(解决"每次调用都触发客户端鉴权"):
  806. * · 执行上下文路由 —— 在**域匹配的那个 iframe 上下文**里发请求(关键修复)。
  807. * 子站是跨域 iframe,从外层页面发就是跨站 → 鉴权/风控
  808. * · credentials: 'include' —— 跨子域时才会带 Cookie(原实现 same-origin,等于一个都不带)
  809. * · 自动补鉴权头 —— 从子站自己的真实请求里抄 token / bx-et
  810. * · 请求串行 + 最小间隔 —— 避免高频请求触发风控
  811. * · 请求超时 —— 被风控挂死时快速失败,不再卡 90 秒
  812. * · 识别风控/未登录响应 —— 用 risk / authFail 字段明确回报,不再假装成功
  813. *
  814. * 🔒 写操作防护:所有出站请求都必须先过 guard(默认只放行读接口)。
  815. */
  816. async function pageFetch(api, {
  817. method = 'GET', url, body, headers = {}, raw = false,
  818. allowWrite, guardIndex, authHeaders = true, timeoutMs,
  819. }) {
  820. const M = String(method || 'GET').toUpperCase();
  821. // ---- 写操作防护(唯一出口,拦在这里就够了)
  822. guard.assertAllowed({ method: M, url, body }, { index: guardIndex, allowWrite });
  823. await throttle();
  824. const extra = authHeaders === false ? {} : await buildAuthHeaders(api, url);
  825. const payload = {
  826. method: M,
  827. credentials: 'include',
  828. headers: { 'Content-Type': 'application/json', ...extra, ...headers },
  829. };
  830. if (body != null && M !== 'GET' && M !== 'HEAD') {
  831. payload.body = typeof body === 'string' ? refreshRequestId(body) : JSON.stringify(body);
  832. }
  833. // ★ 关键:把请求放进"拥有该域的那个上下文"里执行
  834. const wantApp = appForUrl(url);
  835. const ctx = (typeof api.contextFor === 'function') ? api.contextFor(url) : null;
  836. const originMismatch = !!(wantApp && wantApp !== SHOP_APP && !ctx);
  837. const pageUrl = (api && api.target && api.target.url) || '';
  838. const ms = Number(timeoutMs || process.env.NAPOS_REQ_TIMEOUT_MS || 30000);
  839. const code = `
  840. (async () => {
  841. const AC = (typeof AbortController !== 'undefined') ? AbortController : null;
  842. const ctl = AC ? new AC() : null;
  843. const timer = ctl ? setTimeout(() => ctl.abort(), ${ms}) : null;
  844. try {
  845. const res = await fetch(${JSON.stringify(url)}, Object.assign(${JSON.stringify(payload)}, ctl ? { signal: ctl.signal } : {}));
  846. const text = await res.text();
  847. let data;
  848. let parsed = true;
  849. try { data = JSON.parse(text); } catch (e) { parsed = false; data = text.slice(0, 200000); }
  850. if (timer) clearTimeout(timer);
  851. return { ok: res.ok, status: res.status, url: res.url, parsed, data, pageUrl: location.href, origin: location.origin };
  852. } catch (e) {
  853. if (timer) clearTimeout(timer);
  854. return { ok: false, timeout: String(e && e.name) === 'AbortError', error: String((e && e.message) || e), pageUrl: location.href, origin: location.origin };
  855. }
  856. })()
  857. `;
  858. const out = await api.evaluate(code, ctx ? { contextId: ctx.id } : undefined);
  859. if (out && typeof out === 'object') {
  860. const probeText = typeof out.data === 'string' ? out.data : JSON.stringify(out.data || '');
  861. out.risk = RISK_RE.test(probeText);
  862. out.authFail = AUTH_RE.test(probeText);
  863. out.pageUrl = out.pageUrl || pageUrl;
  864. out.contextOrigin = ctx ? ctx.origin : null;
  865. if (originMismatch) out.originMismatch = true;
  866. }
  867. return out;
  868. }
  869. module.exports = {
  870. DEFAULT_PORT, BIZ_HOST_HINT, APPS, FORBIDDEN_REQ_HEADERS,
  871. httpJson, sleep, probe, listTargets,
  872. hostOf, appForUrl, resolveApp, contextForUrl, appUsable, ctxMatchesApp,
  873. findBusinessTarget, findTargetForApp, ensureAppTarget, openAppTarget, tryOpenApp,
  874. rankTarget, rankTargetForApp, targetMatchesApp, isPageTarget,
  875. CDP, withBusinessPage, withPageFor, pageFetch, refreshRequestId,
  876. buildAuthHeaders, throttle,
  877. // ★ 供 skill / kit.js 运行时读取登录态用(跨执行上下文查找,不硬编码)
  878. findKsid, KSID_PROBE, AGW_CRED_PROBE, CHANNEL_ID_PROBE,
  879. };