'use strict'; /** * CDP(Chrome DevTools Protocol)客户端 + 淘宝闪购客户端发现工具。 * * 连的是 eleme-napos 的 remote-debugging-port(默认 9669)。 * * ⚠️ 核心设计(v0.2 修正):客户端里同时存在多个「子站」页面,各自属于不同的域: * * · 商家后台 melody.shop.ele.me —— 订单 / 商品 / 门店 / 营销 * · 数据中心 melody-stats-next.ele.me —— 生意参谋:流量 / 转化 / 报表 * · 单店看板 napos-dashboard-pc.faas.ele.me * * 一个请求必须「由拥有该域的那个页面」发出。 * 若从错误的页面(跨站)发出,服务端会把它判为异常来源 —— * 现象就是**客户端弹鉴权/验证码,接口回「用户未登录」(RGV587)**。 * * 所以下面用 APPS 表把「请求域名」映射到「应该由哪个页面发出」, * 并在拿不到正确页面时 **拒发**(抛 NAPOS_WRONG_ORIGIN),而不是硬发。 */ const { RawWebSocket } = require('./ws'); const guard = require('./guard'); const DEFAULT_PORT = 9669; const BIZ_HOST_HINT = ['melody.shop.ele.me', 'melody-plus', 'melody-electron']; /** * 请求子站路由表(顺序敏感:更具体的域放前面)。 * hosts 该子站「拥有」的请求域名(正则,作用于 hostname) * pageHints 该子站页面 URL 里会出现的特征串,用于在 CDP target 列表里认人 * openUrl 该子站页面的地址;null = 客户端默认页,无需自己开 * authCarrierHosts 需要额外补鉴权头(如 lsycm 的 token)的域名 */ const APPS = [ { // ★ 渠道 / 联盟 / 推广 / 金融保险(实测 2026-10-11) // 三个菜单全部是 r.ele.me 下的跨域 iframe: // 闪购联盟 → https://r.ele.me/bs-taoke-pc/ 路由 /app/unit/zw-promotion__home // 店铺推广 → https://r.ele.me/doujin-isv-manage/... 路由 /app/unit/vas__dashboard // 金融保险 → https://r.ele.me/alsc-enjoy-before-pay-pc/home?… // ★ 三类鉴权各不相同,且都**不需要**专属 H5 上下文(这点与账单域完全相反): // · callFromNapos(app-api.shop.ele.me/…/iOneRoute)→ ksid 在 **body.metas.ksid** // · 推广余额(agw.alsc.taobao.com/gw/h5api/alsc-ad-account.*)→ ksid 请求头 // · 金融(httpizza.ele.me)→ 仅靠 Cookie(同一浏览器会话) // 实测从商家后台主页面(melody.shop.ele.me)直发即可成功,故 ctxHosts 两个都收。 // ★ 必须排在 shop 之前 —— 否则 shop 的 /ele\.me$/ 会吞掉 r.ele.me。 id: 'channel', label: '闪购联盟 / 店铺推广 / 金融保险', hosts: [/(^|\.)r\.ele\.me$/i, /(^|\.)httpizza\.ele\.me$/i], urlHosts: [ /iOneRoute\.callFromNapos/i, /alsc-ad-account\./i, /fn-alsc-fin-ops\./i, ], pageHints: ['r.ele.me'], // ★ 顺序敏感:优先用商家后台主页面上下文 —— 实测 r.ele.me 是 sandbox iframe, // 其 window 缺 AbortController,且主页面(有完整 localStorage/cookie)发同样成功。 ctxHosts: ['melody.shop.ele.me', 'r.ele.me'], openUrl: null, authStyle: 'napos', }, { id: 'stats', label: '数据中心 / 生意参谋', hosts: [ /(^|\.)lsycm\.alibaba\.com$/i, /(^|\.)melody-stats-next\.ele\.me$/i, /(^|\.)napos-dashboard-pc\.faas\.ele\.me$/i, /(^|\.)sycm\.taobao\.com$/i, ], pageHints: ['melody-stats-next.ele.me', 'napos-dashboard-pc.faas.ele.me', 'sycm.taobao.com'], // 该子站页面/iframe 自己的 origin(精确后缀,用于挑执行上下文) ctxHosts: ['melody-stats-next.ele.me', 'napos-dashboard-pc.faas.ele.me', 'sycm.taobao.com'], openUrl: 'https://melody-stats-next.ele.me/new/', // 实测:数据中心是嵌在商家后台页里的跨域 iframe,挂在 /app/unit/dashboard 这类路由上。 // 子站跑丢时可以把主页面导航回这个路由把它带回来(同源导航,不会触发鉴权)。 openPath: process.env.NAPOS_STATS_PATH || '/app/unit/dashboard', authCarrierHosts: [/(^|\.)lsycm\.alibaba\.com$/i], }, { // ★ 财务管理(账单 / 结算 / 余额) // 请求打到 agw.alsc.taobao.com(阿里本地生活网关),但它**必须由财务 H5 自己的 // 执行上下文发出** —— 从商家后台页跨站发出会被网关判为未登录(111006 UNLOGIN)。 // 实测(2026-10-11):宿主页 = https://napos-bill-pc.faas.ele.me(商家后台内的 iframe), // 对应路由 /app/unit/billDetail#app.unit.billDetail。 // 鉴权头(实测抓包):ksid / current-shop-id / X-AGW-BIZ-CONTEXT: currentShopId= id: 'bill', label: '财务管理(账单 / 结算)', hosts: [ /(^|\.)agw\.alsc\.taobao\.com$/i, /(^|\.)napos-bill-pc\.faas\.ele\.me$/i, ], pageHints: ['napos-bill-pc.faas.ele.me'], ctxHosts: ['napos-bill-pc.faas.ele.me'], openUrl: null, openPath: process.env.NAPOS_BILL_PATH || '/app/unit/billDetail', authStyle: 'agw', agw: { currentShopId: '-1', contentType: 'application/json' }, }, { // ★ 成长中心(产能分层 / 诊断 / 周报 / 任务) // 请求打到 agw.ele.me,宿主页 = https://napos-shop-operation-pc.faas.ele.me, // 对应路由 /app/unit/business-task-new#app.unit.business-task-new。 // 实测:成长 H5 的 ksid 存在 cookie(localStorage 里没有),故凭证探测要多来源。 id: 'growth', label: '成长中心', hosts: [ /(^|\.)agw\.ele\.me$/i, /(^|\.)napos-shop-operation-pc\.faas\.ele\.me$/i, ], pageHints: ['napos-shop-operation-pc.faas.ele.me'], ctxHosts: ['napos-shop-operation-pc.faas.ele.me'], openUrl: null, openPath: process.env.NAPOS_GROWTH_PATH || '/app/unit/business-task-new', authStyle: 'agw', agw: { currentShopId: '', contentType: 'application/json;charset=UTF-8' }, }, { id: 'shop', label: '商家后台', hosts: [ /(^|\.)melody\.shop\.ele\.me$/i, /(^|\.)app-api\.shop\.ele\.me$/i, /(^|\.)napos\.ele\.me$/i, /melody-plus/i, /melody-electron/i, /(^|\.)ele\.me$/i, ], pageHints: ['melody.shop.ele.me', 'melody-plus', 'melody-electron'], ctxHosts: ['melody.shop.ele.me', 'melody-plus', 'melody-electron'], openUrl: null, authCarrierHosts: [], }, ]; const SHOP_APP = APPS.find((a) => a.id === 'shop'); /** fetch 禁止设置的请求头,重放时一律剔除(由浏览器自己填) */ const FORBIDDEN_REQ_HEADERS = new Set([ 'host', 'content-length', 'connection', 'origin', 'referer', 'user-agent', 'accept-encoding', 'cookie', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-dest', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'accept-language', ]); /** 风控 / 未登录的响应特征 —— 命中即说明这次访问"触发了鉴权" */ const RISK_RE = /RGV587|FAIL_SYS_USER_VALIDATE|FAIL_SYS_ILLEGAL|punish|captcha|slide/i; const AUTH_RE = /用户未登录|未登录|NOT_LOGIN|not.?logined|please.?login|sessions?expired/i; // ------------------------------------------------------------------ 基础 function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } async function httpJson(url, { method = 'GET', timeout = 5000 } = {}) { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeout); try { const r = await fetch(url, { method, signal: ac.signal }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.json(); } finally { clearTimeout(t); } } /** 探测调试端口是否可用 */ async function probe(port = DEFAULT_PORT) { try { const v = await httpJson(`http://127.0.0.1:${port}/json/version`); return { ok: true, port, version: v }; } catch (e) { return { ok: false, port, error: e.message }; } } async function listTargets(port = DEFAULT_PORT) { return httpJson(`http://127.0.0.1:${port}/json/list`); } // ------------------------------------------------------------------ 子站路由 /** 从 URL 取 hostname(兼容相对路径) */ function hostOf(url) { try { return new URL(String(url)).hostname; } catch { /* 相对路径 */ } return String(url).replace(/^\/\//, '').split('/')[0].split(':')[0]; } /** * 这个请求地址属于哪个子站?未知返回 null * ① 先按 hostname 匹配(hosts) * ② 再按**完整 URL** 匹配(urlHosts)—— 同一个域名下不同业务线走不同宿主/鉴权的场景 * (例:agw.alsc.taobao.com 的「账单」与「推广余额」是两套) */ function appForUrl(url) { const h = hostOf(url); if (!h) return null; const full = String(url || ''); for (const a of APPS) { if (a.hosts.some((re) => re.test(h))) return a; if (a.urlHosts && a.urlHosts.some((re) => re.test(full))) return a; } return null; } /** 把 'stats' / 一个 URL / null 统一解析成 app 对象(未知回退到商家后台) */ function resolveApp(x) { if (!x) return SHOP_APP; if (typeof x === 'object' && x.id) return x; const s = String(x); const byId = APPS.find((a) => a.id === s); if (byId) return byId; return appForUrl(s) || SHOP_APP; } function rankTarget(t) { const url = t.url || ''; let score = 0; if (t.type === 'page') score += 10; if (BIZ_HOST_HINT.some((h) => url.includes(h))) score += 100; if (/^(https?):/.test(url)) score += 5; if (url.startsWith('file://')) score -= 50; if (url.startsWith('devtools://')) score -= 100; return score; } /** * 只有真正的 document 页面才能承载 fetch。 * service_worker / iframe / 后台页里没有 window,也没有站点的登录态, * 从那里发请求一律「用户未登录」——必须排除,否则会误判"来源页已就位"。 */ function isPageTarget(t) { return !!t && t.type === 'page'; } function targetMatchesApp(t, app) { return isPageTarget(t) && app.pageHints.some((h) => (t.url || '').includes(h)); } function rankTargetForApp(t, app) { const url = t.url || ''; let score = 0; if (t.type === 'page') score += 10; if (targetMatchesApp(t, app)) score += 1000; else if (app === SHOP_APP && BIZ_HOST_HINT.some((h) => url.includes(h))) score += 100; if (/^(https?):/.test(url)) score += 5; if (url.startsWith('file://')) score -= 50; if (url.startsWith('devtools://')) score -= 500; return score; } /** 找到业务页面 target(= 商家后台页,向后兼容) */ async function findBusinessTarget(port = DEFAULT_PORT) { const all = await listTargets(port); const usable = all.filter((t) => t.webSocketDebuggerUrl); if (!usable.length) return null; usable.sort((a, b) => rankTarget(b) - rankTarget(a)); return usable[0]; } /** * 找到「属于某个子站」的 target。 * @returns {{target:object, matched:boolean}|null} matched=false 表示没找到匹配页,返回的是兜底页 */ async function findTargetForApp(port, app) { const all = (await listTargets(port)).filter((t) => t.webSocketDebuggerUrl && isPageTarget(t)); if (!all.length) return null; const matched = all.filter((t) => targetMatchesApp(t, app)); if (matched.length) { matched.sort((a, b) => rankTargetForApp(b, app) - rankTargetForApp(a, app)); return { target: matched[0], matched: true }; } const pool = all.slice().sort((a, b) => rankTargetForApp(b, app) - rankTargetForApp(a, app)); return { target: pool[0], matched: false }; } // ------------------------------------------------------------------ CDP class CDP { constructor(wsUrl) { this.wsUrl = wsUrl; this.ws = null; this.seq = 0; this.pending = new Map(); this.handlers = new Map(); this.attached = new Set(); } async open(timeout = 10000) { this.ws = new RawWebSocket(this.wsUrl); this.ws.on('message', (m) => this._onMessage(m)); await this.ws.connect(timeout); return this; } _onMessage(raw) { let msg; try { msg = JSON.parse(raw); } catch { return; } if (msg.id != null && this.pending.has(msg.id)) { const { resolve, reject, timer } = this.pending.get(msg.id); clearTimeout(timer); this.pending.delete(msg.id); if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`)); else resolve(msg.result); return; } if (msg.method) { const list = this.handlers.get(msg.method); if (list) for (const h of list) { try { h(msg.params, msg.sessionId); } catch { /* ignore */ } } } } send(method, params = {}, sessionId) { return new Promise((resolve, reject) => { const id = ++this.seq; const payload = { id, method, params }; if (sessionId) payload.sessionId = sessionId; const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP 调用超时: ${method}`)); }, 90000); this.pending.set(id, { resolve, reject, timer }); this.ws.send(JSON.stringify(payload)); }); } on(method, cb) { if (!this.handlers.has(method)) this.handlers.set(method, []); this.handlers.get(method).push(cb); } async attachTo(targetId) { const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true }); this.attached.add(sessionId); return sessionId; } async detach(sessionId) { try { await this.send('Target.detachFromTarget', { sessionId }); } catch { /* ignore */ } this.attached.delete(sessionId); } close() { try { this.ws && this.ws.close(); } catch { /* ignore */ } } } /** * 真的把子站页面开出来(新建一个 target,不动用户正在看的那个页面)。 * 这是"正确来源页不存在时就地创建"的能力,用于一次性把数据中心页拉起。 */ async function openAppTarget(port, app) { if (!app || !app.openUrl) return null; const p = await probe(port); if (!p.ok) return null; const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000); try { let res = null; for (const opts of [ { url: app.openUrl, newWindow: false, background: true }, { url: app.openUrl, newWindow: true }, { url: app.openUrl }, ]) { try { res = await cdp.send('Target.createTarget', opts); break; } catch { /* 换下一种开法 */ } } return res ? res.targetId : null; } catch { return null; } finally { cdp.close(); } } /** 保证拿到「属于该子站」的页面;没有就尝试开一个并等它就绪 */ async function ensureAppTarget(port, app, { waitMs = 12000 } = {}) { let found = await findTargetForApp(port, app); if (found && found.matched) return found; await openAppTarget(port, app); const deadline = Date.now() + waitMs; while (Date.now() < deadline) { await sleep(700); const f = await findTargetForApp(port, app); if (f && f.matched) return f; } return found; } // ------------------------------------------------------------------ 执行上下文 /** * ★ 核心:子站不一定是一个独立窗口。 * 实测「数据中心」就是嵌在商家后台页面里的**跨域 iframe** —— * 在 CDP 里它不产生新 target,只产生一个新的 executionContext。 * * 于是"从正确的来源发请求" = 在**域匹配的那个执行上下文里**跑 fetch。 * 从外层页面(melody.shop.ele.me)发出去就是跨站请求:服务端判为异常来源 → * 客户端弹鉴权/验证码,或请求直接挂死(既不返回也不报错)。 */ async function collectContexts(cdp, sessionId, ms = 800) { const map = new Map(); cdp.on('Runtime.executionContextCreated', (pa) => { const ctx = pa && pa.context; if (!ctx || !ctx.origin || !/^https?:/i.test(ctx.origin)) return; const aux = ctx.auxData || {}; if (aux.isDefault === false || aux.type === 'isolated') return; // 跳过 Electron 隔离上下文 if (!map.has(ctx.origin)) map.set(ctx.origin, ctx); }); try { await cdp.send('Runtime.enable', {}, sessionId); } catch { /* ignore */ } await sleep(ms); return map; } /** 找一个"域属于该请求"的执行上下文(按 ctxHosts 声明顺序,越靠前越优先) */ function contextForUrl(ctxMap, url) { const app = appForUrl(url); if (!app || !ctxMap) return null; const hints = app.ctxHosts || app.pageHints || []; for (const hint of hints) { for (const [origin, ctx] of ctxMap) { let h; try { h = new URL(origin).hostname; } catch { continue; } if (h === hint || h.endsWith('.' + hint)) return ctx; } } return null; } /** * 某个执行上下文的 origin 是不是"这个子站自己的页面"。 * 用精确的 ctxHosts 后缀匹配 —— 不能用 APPS.hosts 那套宽泛正则, * 否则 shop 的 `ele\.me$` 会把 napos-dashboard-pc.faas.ele.me 也吞进去。 */ function ctxMatchesApp(origin, app) { let h; try { h = new URL(origin).hostname; } catch { return false; } const hints = app.ctxHosts || app.pageHints || []; return hints.some((s) => h === s || h.endsWith('.' + s)); } /** 该子站现在能不能发请求?(独立页面 target,或页面里存在域匹配的执行上下文) */ function appUsable(app, found, ctxMap) { if (found && found.matched) return true; for (const origin of (ctxMap ? ctxMap.keys() : [])) { if (ctxMatchesApp(origin, app)) return true; } return false; } function wrongOriginError(app, found, ctxMap) { const ctxs = [...(ctxMap ? ctxMap.keys() : [])]; const err = new Error( `🛑 拒绝从错误的来源发请求 —— 这正是"每次调用都触发客户端鉴权"的原因。\n` + ` 该请求属于「${app.label}」,必须由这个域自己的页面/上下文发出。\n` + ` 当前页面:${(found && found.target && found.target.url) || '-'}\n` + ` 可用执行上下文:${ctxs.length ? ctxs.join(' , ') : '(无)'}\n` + ` 修复:在客户端打开「${app.label}」页面` + (app.openPath ? `,或让本工具导航过去(napos_call 传 autoOpen=true)` : '') + '。' ); err.code = 'NAPOS_WRONG_ORIGIN'; err.app = app.id; err.availableContexts = ctxs; return err; } /** * 就地打开子站。 * ① 优先新开一个 target(标准 Chromium 支持;本机 Electron 94 实测返回 "Not supported") * ② 退回:把商家后台页导航到子站内嵌路由(同源导航,不会触发鉴权) */ async function tryOpenApp(port, app, fallbackTarget) { const p = await probe(port); if (!p.ok) return false; if (app.openUrl) { const id = await openAppTarget(port, app); if (id) return true; } if (!app.openPath || !fallbackTarget) return false; const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000); try { const sessionId = await cdp.attachTo(fallbackTarget.id); const url = `https://melody.shop.ele.me${app.openPath}`; await cdp.send('Runtime.evaluate', { expression: `location.href = ${JSON.stringify(url)}`, userGesture: true, }, sessionId); await cdp.detach(sessionId).catch(() => {}); return true; } catch { return false; } finally { cdp.close(); } } /** * 连接「某个子站的页面」并执行 fn(api)。 * api = { cdp, sessionId, targetId, target, app, contexts, contextFor, send, on, evaluate } * * @param {*} urlOrApp URL(按域名自动选)或 app id('stats' / 'shop') * @param {{needExact?:boolean, autoOpen?:boolean, timeout?:number}} opts * needExact=true 时,若该子站没有可用的页面/上下文 → 抛 NAPOS_WRONG_ORIGIN(拒绝硬发) */ async function withPageFor(port, urlOrApp, fn, { needExact = false, autoOpen = true, timeout = 15000 } = {}) { const p = await probe(port); if (!p.ok) { throw new Error( `调试端口 ${port} 不可用(${p.error})。\n` + `请先完全退出淘宝闪购客户端,再用 --remote-debugging-port=${port} 重新启动。` ); } const app = resolveApp(urlOrApp); let lastFound = null; let lastCtx = new Map(); for (let attempt = 1; attempt <= 3; attempt++) { const found = await findTargetForApp(port, app); if (!found || !found.target) { throw new Error('没有找到可用的页面 target(客户端可能还没加载完业务页面)'); } const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(timeout); if (!cdp) throw new Error('CDP 连接失败'); const sessionId = await cdp.attachTo(found.target.id); const ctxMap = await collectContexts(cdp, sessionId, attempt === 1 ? 800 : 1500); lastFound = found; lastCtx = ctxMap; const usable = appUsable(app, found, ctxMap); if (usable || !needExact) { const s = (m, params) => cdp.send(m, params, sessionId); const api = { cdp, sessionId, targetId: found.target.id, target: found.target, app, exact: found.matched, contexts: ctxMap, contextFor: (u) => contextForUrl(ctxMap, u), send: s, on: (m, cb) => cdp.on(m, cb), async evaluate(expression, { awaitPromise = true, returnByValue = true, contextId } = {}) { const params = { expression, awaitPromise, returnByValue, userGesture: true, includeCommandLineAPI: true }; if (contextId) params.contextId = contextId; const r = await s('Runtime.evaluate', params); if (r.exceptionDetails) { const d = r.exceptionDetails; throw new Error(`页面内执行出错: ${d.exception?.description || d.text}`); } return r.result?.value; }, }; try { return await fn(api); } finally { await cdp.detach(sessionId).catch(() => {}); cdp.close(); } } // ❌ 来源不对 → 断开、尝试就地打开、重试 await cdp.detach(sessionId).catch(() => {}); cdp.close(); if (!autoOpen || attempt === 3) break; const opened = await tryOpenApp(port, app, found.target); if (!opened) break; await sleep(2500); } throw wrongOriginError(app, lastFound, lastCtx); } /** 向后兼容:等价于「连商家后台页」 */ async function withBusinessPage(port, fn, opts = {}) { return withPageFor(port, SHOP_APP, fn, { ...opts, needExact: false }); } // ------------------------------------------------------------------ 鉴权头 /** * 在页面里装一个「请求头采集器」:把该页面对目标域名发出的真实请求头记下来。 * 页面自己发出的请求一定带齐了服务端要的鉴权(token / bx-et …), * 我们照抄即可 —— 比猜参数可靠得多。 */ function captureInstallCode(host) { return `(() => { try { if (!window.__naposCap || !window.__naposCap.on) { const cap = { on: true, seen: {} }; window.__naposCap = cap; const TARGET = ${JSON.stringify(String(host))}; const isT = (u) => { try { return String(u || '').indexOf(TARGET) !== -1; } catch (e) { return false; } }; const norm = (h) => { const o = {}; try { if (h && typeof h.forEach === 'function') h.forEach((v, k) => { o[String(k)] = String(v); }); else if (h && typeof h === 'object') Object.keys(h).forEach((k) => { o[String(k)] = String(h[k]); }); } catch (e) {} return o; }; const rec = (u, h) => { try { if (!isT(u)) return; const key = String(u).split('?')[0]; if (!cap.seen[key]) cap.seen[key] = { h: norm(h), url: String(u), t: Date.now() }; } catch (e) {} }; const of = window.fetch; if (of) { window.fetch = function (input, init) { try { rec(typeof input === 'string' ? input : (input && input.url), (init && init.headers) || (input && input.headers)); } catch (e) {} return of.apply(this, arguments); }; } const XHR = window.XMLHttpRequest; if (XHR && XHR.prototype) { const ox = XHR.prototype.open, os = XHR.prototype.send, oh = XHR.prototype.setRequestHeader; XHR.prototype.setRequestHeader = function (k, v) { try { this.__nh = this.__nh || {}; this.__nh[k] = v; } catch (e) {} return oh.apply(this, arguments); }; XHR.prototype.open = function (m, u) { try { this.__nu = u; } catch (e) {} return ox.apply(this, arguments); }; XHR.prototype.send = function () { try { rec(this.__nu, this.__nh); } catch (e) {} return os.apply(this, arguments); }; } } return Object.keys(window.__naposCap.seen || {}); } catch (e) { return []; } })()`; } function captureReadCode() { return `(() => { try { const cap = window.__naposCap; if (!cap || !cap.seen) return null; const keys = Object.keys(cap.seen); if (!keys.length) return null; const merged = {}; for (const k of keys) Object.assign(merged, (cap.seen[k] || {}).h || {}); return merged; } catch (e) { return null; } })()`; } /** * 兜底:找 ksid。 * ⚠️ 关键:ksid 往往只存在于**外层页面**的 localStorage / Cookie, * 子站 iframe 自己那份可能没有 —— 所以要在所有上下文里挨个找。 * 抓包实证:真实请求带的鉴权头就是 `token: =-1`。 */ const KSID_PROBE = `(() => { const out = { ksid: null, source: null }; const g = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }; const s = (k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } }; for (const k of ['ksid','KSID','ksid_chain','napos_ksid','chainKsid','_ksid']) { const v = g(k) || s(k); if (v && String(v).length > 8) { out.ksid = String(v); out.source = 'storage:' + k; return out; } } try { const m = (document.cookie || '').match(/(?:^|;\\s*)ksid=([^;]+)/); if (m && m[1] && m[1].length > 8) { out.ksid = decodeURIComponent(m[1]); out.source = 'cookie'; return out; } } catch (e) {} return out; })()`; /** * ★ agw 网关(财务 / 成长)的凭证探测。 * 实测:两个 H5 子站的登录态都是同一枚 ksid,但**存放位置不同** * (财务在 localStorage.ksid,成长只在 cookie.ksid), * 所以这里按 localStorage → sessionStorage → cookie 依次兜底。 * 同时取 unitId 用于拼业务上下文头 `X-AGW-BIZ-CONTEXT: currentShopId=`。 */ const AGW_CRED_PROBE = `(() => { try { const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } }; const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } }; const ksid = g(localStorage, 'ksid') || g(sessionStorage, 'ksid') || ck('ksid'); const unitId = g(localStorage, 'unitId') || g(sessionStorage, 'unitId') || ck('unitId'); const shopId = g(localStorage, 'shopId') || g(sessionStorage, 'shopId') || ck('shopId'); return { ksid: ksid ? String(ksid) : null, unitId: unitId || null, shopId: shopId || null }; } catch (e) { return null; } })()`; const CHANNEL_ID_PROBE = `(() => { try { const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } }; const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } }; const one = (k) => g(localStorage, k) || g(sessionStorage, k) || ck(k); return { ksid: one('ksid') || null, unitId: one('unitId') || null, keeperId: one('keeperId') || null, loginShopId: one('loginShopId') || null, shopName: g(localStorage, 'shopName') || null, }; } catch (e) { return null; } })()`; /** 在所有可用的执行上下文(含外层页面)里找 ksid */ async function findKsid(api) { const ids = []; if (api && api.contexts && api.contexts.size) for (const c of api.contexts.values()) ids.push(c.id); ids.push(undefined); // 再加一次默认上下文兜底 for (const contextId of ids) { try { const v = await api.evaluate(KSID_PROBE, contextId ? { contextId } : undefined); if (v && v.ksid) return v.ksid; } catch { /* 换下一个上下文 */ } } return null; } /** * 为一个跨域数据接口凑齐鉴权头。 * ① 照抄「该子站自己发出的真实请求头」—— 最准 * ② 兜底:跨上下文找 ksid,按 `token: =-1` 拼出来(格式来自真实抓包) */ async function buildAuthHeaders(api, url) { const app = appForUrl(url); if (!app) return {}; // ★ 渠道风格(闪购联盟 / 店铺推广 / 金融保险) // 鉴权分两种,**按域名区分**(实测血泪): // · agw 网关(推广余额)→ ksid 请求头 // · app-api.shop.ele.me 的 callFromNapos → **不能带 ksid 头**!它只允许 content-type // (CORS 白名单里没有 ksid),ksid 必须放进 body.metas(由 kit 层注入); // · httpizza.ele.me(金融)→ 只靠 Cookie,无需任何自定义头。 if (app.authStyle === 'napos') { const host = hostOf(url); const out = { Accept: 'application/json, text/plain, */*' }; if (/(^|\.)agw\./i.test(host)) { const ksid = await findKsid(api); if (ksid) out.ksid = String(ksid); } return out; } // ★ agw 风格(财务管理 / 成长中心) // 与 lsycm 的 `token: =-1` 完全不同 —— 它要的是 // ksid:
// current-shop-id: <-1 | ''> // X-AGW-BIZ-CONTEXT: currentShopId= // 凭证一律「在该子站自己的执行上下文里」运行时读取,不硬编码。 if (app.authStyle === 'agw') { const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null; const evalOpt = ctx ? { contextId: ctx.id } : undefined; let cred = null; try { cred = await api.evaluate(AGW_CRED_PROBE, evalOpt); } catch { /* ignore */ } if (!cred || !cred.ksid) return {}; const out = { ksid: String(cred.ksid), 'current-shop-id': (app.agw && app.agw.currentShopId != null) ? String(app.agw.currentShopId) : '', }; const unit = cred.unitId || cred.shopId; if (unit) out['X-AGW-BIZ-CONTEXT'] = 'currentShopId=' + unit; if (app.agw && app.agw.contentType) out['Content-Type'] = app.agw.contentType; out.Accept = 'application/json, text/plain, */*'; return out; } if (!app.authCarrierHosts || !app.authCarrierHosts.length) return {}; const host = hostOf(url); if (!app.authCarrierHosts.some((re) => re.test(host))) return {}; // ★ 必须在「属于该域的上下文」里采集 —— 外层页面的请求头跟子站不是一套 const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null; const evalOpt = ctx ? { contextId: ctx.id } : undefined; let captured = null; try { await api.evaluate(captureInstallCode(host), evalOpt); } catch { /* 忽略 */ } try { captured = await api.evaluate(captureReadCode(), evalOpt); } catch { /* 忽略 */ } const out = {}; if (captured && typeof captured === 'object') { for (const [k, v] of Object.entries(captured)) { const lk = String(k).toLowerCase(); if (FORBIDDEN_REQ_HEADERS.has(lk)) continue; if (lk === 'content-type' || lk === 'accept') continue; // 由调用方决定 out[k] = String(v); } } const hasToken = Object.keys(out).some((k) => k.toLowerCase() === 'token'); if (!hasToken) { const ksid = await findKsid(api); if (ksid) out.token = `${ksid}=-1`; } return out; } // ------------------------------------------------------------------ 请求节流 let _chain = Promise.resolve(); let _lastReqAt = 0; const MIN_GAP_MS = Number(process.env.NAPOS_MIN_GAP_MS || 300); /** 串行 + 最小间隔:短时间高频请求本身就是触发风控的主因之一 */ function throttle() { const p = _chain.then(async () => { const wait = _lastReqAt + MIN_GAP_MS - Date.now(); if (wait > 0) await sleep(wait); _lastReqAt = Date.now(); }); _chain = p.catch(() => {}); return p; } // ------------------------------------------------------------------ 发请求 /** * 刷新请求体里的一次性 id。 * * 后台的 RPC 调用普遍带 `"id": "<32位hex>|<毫秒时间戳>"`, * 服务端会校验/去重,直接重放旧值可能返回 SERVER_ERROR。 * 这里把符合该模式的 id 重新生成。 */ function refreshRequestId(body) { if (typeof body !== 'string') return body; const t = body.trim(); if (!t || t[0] !== '{') return body; let obj; try { obj = JSON.parse(t); } catch { return body; } let changed = false; const REGEN = /^[0-9a-f]{32}\|\d+$/i; const walk = (v) => { if (Array.isArray(v)) { v.forEach(walk); return; } if (!v || typeof v !== 'object') return; for (const k of Object.keys(v)) { if (k === 'id' && typeof v[k] === 'string' && REGEN.test(v[k])) { const hex = Array.from({ length: 32 }, () => '0123456789ABCDEF'[Math.floor(Math.random() * 16)]).join(''); v[k] = `${hex}|${Date.now()}`; changed = true; } else walk(v[k]); } }; walk(obj); return changed ? JSON.stringify(obj) : body; } /** * 用业务页面的身份发一次请求。 * * v0.2 修正(解决"每次调用都触发客户端鉴权"): * · 执行上下文路由 —— 在**域匹配的那个 iframe 上下文**里发请求(关键修复)。 * 子站是跨域 iframe,从外层页面发就是跨站 → 鉴权/风控 * · credentials: 'include' —— 跨子域时才会带 Cookie(原实现 same-origin,等于一个都不带) * · 自动补鉴权头 —— 从子站自己的真实请求里抄 token / bx-et * · 请求串行 + 最小间隔 —— 避免高频请求触发风控 * · 请求超时 —— 被风控挂死时快速失败,不再卡 90 秒 * · 识别风控/未登录响应 —— 用 risk / authFail 字段明确回报,不再假装成功 * * 🔒 写操作防护:所有出站请求都必须先过 guard(默认只放行读接口)。 */ async function pageFetch(api, { method = 'GET', url, body, headers = {}, raw = false, allowWrite, guardIndex, authHeaders = true, timeoutMs, }) { const M = String(method || 'GET').toUpperCase(); // ---- 写操作防护(唯一出口,拦在这里就够了) guard.assertAllowed({ method: M, url, body }, { index: guardIndex, allowWrite }); await throttle(); const extra = authHeaders === false ? {} : await buildAuthHeaders(api, url); const payload = { method: M, credentials: 'include', headers: { 'Content-Type': 'application/json', ...extra, ...headers }, }; if (body != null && M !== 'GET' && M !== 'HEAD') { payload.body = typeof body === 'string' ? refreshRequestId(body) : JSON.stringify(body); } // ★ 关键:把请求放进"拥有该域的那个上下文"里执行 const wantApp = appForUrl(url); const ctx = (typeof api.contextFor === 'function') ? api.contextFor(url) : null; const originMismatch = !!(wantApp && wantApp !== SHOP_APP && !ctx); const pageUrl = (api && api.target && api.target.url) || ''; const ms = Number(timeoutMs || process.env.NAPOS_REQ_TIMEOUT_MS || 30000); const code = ` (async () => { const AC = (typeof AbortController !== 'undefined') ? AbortController : null; const ctl = AC ? new AC() : null; const timer = ctl ? setTimeout(() => ctl.abort(), ${ms}) : null; try { const res = await fetch(${JSON.stringify(url)}, Object.assign(${JSON.stringify(payload)}, ctl ? { signal: ctl.signal } : {})); const text = await res.text(); let data; let parsed = true; try { data = JSON.parse(text); } catch (e) { parsed = false; data = text.slice(0, 200000); } if (timer) clearTimeout(timer); return { ok: res.ok, status: res.status, url: res.url, parsed, data, pageUrl: location.href, origin: location.origin }; } catch (e) { if (timer) clearTimeout(timer); return { ok: false, timeout: String(e && e.name) === 'AbortError', error: String((e && e.message) || e), pageUrl: location.href, origin: location.origin }; } })() `; const out = await api.evaluate(code, ctx ? { contextId: ctx.id } : undefined); if (out && typeof out === 'object') { const probeText = typeof out.data === 'string' ? out.data : JSON.stringify(out.data || ''); out.risk = RISK_RE.test(probeText); out.authFail = AUTH_RE.test(probeText); out.pageUrl = out.pageUrl || pageUrl; out.contextOrigin = ctx ? ctx.origin : null; if (originMismatch) out.originMismatch = true; } return out; } module.exports = { DEFAULT_PORT, BIZ_HOST_HINT, APPS, FORBIDDEN_REQ_HEADERS, httpJson, sleep, probe, listTargets, hostOf, appForUrl, resolveApp, contextForUrl, appUsable, ctxMatchesApp, findBusinessTarget, findTargetForApp, ensureAppTarget, openAppTarget, tryOpenApp, rankTarget, rankTargetForApp, targetMatchesApp, isPageTarget, CDP, withBusinessPage, withPageFor, pageFetch, refreshRequestId, buildAuthHeaders, throttle, // ★ 供 skill / kit.js 运行时读取登录态用(跨执行上下文查找,不硬编码) findKsid, KSID_PROBE, AGW_CRED_PROBE, CHANNEL_ID_PROBE, };