| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954 |
- '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=<unitId>
- 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();
- this.dead = false;
- }
- async open(timeout = 10000) {
- this.ws = new RawWebSocket(this.wsUrl);
- this.ws.on('message', (m) => this._onMessage(m));
- // 必须接住 error/close:底层 socket 出错时 EventEmitter 无人监听 'error'
- // 会直接崩掉整个进程;断连后 pending 也要立刻兑现而不是干等超时。
- this.ws.on('error', (e) => this._onDead(e instanceof Error ? e : new Error(String(e || 'WebSocket 连接错误'))));
- this.ws.on('close', () => this._onDead(new Error('WebSocket 连接已关闭')));
- await this.ws.connect(timeout);
- return this;
- }
- /** 连接死亡:把所有在途请求立即以该错误兑现,后续 send 也会快速失败 */
- _onDead(err) {
- if (this.dead) return;
- this.dead = true;
- const waiting = [...this.pending.values()];
- this.pending.clear();
- for (const { reject, timer } of waiting) {
- clearTimeout(timer);
- reject(err);
- }
- }
- _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) => {
- if (this.dead || !this.ws || this.ws.readyState !== 1) {
- return reject(new Error(`CDP 连接不可用: ${method}`));
- }
- 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() {
- this._onDead(new Error('CDP 连接已关闭'));
- 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 = new CDP(p.version.webSocketDebuggerUrl);
- try {
- await cdp.open(12000);
- } catch {
- return null; // 连接没建起来,cdp.close() 里已无 socket 可关
- }
- 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 = new CDP(p.version.webSocketDebuggerUrl);
- try {
- await cdp.open(12000);
- 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 = new CDP(p.version.webSocketDebuggerUrl);
- let sessionId;
- try {
- await cdp.open(timeout);
- sessionId = await cdp.attachTo(found.target.id);
- } catch (e) {
- // 建连/附加失败必须关掉半开连接,否则 socket 泄漏拖住进程退不出
- cdp.close();
- throw new Error(`CDP 连接失败(第 ${attempt} 次尝试):${(e && e.message) || e}`);
- }
- 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: <ksid>=-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=<unitId>`。
- */
- 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: <ksid>=-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: <ksid>=-1` 完全不同 —— 它要的是
- // ksid: <H5 自己的 ksid>
- // current-shop-id: <-1 | ''>
- // X-AGW-BIZ-CONTEXT: currentShopId=<unitId>
- // 凭证一律「在该子站自己的执行上下文里」运行时读取,不硬编码。
- 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,
- };
|