cdp.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  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. }
  263. async open(timeout = 10000) {
  264. this.ws = new RawWebSocket(this.wsUrl);
  265. this.ws.on('message', (m) => this._onMessage(m));
  266. await this.ws.connect(timeout);
  267. return this;
  268. }
  269. _onMessage(raw) {
  270. let msg;
  271. try { msg = JSON.parse(raw); } catch { return; }
  272. if (msg.id != null && this.pending.has(msg.id)) {
  273. const { resolve, reject, timer } = this.pending.get(msg.id);
  274. clearTimeout(timer);
  275. this.pending.delete(msg.id);
  276. if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`));
  277. else resolve(msg.result);
  278. return;
  279. }
  280. if (msg.method) {
  281. const list = this.handlers.get(msg.method);
  282. if (list) for (const h of list) { try { h(msg.params, msg.sessionId); } catch { /* ignore */ } }
  283. }
  284. }
  285. send(method, params = {}, sessionId) {
  286. return new Promise((resolve, reject) => {
  287. const id = ++this.seq;
  288. const payload = { id, method, params };
  289. if (sessionId) payload.sessionId = sessionId;
  290. const timer = setTimeout(() => {
  291. this.pending.delete(id);
  292. reject(new Error(`CDP 调用超时: ${method}`));
  293. }, 90000);
  294. this.pending.set(id, { resolve, reject, timer });
  295. this.ws.send(JSON.stringify(payload));
  296. });
  297. }
  298. on(method, cb) {
  299. if (!this.handlers.has(method)) this.handlers.set(method, []);
  300. this.handlers.get(method).push(cb);
  301. }
  302. async attachTo(targetId) {
  303. const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
  304. this.attached.add(sessionId);
  305. return sessionId;
  306. }
  307. async detach(sessionId) {
  308. try { await this.send('Target.detachFromTarget', { sessionId }); } catch { /* ignore */ }
  309. this.attached.delete(sessionId);
  310. }
  311. close() { try { this.ws && this.ws.close(); } catch { /* ignore */ } }
  312. }
  313. /**
  314. * 真的把子站页面开出来(新建一个 target,不动用户正在看的那个页面)。
  315. * 这是"正确来源页不存在时就地创建"的能力,用于一次性把数据中心页拉起。
  316. */
  317. async function openAppTarget(port, app) {
  318. if (!app || !app.openUrl) return null;
  319. const p = await probe(port);
  320. if (!p.ok) return null;
  321. const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
  322. try {
  323. let res = null;
  324. for (const opts of [
  325. { url: app.openUrl, newWindow: false, background: true },
  326. { url: app.openUrl, newWindow: true },
  327. { url: app.openUrl },
  328. ]) {
  329. try { res = await cdp.send('Target.createTarget', opts); break; } catch { /* 换下一种开法 */ }
  330. }
  331. return res ? res.targetId : null;
  332. } catch {
  333. return null;
  334. } finally {
  335. cdp.close();
  336. }
  337. }
  338. /** 保证拿到「属于该子站」的页面;没有就尝试开一个并等它就绪 */
  339. async function ensureAppTarget(port, app, { waitMs = 12000 } = {}) {
  340. let found = await findTargetForApp(port, app);
  341. if (found && found.matched) return found;
  342. await openAppTarget(port, app);
  343. const deadline = Date.now() + waitMs;
  344. while (Date.now() < deadline) {
  345. await sleep(700);
  346. const f = await findTargetForApp(port, app);
  347. if (f && f.matched) return f;
  348. }
  349. return found;
  350. }
  351. // ------------------------------------------------------------------ 执行上下文
  352. /**
  353. * ★ 核心:子站不一定是一个独立窗口。
  354. * 实测「数据中心」就是嵌在商家后台页面里的**跨域 iframe** ——
  355. * 在 CDP 里它不产生新 target,只产生一个新的 executionContext。
  356. *
  357. * 于是"从正确的来源发请求" = 在**域匹配的那个执行上下文里**跑 fetch。
  358. * 从外层页面(melody.shop.ele.me)发出去就是跨站请求:服务端判为异常来源 →
  359. * 客户端弹鉴权/验证码,或请求直接挂死(既不返回也不报错)。
  360. */
  361. async function collectContexts(cdp, sessionId, ms = 800) {
  362. const map = new Map();
  363. cdp.on('Runtime.executionContextCreated', (pa) => {
  364. const ctx = pa && pa.context;
  365. if (!ctx || !ctx.origin || !/^https?:/i.test(ctx.origin)) return;
  366. const aux = ctx.auxData || {};
  367. if (aux.isDefault === false || aux.type === 'isolated') return; // 跳过 Electron 隔离上下文
  368. if (!map.has(ctx.origin)) map.set(ctx.origin, ctx);
  369. });
  370. try { await cdp.send('Runtime.enable', {}, sessionId); } catch { /* ignore */ }
  371. await sleep(ms);
  372. return map;
  373. }
  374. /** 找一个"域属于该请求"的执行上下文(按 ctxHosts 声明顺序,越靠前越优先) */
  375. function contextForUrl(ctxMap, url) {
  376. const app = appForUrl(url);
  377. if (!app || !ctxMap) return null;
  378. const hints = app.ctxHosts || app.pageHints || [];
  379. for (const hint of hints) {
  380. for (const [origin, ctx] of ctxMap) {
  381. let h;
  382. try { h = new URL(origin).hostname; } catch { continue; }
  383. if (h === hint || h.endsWith('.' + hint)) return ctx;
  384. }
  385. }
  386. return null;
  387. }
  388. /**
  389. * 某个执行上下文的 origin 是不是"这个子站自己的页面"。
  390. * 用精确的 ctxHosts 后缀匹配 —— 不能用 APPS.hosts 那套宽泛正则,
  391. * 否则 shop 的 `ele\.me$` 会把 napos-dashboard-pc.faas.ele.me 也吞进去。
  392. */
  393. function ctxMatchesApp(origin, app) {
  394. let h;
  395. try { h = new URL(origin).hostname; } catch { return false; }
  396. const hints = app.ctxHosts || app.pageHints || [];
  397. return hints.some((s) => h === s || h.endsWith('.' + s));
  398. }
  399. /** 该子站现在能不能发请求?(独立页面 target,或页面里存在域匹配的执行上下文) */
  400. function appUsable(app, found, ctxMap) {
  401. if (found && found.matched) return true;
  402. for (const origin of (ctxMap ? ctxMap.keys() : [])) {
  403. if (ctxMatchesApp(origin, app)) return true;
  404. }
  405. return false;
  406. }
  407. function wrongOriginError(app, found, ctxMap) {
  408. const ctxs = [...(ctxMap ? ctxMap.keys() : [])];
  409. const err = new Error(
  410. `🛑 拒绝从错误的来源发请求 —— 这正是"每次调用都触发客户端鉴权"的原因。\n` +
  411. ` 该请求属于「${app.label}」,必须由这个域自己的页面/上下文发出。\n` +
  412. ` 当前页面:${(found && found.target && found.target.url) || '-'}\n` +
  413. ` 可用执行上下文:${ctxs.length ? ctxs.join(' , ') : '(无)'}\n` +
  414. ` 修复:在客户端打开「${app.label}」页面` +
  415. (app.openPath ? `,或让本工具导航过去(napos_call 传 autoOpen=true)` : '') + '。'
  416. );
  417. err.code = 'NAPOS_WRONG_ORIGIN';
  418. err.app = app.id;
  419. err.availableContexts = ctxs;
  420. return err;
  421. }
  422. /**
  423. * 就地打开子站。
  424. * ① 优先新开一个 target(标准 Chromium 支持;本机 Electron 94 实测返回 "Not supported")
  425. * ② 退回:把商家后台页导航到子站内嵌路由(同源导航,不会触发鉴权)
  426. */
  427. async function tryOpenApp(port, app, fallbackTarget) {
  428. const p = await probe(port);
  429. if (!p.ok) return false;
  430. if (app.openUrl) {
  431. const id = await openAppTarget(port, app);
  432. if (id) return true;
  433. }
  434. if (!app.openPath || !fallbackTarget) return false;
  435. const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
  436. try {
  437. const sessionId = await cdp.attachTo(fallbackTarget.id);
  438. const url = `https://melody.shop.ele.me${app.openPath}`;
  439. await cdp.send('Runtime.evaluate', {
  440. expression: `location.href = ${JSON.stringify(url)}`,
  441. userGesture: true,
  442. }, sessionId);
  443. await cdp.detach(sessionId).catch(() => {});
  444. return true;
  445. } catch {
  446. return false;
  447. } finally {
  448. cdp.close();
  449. }
  450. }
  451. /**
  452. * 连接「某个子站的页面」并执行 fn(api)。
  453. * api = { cdp, sessionId, targetId, target, app, contexts, contextFor, send, on, evaluate }
  454. *
  455. * @param {*} urlOrApp URL(按域名自动选)或 app id('stats' / 'shop')
  456. * @param {{needExact?:boolean, autoOpen?:boolean, timeout?:number}} opts
  457. * needExact=true 时,若该子站没有可用的页面/上下文 → 抛 NAPOS_WRONG_ORIGIN(拒绝硬发)
  458. */
  459. async function withPageFor(port, urlOrApp, fn, { needExact = false, autoOpen = true, timeout = 15000 } = {}) {
  460. const p = await probe(port);
  461. if (!p.ok) {
  462. throw new Error(
  463. `调试端口 ${port} 不可用(${p.error})。\n` +
  464. `请先完全退出淘宝闪购客户端,再用 --remote-debugging-port=${port} 重新启动。`
  465. );
  466. }
  467. const app = resolveApp(urlOrApp);
  468. let lastFound = null;
  469. let lastCtx = new Map();
  470. for (let attempt = 1; attempt <= 3; attempt++) {
  471. const found = await findTargetForApp(port, app);
  472. if (!found || !found.target) {
  473. throw new Error('没有找到可用的页面 target(客户端可能还没加载完业务页面)');
  474. }
  475. const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(timeout);
  476. if (!cdp) throw new Error('CDP 连接失败');
  477. const sessionId = await cdp.attachTo(found.target.id);
  478. const ctxMap = await collectContexts(cdp, sessionId, attempt === 1 ? 800 : 1500);
  479. lastFound = found;
  480. lastCtx = ctxMap;
  481. const usable = appUsable(app, found, ctxMap);
  482. if (usable || !needExact) {
  483. const s = (m, params) => cdp.send(m, params, sessionId);
  484. const api = {
  485. cdp, sessionId, targetId: found.target.id, target: found.target, app,
  486. exact: found.matched, contexts: ctxMap,
  487. contextFor: (u) => contextForUrl(ctxMap, u),
  488. send: s,
  489. on: (m, cb) => cdp.on(m, cb),
  490. async evaluate(expression, { awaitPromise = true, returnByValue = true, contextId } = {}) {
  491. const params = { expression, awaitPromise, returnByValue, userGesture: true, includeCommandLineAPI: true };
  492. if (contextId) params.contextId = contextId;
  493. const r = await s('Runtime.evaluate', params);
  494. if (r.exceptionDetails) {
  495. const d = r.exceptionDetails;
  496. throw new Error(`页面内执行出错: ${d.exception?.description || d.text}`);
  497. }
  498. return r.result?.value;
  499. },
  500. };
  501. try {
  502. return await fn(api);
  503. } finally {
  504. await cdp.detach(sessionId).catch(() => {});
  505. cdp.close();
  506. }
  507. }
  508. // ❌ 来源不对 → 断开、尝试就地打开、重试
  509. await cdp.detach(sessionId).catch(() => {});
  510. cdp.close();
  511. if (!autoOpen || attempt === 3) break;
  512. const opened = await tryOpenApp(port, app, found.target);
  513. if (!opened) break;
  514. await sleep(2500);
  515. }
  516. throw wrongOriginError(app, lastFound, lastCtx);
  517. }
  518. /** 向后兼容:等价于「连商家后台页」 */
  519. async function withBusinessPage(port, fn, opts = {}) {
  520. return withPageFor(port, SHOP_APP, fn, { ...opts, needExact: false });
  521. }
  522. // ------------------------------------------------------------------ 鉴权头
  523. /**
  524. * 在页面里装一个「请求头采集器」:把该页面对目标域名发出的真实请求头记下来。
  525. * 页面自己发出的请求一定带齐了服务端要的鉴权(token / bx-et …),
  526. * 我们照抄即可 —— 比猜参数可靠得多。
  527. */
  528. function captureInstallCode(host) {
  529. return `(() => {
  530. try {
  531. if (!window.__naposCap || !window.__naposCap.on) {
  532. const cap = { on: true, seen: {} };
  533. window.__naposCap = cap;
  534. const TARGET = ${JSON.stringify(String(host))};
  535. const isT = (u) => { try { return String(u || '').indexOf(TARGET) !== -1; } catch (e) { return false; } };
  536. const norm = (h) => {
  537. const o = {};
  538. try {
  539. if (h && typeof h.forEach === 'function') h.forEach((v, k) => { o[String(k)] = String(v); });
  540. else if (h && typeof h === 'object') Object.keys(h).forEach((k) => { o[String(k)] = String(h[k]); });
  541. } catch (e) {}
  542. return o;
  543. };
  544. const rec = (u, h) => {
  545. try {
  546. if (!isT(u)) return;
  547. const key = String(u).split('?')[0];
  548. if (!cap.seen[key]) cap.seen[key] = { h: norm(h), url: String(u), t: Date.now() };
  549. } catch (e) {}
  550. };
  551. const of = window.fetch;
  552. if (of) {
  553. window.fetch = function (input, init) {
  554. try { rec(typeof input === 'string' ? input : (input && input.url), (init && init.headers) || (input && input.headers)); } catch (e) {}
  555. return of.apply(this, arguments);
  556. };
  557. }
  558. const XHR = window.XMLHttpRequest;
  559. if (XHR && XHR.prototype) {
  560. const ox = XHR.prototype.open, os = XHR.prototype.send, oh = XHR.prototype.setRequestHeader;
  561. XHR.prototype.setRequestHeader = function (k, v) {
  562. try { this.__nh = this.__nh || {}; this.__nh[k] = v; } catch (e) {}
  563. return oh.apply(this, arguments);
  564. };
  565. XHR.prototype.open = function (m, u) { try { this.__nu = u; } catch (e) {} return ox.apply(this, arguments); };
  566. XHR.prototype.send = function () {
  567. try { rec(this.__nu, this.__nh); } catch (e) {}
  568. return os.apply(this, arguments);
  569. };
  570. }
  571. }
  572. return Object.keys(window.__naposCap.seen || {});
  573. } catch (e) { return []; }
  574. })()`;
  575. }
  576. function captureReadCode() {
  577. return `(() => {
  578. try {
  579. const cap = window.__naposCap;
  580. if (!cap || !cap.seen) return null;
  581. const keys = Object.keys(cap.seen);
  582. if (!keys.length) return null;
  583. const merged = {};
  584. for (const k of keys) Object.assign(merged, (cap.seen[k] || {}).h || {});
  585. return merged;
  586. } catch (e) { return null; }
  587. })()`;
  588. }
  589. /**
  590. * 兜底:找 ksid。
  591. * ⚠️ 关键:ksid 往往只存在于**外层页面**的 localStorage / Cookie,
  592. * 子站 iframe 自己那份可能没有 —— 所以要在所有上下文里挨个找。
  593. * 抓包实证:真实请求带的鉴权头就是 `token: <ksid>=-1`。
  594. */
  595. const KSID_PROBE = `(() => {
  596. const out = { ksid: null, source: null };
  597. const g = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
  598. const s = (k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } };
  599. for (const k of ['ksid','KSID','ksid_chain','napos_ksid','chainKsid','_ksid']) {
  600. const v = g(k) || s(k);
  601. if (v && String(v).length > 8) { out.ksid = String(v); out.source = 'storage:' + k; return out; }
  602. }
  603. try {
  604. const m = (document.cookie || '').match(/(?:^|;\\s*)ksid=([^;]+)/);
  605. if (m && m[1] && m[1].length > 8) { out.ksid = decodeURIComponent(m[1]); out.source = 'cookie'; return out; }
  606. } catch (e) {}
  607. return out;
  608. })()`;
  609. /**
  610. * ★ agw 网关(财务 / 成长)的凭证探测。
  611. * 实测:两个 H5 子站的登录态都是同一枚 ksid,但**存放位置不同**
  612. * (财务在 localStorage.ksid,成长只在 cookie.ksid),
  613. * 所以这里按 localStorage → sessionStorage → cookie 依次兜底。
  614. * 同时取 unitId 用于拼业务上下文头 `X-AGW-BIZ-CONTEXT: currentShopId=<unitId>`。
  615. */
  616. const AGW_CRED_PROBE = `(() => {
  617. try {
  618. const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } };
  619. const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } };
  620. const ksid = g(localStorage, 'ksid') || g(sessionStorage, 'ksid') || ck('ksid');
  621. const unitId = g(localStorage, 'unitId') || g(sessionStorage, 'unitId') || ck('unitId');
  622. const shopId = g(localStorage, 'shopId') || g(sessionStorage, 'shopId') || ck('shopId');
  623. return { ksid: ksid ? String(ksid) : null, unitId: unitId || null, shopId: shopId || null };
  624. } catch (e) { return null; }
  625. })()`;
  626. const CHANNEL_ID_PROBE = `(() => {
  627. try {
  628. const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } };
  629. const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } };
  630. const one = (k) => g(localStorage, k) || g(sessionStorage, k) || ck(k);
  631. return {
  632. ksid: one('ksid') || null,
  633. unitId: one('unitId') || null,
  634. keeperId: one('keeperId') || null,
  635. loginShopId: one('loginShopId') || null,
  636. shopName: g(localStorage, 'shopName') || null,
  637. };
  638. } catch (e) { return null; }
  639. })()`;
  640. /** 在所有可用的执行上下文(含外层页面)里找 ksid */
  641. async function findKsid(api) {
  642. const ids = [];
  643. if (api && api.contexts && api.contexts.size) for (const c of api.contexts.values()) ids.push(c.id);
  644. ids.push(undefined); // 再加一次默认上下文兜底
  645. for (const contextId of ids) {
  646. try {
  647. const v = await api.evaluate(KSID_PROBE, contextId ? { contextId } : undefined);
  648. if (v && v.ksid) return v.ksid;
  649. } catch { /* 换下一个上下文 */ }
  650. }
  651. return null;
  652. }
  653. /**
  654. * 为一个跨域数据接口凑齐鉴权头。
  655. * ① 照抄「该子站自己发出的真实请求头」—— 最准
  656. * ② 兜底:跨上下文找 ksid,按 `token: <ksid>=-1` 拼出来(格式来自真实抓包)
  657. */
  658. async function buildAuthHeaders(api, url) {
  659. const app = appForUrl(url);
  660. if (!app) return {};
  661. // ★ 渠道风格(闪购联盟 / 店铺推广 / 金融保险)
  662. // 鉴权分两种,**按域名区分**(实测血泪):
  663. // · agw 网关(推广余额)→ ksid 请求头
  664. // · app-api.shop.ele.me 的 callFromNapos → **不能带 ksid 头**!它只允许 content-type
  665. // (CORS 白名单里没有 ksid),ksid 必须放进 body.metas(由 kit 层注入);
  666. // · httpizza.ele.me(金融)→ 只靠 Cookie,无需任何自定义头。
  667. if (app.authStyle === 'napos') {
  668. const host = hostOf(url);
  669. const out = { Accept: 'application/json, text/plain, */*' };
  670. if (/(^|\.)agw\./i.test(host)) {
  671. const ksid = await findKsid(api);
  672. if (ksid) out.ksid = String(ksid);
  673. }
  674. return out;
  675. }
  676. // ★ agw 风格(财务管理 / 成长中心)
  677. // 与 lsycm 的 `token: <ksid>=-1` 完全不同 —— 它要的是
  678. // ksid: <H5 自己的 ksid>
  679. // current-shop-id: <-1 | ''>
  680. // X-AGW-BIZ-CONTEXT: currentShopId=<unitId>
  681. // 凭证一律「在该子站自己的执行上下文里」运行时读取,不硬编码。
  682. if (app.authStyle === 'agw') {
  683. const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null;
  684. const evalOpt = ctx ? { contextId: ctx.id } : undefined;
  685. let cred = null;
  686. try { cred = await api.evaluate(AGW_CRED_PROBE, evalOpt); } catch { /* ignore */ }
  687. if (!cred || !cred.ksid) return {};
  688. const out = {
  689. ksid: String(cred.ksid),
  690. 'current-shop-id': (app.agw && app.agw.currentShopId != null) ? String(app.agw.currentShopId) : '',
  691. };
  692. const unit = cred.unitId || cred.shopId;
  693. if (unit) out['X-AGW-BIZ-CONTEXT'] = 'currentShopId=' + unit;
  694. if (app.agw && app.agw.contentType) out['Content-Type'] = app.agw.contentType;
  695. out.Accept = 'application/json, text/plain, */*';
  696. return out;
  697. }
  698. if (!app.authCarrierHosts || !app.authCarrierHosts.length) return {};
  699. const host = hostOf(url);
  700. if (!app.authCarrierHosts.some((re) => re.test(host))) return {};
  701. // ★ 必须在「属于该域的上下文」里采集 —— 外层页面的请求头跟子站不是一套
  702. const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null;
  703. const evalOpt = ctx ? { contextId: ctx.id } : undefined;
  704. let captured = null;
  705. try { await api.evaluate(captureInstallCode(host), evalOpt); } catch { /* 忽略 */ }
  706. try { captured = await api.evaluate(captureReadCode(), evalOpt); } catch { /* 忽略 */ }
  707. const out = {};
  708. if (captured && typeof captured === 'object') {
  709. for (const [k, v] of Object.entries(captured)) {
  710. const lk = String(k).toLowerCase();
  711. if (FORBIDDEN_REQ_HEADERS.has(lk)) continue;
  712. if (lk === 'content-type' || lk === 'accept') continue; // 由调用方决定
  713. out[k] = String(v);
  714. }
  715. }
  716. const hasToken = Object.keys(out).some((k) => k.toLowerCase() === 'token');
  717. if (!hasToken) {
  718. const ksid = await findKsid(api);
  719. if (ksid) out.token = `${ksid}=-1`;
  720. }
  721. return out;
  722. }
  723. // ------------------------------------------------------------------ 请求节流
  724. let _chain = Promise.resolve();
  725. let _lastReqAt = 0;
  726. const MIN_GAP_MS = Number(process.env.NAPOS_MIN_GAP_MS || 300);
  727. /** 串行 + 最小间隔:短时间高频请求本身就是触发风控的主因之一 */
  728. function throttle() {
  729. const p = _chain.then(async () => {
  730. const wait = _lastReqAt + MIN_GAP_MS - Date.now();
  731. if (wait > 0) await sleep(wait);
  732. _lastReqAt = Date.now();
  733. });
  734. _chain = p.catch(() => {});
  735. return p;
  736. }
  737. // ------------------------------------------------------------------ 发请求
  738. /**
  739. * 刷新请求体里的一次性 id。
  740. *
  741. * 后台的 RPC 调用普遍带 `"id": "<32位hex>|<毫秒时间戳>"`,
  742. * 服务端会校验/去重,直接重放旧值可能返回 SERVER_ERROR。
  743. * 这里把符合该模式的 id 重新生成。
  744. */
  745. function refreshRequestId(body) {
  746. if (typeof body !== 'string') return body;
  747. const t = body.trim();
  748. if (!t || t[0] !== '{') return body;
  749. let obj;
  750. try { obj = JSON.parse(t); } catch { return body; }
  751. let changed = false;
  752. const REGEN = /^[0-9a-f]{32}\|\d+$/i;
  753. const walk = (v) => {
  754. if (Array.isArray(v)) { v.forEach(walk); return; }
  755. if (!v || typeof v !== 'object') return;
  756. for (const k of Object.keys(v)) {
  757. if (k === 'id' && typeof v[k] === 'string' && REGEN.test(v[k])) {
  758. const hex = Array.from({ length: 32 }, () => '0123456789ABCDEF'[Math.floor(Math.random() * 16)]).join('');
  759. v[k] = `${hex}|${Date.now()}`;
  760. changed = true;
  761. } else walk(v[k]);
  762. }
  763. };
  764. walk(obj);
  765. return changed ? JSON.stringify(obj) : body;
  766. }
  767. /**
  768. * 用业务页面的身份发一次请求。
  769. *
  770. * v0.2 修正(解决"每次调用都触发客户端鉴权"):
  771. * · 执行上下文路由 —— 在**域匹配的那个 iframe 上下文**里发请求(关键修复)。
  772. * 子站是跨域 iframe,从外层页面发就是跨站 → 鉴权/风控
  773. * · credentials: 'include' —— 跨子域时才会带 Cookie(原实现 same-origin,等于一个都不带)
  774. * · 自动补鉴权头 —— 从子站自己的真实请求里抄 token / bx-et
  775. * · 请求串行 + 最小间隔 —— 避免高频请求触发风控
  776. * · 请求超时 —— 被风控挂死时快速失败,不再卡 90 秒
  777. * · 识别风控/未登录响应 —— 用 risk / authFail 字段明确回报,不再假装成功
  778. *
  779. * 🔒 写操作防护:所有出站请求都必须先过 guard(默认只放行读接口)。
  780. */
  781. async function pageFetch(api, {
  782. method = 'GET', url, body, headers = {}, raw = false,
  783. allowWrite, guardIndex, authHeaders = true, timeoutMs,
  784. }) {
  785. const M = String(method || 'GET').toUpperCase();
  786. // ---- 写操作防护(唯一出口,拦在这里就够了)
  787. guard.assertAllowed({ method: M, url, body }, { index: guardIndex, allowWrite });
  788. await throttle();
  789. const extra = authHeaders === false ? {} : await buildAuthHeaders(api, url);
  790. const payload = {
  791. method: M,
  792. credentials: 'include',
  793. headers: { 'Content-Type': 'application/json', ...extra, ...headers },
  794. };
  795. if (body != null && M !== 'GET' && M !== 'HEAD') {
  796. payload.body = typeof body === 'string' ? refreshRequestId(body) : JSON.stringify(body);
  797. }
  798. // ★ 关键:把请求放进"拥有该域的那个上下文"里执行
  799. const wantApp = appForUrl(url);
  800. const ctx = (typeof api.contextFor === 'function') ? api.contextFor(url) : null;
  801. const originMismatch = !!(wantApp && wantApp !== SHOP_APP && !ctx);
  802. const pageUrl = (api && api.target && api.target.url) || '';
  803. const ms = Number(timeoutMs || process.env.NAPOS_REQ_TIMEOUT_MS || 30000);
  804. const code = `
  805. (async () => {
  806. const AC = (typeof AbortController !== 'undefined') ? AbortController : null;
  807. const ctl = AC ? new AC() : null;
  808. const timer = ctl ? setTimeout(() => ctl.abort(), ${ms}) : null;
  809. try {
  810. const res = await fetch(${JSON.stringify(url)}, Object.assign(${JSON.stringify(payload)}, ctl ? { signal: ctl.signal } : {}));
  811. const text = await res.text();
  812. let data;
  813. let parsed = true;
  814. try { data = JSON.parse(text); } catch (e) { parsed = false; data = text.slice(0, 200000); }
  815. if (timer) clearTimeout(timer);
  816. return { ok: res.ok, status: res.status, url: res.url, parsed, data, pageUrl: location.href, origin: location.origin };
  817. } catch (e) {
  818. if (timer) clearTimeout(timer);
  819. return { ok: false, timeout: String(e && e.name) === 'AbortError', error: String((e && e.message) || e), pageUrl: location.href, origin: location.origin };
  820. }
  821. })()
  822. `;
  823. const out = await api.evaluate(code, ctx ? { contextId: ctx.id } : undefined);
  824. if (out && typeof out === 'object') {
  825. const probeText = typeof out.data === 'string' ? out.data : JSON.stringify(out.data || '');
  826. out.risk = RISK_RE.test(probeText);
  827. out.authFail = AUTH_RE.test(probeText);
  828. out.pageUrl = out.pageUrl || pageUrl;
  829. out.contextOrigin = ctx ? ctx.origin : null;
  830. if (originMismatch) out.originMismatch = true;
  831. }
  832. return out;
  833. }
  834. module.exports = {
  835. DEFAULT_PORT, BIZ_HOST_HINT, APPS, FORBIDDEN_REQ_HEADERS,
  836. httpJson, sleep, probe, listTargets,
  837. hostOf, appForUrl, resolveApp, contextForUrl, appUsable, ctxMatchesApp,
  838. findBusinessTarget, findTargetForApp, ensureAppTarget, openAppTarget, tryOpenApp,
  839. rankTarget, rankTargetForApp, targetMatchesApp, isPageTarget,
  840. CDP, withBusinessPage, withPageFor, pageFetch, refreshRequestId,
  841. buildAuthHeaders, throttle,
  842. // ★ 供 skill / kit.js 运行时读取登录态用(跨执行上下文查找,不硬编码)
  843. findKsid, KSID_PROBE, AGW_CRED_PROBE, CHANNEL_ID_PROBE,
  844. };