diff --git a/29WxOrderWpf/29WxOrderWpf.csproj b/29WxOrderWpf/29WxOrderWpf.csproj index 52569a0..217eb43 100644 --- a/29WxOrderWpf/29WxOrderWpf.csproj +++ b/29WxOrderWpf/29WxOrderWpf.csproj @@ -67,11 +67,12 @@ + - + diff --git a/29WxOrderWpf/29WxOrderWpf.csproj.Backup.tmp b/29WxOrderWpf/29WxOrderWpf.csproj.Backup.tmp new file mode 100644 index 0000000..309a977 --- /dev/null +++ b/29WxOrderWpf/29WxOrderWpf.csproj.Backup.tmp @@ -0,0 +1,90 @@ + + + + WinExe + net462 + _29WxOrderWpf + 29WxFindOrder + true + gear.ico + 12 + x64 + ..\bin\ + false + false + AnyCPU;x64 + + + + x64 + pdbonly + true + ..\..\KJD Cef138\Bin\ + TRACE;RELEASE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\DllReference\NaiBa.Util.dll + true + + + + + + + + + + + + + + + + + + + + + + + + Code + + + Code + + + + + + + diff --git a/29WxOrderWpf/Ctrls/BuyerCtrls/PDDBuyerOrderReadCtrl.xaml.cs b/29WxOrderWpf/Ctrls/BuyerCtrls/PDDBuyerOrderReadCtrl.xaml.cs index 8fc5ba9..21055a9 100644 --- a/29WxOrderWpf/Ctrls/BuyerCtrls/PDDBuyerOrderReadCtrl.xaml.cs +++ b/29WxOrderWpf/Ctrls/BuyerCtrls/PDDBuyerOrderReadCtrl.xaml.cs @@ -11,12 +11,16 @@ using AllBase.Util; using CefSharp; using CefSharp.DevTools.LayerTree; using CefSharp.DevTools.Network; +using CefSharp.DevTools.Page; using CefSharp.Handler; using CefSharp.Wpf; using NaiBa.Util; using NaiBa.Util.Extension; using Newtonsoft.Json.Linq; using NPOI.OpenXmlFormats.Dml.Chart; +using NPOI.SS.Formula.Functions; +using NPOI.XWPF.UserModel; +using Org.BouncyCastle.Utilities; using System; using System.Collections; using System.Collections.Generic; @@ -25,6 +29,7 @@ using System.ComponentModel; using System.Linq; using System.Net; using System.Reflection; +using System.Runtime.Remoting.Contexts; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Security.Policy; @@ -47,6 +52,7 @@ using Work._29Tools.CefTools; using Work.BaseForms; using Work.BuyUsers; using WxOrder.Framework.Commons; +using WxOrder.Framework.Helpers; using static System.Windows.Forms.VisualStyles.VisualStyleElement.Tab; namespace _29WxOrderWpf.Ctrls.BuyerCtrls @@ -79,7 +85,9 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls PropertyChanged?.Invoke(this, propertyChangedEventArgs); } - Dictionary dicPddPickInfo = new Dictionary(); + Dictionary dicPddAftersaleNextQueryCreatedAt = new Dictionary(); + + Dictionary dicPddPickInfo = new Dictionary(); ConcurrentList lstSyncLogistics = new ConcurrentList(); ChromiumWebBrowser cefWebBrowser = null; @@ -251,37 +259,37 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls void AutoGetPddOrder() { - Task.Run(() => - { - Thread.Sleep(5000); - while (true) - { - if (!buyerReadOrderConfig.IsAutoReadOrder) - { - Thread.Sleep(1000); - Dispatcher.Invoke(new Action(() => - { - btnSearch.Content = "自动刷新暂停中"; - })); - continue; - } + Task.Run(async () => + { + Thread.Sleep(5000); + while (true) + { + if (!buyerReadOrderConfig.IsAutoReadOrder) + { + Thread.Sleep(1000); + Dispatcher.Invoke(new Action(() => + { + btnSearch.Content = "自动刷新暂停中"; + })); + continue; + } - if (DateTime.Now > nextGetOrderTime) - { - GetOrderAndAfterSale(); - } - if (isReadBuyerOrder) - { - nextGetOrderTime = DateTime.Now.AddMinutes(25); - } - Thread.Sleep(1000); - Dispatcher.Invoke(new Action(() => - { - var ticks = nextGetOrderTime - DateTime.Now; - btnSearch.Content = "刷新订单 " + ticks.Minutes + ":" + ticks.Seconds; - })); - } - }); + if (DateTime.Now > nextGetOrderTime) + { + await GetOrderAndAfterSale(); + } + if (isReadBuyerOrder) + { + nextGetOrderTime = DateTime.Now.AddMinutes(25); + } + Thread.Sleep(1000); + Dispatcher.Invoke(new Action(() => + { + var ticks = nextGetOrderTime - DateTime.Now; + btnSearch.Content = "刷新订单 " + ticks.Minutes + ":" + ticks.Seconds; + })); + } + }); } public void InitCef() @@ -460,7 +468,7 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls { await Task.Run(() => { - if (data1.Contains("listItemStatusVo") && data1.Contains("afterSalesId") && data1.Contains("statusText")) + if (data1.Contains("listItemStatusVo") && data1.Contains("afterSalesId") && data1.Contains("statusText")) { var cookie2 = cookie1.Replace(";", "&").Replace(" ", ""); var jsonDatas = BaseRegex.GetJsonObjStrJson(ref data1, "listDataMap"); @@ -470,11 +478,15 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls if (!string.IsNullOrEmpty(buyerId) && !string.IsNullOrEmpty(jsonData)) { try - { - var jObj = JsonHelper.JObjectParse(jsonData); + { + var jObj = JsonHelper.JObjectParse(jsonData); jObj["result"] = jObj["all"]; jObj["result"]["listItemVOList"] = jObj["result"]["list"]; - CefReadBuyerAfterSale(buyerId, JsonHelper.SerializeObject(jObj)); + + var nextQueryCreatedAt = BaseRegex.GetStringJson(ref data1, "nextQueryCreatedAt"); + jObj["result"]["nextQueryCreatedAt"] = nextQueryCreatedAt; + + CefReadBuyerAfterSale(buyerId, JsonHelper.SerializeObject(jObj)); } catch { @@ -484,8 +496,8 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls } }); } - // 读取售后 - if (url1.StartsWith("https://mobile.yangkeduo.com/proxy/api/api/blade/after_sales_list")) + // 读取售后 + if (url1.StartsWith("https://mobile.yangkeduo.com/proxy/api/api/blade/after_sales_list")) { await Task.Run(() => { @@ -1080,9 +1092,12 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls if (buyer == null) return; - lastCefReadOrderTime = DateTime.Now; + lastCefReadOrderTime = DateTime.Now; - if (data.Contains("\"listItemVOList\":[]") || data.Contains("\"hasNextQuery\":false")) + var nextQueryCreatedAt = BaseRegex.GetStringJson(ref data, "nextQueryCreatedAt"); + dicPddAftersaleNextQueryCreatedAt[buyer] = nextQueryCreatedAt; + + if (data.Contains("\"listItemVOList\":[]") || data.Contains("\"hasNextQuery\":false")) //if (!data.Contains("/complaints_new_detail.html?after_sales_id=")) { Dispatcher.Invoke(new Action(() => @@ -1480,21 +1495,25 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls } } - private void btnSearch_Click(object sender, RoutedEventArgs e) + private async void btnSearch_Click(object sender, RoutedEventArgs e) { - GetOrderAndAfterSale(); + await GetOrderAndAfterSale(); } - void GetOrderAndAfterSale() - { - ScrollGetPddOrder(); - //MakeWxToPddAfterSales(); - - nextGetOrderTime = DateTime.Now.AddMinutes(25); - } - - void JsGetPddOrder() + async Task GetOrderAndAfterSale() { + await JsGetPddOrder(); + //ScrollGetPddOrder(); + //MakeWxToPddAfterSales(); + + nextGetOrderTime = DateTime.Now.AddMinutes(25); + } + + async Task JsGetPddOrder() + { + if (isReadBuyerOrder) + return; + isReadBuyerOrder = true; lstPddBuyerOrderVoidAfterSaleOrderSn.Clear(); @@ -1502,7 +1521,7 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls cancellationTokenSource.Cancel(); cancellationTokenSource = new CancellationTokenSource(); - Task.Run(() => + await Task.Run(async () => { var tokenSource = cancellationTokenSource; while (isInitBuyerOrder || isInitBuyerAfterSale) @@ -1561,9 +1580,11 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls var t = WPFCEFBrowser.SetCookieAsync(JsonHelper.SerializeObject(cookieLst), buyer.wpf_browser.GetCookieManager(null)); t.Wait(); + // 进入个人主页 buyer.wpf_browser.Load("https://mobile.yangkeduo.com/personal.html"); Thread.Sleep(Randomx.GetId(2000, 3000)); + // 获取取件码 buyer.wpf_browser.Load("https://mobile.yangkeduo.com/psnl_goods_help.html?_t_module_name=assistant_search&refer_scene=no_pickup_package_express&refer_page_name=goods_express"); Thread.Sleep(Randomx.GetId(2000, 3000)); @@ -1574,76 +1595,7 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls #region 售后 if (1 == 1) { - readBuyer = buyer.buyer_id; - currentReadBuyer = buyer.buyer_id; - buyer.wpf_browser.Load("https://mobile.yangkeduo.com/complaint_list.html?refer_page_name=personal&refer_page_sn=10001&tab=all"); - - Dispatcher.Invoke(new Action(() => - { - Panel.SetZIndex(buyer.wpf_browser, maxZIndex++); - if (buyer.IsSelect != true) - { - foreach (var b in GlobalConfig.PDDBuyerLoginList) - { - b.IsSelect = false; - } - buyer.IsSelect = true; - } - })); - - Thread.Sleep(Randomx.GetId(2000, 3000)); - - Dispatcher.Invoke(new Action(() => { MainWindow.Instance.Title = buyer.buyer_nick + " 售后读取中"; })); - BindBuyerOrderReadInfo(buyer, "正在读取售后"); - - top = 0; - times = 0; - lastCefReadOrderTime = DateTime.Now; - while (currentReadBuyer == readBuyer) - { - if (isSkipNext) - break; - - if (tokenSource.IsCancellationRequested) - { - BindBuyerOrderReadInfo(buyer, "已取消读取"); - break; - } - - if (lastCefReadOrderTime < DateTime.Now.AddSeconds(-30)) - { - PddMobileLoginViewModel.CheckBuyerLogin(new List() { buyer }); - if (buyer.status != "在线") - { - BindBuyerOrderReadInfo(buyer, "店铺掉线"); - break; - } - BindBuyerOrderReadInfo(buyer, "停滞时间超过30秒,跳过"); - break; - } - - top += Randomx.GetId(450, 600); - Thread.Sleep(Randomx.GetId(100, 300)); - - var jsStr = @"window.scroll({ - top: document.body.scrollHeight, - left: 0, - behavior: 'smooth' // 可选,使滚动平滑 - });"; - jsStr = @"window.scroll({ - top: " + top + @", - left: 0, - behavior: 'smooth' // 可选,使滚动平滑 - });"; - //var t1 = cefWebBrowser.GetMainFrame().EvaluateScriptAsync(jsStr); - //t1.Wait(1000); - var client = buyer.wpf_browser.GetDevToolsClient(); - if (client != null) - { - var result = client.Runtime.EvaluateAsync(jsStr, null, true, null, null, true, null, true); - result.Wait(5000); - } - } + await JsGetPddAftersaleOrder(buyer, tokenSource); if (tokenSource.IsCancellationRequested) { BindBuyerOrderReadInfo(buyer, "已取消读取"); @@ -1653,11 +1605,13 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls } #endregion - // 待发货 + #region 待发货 readBuyer = buyer.buyer_id; currentReadBuyer = buyer.buyer_id; buyer.wpf_browser.Load("https://mobile.yangkeduo.com/orders.html?type=2"); + BindBuyerOrderReadInfo(buyer, "正在读取待发货"); + Dispatcher.Invoke(new Action(() => { Panel.SetZIndex(buyer.wpf_browser, maxZIndex++); @@ -1670,12 +1624,8 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls buyer.IsSelect = true; } })); - Thread.Sleep(Randomx.GetId(2000, 3000)); - Dispatcher.Invoke(new Action(() => { MainWindow.Instance.Title = buyer.buyer_nick + " 待发货读取中"; })); - BindBuyerOrderReadInfo(buyer, "正在读取待发货"); - top = 0; lastCefReadOrderTime = DateTime.Now; while (currentReadBuyer == readBuyer) @@ -1713,11 +1663,6 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls Thread.Sleep(Randomx.GetId(100, 300)); var jsStr = @"window.scroll({ - top: document.body.scrollHeight, - left: 0, - behavior: 'smooth' // 可选,使滚动平滑 - });"; - jsStr = @"window.scroll({ top: " + top + @", left: 0, behavior: 'smooth' // 可选,使滚动平滑 @@ -1742,15 +1687,15 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls break; } Thread.Sleep(Randomx.GetId(2000, 3000)); + #endregion - Dispatcher.Invoke(new Action(() => { MainWindow.Instance.Title = buyer.buyer_nick + " 已发货读取中"; })); - BindBuyerOrderReadInfo(buyer, "正在读取已发货"); - - // 已发货 + #region 已发货 readBuyer = buyer.buyer_id; currentReadBuyer = buyer.buyer_id; buyer.wpf_browser.Load("https://mobile.yangkeduo.com/orders.html?type=3"); + BindBuyerOrderReadInfo(buyer, "正在读取已发货"); + Dispatcher.Invoke(new Action(() => { Panel.SetZIndex(buyer.wpf_browser, maxZIndex++); @@ -1763,7 +1708,6 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls buyer.IsSelect = true; } })); - Thread.Sleep(Randomx.GetId(2000, 3000)); top = 0; @@ -1795,11 +1739,6 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls Thread.Sleep(Randomx.GetId(100, 300)); var jsStr = @"window.scroll({ - top: document.body.scrollHeight, - left: 0, - behavior: 'smooth' // 可选,使滚动平滑 - });"; - jsStr = @"window.scroll({ top: " + top + @", left: 0, behavior: 'smooth' // 可选,使滚动平滑 @@ -1823,6 +1762,8 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls BindBuyerOrderReadInfo(buyer, "已取消读取"); break; } + Thread.Sleep(Randomx.GetId(2000, 3000)); + #endregion } catch (Exception ex) { @@ -1832,15 +1773,116 @@ namespace _29WxOrderWpf.Ctrls.BuyerCtrls continue; } } - Thread.Sleep(Randomx.GetId(6000, 8000)); + Thread.Sleep(Randomx.GetId(3000, 5000)); } isReadBuyerOrder = false; - Dispatcher.Invoke(new Action(() => { MainWindow.Instance.Title = "订单读取已结束,等待下次读取"; })); BindBuyerOrderReadInfo(null, "订单读取已结束,等待下次读取"); }, cancellationTokenSource.Token); } + public static async Task IsAftersaleJsInstalledAsync(IFrame frame, int timeoutMs = 3000) + { + var deadline = DateTime.Now.AddMilliseconds(timeoutMs); + while (DateTime.Now < deadline) + { + var r = await frame.EvaluateScriptAsync("!!(window.PDAfterSale && window.PDAfterSale.__installed)") + .ConfigureAwait(false); + if (r != null && r.Success && r.Result is bool && (bool)r.Result) return true; + await Task.Delay(100).ConfigureAwait(false); + } + return false; + } + + async Task JsGetPddAftersaleOrder(BuyerLoginInfo buyer, CancellationTokenSource tokenSource) + { + var readBuyer = buyer.buyer_id; + currentReadBuyer = buyer.buyer_id; + buyer.wpf_browser.Load("https://mobile.yangkeduo.com/complaint_list.html?refer_page_name=personal&refer_page_sn=10001&tab=all"); + + BindBuyerOrderReadInfo(buyer, "正在读取售后"); + + Dispatcher.Invoke(new Action(() => + { + Panel.SetZIndex(buyer.wpf_browser, maxZIndex++); + if (buyer.IsSelect != true) + { + foreach (var b in GlobalConfig.PDDBuyerLoginList.ToList()) + { + b.IsSelect = false; + } + buyer.IsSelect = true; + } + })); + Thread.Sleep(Randomx.GetId(2000, 3000)); + + var client = buyer.wpf_browser.GetDevToolsClient(); + if (client == null) + return; + + // 注入触发翻滚的js事件 + string address = ""; + Dispatcher.Invoke(() => { address = buyer.wpf_browser.Address; }); + if (address.StartsWith("https://mobile.yangkeduo.com/complaint_list.html")) + { + string content = ResourceHelper.GetResourceText("/Ctrls/BuyerCtrls/Resources/pdd_afterorder.js", "29WxFindOrder"); + if (string.IsNullOrEmpty(content)) + return; + await client.Runtime.EvaluateAsync(content, null, true, null, null, true, null, true); + + var frame = cefWebBrowser.GetMainFrame(); + if (await IsAftersaleJsInstalledAsync(frame) != true) + { + BindBuyerOrderReadInfo(buyer, "售后单读取预加载失败!"); + return; + } + } + + lastCefReadOrderTime = DateTime.Now; + string tempNextQueryCreatedAt = ""; + while (currentReadBuyer == readBuyer) + { + if (isSkipNext) + break; + + if (tokenSource.IsCancellationRequested) + { + BindBuyerOrderReadInfo(buyer, "已取消读取"); + break; + } + + if (lastCefReadOrderTime < DateTime.Now.AddSeconds(-30)) + { + PddMobileLoginViewModel.CheckBuyerLogin(new List() { buyer }); + if (buyer.status != "在线") + { + BindBuyerOrderReadInfo(buyer, "店铺掉线"); + break; + } + BindBuyerOrderReadInfo(buyer, "停滞时间超过30秒,跳过"); + break; + } + + if (!dicPddAftersaleNextQueryCreatedAt.ContainsKey(buyer)) + dicPddAftersaleNextQueryCreatedAt[buyer] = ""; + if (dicPddAftersaleNextQueryCreatedAt[buyer] == tempNextQueryCreatedAt) + { + await Task.Delay(500); + continue; + } + else + { + } + tempNextQueryCreatedAt = dicPddAftersaleNextQueryCreatedAt[buyer]; + + //1789123589 + //string jsStr = $"PDDOrders.trigger({{offset:'MO-01-{lastOrderSN}',type:0,size:10,mode:'store'}})"; + string jsStr = "PDAfterSale.trigger({cursor:'" + dicPddAftersaleNextQueryCreatedAt[buyer] + "',tab:'all'})"; + await client.Runtime.EvaluateAsync(jsStr, null, true, null, null, true, null, true); + await Task.Delay(Randomx.GetId(1000, 3000)); + } + } + void ScrollGetPddOrder() { isReadBuyerOrder = true; diff --git a/29WxOrderWpf/Ctrls/BuyerCtrls/Resources/pdd_afterorder.js b/29WxOrderWpf/Ctrls/BuyerCtrls/Resources/pdd_afterorder.js new file mode 100644 index 0000000..f49604f --- /dev/null +++ b/29WxOrderWpf/Ctrls/BuyerCtrls/Resources/pdd_afterorder.js @@ -0,0 +1,1436 @@ +/*! + * pdd_afterorder_hook.js · v1.0.0 (由 tools/gen_from_orders.js 生成,请勿直接改) + * --------------------------------------------------------------------------- + * 拼多多 H5「售后/投诉列表」Hook —— https://mobile.yangkeduo.com/complaint_list.html?tab=all + * + * 接口:POST api/blade/after_sales_list(baseURL /proxy/api/) + * body : { range: 'all' | 'pending', nextQueryCreatedAt?: 时间游标, anti_content: '0ao...' } + * result: { listItemVoList: [...], nextQueryCreatedAt, hasNextQuery, userRedDotNum, extraParams } + * tab : 'all' 全部 / 'pending' 待处理(对应 URL 上的 ?tab=) + * 分页 : 时间游标 nextQueryCreatedAt —— 第一页不带该字段,之后传上一页返回的 nextQueryCreatedAt + * + * 目标:不用滚动,直接传一个游标就能把「指定那一页」的售后单拿回来; + * 也能只把请求发出去(trigger),响应交给 CEF 的 OnResourceResponse 拦。 + * 签名(anti_content)始终由页面自己算。 + * + * 常用 API: + * PDAfterSale.load({ cursor: '1630xxxx', tab: 'pending' }) // 自己拿结果 + * PDAfterSale.trigger({ cursor: null, tab: 'all', size: 1 }) // 只发请求(size 见下) + * PDAfterSale.setNextCursor(null, { tab: 'all' }) // 下一次上拉回到第一页 + * PDAfterSale.crawl({ from: null, tab: 'all', maxPages: 5 }) + * + * 注意:该接口**没有 size/page 参数**(页大小由服务端定),`trigger` 的 `size` 选项这里只是 + * 「重试轮数」(页面 requestAftersaleList(N) 的行为),不要当成每页条数。 + * --------------------------------------------------------------------------- + */ +;(function (global) { + 'use strict'; + + /* 已经注入过就只做一次 install,避免重复劫持 */ + if (global.PDAfterSale && global.PDAfterSale.__installed) { + try { global.PDAfterSale.install(); } catch (e) { /* ignore */ } + return; + } + + var VERSION = '1.0.0'; + + /* ======================================================================= + * 1. 常量 / 小工具 + * ===================================================================== */ + + /* 售后列表的 tab:'all' 全部 / 'pending' 待处理(URL 上是 ?tab=) */ + var TAB_LIST = ['all', 'pending']; + /* 游标字段名:售后列表用的是「时间游标」,不是 order 的 offset */ + var CURSOR_FIELD = 'nextQueryCreatedAt'; + /* 列表接口 */ + var ORDER_LIST_RE = /blade\/after_sales_list(\?|$)/; + /* 计数接口(待处理红点数) */ + var COUNT_URL = 'api/blade/red_dot'; + var DEFAULT_TIMEOUT = 8000; + + + function isFn(v) { return typeof v === 'function'; } + function isObj(v) { return !!v && typeof v === 'object'; } + function has(o, k) { return !!o && Object.prototype.hasOwnProperty.call(o, k); } + function noop() { } + function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } + function clone(o) { try { return JSON.parse(JSON.stringify(o)); } catch (e) { return null; } } + + var seq = 0; + + /* ======================================================================= + * 2. 内部状态 + * ===================================================================== */ + + var state = { + installed: false, + version: VERSION, + debug: !!global.__PDD_HOOK_DEBUG__, + + req: null, /* 捕获到的 __webpack_require__ */ + storeClass: null, + serviceClass: null, + + store: null, /* OrdersStore 实例(页面业务 store) */ + service: null, /* OrdersService 实例(真正发请求的类) */ + source: '', /* 实例来源:webpack / fiber */ + + overrides: [], /* setNextOffset 注册的改写信箱 */ + templates: {}, /* tabIndex -> 最近一次请求模板 */ + history: [], /* 请求/响应记录(最多 200 条) */ + logs: [], /* 调试日志 */ + lastAntiContent: '', + lastRequest: null, + lastResponse: null, + patched: [] /* 打过补丁的原型方法,供 unpatch 还原 */ + }; + + function log(kind, msg, data) { + var rec = { time: Date.now(), kind: kind, msg: msg, data: data }; + state.logs.push(rec); + if (state.logs.length > 500) state.logs.shift(); + if (!state.debug) return; + try { + if (data === undefined) console.log('[PDAfterSale][' + kind + '] ' + msg); + else console.log('[PDAfterSale][' + kind + '] ' + msg, data); + } catch (e) { /* ignore */ } + } + function warn(msg, data) { + log('warn', msg, data); + try { console.warn('[PDAfterSale] ' + msg, data === undefined ? '' : data); } catch (e) { } + } + /* ======================================================================= + * 3. 极简事件总线 + * ===================================================================== */ + + var listeners = {}; + + function on(evt, cb) { + if (!isFn(cb)) return noop; + (listeners[evt] || (listeners[evt] = [])).push(cb); + return function offMe() { api.off(evt, cb); }; + } + function off(evt, cb) { + var arr = listeners[evt]; + if (!arr) return; + var i = arr.indexOf(cb); + if (i >= 0) arr.splice(i, 1); + } + function emit(evt, payload) { + var arr = listeners[evt]; + if (!arr || !arr.length) return; + for (var i = 0; i < arr.length; i++) { + try { arr[i](payload); } catch (e) { warn('事件回调抛错:' + evt, e); } + } + } + + /* ======================================================================= + * 4. tab 归一化 / offset 覆盖规则 + * ===================================================================== */ + + /** tab 归一化:售后页是字符串 'all' | 'pending'(顺带接受 0/1) */ + function toTabIndex(t) { + if (t === undefined || t === null || t === '') return null; + var s = String(t).toLowerCase(); + if (s === '0') return 'all'; + if (s === '1') return 'pending'; + return TAB_LIST.indexOf(s) >= 0 ? s : null; + } + + /** tab 的展示值(售后页接口值与展示值一致) */ + function toTabPath(t) { + return toTabIndex(t); + } + + /** + * 注册一条「游标改写规则」:让页面下一次请求带上(或去掉)指定的 nextQueryCreatedAt。 + * @param {string|null} cursor 目标时间游标;null / '' / 0 = 回到第一页(请求里不带该字段) + * @param {object} [opts] { tab, times, always } + * tab 'all' | 'pending'(也接受 type),不传 = 所有 tab + * times 生效次数,默认 1(即「下一次请求」) + * always true = 永久生效直到 clearOffsets + * @returns {function} 手动取消该规则 + */ + function setNextOffset(cursor, opts) { + opts = opts || {}; + var rule = { + id: ++seq, + clear: cursor === null || cursor === undefined || cursor === '' || cursor === 0 || cursor === '0', + offset: cursor, + type: toTabIndex(opts.tab !== undefined ? opts.tab : opts.type), + remain: opts.always ? Infinity : (opts.times > 0 ? opts.times : 1), + createdAt: Date.now() + }; + state.overrides.push(rule); + log('offset', '注册游标改写规则', rule); + return function cancel() { + var i = state.overrides.indexOf(rule); + if (i >= 0) state.overrides.splice(i, 1); + }; + } + + function clearOffsets(type) { + var t = toTabIndex(type); + if (t === null) { state.overrides.length = 0; return 0; } + var removed = 0; + for (var i = state.overrides.length - 1; i >= 0; i--) { + if (state.overrides[i].type === t) { state.overrides.splice(i, 1); removed++; } + } + return removed; + } + + function pickOverride(tabIndex) { + for (var i = 0; i < state.overrides.length; i++) { + var rule = state.overrides[i]; + if (rule.type !== null && rule.type !== tabIndex) continue; + if (rule.remain !== Infinity) { + rule.remain -= 1; + if (rule.remain <= 0) state.overrides.splice(i, 1); + } + return rule; + } + return null; + } + + /* ======================================================================= + * 5. 网络层:XHR / fetch 劫持 + * ===================================================================== */ + + function matchOrderListUrl(url) { + return typeof url === 'string' && ORDER_LIST_RE.test(url); + } + + function tryParseJsonBody(body) { + if (typeof body !== 'string') return null; + var s = body.replace(/^\s+/, ''); + if (s.charAt(0) !== '{') return null; + try { + var o = JSON.parse(s); + return isObj(o) ? o : null; + } catch (e) { return null; } + } + + function installXhrHook() { + var XHR = global.XMLHttpRequest; + if (!XHR || !XHR.prototype || XHR.prototype.__pddHooked) return false; + + var proto = XHR.prototype; + var origOpen = proto.open; + var origSend = proto.send; + var origSetHeader = proto.setRequestHeader; + + proto.open = function (method, url) { + try { + if (this.__pddSkip) return origOpen.apply(this, arguments); + this.__pddHook = { + id: ++seq, + method: String(method || 'GET').toUpperCase(), + url: String(url === undefined || url === null ? '' : url), + headers: {} + }; + } catch (e) { /* ignore */ } + return origOpen.apply(this, arguments); + }; + + if (isFn(origSetHeader)) { + proto.setRequestHeader = function (name, value) { + try { + if (this.__pddHook) this.__pddHook.headers[String(name)] = value; + } catch (e) { /* ignore */ } + return origSetHeader.apply(this, arguments); + }; + } + + proto.send = function (body) { + var out = body; + try { out = onXhrSend(this, body); } catch (e) { warn('send 劫持失败', e); } + return origSend.call(this, out); + }; + + proto.__pddHooked = true; + log('net', 'XMLHttpRequest 已劫持'); + return true; + } + function onXhrSend(xhr, body) { + var info = xhr.__pddHook; + if (!info || !matchOrderListUrl(info.url)) return body; + + var obj = tryParseJsonBody(body); + if (!obj) return body; /* 不是 JSON body,放过 */ + + info.body = obj; + info.tabIndex = toTabIndex(obj.range); + info.requestOffset = obj[CURSOR_FIELD]; /* 时间游标;第一页没有这个字段 */ + info.sentAt = Date.now(); + info.template = { url: info.url, headers: info.headers, body: clone(obj), time: info.sentAt }; + if (obj.anti_content) state.lastAntiContent = obj.anti_content; + + /* 记录模板(按 tab) */ + var tk = info.tabIndex === null ? 'unknown' : info.tabIndex; + state.templates[tk] = info.template; + state.lastRequest = info; + + emit('request', { + id: info.id, url: info.url, headers: info.headers, + body: clone(obj), tab: info.tabIndex, offset: obj[CURSOR_FIELD], source: 'xhr' + }); + + var rule = pickOverride(info.tabIndex); + var out = body; + if (rule) { + var mutated = false; + if (rule.clear) { + if (has(obj, CURSOR_FIELD)) { delete obj[CURSOR_FIELD]; mutated = true; } + } else if (obj[CURSOR_FIELD] !== rule.offset) { + obj[CURSOR_FIELD] = rule.offset; + mutated = true; + } + info.override = { id: rule.id, clear: rule.clear, offset: rule.offset }; + if (mutated) { + out = JSON.stringify(obj); + log('offset', '已改写本次请求游标 -> ' + (rule.clear ? '(第一页/无游标)' : rule.offset), { tab: info.tabIndex }); + } + /* 只要这一发确实带着目标游标出门(含「本来就不带游标」的情况)就对外广播一次 */ + var effective = obj[CURSOR_FIELD]; + var okNow = rule.clear ? (effective === undefined || effective === null) : (String(effective) === String(rule.offset)); + if (okNow) { + emit('override', { + requestId: info.id, tab: info.tabIndex, from: info.requestOffset, + to: (effective === undefined ? null : effective), clear: rule.clear, + url: info.url, source: 'xhr' + }); + } + } + + info.sentBody = obj; + bindXhrResponse(xhr, info); + return out; + } + + function bindXhrResponse(xhr, info) { + var handler = function () { + try { captureXhrResponse(xhr, info); } catch (e) { warn('响应解析失败', e); } + }; + try { + if (isFn(xhr.addEventListener)) { + xhr.addEventListener('load', handler); + xhr.addEventListener('loadend', handler); /* 某些实现 load 不触发时兜底 */ + } else { + var prev = xhr.onload; + xhr.onload = function () { + handler(); + if (isFn(prev)) return prev.apply(this, arguments); + }; + } + } catch (e) { /* ignore */ } + } + + function readResponseText(xhr) { + try { + var rt = xhr.responseType; + if (!rt || rt === 'text') return xhr.responseText; + } catch (e) { /* responseType 不匹配时读 responseText 会抛 */ } + try { if (typeof xhr.response === 'string') return xhr.response; } catch (e) { } + return null; + } + + function captureXhrResponse(xhr, info) { + if (info.__captured) return; + info.__captured = true; + completeResponse(info, readResponseText(xhr), xhr.status); + } + + function completeResponse(info, text, status) { + var json = null; + if (typeof text === 'string' && text) { + try { json = JSON.parse(text); } catch (e) { json = null; } + } + info.status = status; + info.json = json; + info.finishedAt = Date.now(); + state.lastResponse = info; + state.history.push(info); + if (state.history.length > 200) state.history.shift(); + + var items = pickItems(json); + var payload = { + id: info.id, url: info.url, tab: info.tabIndex, + requestOffset: info.requestOffset, /* 页面原本要用的游标(第一页为 undefined) */ + effectiveOffset: info.sentBody ? info.sentBody[CURSOR_FIELD] : info.requestOffset, + itemCount: items.length, + orderCount: items.length, /* 兼容 order_hook 的字段名 */ + nextOffset: pickNextOffset(json), + hasNextQuery: pickHasNext(json), + json: json, + request: info + }; + log('net', 'after_sales_list 响应 ' + (status || '') + ' 条数=' + items.length, + { url: info.url, offset: payload.effectiveOffset }); + emit('response', payload); + } + + function installFetchHook() { + var origFetch = global.fetch; + if (!isFn(origFetch) || origFetch.__pddHooked) return false; + var wrapped = function (input, init) { + var info = null, out = init; + try { + var url = typeof input === 'string' ? input : (input && input.url) || ''; + if (matchOrderListUrl(url)) { + var body = (init && init.body); + var obj = tryParseJsonBody(body); + if (obj) { + info = { + id: ++seq, method: (init && init.method) || 'POST', url: url, + headers: (init && init.headers) || {}, body: obj, + tabIndex: toTabIndex(obj.range), requestOffset: obj[CURSOR_FIELD], + sentAt: Date.now(), source: 'fetch' + }; + if (obj.anti_content) state.lastAntiContent = obj.anti_content; + state.templates[info.tabIndex === null ? 'unknown' : info.tabIndex] = + { url: url, headers: info.headers, body: clone(obj), time: info.sentAt }; + state.lastRequest = info; + emit('request', { + id: info.id, url: url, headers: info.headers, body: clone(obj), + tab: info.tabIndex, offset: obj[CURSOR_FIELD], source: 'fetch' + }); + var rule = pickOverride(info.tabIndex); + if (rule) { + var copy = clone(obj); + if (rule.clear) delete copy[CURSOR_FIELD]; + else copy[CURSOR_FIELD] = rule.offset; + info.sentBody = copy; + out = init || {}; + out.body = JSON.stringify(copy); + emit('override', { + requestId: info.id, tab: info.tabIndex, from: info.requestOffset, + to: (copy[CURSOR_FIELD] === undefined ? null : copy[CURSOR_FIELD]), + clear: rule.clear, url: url, source: 'fetch' + }); + } else { + info.sentBody = obj; + } + } + } + } catch (e) { warn('fetch 劫持失败', e); } + + var p = out === undefined ? origFetch.call(this, input) : origFetch.call(this, input, out); + if (info && p && isFn(p.then)) { + p.then(function (res) { + try { + res.clone().text().then(function (t) { completeResponse(info, t, res.status); }).catch(noop); + } catch (e) { /* ignore */ } + }).catch(noop); + } + return p; + }; + wrapped.__pddHooked = true; + global.fetch = wrapped; + log('net', 'fetch 已劫持'); + return true; + } + + /* ======================================================================= + * 6. webpack 层:抓 __webpack_require__ → 找类 → 打原型补丁 → 收敛实例 + * ===================================================================== */ + + function installWebpackCapture() { + var KEY = '__LOADABLE_LOADED_CHUNKS__'; + var arr; + try { + arr = global[KEY]; + if (!Array.isArray(arr)) { + arr = []; + global[KEY] = arr; + } + } catch (e) { warn('无法挂载 __LOADABLE_LOADED_CHUNKS__', e); return false; } + + /* 已经推入的 chunk 先扫一遍(runtime 函数可能还没执行,仍可包一层) */ + for (var i = 0; i < arr.length; i++) wrapChunkEntry(arr[i]); + + /* webpack runtime 的真实写法是「先读 push,再写 push」: + * chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal)) + * 而且 webpack 的 callback 处理完一个 chunk 后,会把同一个 data 再交给「它读到的那个 push」 + * (parentChunkLoadingFunction)。所以用 getter/setter 接管: + * · getter 返回我们的 wrapper(webpack 会把它当成 parent push) + * · setter 收下 webpack 自己的 callback(downstream) + * chunk 文件 push 进来时先经过我们(给 data[2] 包一层,接住 __webpack_require__), + * 再原样转发给 webpack;webpack 内部的 parent 回调落到「重入分支」退化成原生 push, + * 语义与没装 hook 时完全一致(数组里照常累积 chunk 记录,别的库也读得到)。 */ + var parentPush = arr.push; + var downstream = null; + var inDownstream = false; + + function pushWrapper(data) { + try { wrapChunkEntry(data); } catch (e) { /* ignore */ } + if (inDownstream) return parentPush.apply(arr, arguments); /* webpack 内部的 parent 回调 */ + inDownstream = true; + try { + return (isFn(downstream) ? downstream : parentPush).apply(arr, arguments); + } finally { + inDownstream = false; + } + } + + try { + Object.defineProperty(arr, 'push', { + configurable: true, + get: function () { return pushWrapper; }, + set: function (fn) { if (isFn(fn)) downstream = fn; } + }); + } catch (e) { + /* 老引擎兜底:直接改写 push(runtime 之后装载会被它覆盖,但还能兜住懒加载 chunk) */ + try { arr.push = pushWrapper; } catch (e2) { /* ignore */ } + } + log('webpack', 'chunk push 已挂载'); + return true; + } + + function wrapChunkEntry(entry) { + if (!Array.isArray(entry) || entry.length < 3) return; + var runtime = entry[2]; + if (!isFn(runtime) || runtime.__pddWrapped) return; + var wrapped = function (req) { + try { captureRequire(req); } catch (e) { /* ignore */ } + return runtime.apply(this, arguments); + }; + wrapped.__pddWrapped = true; + wrapped.__pddOrig = runtime; + try { entry[2] = wrapped; } catch (e) { /* ignore */ } + } + + function captureRequire(req) { + if (!isFn(req)) return; + if (!state.req) { + state.req = req; + log('webpack', '__webpack_require__ 已捕获'); + emit('require', req); + } + scanModules(); + } + + function eachModuleExport(cb) { + var cache = state.req && state.req.c; + if (!cache) return; + for (var id in cache) { + var mod; + try { mod = cache[id]; } catch (e) { continue; } + var exp = mod && mod.exports; + if (!exp) continue; + try { + cb(exp, id); + if (isObj(exp) || isFn(exp)) { + for (var k in exp) { + if (!has(exp, k)) continue; + var v; + try { v = exp[k]; } catch (e) { continue; } + if (v && v !== exp) cb(v, id + '#' + k); + } + } + } catch (e) { /* ignore */ } + } + } + + function looksLikeService(v) { + /* 售后页的 OrdersService:getList(列表)+ getAfterSaleCount(红点) */ + return isFn(v) && v.prototype && + isFn(v.prototype.getList) && isFn(v.prototype.getAfterSaleCount); + } + + function looksLikeStore(v) { + /* 售后页的 store:requestAftersaleList(取数)+ onLoadDown(滚动加载入口) */ + return isFn(v) && v.prototype && + isFn(v.prototype.requestAftersaleList) && isFn(v.prototype.onLoadDown); + } + + function scanModules() { + if (!state.req) return false; + eachModuleExport(function (exp) { + if (!state.serviceClass && looksLikeService(exp)) { + state.serviceClass = exp; + patchPrototype(exp, ['getList', 'getAfterSaleCount'], adoptService); + log('webpack', '找到 AfterSaleService 类,已打补丁'); + } + if (!state.storeClass && looksLikeStore(exp)) { + state.storeClass = exp; + patchPrototype(exp, ['requestAftersaleList', 'onLoadDown', 'initList'], adoptStore); + log('webpack', '找到 AfterSaleStore 类,已打补丁'); + } + }); + return !!(state.storeClass && state.serviceClass); + } + + function patchPrototype(Cls, names, onInstance) { + if (!Cls || !Cls.prototype) return; + for (var i = 0; i < names.length; i++) { + (function (name) { + var desc = Object.getOwnPropertyDescriptor(Cls.prototype, name); + if (!desc || !isFn(desc.value) || desc.value.__pddPatched) return; + var orig = desc.value; + var wrapper = function () { + try { onInstance(this); } catch (e) { /* ignore */ } + return orig.apply(this, arguments); + }; + wrapper.__pddPatched = true; + wrapper.__pddOrig = orig; + try { + Object.defineProperty(Cls.prototype, name, { + configurable: true, + enumerable: desc.enumerable, + writable: true, + value: wrapper + }); + state.patched.push({ Cls: Cls, name: name, orig: orig }); + } catch (e) { /* ignore */ } + })(names[i]); + } + } + + function adoptService(inst) { + if (!state.service && inst) { + state.service = inst; + if (!state.source) state.source = 'webpack'; + log('store', '已拿到售后 service 实例(getList / getAfterSaleCount)'); + emit('service', inst); + emit('ready', { store: state.store, service: state.service }); + } + } + + function adoptStore(inst) { + if (!state.store && inst) { + state.store = inst; + if (!state.source) state.source = 'webpack'; + log('store', '已拿到售后 store 实例(requestAftersaleList / onLoadDown)'); + try { if (inst.$service) adoptService(inst.$service); } catch (e) { /* ignore */ } + emit('store', inst); + emit('ready', { store: state.store, service: state.service }); + } + } + + /* ======================================================================= + * 7. React fiber 兜底:从 #main 往里扫,命中 props.ordersStore + * ===================================================================== */ + + function fiberRoots() { + var roots = []; + try { + var el = (global.document && (global.document.getElementById('main') || global.document.body)) || null; + if (el) { + var rc = el._reactRootContainer || el.__reactRootContainer; + var cur = rc && rc._internalRoot && rc._internalRoot.current; + if (cur) roots.push(cur); + } + if (!roots.length && global.document && global.document.body && global.document.body.querySelectorAll) { + var all = global.document.body.querySelectorAll('*'); + for (var i = 0; i < all.length && i < 400; i++) { + var node = all[i]; + var keys; + try { keys = Object.keys(node); } catch (e) { continue; } + for (var j = 0; j < keys.length; j++) { + var k = keys[j]; + if (k.indexOf('__reactInternalInstance$') === 0 || k.indexOf('__reactFiber$') === 0) { + var f = node[k]; + while (f && f.return) f = f.return; /* 一路爬到 HostRoot */ + if (f && f.stateNode) roots.push(f); + break; + } + } + } + } + } catch (e) { /* ignore */ } + return roots; + } + + function adoptFromBag(bag) { + if (!isObj(bag) && !isFn(bag)) return; + try { + /* service 实例:有 getList + getAfterSaleCount */ + if (isFn(bag.getList) && isFn(bag.getAfterSaleCount)) adoptService(bag); + /* store 实例:有 requestAftersaleList + onLoadDown */ + if (isFn(bag.requestAftersaleList) && isFn(bag.onLoadDown)) adoptStore(bag); + var keys = ['$service', 'service', 'aftersaleStore', 'store', 'afterSaleStore']; + for (var i = 0; i < keys.length; i++) { + var v; + try { v = bag[keys[i]]; } catch (e) { continue; } + if (!v) continue; + if (isFn(v.getList) && isFn(v.getAfterSaleCount)) adoptService(v); + if (isFn(v.requestAftersaleList) && isFn(v.onLoadDown)) adoptStore(v); + } + } catch (e) { /* ignore */ } + } + + function scanFiber(limit) { + var roots = fiberRoots(); + for (var r = 0; r < roots.length; r++) { + var queue = [roots[r]]; + var visited = 0; + while (queue.length && visited < (limit || 3000)) { + var f = queue.shift(); + visited++; + try { + adoptFromBag(f.memoizedProps); + adoptFromBag(f.pendingProps); + adoptFromBag(f.stateNode); + } catch (e) { /* ignore */ } + if (state.store && state.service) { state.source = state.source || 'fiber'; return true; } + try { + if (f.child) queue.push(f.child); + if (f.sibling) queue.push(f.sibling); + } catch (e) { /* ignore */ } + } + } + if ((!state.store || !state.service) && roots.length) state.source = state.source || 'fiber'; + return !!(state.store && state.service); + } + /* ======================================================================= + * 8. 启动扫描 & 就绪等待 + * ===================================================================== */ + + var scannerTimer = null; + var scannerDeadline = 0; + + var fiberMisses = 0; + + function startScanner(deadlineMs) { + scannerDeadline = Date.now() + (deadlineMs || 120000); + if (scannerTimer) return; + scannerTimer = setInterval(function () { + try { + scanModules(); + if (!state.store || !state.service) { + /* 实例没拿到时轮询 fiber:前 20 次每次都扫,之后降频,避免空转吃 CPU */ + fiberMisses++; + if (fiberMisses < 20 || fiberMisses % 8 === 0) scanFiber(800); + } else { + fiberMisses = 0; + } + } catch (e) { /* ignore */ } + if ((state.store && state.service) || Date.now() > scannerDeadline) { + clearInterval(scannerTimer); + scannerTimer = null; + } + }, 250); + } + + function scan() { + var modules = false; + try { modules = scanModules(); } catch (e) { /* ignore */ } + if (!state.store || !state.service) { try { scanFiber(3000); } catch (e) { /* ignore */ } } + return { store: state.store, service: state.service, source: state.source, modules: modules }; + } + + function waitFor(cond, timeoutMs, hint) { + return new Promise(function (resolve, reject) { + if (cond()) return resolve(true); + scan(); + if (cond()) return resolve(true); + var done = false; + var iv, stop; + var timer = setTimeout(function () { + if (done) return; + done = true; + clearInterval(iv); + stop(); + reject(new Error(hint || ('等待超时(' + timeoutMs + 'ms):没有拿到页面 store/service'))); + }, timeoutMs || DEFAULT_TIMEOUT); + iv = setInterval(check, 200); + stop = on('ready', check); + function check() { + if (done || !cond()) return; + done = true; + clearTimeout(timer); + clearInterval(iv); + stop(); + resolve(true); + } + }); + } + + function ready(timeoutMs) { + return waitFor(function () { return !!(state.store && state.service); }, timeoutMs || DEFAULT_TIMEOUT) + .then(function () { return { store: state.store, service: state.service }; }); + } + + /* ======================================================================= + * 9. 请求参数组装(优先用页面自己的 getOrderParamsFromQuery) + * ===================================================================== */ + + function parseSearch(search) { + var out = {}; + var s = String(search || ''); + if (s.charAt(0) === '?') s = s.slice(1); + if (!s) return out; + var parts = s.split('&'); + for (var i = 0; i < parts.length; i++) { + if (!parts[i]) continue; + var idx = parts[i].indexOf('='); + var k = idx < 0 ? parts[i] : parts[i].slice(0, idx); + var v = idx < 0 ? '' : parts[i].slice(idx + 1); + try { out[decodeURIComponent(k)] = decodeURIComponent(v); } + catch (e) { out[k] = v; } + } + return out; + } + + /** 售后页的「第一页」= 请求里不带 nextQueryCreatedAt */ + function normalizeOffset(v) { + if (v === undefined || v === null || v === '' || v === 0 || v === '0') return null; + return v; + } + + /* ======================================================================= + * 10. 业务 API:load / crawl / replay + * ===================================================================== */ + + /** + * 取「指定游标之后」的一页售后单(自己拿结果)。 + * @param {object} opts + * cursor / offset 时间游标(上一页返回的 nextQueryCreatedAt);null / '' / 0 / 省略 = 第一页 + * tab 'all' | 'pending'(默认页面当前 tab,其次 URL ?tab=) + * advance true = 把游标推进写回页面(下次正常上拉接着这一页);默认 false 不打扰页面 + * timeout 等待 store/service 的超时,默认 8000ms + * useReplayFallback 拿不到 store 时是否退回「网络层回放」,默认 true + */ + function load(opts) { + opts = opts || {}; + var cursor = normalizeOffset(opts.cursor !== undefined ? opts.cursor : opts.offset); + var useReplayFallback = opts.useReplayFallback !== false; + var timeout = opts.timeout || DEFAULT_TIMEOUT; + + return waitFor(function () { return !!(state.store && state.service); }, timeout) + .catch(function (err) { + if (!useReplayFallback) throw err; + var tpl = pickTemplate(opts.tab); + if (!tpl) throw err; + warn('未拿到页面 store,改用请求模板回放(anti_content 为最近一次抓到的值,时效有限)'); + return replay(cursor, { tab: opts.tab, template: tpl }) + .then(function (json) { return wrapReplayResult(json, opts, cursor); }); + }) + .then(function (res) { + if (res && res.__replayed) return res; + return doLoadWithService(opts, cursor); + }); + } + + /** + * 走页面自己的 service.getList(= 页面 requestAftersaleList 内部调的那个函数)取一页。 + * 好处:query/anti_content/请求头全部是页面自己的;且不会动页面的 currentTab 与列表状态。 + */ + function doLoadWithService(opts, cursor) { + var store = state.store, svc = state.service; + var tab = toTabIndex(opts.tab); + if (tab === null) { + /* 显式传了未知 tab → 直接报错,别静默取错 tab */ + if (opts.tab !== undefined && opts.tab !== null && opts.tab !== '') { + return Promise.reject(new Error('未知 tab:' + opts.tab + '(可用 all / pending)')); + } + tab = toTabIndex(store && store.currentTab); + if (tab === null) tab = tabFromUrl(); + if (tab === null) tab = 'all'; + } + if (!isFn(svc.getList)) return Promise.reject(new Error('页面 service 上没有 getList')); + + var params = buildListParams(store, tab, cursor); + var startedAt = Date.now(); + + return Promise.resolve() + .then(function () { return svc.getList(params); }) + .then(function (json) { + var r = (json && json.result) || {}; + var items = r.listItemVoList || []; + /* advance:把游标推进写回页面的 requestMap,下一次正常上拉就接着这一页往下走 */ + if (opts.advance && r.nextQueryCreatedAt && store && store.requestMap && store.requestMap[tab]) { + try { store.requestMap[tab][CURSOR_FIELD] = r.nextQueryCreatedAt; } catch (e) { /* ignore */ } + } + var captured = findCapturedResponse(startedAt, tab); + return { + ok: !(json && json.error_code), + via: 'service', + tab: tab, tabType: tab, + requestCursor: cursor, + count: items.length, + items: items, listData: items, orders: items, /* orders 只是兼容命名 */ + nextCursor: r.nextQueryCreatedAt || null, + nextOffset: r.nextQueryCreatedAt || null, + hasNextQuery: !!r.hasNextQuery, + pendingCount: r.userRedDotNum, + extraParams: r.extraParams, + error: (json && json.error_msg) || null, + raw: (captured && captured.json) || json + }; + }); + } + + function findCapturedResponse(sinceTs, tab) { + for (var i = state.history.length - 1; i >= 0; i--) { + var h = state.history[i]; + if (!h.sentAt || h.sentAt < sinceTs) continue; + if (tab !== null && tab !== undefined && h.tabIndex !== tab) continue; + return h; + } + return null; + } + + function pickTemplate(type) { + var tab = toTabIndex(type); + if (tab !== null && state.templates[tab]) return state.templates[tab]; + var last = state.lastRequest; + if (last && last.template) return last.template; + var keys = Object.keys(state.templates); + return keys.length ? state.templates[keys[keys.length - 1]] : null; + } + + /** + * 纯网络层回放:拿最近一次抓到的请求模板,换上游标再发一次(不依赖页面 store)。 + * 用的是「上次抓到的 anti_content」,几分钟内一般可用。 + */ + function replay(cursor, opts) { + opts = opts || {}; + var tpl = opts.template || pickTemplate(opts.tab); + if (!tpl) return Promise.reject(new Error('还没有捕获到 after_sales_list 请求模板,先让页面加载一次')); + if (tpl.time && Date.now() - tpl.time > (opts.maxAgeMs || 5 * 60 * 1000)) { + warn('请求模板已超过 5 分钟,anti_content 可能失效'); + } + var body = clone(tpl.body) || {}; + if (cursor === null || cursor === undefined || cursor === '') delete body[CURSOR_FIELD]; + else body[CURSOR_FIELD] = cursor; + var headers = clone(tpl.headers) || {}; + if (body.anti_content) headers['anti-content'] = body.anti_content; + + return new Promise(function (resolve, reject) { + var xhr = new global.XMLHttpRequest(); + xhr.__pddSkip = true; + try { xhr.open('POST', tpl.url, true); } catch (e) { return reject(e); } + Object.keys(headers).forEach(function (k) { + try { xhr.setRequestHeader(k, headers[k]); } catch (e) { /* ignore */ } + }); + xhr.onload = function () { + try { + var json = JSON.parse(xhr.responseText); + emit('replay', { url: tpl.url, offset: body[CURSOR_FIELD], json: json }); + resolve(json); + } catch (e) { reject(e); } + }; + xhr.onerror = function () { reject(new Error('replay 网络错误')); }; + xhr.send(JSON.stringify(body)); + }); + } + + function wrapReplayResult(json, opts, cursor) { + var items = pickItems(json); + var tab = toTabIndex(opts.tab); + return { + __replayed: true, + ok: !!(json && !json.error_code), + via: 'replay', + tab: tab, tabType: tab, + requestCursor: cursor, + count: items.length, + items: items, listData: items, orders: items, + nextCursor: pickNextOffset(json), + nextOffset: pickNextOffset(json), + hasNextQuery: pickHasNext(json), + raw: json, + error: (json && json.error_msg) || null + }; + } + + /** + * 从一个游标开始连续翻页(逐页串行)。 + * @param {object} opts { from, tab, maxPages, gapMs, onPage, stop, advance } + * from 省略 / null = 从第一页开始 + */ + function crawl(opts) { + opts = opts || {}; + var maxPages = opts.maxPages > 0 ? opts.maxPages : 5; + var gap = opts.gapMs === undefined ? 1000 : opts.gapMs; + var pages = 0; + var collected = []; + var cursor = normalizeOffset(opts.from); + + function step() { + if (pages >= maxPages) return Promise.resolve(); + return load({ + cursor: cursor, + tab: opts.tab, + advance: !!opts.advance, + timeout: opts.timeout + }).then(function (res) { + pages++; + if (res.count) collected = collected.concat(res.items); + try { if (isFn(opts.onPage)) opts.onPage(res, pages); } catch (e) { warn('onPage 抛错', e); } + if (isFn(opts.stop) && opts.stop(res, pages)) return; + if (!res.count) return; + if (!res.hasNextQuery) return; + if (!res.nextCursor) return; + if (String(res.nextCursor) === String(cursor)) { + warn('游标没有前进(' + cursor + '),停止翻页'); + return; + } + cursor = res.nextCursor; + if (gap > 0) return sleep(gap).then(step); + return step(); + }); + } + + return step().then(function () { + return { pages: pages, items: collected, orders: collected, lastCursor: cursor }; + }); + } + + /* ======================================================================= + * 10.5 trigger —— 给外部(C# / 控制台)用:只负责「把这次请求发出去」 + * + * 与 load() 的分工: + * load() = 自己等结果,返回订单数组(适合 JS 侧直接消费) + * trigger() = 按你给的 offset 触发「页面自己的上拉加载代码」,不等结果、不碰结果, + * 响应由 CEF 的 OnResourceResponse / ResponseFilter 照常拦截(适合 C# 侧采集) + * + * 关键点:请求的 query 参数、anti_content 签名、请求头、body 结构全部由页面自己生成, + * 我们只把「页面打算用的 offset」换成你给的值,所以与用户手滑到底发出的请求一模一样。 + * ===================================================================== */ + + /** 找页面上可能承载订单列表的滚动容器 */ + function findScrollContainers() { + var out = []; + try { + var doc = global.document || {}; + var de = doc.documentElement, body = doc.body; + if (de) out.push(de); + if (body) out.push(body); + var node = null; + if (isFn(doc.querySelector)) node = doc.querySelector('.react-base-list-wrapper'); + if (!node && isFn(doc.getElementById)) node = doc.getElementById('main'); + var hops = 0; + while (node && hops < 8) { + if (node.scrollHeight && node.clientHeight && node.scrollHeight > node.clientHeight + 20 + && out.indexOf(node) < 0) out.push(node); + node = node.parentElement; + hops++; + } + } catch (e) { /* ignore */ } + return out; + } + + function makeScrollEvent() { + try { return new global.Event('scroll'); } catch (e) { } + try { + var ev = global.document.createEvent('Event'); + ev.initEvent('scroll', false, false); + return ev; + } catch (e2) { return null; } + } + + /** 把列表滚到底并补一发 scroll 事件(等价于用户滑到底) */ + function pulseScroll() { + var nodes = findScrollContainers(); + var ev = makeScrollEvent(); + for (var i = 0; i < nodes.length; i++) { + try { + nodes[i].scrollTop = nodes[i].scrollHeight; + if (ev && isFn(nodes[i].dispatchEvent)) nodes[i].dispatchEvent(ev); + } catch (e) { /* ignore */ } + } + try { + var h = (global.document.body || {}).scrollHeight || 100000; + if (isFn(global.scrollTo)) global.scrollTo(0, h); + } catch (e) { /* ignore */ } + return nodes.length; + } + + /** + * 触发一次取数(同步返回 ack,真正的请求异步发出)—— 结果交给 CEF 拦。 + * @param {object} opts + * cursor / offset 目标时间游标;null / '' / 0 / 省略 = 第一页(请求里不带该字段) + * tab 'all' | 'pending',不传 = 页面当前 tab / URL ?tab= + * mode 'auto'(默认:有 store 用 service,没有就真滚列表)| 'service' | 'loadDown' | 'scroll' + * service = 走页面 service.getList(= 页面 requestAftersaleList 内部调的函数) + * loadDown = 走页面 store.onLoadDown()(滚动到底真正执行的那个函数) + * scroll = 真把列表滚到底,靠游标改写逼页面自己发请求(不需要 store) + * remap 仅 loadDown:是否顺带切换页面 tab(默认 true) + * timeout 等 store 就绪的超时(默认 8000ms) + * @returns {object} 可直接 JSON 序列化的 ack + */ + function trigger(opts) { + opts = opts || {}; + var ack = { + ok: false, accepted: false, pending: false, requestId: ++seq, + mode: null, tab: null, tabType: null, + offset: normalizeOffset(opts.cursor !== undefined ? opts.cursor : opts.offset), + clear: false, effectiveOffset: null, + itemCount: null, nextCursor: null, hasNextQuery: null, + error: null, ts: Date.now() + }; + ack.clear = ack.offset === null; + try { + var tab = toTabIndex(opts.tab); + if (tab === null) { + if (opts.tab !== undefined && opts.tab !== null && opts.tab !== '') { + ack.error = '未知 tab:' + opts.tab + '(可用 all / pending)'; + return ack; + } + tab = toTabIndex(state.store && state.store.currentTab) || tabFromUrl() || 'all'; + } + ack.tab = tab; + ack.tabType = tab; + + var mode = opts.mode || 'auto'; + var hasStore = !!(state.store && state.service); + if (mode === 'auto') mode = hasStore ? 'service' : 'scroll'; + if ((mode === 'service' || mode === 'loadDown') && !hasStore) mode = 'scroll'; + + if (mode === 'scroll') { + ack.mode = 'scroll'; + ack.accepted = true; + ack.ok = true; + ack.pending = true; + scrollEmulate(ack, tab); + return ack; + } + + ack.mode = mode; + ack.accepted = true; + ack.ok = true; + if (!hasStore) { + ack.pending = true; + var self = ack; + waitFor(function () { return !!(state.store && state.service); }, opts.timeout || DEFAULT_TIMEOUT) + .then(function () { fireStoreTrigger(self); }, + function (e) { + self.pending = false; + self.error = String((e && e.message) || e); + emit('triggerError', self); + }); + return ack; + } + fireStoreTrigger(ack); + return ack; + } catch (e) { + ack.error = String((e && e.message) || e); + return ack; + } + } + + /** 走页面自己的代码发一次请求(service.getList 或 store.onLoadDown) */ + function fireStoreTrigger(ack) { + var store = state.store, svc = state.service; + if (!store || !svc) { + ack.pending = false; + ack.error = 'store/service 未就绪'; + emit('triggerError', ack); + return; + } + var startedAt = Date.now(); + var p; + if (ack.mode === 'loadDown') { + p = Promise.resolve().then(function () { + if (toTabIndex(store.currentTab) !== ack.tab) { + if (!isFn(store.changeTab)) throw new Error('store 上没有 changeTab,无法切到 tab=' + ack.tab); + store.changeTab(ack.tab); + } + var rm = store.requestMap && store.requestMap[ack.tab]; + if (rm) { + if (ack.clear) { try { delete rm[CURSOR_FIELD]; } catch (e) { /* ignore */ } } + else rm[CURSOR_FIELD] = ack.offset; + } + if (!isFn(store.onLoadDown)) throw new Error('store 上没有 onLoadDown'); + return store.onLoadDown(); + }); + } else { + var params = buildListParams(store, ack.tab, ack.offset); + p = Promise.resolve().then(function () { return svc.getList(params); }); + } + + emit('trigger', ack); + p.then(function (res) { + var captured = findCapturedResponse(startedAt, ack.tab); + var capturedJson = captured && captured.json; + var r = (res && res.result) || {}; + var items = (r.listItemVoList) || + (res && res.listData) || + (capturedJson ? pickItems(capturedJson) : []); + ack.itemCount = items.length; + ack.nextCursor = pickServiceNextOffset(res) || (capturedJson ? pickNextOffset(capturedJson) : null); + ack.hasNextQuery = capturedJson ? pickHasNext(capturedJson) + : (res && res.hasNextQuery !== undefined ? !!res.hasNextQuery : null); + ack.pending = false; + if (captured && captured.sentBody) { + ack.effectiveOffset = captured.sentBody[CURSOR_FIELD]; + if (ack.effectiveOffset === undefined) ack.effectiveOffset = null; + } + log('trigger', '已按游标 ' + (ack.clear ? '(第一页)' : ack.offset) + ' 触发(' + ack.mode + '),返回 ' + items.length + ' 条'); + emit('triggerDone', ack); + }, function (e) { + ack.pending = false; + ack.error = String((e && e.message) || e); + emit('triggerError', ack); + }); + } + + /** 没有 store 时的终极兜底:真滚列表 + 改写「页面下一次上拉」的游标 */ + function scrollEmulate(ack, tab) { + var matchedId = null; + var pulses = 0; + var timer = null; + var respTimer = null; + var cancelRule = setNextOffset(ack.clear ? null : ack.offset, { tab: tab }); + + function cleanup() { + if (timer) { clearInterval(timer); timer = null; } + if (respTimer) { clearTimeout(respTimer); respTimer = null; } + offOverride(); + offResponse(); + } + var offOverride = on('override', function (p) { + if (matchedId) return; + if (p.tab !== null && p.tab !== undefined && tab !== null && p.tab !== tab) return; + matchedId = p.requestId; + ack.effectiveOffset = p.to; + ack.pending = false; + if (timer) { clearInterval(timer); timer = null; } + emit('trigger', ack); + respTimer = setTimeout(function () { + if (!ack.pending) return; + ack.pending = false; + ack.error = '请求已发出但没有等到 after_sales_list 响应(15s)'; + cleanup(); + emit('triggerError', ack); + }, 15000); + }); + var offResponse = on('response', function (p) { + if (!matchedId || p.id !== matchedId) return; + ack.itemCount = p.orderCount; + ack.nextCursor = p.nextOffset; + ack.hasNextQuery = p.hasNextQuery; + ack.pending = false; + cleanup(); + emit('triggerDone', ack); + }); + + pulseScroll(); + timer = setInterval(function () { + pulses++; + pulseScroll(); + if (pulses >= 12) { + cleanup(); + if (!matchedId) { + try { cancelRule(); } catch (e) { /* ignore */ } + ack.pending = false; + ack.error = '模拟滚动没有触发任何请求(页面可能已经没有更多,或列表不在滚动容器内)'; + emit('triggerError', ack); + } + } + }, 250); + } + + /* ======================================================================= + * 11. 调试 / 还原 + * ===================================================================== */ + + /** 从响应里取列表(原始字段) */ + function pickItems(json) { + var r = json && json.result; + if (r && Array.isArray(r.listItemVoList)) return r.listItemVoList; + if (json && Array.isArray(json.listItemVoList)) return json.listItemVoList; + return []; + } + + /** 下一个时间游标 */ + function pickNextOffset(json) { + var r = json && json.result; + if (r && r.nextQueryCreatedAt !== undefined && r.nextQueryCreatedAt !== null) return r.nextQueryCreatedAt; + return null; + } + + /** 服务端是否还有下一页 */ + function pickHasNext(json) { + var r = json && json.result; + return !!(r && r.hasNextQuery); + } + + /** 从页面取数结果里读游标(service.getList 的原始响应 / requestAftersaleList 的加工结果都支持) */ + function pickServiceNextOffset(res) { + if (!res) return null; + if (res.result && res.result.nextQueryCreatedAt) return res.result.nextQueryCreatedAt; + if (res.nextCursor) return res.nextCursor; + if (res.nextOffset) return res.nextOffset; + return null; + } + + /** 从页面 URL 上取 tab(?tab=all|pending) */ + function tabFromUrl() { + try { + var q = parseSearch(global.location && global.location.search ? global.location.search : ''); + return toTabIndex(q.tab); + } catch (e) { return null; } + } + + /** 组装一次 requestAftersaleList/getList 用的参数(复制页面 requestMap[tab],不污染页面状态) */ + function buildListParams(store, tab, cursor) { + var base = (store && store.requestMap && store.requestMap[tab]) || {}; + var params = {}; + for (var k in base) { if (has(base, k)) params[k] = base[k]; } + params.range = params.range || tab; + if (cursor === null || cursor === undefined || cursor === '') delete params[CURSOR_FIELD]; + else params[CURSOR_FIELD] = cursor; + return params; + } + + /** 待处理红点数(api/blade/red_dot) */ + function getPendingCount() { + return waitFor(function () { return !!(state.store && state.service); }, DEFAULT_TIMEOUT) + .then(function () { return state.service.getAfterSaleCount(); }); + } + + function unpatch() { + var n = 0; + while (state.patched.length) { + var p = state.patched.pop(); + try { + Object.defineProperty(p.Cls.prototype, p.name, { + configurable: true, enumerable: true, writable: true, value: p.orig + }); + n++; + } catch (e) { /* ignore */ } + } + return n; + } + + function dump() { + var lines = []; + lines.push('PDAfterSale v' + VERSION + ' | store=' + (state.store ? 'ok' : '-') + + ' service=' + (state.service ? 'ok' : '-') + ' source=' + (state.source || '-')); + lines.push('tab: all=全部 pending=待处理(URL ?tab=); 游标字段 ' + CURSOR_FIELD); + if (state.store) { + try { + var rm = state.store.requestMap || {}; + var snap = {}; + for (var k in rm) { if (has(rm, k)) snap[k] = rm[k][CURSOR_FIELD]; } + lines.push('页面游标 requestMap: ' + JSON.stringify(snap)); + } catch (e) { /* ignore */ } + } + lines.push('入口: load()=自己取结果 / trigger()=只把页面自己的请求发出去(给 CEF 拦)'); + lines.push('已捕获 after_sales_list 请求 ' + state.history.length + ' 次:'); + var tail = state.history.slice(-10); + for (var i = 0; i < tail.length; i++) { + var h = tail[i]; + var n = pickItems(h.json).length; + lines.push(' #' + h.id + ' tab=' + h.tabIndex + ' cursor=' + + (h.sentBody ? h.sentBody[CURSOR_FIELD] : h.requestOffset) + ' → ' + n + ' 条' + + ' next=' + (pickNextOffset(h.json) || '-') + + ' hasNext=' + pickHasNext(h.json)); + } + var text = lines.join('\n'); + try { console.log(text); } catch (e) { /* ignore */ } + return text; + } + + function help() { + var text = [ + '[PDAfterSale] 用法速查(售后/投诉列表 complaint_list.html)', + ' PDAfterSale.state // store / service / 请求历史', + ' PDAfterSale.dump() // 打印状态 + 最近 10 次 after_sales_list 请求', + ' PDAfterSale.load({ cursor: "1630xxx", tab: "pending" })', + ' → 取「该游标之后」的一页(cursor 省略 = 第一页),返回 { items, nextCursor, hasNextQuery, raw }', + ' PDAfterSale.trigger({ cursor: null, tab: "all" })', + ' → 只把页面自己的请求发出去(CEF 拦响应),同步返回 ack', + ' mode: service(service.getList)/ loadDown(store.onLoadDown)/ scroll(真滚列表)', + ' PDAfterSale.setNextCursor("1630xxx", { tab: "all" }) // 下一次上拉去取这一页', + ' PDAfterSale.setNextCursor(null, { tab: "all" }) // 下一次上拉回到第一页', + ' PDAfterSale.crawl({ from: null, tab: "all", maxPages: 5 })', + ' PDAfterSale.on("response", function (p) { console.log(p.itemCount, p.nextOffset); })', + ' PDAfterSale.getPendingCount() // api/blade/red_dot 的待处理红点数', + ' PDAfterSale.scan() / PDAfterSale.unpatch()' + ].join('\n'); + try { console.log(text); } catch (e) { /* ignore */ } + return text; + } + + /* ======================================================================= + * 12. 对外接口 + * ===================================================================== */ + + var api = { + version: VERSION, + install: install, + state: state, + on: on, + off: off, + emit: emit, + scan: scan, + unpatch: unpatch, + dump: dump, + help: help, + load: load, + getListAt: function (cursor, opts) { + var o = opts || {}; + o.cursor = cursor; + return load(o); + }, + replay: function (cursor, opts) { + return replay(cursor, opts).then(function (json) { + return wrapReplayResult(json, opts || {}, normalizeOffset(cursor)); + }); + }, + crawl: crawl, + trigger: trigger, + triggerAsync: function (opts) { + return new Promise(function (resolve) { + var ack = trigger(opts); + if (ack.error && !ack.accepted) return resolve(ack); + var stopDone, stopErr; + function fin(p) { + if (!p || p.requestId !== ack.requestId) return; + if (stopDone) stopDone(); + if (stopErr) stopErr(); + resolve(p); + } + stopDone = on('triggerDone', fin); + stopErr = on('triggerError', fin); + }); + }, + scrollToLoadMore: function (opts) { + var o = opts || {}; + o.mode = 'scroll'; + return trigger(o); + }, + setNextCursor: setNextOffset, + setNextOffset: setNextOffset, + clearOffsets: clearOffsets, + listOffsets: function () { return state.overrides.slice(); }, + getPendingCount: getPendingCount, + tabOf: function (t) { return { tab: toTabIndex(t), path: toTabPath(t) }; }, + ready: ready + }; + + Object.defineProperty(api, 'store', { get: function () { return state.store; } }); + Object.defineProperty(api, 'service', { get: function () { return state.service; } }); + Object.defineProperty(api, '__installed', { get: function () { return state.installed; } }); + + + function install() { + if (state.installed) { + try { scan(); } catch (e) { /* ignore */ } + return api; + } + state.installed = true; + state.debug = !!global.__PDD_HOOK_DEBUG__; + + installWebpackCapture(); + installXhrHook(); + installFetchHook(); + startScanner(); + + try { + console.log('%c[PDAfterSale] v' + VERSION + ' 已注入(PDAfterSale.help() 看用法)', + 'color:#e02e24;font-weight:bold'); + } catch (e) { /* ignore */ } + + emit('install', api); + setTimeout(function () { try { scan(); } catch (e) { /* ignore */ } }, 0); + return api; + } + + global.PDAfterSale = api; + install(); +})(typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : this));