// 抓取器:抓厂家树/系列规格/价格,按新服务布局落盘(断点续抓,多线程安全) // 两种价格通道:直连 dq123(默认) / 经内置旧代理 localhost:8081(带会话伪装,命中率高——工具自动启动它) // 8081 是工具专用端口:node 新服务(元件库网页)占了 8080,两者须能同时跑 // 种子模式:从旧代理 proxy_cache 提取真实查询过的组合(sid→bg 列表),优先按种子形状抓价(重放+单变化) using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Net; using System.Security.Cryptography; using System.Text; using System.Threading; namespace yjktool { internal class 抓取器 { // 域名映射照旧 python 代理 private const string 电数据 = "https://dian.dq123.com/electricData?v="; private const string 价格口 = "https://dx2.dhub.com.cn/pcprice/default/dx2/interface/webGetPrice?infoJson="; // 经旧代理(路径同源,代理按前缀转发到真实域名并注入会话头) private const string 代电数据 = "http://127.0.0.1:8081/electricData?v="; private const string 代价格口 = "http://127.0.0.1:8081/dx2/pcprice/default/dx2/interface/webGetPrice?infoJson="; public string 输出根 { get; private set; } private string ZipDir { get { return Path.Combine(输出根, "dian", "electricData_zips"); } } private string 价Dir { get { return Path.Combine(输出根, "dx2_webGetPrice"); } } /// 价格请求是否经旧代理(8081)。树/系列包恒直连(静态资源无会话要求)。 public bool 经旧代理; /// 种子组合:旧代理缓存里真实查询过的 (系列→组合串列表),价格抓取优先按种子形状。 /// 种子表:sid → [bg组合串, t历史时间戳] 列表。 /// 终版机制(实测):命中 = 系列在有效时刻 t 查过(服务器按 sid+t 记录) AND 组合是真实型号—— /// 系列只要有一个种子的 t,即可解锁它全部规格组组合的价格查询;t 用当前时间必返空。 private Dictionary> 种子 = new Dictionary>(); private int 无t系列数; private int 无t提示过; // 进度/日志回调(UI 线程调度由调用方处理) public Action 日志 { get; set; } public Action 进度 { get; set; } // (当前,总数) public volatile bool 停止; public 抓取器(string 输出目录) { 输出根 = 输出目录; Directory.CreateDirectory(ZipDir); Directory.CreateDirectory(价Dir); // net48 下显式启用 TLS1.2(dq123 全站 https,不设会握手失败) try { ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12; } catch { } ServicePointManager.DefaultConnectionLimit = 32; } /// 从缓存/数据目录加载种子(webGetPrice 响应里的 bg 组合串+原始 t 时间戳)。 /// 可多处调用合并(旧代理缓存+元件库网页 data 的现成真价都吃)。返回种子系列数。 public int 加载种子(string 目录) { if (种子 == null) 种子 = new Dictionary>(); try { foreach (string f in Directory.GetFiles(目录, "*webGetPrice*.data")) { try { string body = File.ReadAllText(f); foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(body, "\"content\"\\s*:\\s*\"([A-Za-z0-9+/=]+)\"")) { string decoded; try { decoded = Encoding.UTF8.GetString(Convert.FromBase64String(m.Groups[1].Value)); } catch { continue; } if (string.IsNullOrEmpty(decoded) || decoded.Trim() == "[]") continue; // ★ JavaScriptSerializer.Deserialize 对顶层数组返回 Object[](不是 ArrayList), // 按 IEnumerable 遍历两者通吃——之前 as ArrayList 恒 null,种子从来没加载成功过 System.Collections.IEnumerable 条集 = 解析器.Deserialize(decoded) as System.Collections.IEnumerable; if (条集 == null) continue; foreach (object o in 条集) { var it = o as Dictionary; if (it == null || !it.ContainsKey("sid")) continue; string sid = Convert.ToString(it["sid"]); string bg = it.ContainsKey("bg") ? Convert.ToString(it["bg"]) : ""; // ★ t 是 13 位毫秒时间戳:JavaScriptSerializer 把 JSON 整数解析成 double, // Convert.ToString(double) 会变成科学计数法(1.785401168E+12)拼进 time 服务器不认—— // 必须经 ToDouble→ToInt64 还原成纯数字串 string t = ""; object tv; if (it.TryGetValue("t", out tv) && tv != null) { try { t = Convert.ToInt64(Convert.ToDouble(tv)).ToString(); } catch { } } if (string.IsNullOrEmpty(sid) || string.IsNullOrEmpty(bg) || bg.IndexOf('-') < 0) continue; if (string.IsNullOrEmpty(t)) continue; // 没 t 的种子重放不了(当前时间必空),跳过 List l; if (!种子.TryGetValue(sid, out l)) { l = new List(); 种子[sid] = l; } if (!l.Any(e => e[0] == bg)) l.Add(new[] { bg, t }); } } } catch { } } } catch { } return 种子.Count; } private void L(string s) { if (日志 != null) 日志(s); } // ---------- HTTP(简单重试) ---------- private byte[] Get(string url, int timeoutMs) { for (int 试 = 0; 试 < 3 && !停止; 试++) { try { var req = (HttpWebRequest)WebRequest.Create(url); req.Method = "GET"; req.Timeout = timeoutMs; req.ReadWriteTimeout = timeoutMs; req.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0"; req.Referer = "https://www.dq123.com/"; req.Proxy = null; using (var r = (HttpWebResponse)req.GetResponse()) using (var ms = new MemoryStream()) { r.GetResponseStream().CopyTo(ms); return ms.ToArray(); } } catch (WebException we) { var hr = we.Response as HttpWebResponse; if (hr != null && (int)hr.StatusCode >= 400 && (int)hr.StatusCode < 500) return null; // 4xx(404 无此包)不重试,快速跳过 Thread.Sleep(600); } catch (Exception) { return null; } } return null; } // ---------- v 参数(base64 逻辑名去 padding) ---------- private static string V参数(string 逻辑名) { string b = Convert.ToBase64String(Encoding.UTF8.GetBytes(逻辑名)); return b.TrimEnd('='); } /// 读 ZIP 字节数组里的首个文件文本(JSON)。失败返回 null。 private static string 读Zip内文(byte[] zip) { try { using (var ms = new MemoryStream(zip)) using (var z = new ZipArchive(ms)) { var e = z.Entries[0]; using (var r = new StreamReader(e.Open(), Encoding.UTF8)) return r.ReadToEnd(); } } catch { return null; } } // ---------- JSON 反序列化(JavaScriptSerializer) ---------- private static readonly System.Web.Script.Serialization.JavaScriptSerializer 解析器 = new System.Web.Script.Serialization.JavaScriptSerializer { MaxJsonLength = 64 * 1024 * 1024 }; /// 系列包 → 主规格组有序表(aq顺序号→as选项列表)。 /// ★ 实测规则(1122 对照页面真实组合 337-350-15-9-29-30-37 破解): /// 参与组合的组 = 顶层 c 键里 bc==0 且 p==2 的项;段顺序 = aq 字段升序; /// bc!=0 / p!=2 的组(附件名称、延时类型等)不参与组合。 /// 系列包名 = 4/z_p/{系列id}.jss(不是 z_seri)。 private static SortedDictionary> 规格组(string 系文) { var g = new SortedDictionary>(); try { var root = 解析器.Deserialize>(系文); object co; if (root == null || !root.TryGetValue("c", out co)) return g; var cArr = co as System.Collections.ArrayList; if (cArr == null) return g; foreach (object o in cArr) { var 组 = o as Dictionary; if (组 == null) continue; // ★ 终版规则(SDK Ke 过滤器:z 数组=an==1;两厂实测 725/1122 真实组合段 100% 命中): // p 各家不同(正泰=1 德力西=2),不能加 p 条件 if (Convert.ToInt32(组.ContainsKey("an") ? 组["an"] : 0) != 1) continue; int aq = Convert.ToInt32(组.ContainsKey("aq") ? 组["aq"] : 0); var ar = 组.ContainsKey("ar") ? 组["ar"] as System.Collections.ArrayList : null; if (ar == null || ar.Count == 0) continue; var l = new List(); foreach (object oo in ar) { var 选项 = oo as Dictionary; if (选项 == null || !选项.ContainsKey("as")) continue; l.Add(Convert.ToInt32(选项["as"])); } if (l.Count > 0 && !g.ContainsKey(aq)) g[aq] = l; } } catch { } return g; } // ---------- 最小 JSON 取值(避免引第三方库;仅取固定结构字段) ---------- private static System.Text.RegularExpressions.MatchCollection 正则匹配(string json, string pattern) { return System.Text.RegularExpressions.Regex.Matches(json, pattern); } /// 解析厂家树 ZIP 文本 → 叶子系列号(s 字段非 0)列表。 public static List 树系列(string 树文) { var r = new List(); foreach (System.Text.RegularExpressions.Match m in 正则匹配(树文, "\"s\"\\s*:\\s*(\\d+)")) { string v = m.Groups[1].Value; if (v != "0" && !r.Contains(v)) r.Add(v); } return r; } /// 组合策略(照 preheat 脚本):默认组合 + 每组每选项单独变化。 private static List 组合(SortedDictionary> g) { if (g.Count == 0) return new List(); var 默认 = g.Values.Select(l => l[0].ToString()).ToList(); var r = new List { string.Join("-", 默认) }; for (int i = 0; i < g.Count; i++) { foreach (int ab in g.Values.ElementAt(i).Skip(1)) { var c = new List(默认) { }; c[i] = ab.ToString(); r.Add(string.Join("-", c)); } } return r; } // ---------- webGetPrice 哈希(复刻 python json.dumps 分隔符) ---------- private static string PyDumps(string json) { // 本场景 JSON 只含 ASCII 字符串/数字/数组/对象,手工重排分隔符即可: // 把 "," 与 ":" 后补空格(仅结构符,字符串内部不会出现裸的这些符号——base64 内容安全) var sb = new StringBuilder(json.Length * 2); bool 引号内 = false; for (int i = 0; i < json.Length; i++) { char c = json[i]; sb.Append(c); if (c == '"') 引号内 = !引号内; else if (!引号内 && (c == ',' || c == ':')) { char 后 = i + 1 < json.Length ? json[i + 1] : ' '; if (后 != ' ') sb.Append(' '); } } return sb.ToString(); } public static string 价格哈希(string infoJsonB64) { string json = Encoding.UTF8.GetString(Convert.FromBase64String(infoJsonB64)); // 剥 time 字段("time":123,) —— 手工正则 json = System.Text.RegularExpressions.Regex.Replace(json, "\"time\"\\s*:\\s*\\d+\\s*,?\\s*", ""); json = json.Replace(",}", "}").Replace(",]", "]"); string 稳定 = "infoJson=" + Convert.ToBase64String(Encoding.UTF8.GetBytes(PyDumps(json))); using (var md5 = MD5.Create()) return BitConverter.ToString(md5.ComputeHash(Encoding.UTF8.GetBytes(稳定))).Replace("-", "").ToLower().Substring(0, 8); } // ---------- 抓取主流程 ---------- /// 抓一个厂家:树+全部系列+全部组合价格。返回 (系列数, 价格数)。任何异常不外抛(记日志返回 0)。 public KeyValuePair 抓厂家(string 树id, string 厂名, int 线程数) { try { return 抓厂家内部(树id, 厂名, 线程数); } catch (Exception ex) { L("! 厂家 " + 厂名 + " 抓取异常: " + ex.Message); return new KeyValuePair(0, 0); } } private KeyValuePair 抓厂家内部(string 树id, string 厂名, int 线程数) { // 1) 厂家树(断点:已存在直接读) string 树zip路径 = Path.Combine(ZipDir, 树id + ".zip"); string 树文 = null; if (File.Exists(树zip路径)) { 树文 = 读Zip内文(File.ReadAllBytes(树zip路径)); } if (树文 == null) { byte[] z = Get(电数据 + V参数("z_seri/" + 树id + ".jss") + "&t=" + 时间戳(), 20000); if (z == null || z.Length < 100) { L("× 厂家 " + 厂名 + " 树拉取失败"); return new KeyValuePair(0, 0); } File.WriteAllBytes(树zip路径, z); 树文 = 读Zip内文(z); Thread.Sleep(50); } if (树文 == null) { L("× 厂家 " + 厂名 + " 树解析失败"); return new KeyValuePair(0, 0); } var 系列 = 树系列(树文); L("▶ " + 厂名 + ": " + 系列.Count + " 个系列"); 无t系列数 = 0; 无t提示过 = 0; // 2) 并发抓系列 var 队列 = new ConcurrentQueue(系列); var 计数 = new int[2]; var 线程 = new List(); for (int i = 0; i < Math.Max(1, 线程数); i++) { var t = new Thread(delegate() { string sid; while (!停止 && 队列.TryDequeue(out sid)) { int 局系 = 0, 局价 = 0; string szPath = Path.Combine(ZipDir, sid + ".zip"); string 系文 = null; try { 系文 = File.Exists(szPath) ? 读Zip内文(File.ReadAllBytes(szPath)) : null; if (系文 == null) { byte[] z = Get(电数据 + V参数("4/z_p/" + sid + ".jss") + "&t=" + 时间戳(), 20000); if (z != null && z.Length > 100) { File.WriteAllBytes(szPath, z); 系文 = 读Zip内文(z); Thread.Sleep(20); } } if (系文 != null && 系文.IndexOf("\"c\"") >= 0) { 局系 = 1; // ★ 终版机制(实测):命中 = 系列在有效时刻 t 查过 AND 组合是真实型号。 // 每个系列只要有一个种子的 t,就能解锁它全部规格组组合;无 t 的系列必空,跳过查价。 List sd; bool 有种子 = 种子.TryGetValue(sid, out sd) && sd.Count > 0; if (!有种子) { Interlocked.Increment(ref 无t系列数); if (无t提示过 == 0) { int n = Interlocked.Increment(ref 无t提示过); if (n == 1) L("· 提示: 无有效时刻(t)的系列跳过价格查询(服务器对没查过的系列+当前时间必返空)——已跳过 " + sid + " 等"); } } else { string t种 = sd[0][1]; // 系列任一种子的 t(系列级会话标识,只配自己系列) // ★ 组合恒用当前系列包规格组生成(默认+单变化):编号与页面渲染同源,查到的价页面必能命中。 // 种子 bg(旧版本编号)不再作基底——跨版本编号混合的组合页面永远点不出来(725 教训:75条仅1条可用)。 var cs = 组合(规格组(系文)); foreach (string c in cs) { string info = 构造infoJson(sid, c, t种); string h = 价格哈希(info); string 价path = Path.Combine(价Dir, "dx2_pcprice_default_dx2_interface_webGetPrice_" + h + ".data"); if (!File.Exists(价path)) { string 口 = 经旧代理 ? 代价格口 : 价格口; byte[] body = Get(口 + info, 20000); // ★ 空结果不落盘:content="W10="(base64("[]"))或 content="" 都表示该组合厂家无报价, // 存了也是废数据(旧代理靠运行时合成兜底,新服务只需要真价格) string 文 = body == null ? null : Encoding.UTF8.GetString(body); bool 有价 = 文 != null && 文.Length > 30 && 文.Length < 300000 && 文.IndexOf("\"content\":\"W10=\"") < 0 && 文.IndexOf("\"content\": \"W10=\"") < 0 && 文.IndexOf("\"content\":\"\"") < 0 && 文.IndexOf("\"content\": \"\"") < 0; if (有价) { File.WriteAllBytes(价path, body); var mb = System.Text.RegularExpressions.Regex.Match(文, "\"bh\"\\s*:\\s*\"?([0-9.]+)"); L(" ¥ 系列" + sid + " 命中真价" + (mb.Success ? " " + mb.Groups[1].Value + " 元" : "") + ": " + c); } Thread.Sleep(20); } 局价++; } } } } catch (Exception ex_series) { L("· 系列 " + sid + " 异常: " + ex_series.Message); } Interlocked.Add(ref 计数[0], 局系); Interlocked.Add(ref 计数[1], 局价); if (进度 != null) 进度(0, 0); } }); t.IsBackground = true; 线程.Add(t); t.Start(); } foreach (var t in 线程) t.Join(); if (无t系列数 > 0) L("· " + 厂名 + ": " + 无t系列数 + " 个系列无有效时刻(t),已跳过价格查询(可先在预热页面浏览这些系列再抓)"); return new KeyValuePair(计数[0], 计数[1]); } private static long 时间戳() { return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalMilliseconds; } /// 构造 infoJson 的 base64。t 必须传历史时间戳(种子的原始 t 或兜底 t): /// 实测服务器只对「真实查过的组合+历史时间戳」返回真价,当前时间必空。 private static string 构造infoJson(string 系列, string 组合串, string t) { // {"s": "1122", "options": ["337-350"], "time": 123, "from": "dx2jsapi"} —— 键序照 SDK string json = "{\"s\": \"" + 系列 + "\", \"options\": [\"" + 组合串 + "\"], \"time\": " + t + ", \"from\": \"dx2jsapi\"}"; return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); } } }