Files
ctbjrj/yjktool/抓取器.cs
T

423 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 抓取器:抓厂家树/系列规格/价格,按新服务布局落盘(断点续抓,多线程安全)
// 两种价格通道:直连 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"); } }
/// <summary>价格请求是否经旧代理(8081)。树/系列包恒直连(静态资源无会话要求)。</summary>
public bool 经旧代理;
/// <summary>种子组合:旧代理缓存里真实查询过的 (系列→组合串列表),价格抓取优先按种子形状。</summary>
/// <summary>种子表:sid → [bg组合串, t历史时间戳] 列表。
/// 终版机制(实测):命中 = 系列在有效时刻 t 查过(服务器按 sid+t 记录) AND 组合是真实型号——
/// 系列只要有一个种子的 t,即可解锁它全部规格组组合的价格查询;t 用当前时间必返空。</summary>
private Dictionary<string, List<string[]>> 种子 = new Dictionary<string, List<string[]>>();
private int 无t系列数;
private int 无t提示过;
// 进度/日志回调(UI 线程调度由调用方处理)
public Action<string> 日志 { get; set; }
public Action<int, int> 进度 { 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;
}
/// <summary>从缓存/数据目录加载种子(webGetPrice 响应里的 bg 组合串+原始 t 时间戳)。
/// 可多处调用合并(旧代理缓存+元件库网页 data 的现成真价都吃)。返回种子系列数。</summary>
public int 加载种子(string 目录)
{
if (种子 == null) 种子 = new Dictionary<string, List<string[]>>();
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> 对顶层数组返回 Object[](不是 ArrayList),
// 按 IEnumerable 遍历两者通吃——之前 as ArrayList 恒 null,种子从来没加载成功过
System.Collections.IEnumerable 条集 = 解析器.Deserialize<object>(decoded) as System.Collections.IEnumerable;
if (条集 == null) continue;
foreach (object o in 条集)
{
var it = o as Dictionary<string, object>;
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<string[]> l;
if (!种子.TryGetValue(sid, out l)) { l = new List<string[]>(); 种子[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('=');
}
/// <summary>读 ZIP 字节数组里的首个文件文本(JSON)。失败返回 null。</summary>
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 };
/// <summary>系列包 → 主规格组有序表(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)。</summary>
private static SortedDictionary<int, List<int>> 规格组(string 系文)
{
var g = new SortedDictionary<int, List<int>>();
try
{
var root = 解析器.Deserialize<Dictionary<string, object>>(系文);
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<string, object>;
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<int>();
foreach (object oo in ar)
{
var 选项 = oo as Dictionary<string, object>;
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);
}
/// <summary>解析厂家树 ZIP 文本 → 叶子系列号(s 字段非 0)列表。</summary>
public static List<string> 树系列(string 树文)
{
var r = new List<string>();
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;
}
/// <summary>组合策略(照 preheat 脚本):默认组合 + 每组每选项单独变化。</summary>
private static List<string> 组合(SortedDictionary<int, List<int>> g)
{
if (g.Count == 0) return new List<string>();
var 默认 = g.Values.Select(l => l[0].ToString()).ToList();
var r = new List<string> { string.Join("-", 默认) };
for (int i = 0; i < g.Count; i++)
{
foreach (int ab in g.Values.ElementAt(i).Skip(1))
{
var c = new List<string>(默认) { };
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);
}
// ---------- 抓取主流程 ----------
/// <summary>抓一个厂家:树+全部系列+全部组合价格。返回 (系列数, 价格数)。任何异常不外抛(记日志返回 0)。</summary>
public KeyValuePair<int, int> 抓厂家(string 树id, string 厂名, int 线程数)
{
try
{
return 抓厂家内部(树id, 厂名, 线程数);
}
catch (Exception ex)
{
L("! 厂家 " + 厂名 + " 抓取异常: " + ex.Message);
return new KeyValuePair<int, int>(0, 0);
}
}
private KeyValuePair<int, int> 抓厂家内部(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<int, int>(0, 0); }
File.WriteAllBytes(树zip路径, z);
树文 = 读Zip内文(z);
Thread.Sleep(50);
}
if (树文 == null) { L("× 厂家 " + 厂名 + " 树解析失败"); return new KeyValuePair<int, int>(0, 0); }
var 系列 = 树系列(树文);
L("▶ " + 厂名 + ": " + 系列.Count + " 个系列");
无t系列数 = 0; 无t提示过 = 0;
// 2) 并发抓系列
var 队列 = new ConcurrentQueue<string>(系列);
var 计数 = new int[2];
var 线程 = new List<Thread>();
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<string[]> 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<int, int>(计数[0], 计数[1]);
}
private static long 时间戳() { return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalMilliseconds; }
/// <summary>构造 infoJson 的 base64。t 必须传历史时间戳(种子的原始 t 或兜底 t):
/// 实测服务器只对「真实查过的组合+历史时间戳」返回真价,当前时间必空。</summary>
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));
}
}
}