|
@@ -0,0 +1,966 @@
|
|
|
|
|
+'use strict';
|
|
|
|
|
+/**
|
|
|
|
|
+ * napos kit —— 给「运营 skill 脚本」复用的运行时工具箱。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么单独抽一层:
|
|
|
|
|
+ * MCP 的 9 个工具是「一次请求」粒度(napos_call),拿不下跨域签名、多轮拼装、
|
|
|
|
|
+ * 分页聚合这类活儿。skill 需要的是「一次调用完成一个业务动作」。
|
|
|
|
|
+ * 所以这里把公共能力沉淀下来:登录态获取、签名、跨域取数、输出落盘。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 可迁移的关键设计:ksid(登录态)一律**运行时从页面里读**,绝不硬编码。
|
|
|
|
|
+ * 硬编码的脚本拷到别的电脑必然失效 —— 这是能力迁移的第一道坎。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 依赖:仅 ./cdp(同目录)。零第三方依赖。
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+const fs = require('fs');
|
|
|
|
|
+const path = require('path');
|
|
|
|
|
+const crypto = require('crypto');
|
|
|
|
|
+const cdp = require('./cdp');
|
|
|
|
|
+const guard = require('./guard');
|
|
|
|
|
+
|
|
|
|
|
+/** 数据中心里「实测不带签名也安全」的白名单(与 guard 共用同一份,避免两处打架) */
|
|
|
|
|
+const SIGN_SAFE_PATHS = guard.SIGN_SAFE_PATHS;
|
|
|
|
|
+
|
|
|
|
|
+const DEFAULT_PORT = Number(process.env.NAPOS_CDP_PORT || cdp.DEFAULT_PORT || 9669);
|
|
|
|
|
+
|
|
|
|
|
+/** 经营数据(数据中心)—— 唯一的自定义区间取数口 */
|
|
|
|
|
+const HISTORY_URL = 'https://lsycm.alibaba.com/api/overview/chain/custom/historyOverview';
|
|
|
|
|
+const HISTORY_METHOD = 'historyOverview'; // 参与签名的 targetId
|
|
|
|
|
+const SALT = '!@naposStats@!';
|
|
|
|
|
+
|
|
|
|
|
+const md5 = (s) => crypto.createHash('md5').update(String(s), 'utf8').digest('hex');
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 接口签名。算法来自真实抓包反推:
|
|
|
|
|
+ * secretkey = md5(ksid + '&' + targetId + '&' + timestamp + '&' + '!@naposStats@!')
|
|
|
|
|
+ */
|
|
|
|
|
+function sign(ksid, targetId, timestamp, salt = SALT) {
|
|
|
|
|
+ return md5(`${ksid}&${targetId}&${timestamp}&${salt}`);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 登录态
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 运行时读取登录态 ksid。
|
|
|
|
|
+ * 不传 api 时自己开一次页面连接;传了就复用(省一次握手)。
|
|
|
|
|
+ * @returns {Promise<string|null>}
|
|
|
|
|
+ */
|
|
|
|
|
+async function getKsid({ port = DEFAULT_PORT, api = null } = {}) {
|
|
|
|
|
+ if (api) return cdp.findKsid(api);
|
|
|
|
|
+ return cdp.withPageFor(port, 'shop', (a) => cdp.findKsid(a), { autoOpen: false });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 A:直接调(无需签名)
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 调一个商家后台/数据中心接口(不带签名)。
|
|
|
|
|
+ * 自动按域名选正确的子站页面;拿不到正确来源页时**拒发**(避免触发客户端鉴权)。
|
|
|
|
|
+ */
|
|
|
|
|
+async function call({ port = DEFAULT_PORT, url, method = 'GET', query = {}, body = null, headers = {}, needExact = true } = {}) {
|
|
|
|
|
+ if (!url) throw new Error('call 需要 url');
|
|
|
|
|
+ let full = url;
|
|
|
|
|
+ if (query && Object.keys(query).length) {
|
|
|
|
|
+ const u = new URL(url);
|
|
|
|
|
+ for (const [k, v] of Object.entries(query)) {
|
|
|
|
|
+ if (v === undefined || v === null) continue;
|
|
|
|
|
+ u.searchParams.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
|
|
|
+ }
|
|
|
|
|
+ full = u.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ return cdp.withPageFor(port, full, (api) =>
|
|
|
|
|
+ cdp.pageFetch(api, { method, url: full, body, headers, allowWrite: false }),
|
|
|
|
|
+ { needExact, autoOpen: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 B:签名调(经营数据)
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 经营数据 historyOverview —— 支持任意区间(含超 30 天)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {object} o
|
|
|
|
|
+ * @param {string} [o.chainId='-1'] 链 ID,'-1' = 全公司
|
|
|
|
|
+ * @param {string} [o.shopListIn=''] 门店 ID;非空 = 单店/多店粒度(这是钥匙,不是 shopId)
|
|
|
|
|
+ * @param {string} [o.from] 起始日 YYYYMMDD
|
|
|
|
|
+ * @param {string} [o.to] 结束日 YYYYMMDD
|
|
|
|
|
+ * @param {string} [o.dateType='self_define'] ★ 自定义区间必须用 self_define
|
|
|
|
|
+ * 预设值:recent_7d / recent_30d / week / month
|
|
|
|
|
+ * @param {string} [o.compareOption='']
|
|
|
|
|
+ * @returns {Promise<{code:*, all:object|null, rows:object[], daily:object[], ksidSource:string}>}
|
|
|
|
|
+ */
|
|
|
|
|
+async function statsHistory(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const url = o.url || HISTORY_URL;
|
|
|
|
|
+ const target = o.target || HISTORY_METHOD;
|
|
|
|
|
+ const chainId = String(o.chainId == null ? '-1' : o.chainId);
|
|
|
|
|
+ const shopListIn = o.shopListIn == null ? '' : String(o.shopListIn);
|
|
|
|
|
+ const dateType = o.dateType || 'self_define';
|
|
|
|
|
+ const from = normDay(o.from);
|
|
|
|
|
+ const to = normDay(o.to);
|
|
|
|
|
+ const key = o.key || dateType;
|
|
|
|
|
+
|
|
|
|
|
+ return cdp.withPageFor(port, url, async (api) => {
|
|
|
|
|
+ const ksid = await cdp.findKsid(api);
|
|
|
|
|
+ if (!ksid) {
|
|
|
|
|
+ const err = new Error(
|
|
|
|
|
+ '读不到登录态 ksid:客户端可能未登录,或数据中心页面未就绪。\n' +
|
|
|
|
|
+ ' 处理:在客户端打开「数据中心/生意参谋」页面后重试。'
|
|
|
|
|
+ );
|
|
|
|
|
+ err.code = 'NAPOS_NO_KSID';
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ const ts = Date.now();
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ shopId: '', secretkey: sign(ksid, target, ts), timestamp: ts,
|
|
|
|
|
+ chainId, dateType, key, compareOption: o.compareOption || '',
|
|
|
|
|
+ beginDate: from || '', endDate: to || '', bizDate: o.bizDate || to || '',
|
|
|
|
|
+ shopListIn,
|
|
|
|
|
+ };
|
|
|
|
|
+ const res = await cdp.pageFetch(api, { method: 'POST', url, body, allowWrite: false });
|
|
|
|
|
+ const d = (res && res.data) || {};
|
|
|
|
|
+ let rowsRaw = [];
|
|
|
|
|
+ if (Array.isArray(d.data)) rowsRaw = d.data;
|
|
|
|
|
+ else if (d.data && Array.isArray(d.data.list)) rowsRaw = d.data.list;
|
|
|
|
|
+ else if (Array.isArray(d.result)) rowsRaw = d.result;
|
|
|
|
|
+ const daily = rowsRaw.filter((x) => x && x.ds && /^\d{8}$/.test(String(x.ds)));
|
|
|
|
|
+ const all = rowsRaw.find((x) => x && x.ds === 'all') || null;
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail,
|
|
|
|
|
+ code: d.code != null ? d.code : (d.success === false ? 'success:false' : undefined),
|
|
|
|
|
+ msg: d.msg || d.message || undefined,
|
|
|
|
|
+ contextOrigin: res.contextOrigin || null,
|
|
|
|
|
+ all, rows: rowsRaw, daily,
|
|
|
|
|
+ _raw: res,
|
|
|
|
|
+ };
|
|
|
|
|
+ }, { needExact: true, autoOpen: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normDay(v) {
|
|
|
|
|
+ if (v == null || v === '') return '';
|
|
|
|
|
+ const s = String(v).replace(/[-/.]/g, '').trim();
|
|
|
|
|
+ return /^\d{8}$/.test(s) ? s : String(v);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 天数差(YYYYMMDD) */
|
|
|
|
|
+function dayDiff(from, to) {
|
|
|
|
|
+ const p = (s) => new Date(Number(s.slice(0, 4)), Number(s.slice(4, 6)) - 1, Number(s.slice(6, 8)));
|
|
|
|
|
+ return Math.round((p(normDay(to)) - p(normDay(from))) / 86400000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 今天 YYYYMMDD(本地时区) */
|
|
|
|
|
+function today() {
|
|
|
|
|
+ const d = new Date();
|
|
|
|
|
+ const z = (n) => String(n).padStart(2, '0');
|
|
|
|
|
+ return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** N 天前 YYYYMMDD */
|
|
|
|
|
+function daysAgo(n) {
|
|
|
|
|
+ const d = new Date(Date.now() - n * 86400000);
|
|
|
|
|
+ const z = (x) => String(x).padStart(2, '0');
|
|
|
|
|
+ return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 C:xtop 网关(商家后台)
|
|
|
|
|
+
|
|
|
|
|
+const XTOP_BASE = 'https://app-api.shop.ele.me/xtop';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 调 xtop 网关接口。
|
|
|
|
|
+ * 真实请求体结构(来自抓包):
|
|
|
|
|
+ * { metas: { shopId, ksid, loginRestaurantId }, params: { request: {...} } }
|
|
|
|
|
+ * ★ ksid 在 body 里也要带 —— 同样运行时读取,不硬编码。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {object} o
|
|
|
|
|
+ * @param {string} o.method 完整服务方法名,如 'xtop.napos.keeper.shop.queryShopTree'
|
|
|
|
|
+ * @param {object} [o.request] 业务参数(放进 params.request)
|
|
|
|
|
+ * @param {number|string} [o.shopId=-1]
|
|
|
|
|
+ * @param {number|string} [o.loginRestaurantId=-1]
|
|
|
|
|
+ */
|
|
|
|
|
+async function xtop(o = {}) {
|
|
|
|
|
+ if (!o.method) throw new Error('xtop 需要 method');
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const url = `${XTOP_BASE}/${o.method}/1.0`;
|
|
|
|
|
+ return cdp.withPageFor(port, 'shop', async (api) => {
|
|
|
|
|
+ const ksid = o.ksid || (await cdp.findKsid(api));
|
|
|
|
|
+ if (!ksid) {
|
|
|
|
|
+ const err = new Error('读不到登录态 ksid —— 客户端可能未登录。');
|
|
|
|
|
+ err.code = 'NAPOS_NO_KSID';
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ metas: { shopId: o.shopId == null ? -1 : o.shopId, ksid, loginRestaurantId: o.loginRestaurantId == null ? -1 : o.loginRestaurantId },
|
|
|
|
|
+ params: { request: o.request || {} },
|
|
|
|
|
+ };
|
|
|
|
|
+ const res = await cdp.pageFetch(api, { method: 'POST', url, body, allowWrite: false });
|
|
|
|
|
+ return { ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail, data: res.data };
|
|
|
|
|
+ }, { needExact: false, autoOpen: false });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 门店组织树 —— 一次拿到「总部 → 连锁 → 门店」三层 + 每店营业状态。
|
|
|
|
|
+ * @returns {Promise<{merchant:object|null, chains:object[], shops:object[], raw:object}>}
|
|
|
|
|
+ */
|
|
|
|
|
+async function shopTree(o = {}) {
|
|
|
|
|
+ const r = await xtop({
|
|
|
|
|
+ port: o.port,
|
|
|
|
|
+ method: 'xtop.napos.keeper.shop.queryShopTree',
|
|
|
|
|
+ request: { restaurantId: o.restaurantId == null ? -1 : o.restaurantId, rootChainId: o.rootChainId == null ? -1 : o.rootChainId },
|
|
|
|
|
+ shopId: -1, loginRestaurantId: -1,
|
|
|
|
|
+ });
|
|
|
|
|
+ const root = (r.data && r.data.result && r.data.result.shopTreeNode) || null;
|
|
|
|
|
+ const chains = [];
|
|
|
|
|
+ const shops = [];
|
|
|
|
|
+ const walk = (n, chain) => {
|
|
|
|
|
+ if (!n) return;
|
|
|
|
|
+ if (n.nodeType === 'SHOP') {
|
|
|
|
|
+ shops.push({
|
|
|
|
|
+ shopId: n.id, shopName: n.name, busyLevel: n.busyLevel || null,
|
|
|
|
|
+ chainId: chain ? chain.chainId : null, chainName: chain ? chain.chainName : null,
|
|
|
|
|
+ restaurantType: n.restaurantType, shopBizType: n.shopBizType, shopSubBizType: n.shopSubBizType,
|
|
|
|
|
+ });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (n.nodeType === 'ROOT_CHAIN' || n.menuType === 'CHAIN') {
|
|
|
|
|
+ const c = { chainId: n.id, chainName: n.name, parentId: n.parentId };
|
|
|
|
|
+ chains.push(c);
|
|
|
|
|
+ (n.childList || []).forEach((k) => walk(k, c));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ (n.childList || []).forEach((k) => walk(k, chain)); // MERCHANT / UNIT 层透传
|
|
|
|
|
+ };
|
|
|
|
|
+ walk(root, null);
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: r.ok, code: (r.data && r.data.code) || r.status,
|
|
|
|
|
+ merchant: root ? { id: root.id, name: root.name, nodeType: root.nodeType } : null,
|
|
|
|
|
+ chains, shops, raw: r.data,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 营业状态语义(busyLevel) */
|
|
|
|
|
+const BUSY_LEVEL = {
|
|
|
|
|
+ OPEN: { label: '营业中', group: '在营', weight: 1 },
|
|
|
|
|
+ CLOSE: { label: '休息中(非营业时段)', group: '在营', weight: 2 },
|
|
|
|
|
+ SUSPEND_BUSINESS: { label: '暂停营业', group: '异常', weight: 3 },
|
|
|
|
|
+ INVALID: { label: '未生效 / 已下架', group: '异常', weight: 4 },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 D:数据中心其它只读接口
|
|
|
|
|
+
|
|
|
|
|
+const STATS_BASE = 'https://lsycm.alibaba.com';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 调数据中心(lsycm)任意只读 POST 接口。
|
|
|
|
|
+ * 自动走数据中心上下文 + 自动补齐该域鉴权头。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 风控自保:lsycm 的验签接口若不带 secretkey,服务端判为伪造并弹验证码。
|
|
|
|
|
+ * 这里对「非白名单」接口**自动补签名**(timestamp + secretkey),调用方无需关心。
|
|
|
|
|
+ */
|
|
|
|
|
+async function statsApi(pathname, body = {}, o = {}) {
|
|
|
|
|
+ const url = /^https?:/i.test(pathname) ? pathname : STATS_BASE + pathname;
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const path = (() => { try { return new URL(url).pathname; } catch { return pathname; } })();
|
|
|
|
|
+ const isStats = /(^|\.)lsycm\.alibaba\.com$/i.test((() => { try { return new URL(url).hostname; } catch { return ''; } })());
|
|
|
|
|
+ const needSign = isStats && !SIGN_SAFE_PATHS.some((re) => re.test(path));
|
|
|
|
|
+
|
|
|
|
|
+ return cdp.withPageFor(port, url, async (api) => {
|
|
|
|
|
+ const payload = { ...body };
|
|
|
|
|
+ if (needSign && !payload.secretkey) {
|
|
|
|
|
+ const ksid = await cdp.findKsid(api);
|
|
|
|
|
+ if (!ksid) {
|
|
|
|
|
+ const err = new Error('读不到登录态 ksid,无法为验签接口补签名。请确认客户端已登录。');
|
|
|
|
|
+ err.code = 'NAPOS_NO_KSID';
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ const ts = Date.now();
|
|
|
|
|
+ // targetId 对齐前端真实行为:有 chainId 用 chainId,否则用 shopId(实测 16 个接口 100% 命中)
|
|
|
|
|
+ const targetId = o.target != null
|
|
|
|
|
+ ? o.target
|
|
|
|
|
+ : (payload.chainId != null && payload.chainId !== ''
|
|
|
|
|
+ ? String(payload.chainId)
|
|
|
|
|
+ : (payload.shopId != null && payload.shopId !== '' ? String(payload.shopId) : path.split('/').filter(Boolean).pop()));
|
|
|
|
|
+ payload.timestamp = ts;
|
|
|
|
|
+ payload.secretkey = sign(ksid, targetId, ts);
|
|
|
|
|
+ payload.shopId = payload.shopId == null ? '' : payload.shopId;
|
|
|
|
|
+ if (payload.compareOption == null) payload.compareOption = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const r = await cdp.pageFetch(api, { method: o.method || 'POST', url, body: payload, allowWrite: false });
|
|
|
|
|
+ return { ok: !!r.ok, status: r.status, risk: !!r.risk, authFail: !!r.authFail, contextOrigin: r.contextOrigin, data: r.data };
|
|
|
|
|
+ }, { needExact: true, autoOpen: false });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 经营归因(近 7 日 vs 上周)—— 返回 factors[],指出「谁在拉高/拉低」。
|
|
|
|
|
+ * ⚠️ 固定窗口,不随日期参数变化;要自定义区间请用 statsHistory()。
|
|
|
|
|
+ */
|
|
|
|
|
+async function businessSummary(o = {}) {
|
|
|
|
|
+ const r = await statsApi('/api/overview/chain/custom/queryBusinessSummary',
|
|
|
|
|
+ { chainId: String(o.chainId == null ? '-1' : o.chainId) }, { port: o.port });
|
|
|
|
|
+ const d = (r.data && r.data.data) || null;
|
|
|
|
|
+ return { ...r, summary: d };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 城市维度汇总(近 7 日 vs 上周)—— 用于区域/渠道对比。
|
|
|
|
|
+ * ⚠️ 同样是固定窗口。
|
|
|
|
|
+ */
|
|
|
|
|
+async function cityList(o = {}) {
|
|
|
|
|
+ const r = await statsApi('/api/naposOverview/queryChainCityList',
|
|
|
|
|
+ { chainId: String(o.chainId == null ? '-1' : o.chainId) }, { port: o.port });
|
|
|
|
|
+ const rows = (r.data && r.data.data && r.data.data.data) || (r.data && r.data.data) || [];
|
|
|
|
|
+ return { ...r, cities: Array.isArray(rows) ? rows : [] };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 数据最近更新时间 */
|
|
|
|
|
+async function latestTime(o = {}) {
|
|
|
|
|
+ const r = await statsApi('/api/naposOverview/getLatestTime', {}, { port: o.port });
|
|
|
|
|
+ return (r.data && r.data.data) || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 D:melody 业务接口(履约 / 订单 / 设置)
|
|
|
|
|
+
|
|
|
|
|
+const MELODY_BASE = 'https://app-api.shop.ele.me';
|
|
|
|
|
+
|
|
|
|
|
+/** 生成 melody 风格请求 id:<32位大写HEX>|<毫秒时间戳> */
|
|
|
|
|
+function melodyReqId() {
|
|
|
|
|
+ let h = '';
|
|
|
|
|
+ for (let i = 0; i < 32; i++) h += '0123456789ABCDEF'[(Math.random() * 16) | 0];
|
|
|
|
|
+ return `${h}|${Date.now()}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 调商家后台「melody」业务接口(body 结构来自真实抓包)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * body = { service, method, params, id, metas:{appVersion,appName:'melody',ksid,shopId}, ncp:'2.0.0' }
|
|
|
|
|
+ * ★ ksid 放在 metas 里 —— 运行时读取,不硬编码。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {object} o
|
|
|
|
|
+ * @param {string} o.service 如 'PollingService'
|
|
|
|
|
+ * @param {string} o.method 如 'abnormalOrders'
|
|
|
|
|
+ * @param {string} o.path 接口路径,如 '/fulfill/weborder/abnormalOrders/'
|
|
|
|
|
+ * @param {object} [o.params] 业务参数(内含 shopId)
|
|
|
|
|
+ */
|
|
|
|
|
+async function melody(o = {}) {
|
|
|
|
|
+ if (!o.service || !o.method || !o.path) throw new Error('melody 需要 service / method / path');
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const url = `${MELODY_BASE}${o.path}?method=${o.service}.${o.method}`;
|
|
|
|
|
+ return cdp.withPageFor(port, url, async (api) => {
|
|
|
|
|
+ const ksid = o.ksid || (await cdp.findKsid(api));
|
|
|
|
|
+ if (!ksid) {
|
|
|
|
|
+ const err = new Error('读不到登录态 ksid —— 客户端可能未登录。');
|
|
|
|
|
+ err.code = 'NAPOS_NO_KSID';
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ const params = o.params || {};
|
|
|
|
|
+ const shopId = o.shopId != null ? o.shopId : (params.shopId != null ? params.shopId : -1);
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ service: o.service, method: o.method, params,
|
|
|
|
|
+ id: melodyReqId(),
|
|
|
|
|
+ metas: { appVersion: o.appVersion || '1.0.0', appName: 'melody', ksid, shopId },
|
|
|
|
|
+ ncp: '2.0.0',
|
|
|
|
|
+ };
|
|
|
|
|
+ const res = await cdp.pageFetch(api, { method: 'POST', url, body, allowWrite: false });
|
|
|
|
|
+ const d = res.data || {};
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail,
|
|
|
|
|
+ error: d.error || null,
|
|
|
|
|
+ result: d.result !== undefined ? d.result : (d.data !== undefined ? d.data : null),
|
|
|
|
|
+ code: d.code, raw: d,
|
|
|
|
|
+ };
|
|
|
|
|
+ }, { needExact: false, autoOpen: false });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 订单履约巡检 —— 一次抓「当前」的异常/待办订单计数(全公司或指定门店)。
|
|
|
|
|
+ * ⚠️ 这是**实时快照**,不是历史;要看趋势必须定时落库累积。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns {Promise<{newOrder:number, urge:number, preparing:number, callDelivery:number,
|
|
|
|
|
+ * riderAccept:number, unprocessedBooking:number, bookingOrderIds:string[],
|
|
|
|
|
+ * exceptional:number, cancel:number, refund:number, fastRefund:number,
|
|
|
|
|
+ * noProcesNegotiate:*, timeOutNegotiate:*, needRemindException:boolean, raw:object}>}
|
|
|
|
|
+ */
|
|
|
|
|
+async function orderAlerts(o = {}) {
|
|
|
|
|
+ const shopId = o.shopId == null ? -1 : o.shopId;
|
|
|
|
|
+ const P = { port: o.port, ksid: o.ksid, shopId };
|
|
|
|
|
+ const [ab, non, un] = await Promise.all([
|
|
|
|
|
+ melody({ ...P, service: 'PollingService', method: 'abnormalOrders', path: '/fulfill/weborder/abnormalOrders/', params: { shopId } }),
|
|
|
|
|
+ melody({ ...P, service: 'PollingService', method: 'nonCoreOrders', path: '/fulfill/weborder/nonCoreOrders/', params: { shopId } }),
|
|
|
|
|
+ melody({ ...P, service: 'PollingService', method: 'unprocessedOrders', path: '/fulfill/weborder/unprocessedOrders/', params: { shopId } }),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const A = (ab.result && typeof ab.result === 'object') ? ab.result : {};
|
|
|
|
|
+ const N = (non.result && typeof non.result === 'object') ? non.result : {};
|
|
|
|
|
+ const U = (un.result && typeof un.result === 'object') ? un.result : {};
|
|
|
|
|
+ const n = (v) => Number(v) || 0;
|
|
|
|
|
+ const bookingIds = (N.bookingNotice && Array.isArray(N.bookingNotice.orderIds)) ? N.bookingNotice.orderIds : [];
|
|
|
|
|
+ return {
|
|
|
|
|
+ shopId,
|
|
|
|
|
+ newOrder: n(U.newOrderCount),
|
|
|
|
|
+ urge: n(N.urgeOrderCount),
|
|
|
|
|
+ preparing: n(N.preparingOrderCount),
|
|
|
|
|
+ callDelivery: n(N.waitingCallDeliveryCount),
|
|
|
|
|
+ riderAccept: n(N.riderAcceptOrderCount),
|
|
|
|
|
+ unprocessedBooking: n(N.unprocessedBookingOrderCount),
|
|
|
|
|
+ bookingOrderIds: bookingIds,
|
|
|
|
|
+ exceptional: n(A.exceptionalOrderCount),
|
|
|
|
|
+ cancel: n(A.cancelOrderCount),
|
|
|
|
|
+ refund: n(A.refundOrderCount),
|
|
|
|
|
+ fastRefund: n(A.fastRefundOrderCount),
|
|
|
|
|
+ noProcesNegotiate: A.noProcesNegotiateOrder == null ? null : n(A.noProcesNegotiateOrder),
|
|
|
|
|
+ timeOutNegotiate: A.timeOutNegotiateOrder == null ? null : n(A.timeOutNegotiateOrder),
|
|
|
|
|
+ needRemindException: !!A.needRemindExceptionOrder,
|
|
|
|
|
+ raw: { abnormal: A, nonCore: N, unprocessed: U },
|
|
|
|
|
+ risk: ab.risk || non.risk || un.risk,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 接单/履约设置(91 字段,含预订单、备餐提醒、自动接单等) */
|
|
|
|
|
+async function orderSetting(o = {}) {
|
|
|
|
|
+ const shopId = o.shopId == null ? -1 : o.shopId;
|
|
|
|
|
+ const r = await melody({ port: o.port, ksid: o.ksid, shopId, service: 'ShopSettingService', method: 'getOrderSetting', path: '/nevermore/invoke/', params: { shopId } });
|
|
|
|
|
+ const setting = (r.result && r.result.result) ? r.result.result : (r.result || null);
|
|
|
|
|
+ return { ...r, setting };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 售后托管设置(是否开启托管/补发/补偿,含补偿比例上限) */
|
|
|
|
|
+async function afterSaleHosting(o = {}) {
|
|
|
|
|
+ const shopId = o.shopId == null ? -1 : o.shopId;
|
|
|
|
|
+ const r = await xtop({ port: o.port, method: 'xtop.eleme.merchant.fulfillment.shopSettings.getAfterSaleHostingSettings', request: { shopId }, shopId: -1 });
|
|
|
|
|
+ const inner = (r.data && r.data.result && r.data.result.data) ? r.data.result.data : null;
|
|
|
|
|
+ return { ...r, hosting: inner };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 出餐/配送设置类小接口合集(提醒时长、索赔提示、轮询策略) */
|
|
|
|
|
+async function orderTips(o = {}) {
|
|
|
|
|
+ const shopId = o.shopId == null ? -1 : o.shopId;
|
|
|
|
|
+ const P = { port: o.port, ksid: o.ksid, shopId };
|
|
|
|
|
+ const [cook, claim, poll] = await Promise.all([
|
|
|
|
|
+ melody({ ...P, service: 'ShopSettingService', method: 'getRemindCookTimeSetting', path: '/fulfill/strategy/getRemindCookTimeSetting/', params: { shopId } }),
|
|
|
|
|
+ melody({ ...P, service: 'DeliveryClaimService', method: 'getOrderClaimTips', path: '/fulfill/weborder/getOrderClaimTips/', params: { shopId } }),
|
|
|
|
|
+ melody({ ...P, service: 'PollingService', method: 'getPollingStrategy', path: '/fulfill/weborder/getPollingStrategy/', params: { shopId } }),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return { cookTime: cook.result, claimTips: claim.result, polling: poll.result };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 E:agw 网关(财务 / 成长)
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * agw 网关 = 阿里本地生活网关,财务(账单/结算)与成长中心都挂在上面。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 与 lsycm 完全不同的两个坑(实测,2026-10-11):
|
|
|
|
|
+ * ① 请求**必须**由「该子站自己的 H5 执行上下文」发出 —— 子站是商家后台页里的
|
|
|
|
|
+ * 跨域 iframe,从外层页面发会被判为伪造来源 → 网关直接回 `111006 用户未登录`。
|
|
|
|
|
+ * 财务 H5 → https://napos-bill-pc.faas.ele.me 路由 /app/unit/billDetail
|
|
|
|
|
+ * 成长 H5 → https://napos-shop-operation-pc.faas.ele.me 路由 /app/unit/business-task-new
|
|
|
|
|
+ * (路由表与鉴权头已在 cdp.js 的 APPS / buildAuthHeaders 里配好,这里只管取数)
|
|
|
|
|
+ * ② 鉴权是 `ksid` + `current-shop-id` + `X-AGW-BIZ-CONTEXT` 三个头,**不是** secretkey。
|
|
|
|
|
+ */
|
|
|
|
|
+const AGW_BASE = { bill: 'https://agw.alsc.taobao.com', growth: 'https://agw.ele.me' };
|
|
|
|
|
+const AGW_QUERY = {
|
|
|
|
|
+ bill: { source: 'ELE', h: 'napos-bill-pc.faas.ele.me' },
|
|
|
|
|
+ growth: { source: 'ELE' },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** YYYYMMDD → 毫秒(which: 'start' 当日 00:00:00 / 'end' 当日 23:59:59.999) */
|
|
|
|
|
+function msOf(day, which) {
|
|
|
|
|
+ const s = normDay(day);
|
|
|
|
|
+ if (!/^\d{8}$/.test(s)) return undefined;
|
|
|
|
|
+ const y = +s.slice(0, 4), m = +s.slice(4, 6), d = +s.slice(6, 8);
|
|
|
|
|
+ return which === 'end' ? new Date(y, m - 1, d, 23, 59, 59, 999).getTime() : new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 毫秒 → YYYYMMDD */
|
|
|
|
|
+function ymd(ms) {
|
|
|
|
|
+ const d = new Date(Number(ms));
|
|
|
|
|
+ const z = (n) => String(n).padStart(2, '0');
|
|
|
|
|
+ return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 毫秒 → YYYY-MM-DD */
|
|
|
|
|
+function ymdDash(ms) {
|
|
|
|
|
+ const s = ymd(ms);
|
|
|
|
|
+ return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 确保某个 agw 子站的宿主页已就绪(不存在就导航主页面过去把它拉起来)。
|
|
|
|
|
+ * @returns {Promise<{ok:boolean, opened:boolean, contexts:string[]}>}
|
|
|
|
|
+ */
|
|
|
|
|
+async function ensureApp(appId, o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const app = typeof appId === 'object' && appId.id ? appId : cdp.resolveApp(appId);
|
|
|
|
|
+ const has = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const ctxs = await cdp.withPageFor(port, app.id, (api) => [...api.contexts.keys()], { needExact: false, autoOpen: false });
|
|
|
|
|
+ return ctxs.some((o2) => cdp.ctxMatchesApp(o2, app)) ? ctxs : null;
|
|
|
|
|
+ } catch { return null; }
|
|
|
|
|
+ };
|
|
|
|
|
+ const hit = await has();
|
|
|
|
|
+ if (hit) return { ok: true, opened: false, contexts: hit };
|
|
|
|
|
+ if (!app.openPath) return { ok: false, opened: false, contexts: [] };
|
|
|
|
|
+ // 导航主页面到该子站路由(同源导航,不会触发鉴权),把 iframe 带起来
|
|
|
|
|
+ try {
|
|
|
|
|
+ await cdp.withPageFor(port, 'shop', (api) => api.evaluate(
|
|
|
|
|
+ `location.href = ${JSON.stringify('https://melody.shop.ele.me' + app.openPath)}`
|
|
|
|
|
+ ), { needExact: false, autoOpen: false });
|
|
|
|
|
+ } catch { /* 忽略 */ }
|
|
|
|
|
+ const deadline = Date.now() + Number(o.waitMs || 20000);
|
|
|
|
|
+ while (Date.now() < deadline) {
|
|
|
|
|
+ await cdp.sleep(1200);
|
|
|
|
|
+ const r = await has();
|
|
|
|
|
+ if (r) return { ok: true, opened: true, contexts: r };
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ok: false, opened: true, contexts: [] };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 读该 agw 子站上下文里的凭证(ksid / unitId / shopId)—— 全部运行时读,不硬编码 */
|
|
|
|
|
+async function agwCred(appId, o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const app = cdp.resolveApp(appId);
|
|
|
|
|
+ return cdp.withPageFor(port, app.id, async (api) => {
|
|
|
|
|
+ let ctx = null;
|
|
|
|
|
+ for (const [origin, c] of api.contexts) { if (cdp.ctxMatchesApp(origin, app)) { ctx = c; break; } }
|
|
|
|
|
+ const v = await api.evaluate(cdp.AGW_CRED_PROBE, ctx ? { contextId: ctx.id } : undefined);
|
|
|
|
|
+ return v || {};
|
|
|
|
|
+ }, { needExact: false, autoOpen: false });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 调一个 agw 接口(body 固定为 { params: [...] } 形态) */
|
|
|
|
|
+async function agw(appId, path, params, o = {}) {
|
|
|
|
|
+ const base = AGW_BASE[appId];
|
|
|
|
|
+ if (!base) throw new Error(`agw 不支持子站 ${appId}(现支持:${Object.keys(AGW_BASE).join(' / ')})`);
|
|
|
|
|
+ const url = /^https?:/i.test(path) ? path : base + path;
|
|
|
|
|
+ const query = { ...(AGW_QUERY[appId] || {}), ...(o.query || {}) };
|
|
|
|
|
+ const r = await call({ port: o.port, url, method: 'POST', query, body: { params }, needExact: o.needExact !== false });
|
|
|
|
|
+ const d = (r.data && (r.data.data !== undefined ? r.data.data : r.data.result)) || null;
|
|
|
|
|
+ return { ...r, data: r.data, payload: d };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 财务:账单 / 结算 -------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 可查账单的时间范围(最早可查日 + 服务器当前时间)。
|
|
|
|
|
+ * 实测最早可查 ≈ 2023-12-01(比经营数据只保留 180 天宽松得多)。
|
|
|
|
|
+ */
|
|
|
|
|
+async function billRange(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ await ensureApp('bill', { port, waitMs: o.waitMs });
|
|
|
|
|
+ const r = await agw('bill', '/gw/h5api/ele-shop-bill-query.GrayFacade.getGrayDatetime', [{}], { port });
|
|
|
|
|
+ const d = r.payload || {};
|
|
|
|
|
+ return { ...r, startDisplayDate: d.startDisplayDate || null, currentDateTime: d.currentDateTime || null,
|
|
|
|
|
+ startDay: d.startDisplayDate ? ymd(d.startDisplayDate) : null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 连锁结算账单(日结,逐日一行;自动翻页聚合)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 血泪教训:`shopId` / `chainId` 传 `'-1'` 会**返回空列表**(和经营数据一样,
|
|
|
|
|
+ * -1 在账单域不是"全公司")。必须传真实 `unitId`,脚本已自动读取。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns {Promise<{ok,unitId,biz,from,to,rows:object[],meta:{totalCount,shopCount,settleAmount,summaryPeriod}}>}
|
|
|
|
|
+ * rows[] = { billDate, incomeAmount, expendAmount, settleAmount, settleStatus, settleShopCount }
|
|
|
|
|
+ * settleStatus: TO_BE_SETTLED(待结算) / SETTLED(已结算) …
|
|
|
|
|
+ */
|
|
|
|
|
+async function settleList(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const ens = await ensureApp('bill', { port, waitMs: o.waitMs });
|
|
|
|
|
+ let unit = o.unitId || o.shopId || '';
|
|
|
|
|
+ if (!unit) unit = (await agwCred('bill', { port })).unitId || '';
|
|
|
|
|
+ unit = String(unit || '');
|
|
|
|
|
+ if (!unit) {
|
|
|
|
|
+ const err = new Error('读不到 unitId —— 无法确定账单主体。请确认财务页已登录(napos-ops-core 体检)。');
|
|
|
|
|
+ err.code = 'NAPOS_NO_UNIT';
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ const biz = o.settleBizType || 'TRADE';
|
|
|
|
|
+ const from = o.from ? normDay(o.from) : daysAgo(o.days == null ? 30 : Number(o.days));
|
|
|
|
|
+ const to = o.to ? normDay(o.to) : today();
|
|
|
|
|
+ const size = Number(o.pageSize || 100);
|
|
|
|
|
+ const maxPage = Number(o.maxPage || 60);
|
|
|
|
|
+ const rows = [];
|
|
|
|
|
+ let pageNo = 1, totalPage = 1, meta = null;
|
|
|
|
|
+ while (pageNo <= Math.min(totalPage, maxPage)) {
|
|
|
|
|
+ const r = await agw('bill', '/gw/h5api/ele-shop-bill-query.ShopSummaryQueryFacade.queryChainSettleListByPage',
|
|
|
|
|
+ [{ shopId: unit, pageNo, pageSize: size, startBillDate: msOf(from, 'start'), endBillDate: msOf(to, 'end'), chainId: unit, settleBizType: biz }],
|
|
|
|
|
+ { port });
|
|
|
|
|
+ const d = r.payload || {};
|
|
|
|
|
+ if (Array.isArray(d.list)) rows.push(...d.list);
|
|
|
|
|
+ totalPage = Number(d.totalPage || 1) || 1;
|
|
|
|
|
+ if (!meta) meta = { totalCount: d.totalCount != null ? d.totalCount : null, shopCount: d.shopCount != null ? d.shopCount : null, settleAmount: d.settleAmount != null ? d.settleAmount : null, summaryPeriod: d.summaryPeriod || null };
|
|
|
|
|
+ if (!Array.isArray(d.list) || d.list.length < size) break;
|
|
|
|
|
+ pageNo++;
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ok: true, unitId: unit, biz, from, to, rows, meta, ensured: ens };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 结算账单区间汇总(按日聚合 → 收入/支出/结算额/天数/状态分布) */
|
|
|
|
|
+function summarizeSettle(rows) {
|
|
|
|
|
+ const n = (v) => Number(v) || 0;
|
|
|
|
|
+ const s = { days: rows.length, income: 0, expense: 0, settle: 0, settleShopCount: 0, status: {} };
|
|
|
|
|
+ for (const r of rows) {
|
|
|
|
|
+ s.income += n(r.incomeAmount);
|
|
|
|
|
+ s.expense += n(r.expendAmount);
|
|
|
|
|
+ s.settle += n(r.settleAmount);
|
|
|
|
|
+ s.settleShopCount += n(r.settleShopCount);
|
|
|
|
|
+ const k = r.settleStatus || 'UNKNOWN';
|
|
|
|
|
+ s.status[k] = (s.status[k] || 0) + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ s.income = +s.income.toFixed(2);
|
|
|
|
|
+ s.expense = +s.expense.toFixed(2);
|
|
|
|
|
+ s.settle = +s.settle.toFixed(2);
|
|
|
|
|
+ s.margin = s.income > 0 ? +((s.settle / s.income) * 100).toFixed(2) : 0; // 结算率
|
|
|
|
|
+ return s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 成长:产能 / 诊断 / 周报 / 任务 -------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+/** 成长中心汇总:门店产能分层(高/中/低)+ 经营诊断(L1/L2 未通过门店数) */
|
|
|
|
|
+async function growthSummary(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ await ensureApp('growth', { port, waitMs: o.waitMs });
|
|
|
|
|
+ const chainId = o.chainId == null ? '' : String(o.chainId);
|
|
|
|
|
+ const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getSummaryInfo', [{ chainId }], { port });
|
|
|
|
|
+ return { ...r, summary: r.payload || null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 成长周报(GMV / 营收 / 曝光 / 访购率 + 环比 + 核心策略) */
|
|
|
|
|
+async function growthWeekly(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ await ensureApp('growth', { port, waitMs: o.waitMs });
|
|
|
|
|
+ const chainId = o.chainId == null ? '' : String(o.chainId);
|
|
|
|
|
+ const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getWeeklyReportData',
|
|
|
|
|
+ [{ chainId, brandId: o.brandId == null ? -1 : o.brandId }], { port });
|
|
|
|
|
+ return { ...r, weekly: r.payload || null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 生意任务完成率(按门店 DetailType=DETAIL_TYPE_SHOP,自动翻页) */
|
|
|
|
|
+async function growthTaskRate(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ await ensureApp('growth', { port, waitMs: o.waitMs });
|
|
|
|
|
+ const chainId = o.chainId == null ? '' : String(o.chainId);
|
|
|
|
|
+ const size = Number(o.size || 50), maxPage = Number(o.maxPage || 20);
|
|
|
|
|
+ const rows = [];
|
|
|
|
|
+ let page = 1, total = 1;
|
|
|
|
|
+ while (page <= Math.min(total, maxPage)) {
|
|
|
|
|
+ const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getBusinessTaskCompleteRateByPage',
|
|
|
|
|
+ [{ chainId, page, size, data: { detailType: o.detailType || 'DETAIL_TYPE_SHOP', brandId: o.brandId == null ? -1 : o.brandId, useNew: true } }], { port });
|
|
|
|
|
+ const d = r.payload || {};
|
|
|
|
|
+ if (Array.isArray(d.data)) rows.push(...d.data);
|
|
|
|
|
+ total = Number(d.total || 0) ? Math.ceil(Number(d.total) / size) : 1;
|
|
|
|
|
+ if (!Array.isArray(d.data) || d.data.length < size) break;
|
|
|
|
|
+ page++;
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ok: true, rows };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 成长中心品牌列表(brandId 用于周报 / 任务过滤) */
|
|
|
|
|
+async function growthBrands(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ await ensureApp('growth', { port, waitMs: o.waitMs });
|
|
|
|
|
+ const chainId = o.chainId == null ? '' : String(o.chainId);
|
|
|
|
|
+ const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getBrands', [{ chainId }], { port });
|
|
|
|
|
+ return { ...r, brands: r.payload || null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 形态 F:渠道 / 联盟 / 金融
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 渠道域身份(ksid / unitId / keeperId / 当前店)。
|
|
|
|
|
+ * ★ 全部运行时读(localStorage → sessionStorage → cookie),绝不硬编码 —— 换机可用。
|
|
|
|
|
+ * 实测:localStorage 与 cookie 里都有 unitId / keeperId / ksid(同值)。
|
|
|
|
|
+ */
|
|
|
|
|
+async function channelIdentity(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const app = cdp.resolveApp('channel');
|
|
|
|
|
+ return cdp.withPageFor(port, app.id, async (api) => {
|
|
|
|
|
+ const ids = [undefined];
|
|
|
|
|
+ for (const [origin, c] of api.contexts) if (cdp.ctxMatchesApp(origin, app)) ids.push(c.id);
|
|
|
|
|
+ let fallback = {};
|
|
|
|
|
+ for (const contextId of ids) {
|
|
|
|
|
+ let v = null;
|
|
|
|
|
+ try { v = await api.evaluate(cdp.CHANNEL_ID_PROBE, contextId ? { contextId } : undefined); } catch { continue; }
|
|
|
|
|
+ if (v && v.ksid && v.unitId) return v;
|
|
|
|
|
+ if (v && v.ksid && !fallback.ksid) fallback = v;
|
|
|
|
|
+ }
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }, { needExact: false, autoOpen: false });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const VAS_URL = 'https://app-api.shop.ele.me/xtop/xtop.vas.gateway.iOneRoute.callFromNapos/1.0';
|
|
|
|
|
+
|
|
|
|
|
+/** callFromNapos 的 reqId(实测格式:<随机数><毫秒><unitId>z6SvSyc9A+C) */
|
|
|
|
|
+function vasReqId(unitId) {
|
|
|
|
|
+ return `${Math.floor(Math.random() * 1e12)}${Date.now()}${unitId}z6SvSyc9A+C`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 调「增值服务」网关(vas)—— 闪购联盟 / 店铺推广的公共入口。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ★ 与 lsycm(签名)和 agw(头鉴权)都不同:鉴权是 **body.metas.ksid**(请求头里没有),
|
|
|
|
|
+ * 业务参数形如 `params.params = [身份包{loginId,bizCode,isChain}, 业务参数]`。
|
|
|
|
|
+ * bizCode:闪购联盟 = 'eleCps' / 店铺推广 = 'eleChain'。
|
|
|
|
|
+ * ★ 实测(2026-10-11):从商家后台主页面直接发即可成功,**不需要**专属 H5 上下文。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {string} service 例 oneReportService
|
|
|
|
|
+ * @param {string} method 例 findTotalReport
|
|
|
|
|
+ * @param {object} arg 业务参数(params[1])
|
|
|
|
|
+ * @param {object} o { bizCode, isChain, unitId, identity, port }
|
|
|
|
|
+ */
|
|
|
|
|
+async function vas(service, method, arg = {}, o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const id = o.identity || await channelIdentity({ port });
|
|
|
|
|
+ const unit = o.unitId || id.unitId;
|
|
|
|
|
+ if (!id.ksid) { const e = new Error('读不到 ksid —— 请确认客户端已登录商家后台(napos-ops-core 体检)。'); e.code = 'NAPOS_NO_KSID'; throw e; }
|
|
|
|
|
+ if (!unit) { const e = new Error('读不到 unitId —— 请确认客户端已登录商家后台。'); e.code = 'NAPOS_NO_UNIT'; throw e; }
|
|
|
|
|
+ const url = `${VAS_URL}?service=${encodeURIComponent(service)}&method=${encodeURIComponent(method)}`;
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ metas: { ksid: String(id.ksid), shopId: Number(unit), appName: 'napos' },
|
|
|
|
|
+ params: {
|
|
|
|
|
+ from: 'napos', to: 'alsc-ad-onebp', method, service,
|
|
|
|
|
+ params: [{ loginId: Number(unit), bizCode: o.bizCode || 'eleCps', isChain: o.isChain !== false }, arg || {}],
|
|
|
|
|
+ reqId: vasReqId(unit),
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ const r = await call({ port, url, method: 'POST', body, needExact: true });
|
|
|
|
|
+ const d = r.data || {};
|
|
|
|
|
+ const payload = d.result ? d.result.data : (d.data !== undefined ? d.data : null);
|
|
|
|
|
+ return { ...r, payload };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 渠道:闪购联盟(CPS / 联盟) --------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+/** 联盟开通概览:门店总数 / CPS 已开通门店数 / 权限码 / 商品与卡券概况 */
|
|
|
|
|
+async function cpsShopInfo(o = {}) {
|
|
|
|
|
+ const r = await vas('shopService', 'getShopInfo', {}, { bizCode: 'eleCps', isChain: true, ...o });
|
|
|
|
|
+ return { ...r, info: r.payload || null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 联盟权益列表(queryType:AVOID_COMMISSION 免佣 …) */
|
|
|
|
|
+async function cpsRights(queryType = 'AVOID_COMMISSION', status = [1, 2], o = {}) {
|
|
|
|
|
+ const r = await vas('oneRightService', 'getRightInfoList', { queryType, status }, { bizCode: 'eleCps', isChain: true, ...o });
|
|
|
|
|
+ return { ...r, rows: Array.isArray(r.payload) ? r.payload : [] };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 联盟合约签署状态(signBaseValue / openValue / invoiceType) */
|
|
|
|
|
+async function cpsContract(o = {}) {
|
|
|
|
|
+ const r = await vas('oneUserService', 'getSignedContract', {}, { bizCode: 'eleCps', isChain: true, ...o });
|
|
|
|
|
+ return { ...r, contract: r.payload || null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 联盟门店授权 */
|
|
|
|
|
+async function cpsAuthority(o = {}) {
|
|
|
|
|
+ const r = await vas('shopService', 'queryShopAuthority', { isChain: true }, { bizCode: o.bizCode || 'eleCps', isChain: true, ...o });
|
|
|
|
|
+ return { ...r, authority: r.payload || null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 联盟推广组(oneAdGroupService.get) */
|
|
|
|
|
+async function cpsAdGroup(o = {}) {
|
|
|
|
|
+ const r = await vas('oneAdGroupService', 'get', {}, { bizCode: 'eleCps', isChain: true, ...o });
|
|
|
|
|
+ return { ...r, groups: r.payload };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 渠道:店铺推广(推广余额 / 花费 / 锦囊) ---------------------------------
|
|
|
|
|
+
|
|
|
|
|
+/** 推广账户余额(连锁汇总;detail 里是各类余额分项,balance 单位为分) */
|
|
|
|
|
+async function promoBalance(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const r = await call({
|
|
|
|
|
+ port, method: 'POST', needExact: true,
|
|
|
|
|
+ url: 'https://agw.alsc.taobao.com/gw/h5api/alsc-ad-account.IGwBalanceService.findBalanceInfo',
|
|
|
|
|
+ query: { source: 'ELE', h: 'r.ele.me' },
|
|
|
|
|
+ body: { params: [{}] },
|
|
|
|
|
+ });
|
|
|
|
|
+ const d = (r.data && r.data.result) || null;
|
|
|
|
|
+ const fen = d && d.detail ? Number(d.detail.balance || 0) : null;
|
|
|
|
|
+ return { ...r, balance: d, balanceFen: fen, balanceYuan: fen == null ? null : (fen / 100).toFixed(2) };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 门店推广余额汇总(total 单位为分) */
|
|
|
|
|
+async function promoLeafBalance(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const r = await call({
|
|
|
|
|
+ port, method: 'POST', needExact: true,
|
|
|
|
|
+ url: 'https://agw.alsc.taobao.com/gw/h5api/alsc-ad-account.IGwBalanceService.findLeafShopBalanceInfoAsync',
|
|
|
|
|
+ query: { source: 'ELE', h: 'r.ele.me' },
|
|
|
|
|
+ body: { params: [{}] },
|
|
|
|
|
+ });
|
|
|
|
|
+ const d = (r.data && r.data.result && r.data.result.result) || null;
|
|
|
|
|
+ const fen = d ? Number(d.total || 0) : null;
|
|
|
|
|
+ return { ...r, summary: d, totalFen: fen, totalYuan: fen == null ? null : (fen / 100).toFixed(2) };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 推广花费报表(默认近 7 天;reportStartDate/reportEndDate 支持自定义区间) */
|
|
|
|
|
+async function promoReport(o = {}) {
|
|
|
|
|
+ const endMs = o.to ? msOf(o.to, 'end') : (Date.now() - 86400000);
|
|
|
|
|
+ const startMs = o.from ? msOf(o.from, 'start') : (endMs - Math.max(0, Number(o.days || 7) - 1) * 86400000);
|
|
|
|
|
+ const from = ymdDash(startMs), to = ymdDash(endMs);
|
|
|
|
|
+ const arg = { reportTypeEnum: o.reportType || 'CHAIN_HIS_COST', showDetail: true, reportStartDate: from, reportEndDate: to };
|
|
|
|
|
+ const r = await vas('oneReportService', 'findTotalReport', arg, { bizCode: o.bizCode || 'eleChain', isChain: false, ...o });
|
|
|
|
|
+ return { ...r, report: r.payload || null, from, to };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 店铺推广:锦囊 / 流量袋 */
|
|
|
|
|
+async function silkBag(o = {}) {
|
|
|
|
|
+ const arg = {
|
|
|
|
|
+ shopId: Number(o.shopId || 0), pageSize: Number(o.pageSize || 20),
|
|
|
|
|
+ stateList: o.stateList || [10, 20], pageNum: Number(o.pageNum || 1),
|
|
|
|
|
+ bagType: o.bagType || 'BROCADEBAG',
|
|
|
|
|
+ };
|
|
|
|
|
+ const r = await vas('marketingToolService', 'findSilkBagPage', arg, { bizCode: o.bizCode || 'eleChain', isChain: false, ...o });
|
|
|
|
|
+ const d = r.payload;
|
|
|
|
|
+ const rows = Array.isArray(d) ? d : (d && Array.isArray(d.data) ? d.data : []);
|
|
|
|
|
+ return { ...r, rows, total: d && d.totalCount != null ? d.totalCount : rows.length };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 渠道:金融保险(储值卡 / 可见金融产品) ---------------------------------
|
|
|
|
|
+
|
|
|
|
|
+/** 可见金融资源(借款等,code:napos_eat_loan_profit …) */
|
|
|
|
|
+async function finResources(code = 'napos_eat_loan_profit', o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const r = await call({
|
|
|
|
|
+ port, method: 'POST', needExact: true,
|
|
|
|
|
+ url: 'https://agw.ele.me/gw/h5api/fn-alsc-fin-ops.alsc-fin-ops.common-config',
|
|
|
|
|
+ query: { source: 'ELE', h: 'r.ele.me' },
|
|
|
|
|
+ body: { params: [{ className: 'me.ele.fin.promotion.fn.service.H5ResourceService', methodName: 'queryVisibleResourceList', param: { code, cityId: o.cityId || '' } }] },
|
|
|
|
|
+ });
|
|
|
|
|
+ return { ...r, rows: (r.data && r.data.data) || [] };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 储值卡可签门店清单(全公司,unitType=UNIT) */
|
|
|
|
|
+async function finShops(o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const id = o.identity || await channelIdentity({ port });
|
|
|
|
|
+ const unitId = o.unitId || id.unitId, keeperId = o.keeperId || id.keeperId;
|
|
|
|
|
+ if (!unitId || !keeperId) { const e = new Error('读不到 unitId / keeperId —— 请确认客户端已登录商家后台。'); e.code = 'NAPOS_NO_UNIT'; throw e; }
|
|
|
|
|
+ const r = await call({
|
|
|
|
|
+ port, method: 'POST', needExact: true,
|
|
|
|
|
+ url: 'https://httpizza.ele.me/xtop/xtop.storedcard.commonShopApi.getShopIdsByKeeperIdAndUnitId/1.0',
|
|
|
|
|
+ body: { params: { request: { keeperId: Number(keeperId), unitId: Number(unitId), unitType: o.unitType || 'UNIT' } } },
|
|
|
|
|
+ });
|
|
|
|
|
+ const ids = (r.data && r.data.result && r.data.result.data && r.data.result.data.shopIds) || [];
|
|
|
|
|
+ return { ...r, shopIds: ids };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 储值卡签约状态(逐店 status:SIGNED / CAN_SIGN …) */
|
|
|
|
|
+async function finSign(shopIds, o = {}) {
|
|
|
|
|
+ const port = Number(o.port || DEFAULT_PORT);
|
|
|
|
|
+ const id = o.identity || await channelIdentity({ port });
|
|
|
|
|
+ const unitId = o.unitId || id.unitId;
|
|
|
|
|
+ if (!unitId) { const e = new Error('读不到 unitId —— 请确认客户端已登录商家后台。'); e.code = 'NAPOS_NO_UNIT'; throw e; }
|
|
|
|
|
+ const r = await call({
|
|
|
|
|
+ port, method: 'POST', needExact: true,
|
|
|
|
|
+ url: 'https://httpizza.ele.me/xtop/xtop.storedcard.merchant.signrecord.get/1.0',
|
|
|
|
|
+ body: { params: { request: { shopIds: (shopIds || []).map(Number), shopType: o.shopType || 'UNIT', unitId: Number(unitId) } } },
|
|
|
|
|
+ });
|
|
|
|
|
+ const rows = (r.data && r.data.result && r.data.result.data) || [];
|
|
|
|
|
+ return { ...r, rows };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 商品域(nevermore.goods)
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * nevermore.goods 网关通用只读调用。
|
|
|
|
|
+ * 商品 / 门店属性 / 菜单 / 回收站等接口都在这个网关下,方法名在 query 的 method 里。
|
|
|
|
|
+ */
|
|
|
|
|
+async function goodsInvoke(service, method, params = {}, shopId = -1, o = {}) {
|
|
|
|
|
+ return melody({ service, method, path: '/nevermore.goods/invoke/', params, shopId, appVersion: o.appVersion || '4.4.0' });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 门店商品属性(包装费 / 口味分类 / 各类开关)—— 返回 result 为对象 */
|
|
|
|
|
+async function restaurantAttribute(shopId, o = {}) {
|
|
|
|
|
+ return goodsInvoke('GrayService', 'getRestaurantAttribute', { shopId }, shopId, o);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 输出落盘
|
|
|
|
|
+
|
|
|
|
|
+function ensureDir(p) {
|
|
|
|
|
+ try { fs.mkdirSync(p, { recursive: true }); } catch { /* ignore */ }
|
|
|
|
|
+ return p;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function csvCell(v) {
|
|
|
|
|
+ if (v === null || v === undefined) return '';
|
|
|
|
|
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
|
|
|
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 写 CSV(UTF-8 BOM + CRLF,Excel 直接可读) */
|
|
|
|
|
+function writeCsv(file, header, rows) {
|
|
|
|
|
+ const head = Array.isArray(header) ? header : Object.keys(rows[0] || {});
|
|
|
|
|
+ const lines = [head.join(',')];
|
|
|
|
|
+ for (const r of rows) {
|
|
|
|
|
+ // 支持两种行形态:数组(按表头顺序)或对象(键 = 表头文字)
|
|
|
|
|
+ const cells = Array.isArray(r) ? r : head.map((k) => (r ? r[k] : ''));
|
|
|
|
|
+ lines.push(cells.map(csvCell).join(','));
|
|
|
|
|
+ }
|
|
|
|
|
+ ensureDir(path.dirname(file));
|
|
|
|
|
+ fs.writeFileSync(file, '\ufeff' + lines.join('\r\n'), 'utf8');
|
|
|
|
|
+ return file;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function writeJson(file, obj) {
|
|
|
|
|
+ ensureDir(path.dirname(file));
|
|
|
|
|
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2), 'utf8');
|
|
|
|
|
+ return file;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 汇总口径
|
|
|
|
|
+
|
|
|
|
|
+/** 经营指标字段(historyOverview 返回值)*/
|
|
|
|
|
+const METRIC_KEYS = [
|
|
|
|
|
+ 'valid_order_cnt', 'gross_amt', 'exp_uv', 'clk_exp_rate', 'ord_clk_rate',
|
|
|
|
|
+ 'per_actual_pay', 'order_user_cnt', 'income_amt', 'expense_amt', 'shop_roi',
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+/** 把逐日序列按指标求和/求均值,得到区间合计 */
|
|
|
|
|
+function summarize(daily) {
|
|
|
|
|
+ const s = { days: daily.length };
|
|
|
|
|
+ for (const k of METRIC_KEYS) s[k] = 0;
|
|
|
|
|
+ let rateW = 0, rateD = 0;
|
|
|
|
|
+ for (const r of daily) {
|
|
|
|
|
+ for (const k of ['valid_order_cnt', 'gross_amt', 'exp_uv', 'order_user_cnt', 'income_amt', 'expense_amt']) {
|
|
|
|
|
+ s[k] += Number(r[k]) || 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ const uv = Number(r.exp_uv) || 0;
|
|
|
|
|
+ if (uv > 0) {
|
|
|
|
|
+ if (r.clk_exp_rate != null) { rateW += (Number(r.clk_exp_rate) || 0) * uv; rateD += uv; }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ s.clk_exp_rate = rateD > 0 ? +(rateW / rateD).toFixed(4) : 0;
|
|
|
|
|
+ s.per_actual_pay = s.valid_order_cnt > 0 ? +(s.gross_amt / s.valid_order_cnt).toFixed(2) : 0;
|
|
|
|
|
+ s.ord_clk_rate = s.exp_uv > 0 ? +(s.valid_order_cnt / s.exp_uv).toFixed(4) : 0;
|
|
|
|
|
+ for (const k of ['gross_amt', 'income_amt', 'expense_amt']) s[k] = +s[k].toFixed(2);
|
|
|
|
|
+ return s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ------------------------------------------------------------------ 便捷
|
|
|
|
|
+
|
|
|
|
|
+/** 状态体检(不含 MCP 目录,纯通道) */
|
|
|
|
|
+async function status(port = DEFAULT_PORT) {
|
|
|
|
|
+ const p = await cdp.probe(port);
|
|
|
|
|
+ const out = { port, portOk: p.ok, browser: p.ok ? p.version.Browser : null };
|
|
|
|
|
+ if (p.ok) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const targets = (await cdp.listTargets(port)).filter((t) => t.webSocketDebuggerUrl);
|
|
|
|
|
+ out.targets = targets.map((t) => `${t.type} | ${t.url}`);
|
|
|
|
|
+ const ctxs = await cdp.withPageFor(port, 'shop', (api) => [...api.contexts.keys()], { autoOpen: false });
|
|
|
|
|
+ out.contexts = ctxs;
|
|
|
|
|
+ } catch (e) { out.probeError = String(e.message || e); }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+module.exports = {
|
|
|
|
|
+ DEFAULT_PORT, HISTORY_URL, HISTORY_METHOD, SALT, METRIC_KEYS, XTOP_BASE, STATS_BASE, MELODY_BASE, BUSY_LEVEL, AGW_BASE,
|
|
|
|
|
+ md5, sign, getKsid, call, xtop, shopTree, statsHistory, statsApi, businessSummary, cityList, latestTime, status,
|
|
|
|
|
+ melody, melodyReqId, orderAlerts, orderSetting, afterSaleHosting, orderTips,
|
|
|
|
|
+ ensureApp, agwCred, agw, billRange, settleList, summarizeSettle,
|
|
|
|
|
+ growthSummary, growthWeekly, growthTaskRate, growthBrands,
|
|
|
|
|
+ channelIdentity, vas, vasReqId,
|
|
|
|
|
+ cpsShopInfo, cpsRights, cpsContract, cpsAuthority, cpsAdGroup,
|
|
|
|
|
+ promoBalance, promoLeafBalance, promoReport, silkBag,
|
|
|
|
|
+ finResources, finShops, finSign,
|
|
|
|
|
+ goodsInvoke, restaurantAttribute,
|
|
|
|
|
+ normDay, dayDiff, today, daysAgo, msOf, ymd, ymdDash,
|
|
|
|
|
+ ensureDir, writeCsv, writeJson, summarize,
|
|
|
|
|
+ cdp,
|
|
|
|
|
+};
|