Files
dcalc/scripts/yxdr_coinsale.py
T
2026-11-03 21:44:30 +08:00

312 lines
14 KiB
Python

#!/usr/bin/env python3
"""Fetch the initial YXDR DNF coin offers; Python 3 standard library only.
Examples (run from the repository root):
python3 scripts/yxdr_coinsale.py
python3 scripts/yxdr_coinsale.py --regions kua1 --price1 100
python3 scripts/yxdr_coinsale.py --regions kua3a kua3b --limit 200
The site has nine region entries: kua1, kua2, kua3a, kua3b, kua4..kua8.
For EACH region, fetch fresh bijiaSecret from its HTML, then load the referenced
CoinSale_*.js. Each bijiaqiChanels entry supplies an opaque data STRING and sign.
POST secret = md5(bijiaSecret.secret + '_' + PfId + '_' + sign), with the
unchanged data/sign and the page's time. No page/pageSize parameter is present
in the site's coinsale request. We save every initial response and additionally
export the cheapest --limit filtered offers (default 200). This cutoff is ours:
the UI also has platform-preservation, deduplication and random tie-breaking.
Do not reuse a captured secret/time across runs or reconstruct the data string:
its original representation is signed. Public unauthenticated use only; respect
the site's access limits. Default region-start interval is 60 seconds. A limit
response stops the entire run, preserving results already written. No retries.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
from copy import copy
import csv
import hashlib
import json
import math
from datetime import datetime, timezone
from html.parser import HTMLParser
from http.cookiejar import CookieJar
from pathlib import Path
import re
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlsplit
from urllib.request import HTTPCookieProcessor, Request, build_opener
BASE = "https://www.yxdr.com"
REGIONS = {
"kua1": "跨一区", "kua2": "跨二区", "kua3a": "跨三A区",
"kua3b": "跨三B区", "kua4": "跨四区", "kua5": "跨五区",
"kua6": "跨六区", "kua7": "跨七区", "kua8": "跨八区",
}
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
}
# Disabled/removed in the public DNF coinsale frontend observed 2026-11-02.
DISABLED_PLATFORMS = {1, 12, 21, 50, 51, 52, 53}
class RateLimited(RuntimeError):
pass
class Scripts(HTMLParser):
def __init__(self):
super().__init__()
self.sources = []
def handle_starttag(self, tag, attrs):
if tag == "script":
src = dict(attrs).get("src")
if src:
self.sources.append(src)
def read_variable(source, name):
"""Decode a JSON assignment without evaluating arbitrary site JavaScript."""
match = re.search(r"\b(?:window\.)?" + re.escape(name) + r"\s*=\s*", source)
if not match:
raise ValueError(f"找不到 {name};页面可能受限、发生跳转或结构已变化")
return json.JSONDecoder().raw_decode(source, match.end())[0]
class Client:
def __init__(self, timeout):
self.cookies = CookieJar()
self.opener = build_opener(HTTPCookieProcessor(self.cookies))
self.timeout = timeout
def fork(self):
client = Client(self.timeout)
for cookie in self.cookies:
client.cookies.set_cookie(copy(cookie))
return client
def request(self, url, payload=None, referer=None):
parts = urlsplit(url)
if parts.scheme != "https" or parts.netloc != "www.yxdr.com":
raise ValueError(f"拒绝将请求发送到站外地址:{url}")
headers = dict(HEADERS)
body = None
if referer:
headers["Referer"] = referer
if payload is not None:
headers.update({"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Origin": BASE, "X-Requested-With": "XMLHttpRequest"})
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
try:
with self.opener.open(Request(url, data=body, headers=headers),
timeout=self.timeout) as response:
source = response.read().decode("utf-8-sig")
except HTTPError as exc:
source = exc.read().decode("utf-8", errors="replace")
if exc.code == 429 or "LimitDeny" in source or "访问频率过高" in source:
raise RateLimited("站点限制访问频率,已停止;请稍后再运行") from exc
raise RuntimeError(f"HTTP {exc.code}: {url}") from exc
if "LimitDeny" in source or "访问频率过高" in source:
raise RateLimited("站点限制访问频率,已停止;请稍后再运行")
return source
def make_payload(channel, seed, user, price1, price2):
platform = json.loads(channel["data"])
secret_input = f"{seed['secret']}_{platform['PfId']}_{channel['sign']}"
return {
"data": channel["data"], # Keep the signed STRING byte-for-byte.
"sign": channel["sign"],
"price1": price1, "price2": price2,
"cross": 0, "dnfCross": user["dnfCross"],
"time": seed["time"],
"secret": hashlib.md5(secret_input.encode("utf-8")).hexdigest(),
}
def select_offers(rows, price1, price2, limit):
selected = []
for row in rows:
money, number, price = row["Money"], row["Number"], row["Price"]
if money != -1 and (money * number < 5 or price == 0):
continue
if price1 and money * number < price1:
continue
if price2 and "ling" not in (row.get("TradeType") or "") and money > price2:
continue
if money >= 1_000_000:
continue
selected.append(dict(row))
selected.sort(key=lambda row: (row["Price"], row["PfId"], row.get("BuyUrl") or ""))
# The frontend merges repeated listing URLs for these platforms.
result, seen = [], set()
for row in selected:
key = (row["PfId"], row.get("BuyUrl"))
if row["PfId"] in {1, 40, 41} and key[1]:
if key in seen:
continue
seen.add(key)
result.append(row)
return result[:limit]
def fetch_region(client, region, args):
# Get the canonical page; filter values are explicitly sent to coinsale.
# The supplied query URL can redirect to the canonical page on a fresh visit.
page_url = f"{BASE}/bijiaqi/dnf/youxibi/{region}"
page = client.request(page_url)
seed = read_variable(page, "bijiaSecret")
user = read_variable(page, "bijiaUserInfo")
scripts = Scripts()
scripts.feed(page)
config_src = next((src for src in scripts.sources if "CoinSale_" in src), None)
if not config_src:
raise ValueError("页面没有下发 CoinSale 区服配置")
config_url = urljoin(page_url, config_src)
config = client.request(config_url, referer=page_url)
channels = read_variable(config, "bijiaqiChanels")
report = {
"region": region, "region_name": REGIONS[region], "source": page_url,
"config_url": config_url, "fetched_at": datetime.now(timezone.utc).isoformat(),
"price1": args.price1, "price2": args.price2, "limit": args.limit,
"channels": [], "items": [],
}
rows, requests = [], []
for channel in channels:
platform = json.loads(channel["data"])
pf_id = platform["PfId"]
entry = {"platform_id": pf_id, "platform_name": platform["PfName"],
"coin_no": platform["CoinNo"], "server": platform["GsName"]}
report["channels"].append(entry)
if pf_id in DISABLED_PLATFORMS:
entry["status"] = "skipped_by_public_frontend"
continue
payload = make_payload(channel, seed, user, args.price1, args.price2)
requests.append((client.fork(), platform, payload, entry))
def fetch_channel(task):
session, platform, payload, entry = task
pf_id = platform["PfId"]
channel_rows = []
try:
response = json.loads(session.request(f"{BASE}/bijia/coinsale", payload, page_url))
entry["response"] = response # Preserve failed/empty responses, too.
if response.get("success") and isinstance(response.get("data"), list):
entry["status"] = "success"
entry["count"] = len(response["data"])
for item in response["data"]:
channel_rows.append({**item, "PfId": pf_id, "PfName": platform["PfName"],
"CoinNo": platform["CoinNo"], "Region": region,
"RegionName": REGIONS[region],
"CoinUnit": platform["CoinUnit"],
"SourceServer": platform["GsName"]})
else:
entry["status"] = "failed"
entry["error"] = response.get("debug") or "接口返回 success=0 或 data 格式异常"
except RateLimited:
raise
except (RuntimeError, URLError, TimeoutError, ValueError) as exc:
entry.update(status="failed", error=str(exc))
print(f" {platform['PfName']}/{platform['CoinNo']}: "
f"{entry['status']} {entry.get('count', 0)} 条"
f" {entry.get('error', '')}", flush=True)
return channel_rows
# The webpage dispatches its channel requests together. Send them while the
# freshly issued signing material is current, with an isolated cookie jar
# per worker; keep result ordering deterministic.
with ThreadPoolExecutor(max_workers=5) as pool:
for channel_rows in pool.map(fetch_channel, requests):
rows.extend(channel_rows)
report["raw_offer_count"] = len(rows)
report["items"] = select_offers(rows, args.price1, args.price2, args.limit)
active = [entry for entry in report["channels"]
if entry["status"] != "skipped_by_public_frontend"]
successful = sum(entry["status"] == "success" for entry in active)
report["status"] = ("success" if active and successful == len(active)
else "partial" if successful else "failed")
return report
def save_region(directory, report):
name = report["region"]
(directory / f"{name}.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
fields = ["Region", "RegionName", "PfId", "PfName", "CoinNo", "SourceServer",
"Price", "Amount", "CoinUnit", "Money", "Number", "TradeType", "Seller",
"State", "BuyUrl", "ListUrl"]
with (directory / f"{name}.csv").open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(report["items"])
def nonnegative(value):
number = float(value)
if not math.isfinite(number) or number < 0:
raise argparse.ArgumentTypeError("必须是非负有限数值")
return number
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--regions", nargs="+", choices=REGIONS, default=list(REGIONS))
parser.add_argument("--price1", type=nonnegative, default=0, help="最低总价,元")
parser.add_argument("--price2", type=nonnegative, default=0, help="最高总价,0 表示不限")
parser.add_argument("--limit", type=int, default=200)
parser.add_argument("--interval", type=nonnegative, default=60,
help="相邻跨区开始请求的最短间隔秒数,默认60")
parser.add_argument("--timeout", type=nonnegative, default=30)
parser.add_argument("--output", type=Path,
default=Path("tmp/yxdr") / datetime.now().strftime("%Y%m%d-%H%M%S"))
args = parser.parse_args()
if args.limit < 1 or args.timeout <= 0:
parser.error("limit 和 timeout 必须大于0")
if args.price2 and args.price1 > args.price2:
parser.error("price1 不能大于 price2")
args.output.mkdir(parents=True, exist_ok=True)
client = Client(args.timeout)
summary, last_start, failed = [], None, False
for region in dict.fromkeys(args.regions):
if last_start is not None:
remaining = args.interval - (time.monotonic() - last_start)
if remaining > 0:
print(f"等待 {remaining:.1f} 秒后获取 {REGIONS[region]}", flush=True)
time.sleep(remaining)
last_start = time.monotonic()
print(f"获取 {REGIONS[region]} ({region})", flush=True)
stop = False
try:
report = fetch_region(client, region, args)
save_region(args.output, report)
status = report["status"]
summary.append({"region": region, "status": status,
"raw_count": report["raw_offer_count"],
"exported_count": len(report["items"])})
failed |= status != "success"
except (RateLimited, RuntimeError, URLError, TimeoutError, ValueError) as exc:
failed = True
summary.append({"region": region, "status": "failed", "error": str(exc)})
print(f" 失败:{exc}", file=sys.stderr, flush=True)
stop = isinstance(exc, RateLimited)
(args.output / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if stop:
break
print(f"输出目录:{args.output.resolve()}", flush=True)
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())