feat:添加colg对接功能

This commit is contained in:
2026-11-03 21:44:30 +08:00
parent 3937185d0a
commit 4709ea4328
13 changed files with 1742 additions and 79 deletions
+20
View File
@@ -29,6 +29,26 @@ uv run ruff check .
- `core/basic/equipment_registry.py`: equipment version registry and cache.
- `database/connect.py`: SQLite and Redis connections.
## Gold Cache and Notifications
- Application startup checks both realtime and official gold caches, then checks
them every minute. Data older than 30 minutes refreshes without HTTP traffic.
Redis locks prevent duplicate work across workers. Realtime region pages stay
8 seconds apart. Failures retain the last successful data for up to 24 hours.
- Official authentication uses `HELPER_TOKEN` and `HELPER_ID`. Authentication
failures (`returnCode=-30003`, explicit token-expiry messages or HTTP 401) can
notify via Bark. Network errors do not trigger token-expiry notifications.
- Set optional `BARK_URL` in `config/.env`, `.env.dev`, `.env.prod` or the process
environment to your full Bark push URL, including its device key. There is no
default destination; unset/blank means no Bark request. Restart after changing
credentials. A successful alert is deduplicated per token for 24 hours; a new
token permits a new alert. Failed pushes remain retryable. No credentials are
included in notification text or logged push errors.
- Official gold responses also contain `max` and `min`: the largest/smallest
values of `10000 / price` across both rank arrays, in 万金币 per 100点券,
rounded to 4 decimals. Nonpositive/invalid prices are excluded; absent valid
quotes yield `null`. Existing rank arrays retain their 点券 per 100万金币 unit.
## Compatibility Rules
- Do not change `/calc` request and response contract without frontend migration approval.
+45 -62
View File
@@ -1,19 +1,17 @@
import asyncio
import logging
from api.types.colg import HistoryGold, OfficialGold, RealTimeGold
from api.utils import official_gold, thirdpart_gold
from api.core.Gzip import register_gzip_request
import json
from fastapi import APIRouter, Query
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from redis.asyncio import Redis as AsyncRedis
from api.core.Response import response
from api.core.Redis import get_redis_info, get_redis_info_async
from api.dp import AsyncRedisDep, RedisDep
from typing import Annotated
import requests
from config.main import config
from datetime import datetime, timezone, timedelta
from api.core.Response import Return as Response
import aiohttp
router = APIRouter()
logger = logging.getLogger('dcalc.colg')
@@ -35,36 +33,6 @@ url = 'https://poservice.colg.cn/api/v1/trend/getCoinTrendDataKol'
payload = {'token': config.COLG_TOKEN, 'area': 0, 'time_type': 'other', 'start_time': '2026-01-06', 'end_time': '2026-01-06'}
payload_helper = {
'r': 'godRank/godDealRank',
'userId': config.HELPER_ID,
'token': config.HELPER_TOKEN,
}
OFFICIAL_GOLD_URL = 'https://dzhu.qq.com/zangyi/activity/app'
OFFICIAL_GOLD_REGIONS = ('1', '2', '3A', '3B', '4', '5', '6', '7')
async def _fetch_official_gold() -> list[dict]:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async def fetch_region(region: str) -> dict:
form = {**payload_helper, 'serverNo': region}
async with session.post(OFFICIAL_GOLD_URL, data=form) as resp:
resp.raise_for_status()
payload = await resp.json(content_type=None)
data = payload['data']
return {
'updataTime': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'region': f'跨{region}',
'goldDealRank': [int(item['price']) for item in data['godDealRank']],
'goldTopDealRank': [int(item['price']) for item in data['godTopDealRank']],
}
return await asyncio.gather(*(fetch_region(region) for region in OFFICIAL_GOLD_REGIONS))
def get_date_str(timestamp: int) -> str:
utc_time = datetime.fromtimestamp(timestamp, tz=timezone.utc)
beijing_time = utc_time + timedelta(hours=8)
@@ -72,30 +40,13 @@ def get_date_str(timestamp: int) -> str:
@router.get('/gold/realtime/', operation_id='realtimeGold', response_model=Response[list[RealTimeGold]])
def get_real_time_gold(redis: RedisDep) -> Response[list[RealTimeGold]]:
"""
返回所有跨区的实时金价信息
"""
key = 'openapi:colg:real_time_gold'
try:
async def get_real_time_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
"""获取网站金币比例,数据为各跨区首次报价前200条的平均万金币/元。
def get_gold_data():
res = []
for region_name, region_id in regionMap.items():
payload['area'] = region_id
payload['start_time'] = datetime.now().strftime('%Y-%m-%d')
payload['end_time'] = datetime.now().strftime('%Y-%m-%d')
response = requests.post(url, headers={'Content-Type': 'application/json'}, data=json.dumps(payload))
response.raise_for_status()
resp = response.json().get('data', {})
res.append({'updataTime': get_date_str(resp['x'][-1]), 'region': region_name, 'goldPrice': resp['y'][-1]})
return res
except Exception:
# print(f"File not found: {e}")
return response(data=[])
# 设置半小时过期一次
data = get_redis_info(redis, key, get_gold_data, 30 * 60)
return response(data=data)
updataTime为数字秒级时间戳,code为跨区标识。与thirdpart接口共用Redis缓存和
后台刷新任务:30分钟自动更新,也支持按需补齐,逐区间隔8秒;初始化未齐时响应体code=202。
"""
return await get_thirdpart_gold(redis, background_tasks)
@router.get('/gold/history/', operation_id='historyGold', response_model=Response[HistoryGold] | Response[None])
@@ -171,7 +122,7 @@ def get_history_gold(region: Annotated[int, Query(..., description='跨区id')],
@router.get('/gold/official/', operation_id='officialGold', response_model=Response[list[OfficialGold]])
async def get_official_gold(redis: AsyncRedisDep) -> Response[list[OfficialGold]]:
async def get_official_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)]) -> Response[list[OfficialGold]]:
"""
获取DNF官方金币寄售实时数据
@@ -180,11 +131,43 @@ async def get_official_gold(redis: AsyncRedisDep) -> Response[list[OfficialGold]
Note:
- 该接口会返回所有跨区的官方金币寄售数据,包括最低成交价排名前5的价格和最高成交价排名前5的价格
- 数据来源于DNF官方提供的接口,可能会有一定的延迟和不稳定性
- 30分钟自动刷新,失败保留最多24小时的旧值;认证失败且配置了BARK_URL时发送通知
"""
key = 'openapi:colg:real_time_gold_officical'
try:
data = await get_redis_info_async(redis, key, _fetch_official_gold, 30 * 60)
data = await official_gold.get_data(redis)
except Exception:
logger.exception('fetch official gold failed')
return response(data=[])
return response(code=503, data=[], message='官方金价缓存暂不可用')
if not data:
return response(code=503, data=[], message='官方金价暂不可用,请稍后重试')
return response(data=data)
@router.get('/gold/thirdpart/', operation_id='thirdpartGold', response_model=Response[list[RealTimeGold]])
async def get_thirdpart_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
"""获取跨1~跨8(含跨3A/3B)的第三方首批报价均值,单位:万金币/元。
不限制最低总价;各平台首次返回的有效报价汇总、去重、按单价取前200条,
对每条报价的1/Price取算术平均,不按库存或份数加权。保留上游原始价格,
不额外计算交易手续费。该接口无上游分页参数,前200条是本接口的样本口径。
每30分钟自动后台刷新,跨区页面最短间隔8秒(每分钟不超过8次),Redis锁避免重复刷新。
初始化跨区未齐时响应体code=202并注明进度;9区缓存齐全才返回code=200。
刷新中可返回部分已完成跨区,缺失跨区不补0。
已有缓存即时返回,失败保留原更新时间,旧值最多保留24小时。
该接口在DEBUG模式下也使用缓存,Redis不可用时不直接抓取上游。
"""
try:
data = await thirdpart_gold.read_cache(redis)
except Exception:
logger.exception('read thirdpart gold cache failed')
return response(code=503, data=[], message='第三方金价缓存暂不可用')
try:
await thirdpart_gold.schedule_refresh(redis, background_tasks, data)
except Exception:
logger.exception('schedule thirdpart gold refresh failed')
return response(code=200 if data else 503, data=data, message='第三方金价刷新暂不可用')
if len(data) < len(thirdpart_gold.REGIONS):
return response(code=202, data=data,
message=f'第三方金价缓存尚未齐全({len(data)}/{len(thirdpart_gold.REGIONS)}跨区),后台刷新中或等待重试')
return response(data=data)
+24 -5
View File
@@ -1,14 +1,30 @@
from pydantic import BaseModel, Field
GOLD_REGION_CODES = {
'跨1': 'k1', '跨2': 'k2', '跨3A': 'k3a', '跨3B': 'k3b',
'跨4': 'k4', '跨5': 'k5', '跨6': 'k6', '跨7': 'k7', '跨8': 'k8',
}
class RealTimeGold(BaseModel):
updataTime: str = Field(..., description='更新时间')
updataTime: int = Field(..., ge=0, description='更新时间,Unix秒级时间戳')
"""更新时间"""
region: str = Field(..., description='跨区')
"""跨区"""
goldPrice: float|str = Field(..., description='金价')
code: str = Field(..., description='跨区code:k1、k2、k3a、k3b、k4、k5、k6、k7、k8')
goldPrice: float = Field(..., gt=0, allow_inf_nan=False, description='金币比例,1元对应的万金币数量')
"""金价"""
class ThirdPartGoldCache(RealTimeGold):
"""内部缓存数据,不作为接口返回模型。"""
goldPrice: float = Field(..., gt=0, allow_inf_nan=False, description='首页有效报价换算为万金币/元后的算术平均值')
sampleCount: int = Field(..., ge=1, le=200, description='参与均值计算的报价条数')
stale: bool = Field(..., description='数据是否已超过30分钟刷新周期')
class DayGold(BaseModel):
date: str = Field(..., description='日期')
"""日期"""
@@ -25,11 +41,14 @@ class HistoryGold(BaseModel):
"""echarts数据"""
class OfficialGold(BaseModel):
updataTime: str = Field(..., description='更新时间')
updataTime: str = Field(..., pattern=r'^\d+$', description='更新时间,Unix秒级时间戳字符串')
"""更新时间"""
region: str = Field(..., description='跨区')
"""跨区"""
goldDealRank: list[float|str] = Field(..., description='金币寄售最低成交价排名前5的价格')
code: str = Field(..., description='跨区code:k1、k2、k3a、k3b、k4、k5、k6、k7')
goldDealRank: list[float] = Field(..., max_length=5, description='金币寄售最低成交价,最多5项,每100万金币对应的点券数量')
"""金币寄售最低成交价排名前5的价格"""
goldTopDealRank: list[float|str] = Field(..., description='金币寄售最高成交价排名前5的价格')
goldTopDealRank: list[float] = Field(..., max_length=5, description='金币寄售最高成交价,最多5项,每100万金币对应的点券数量')
"""金币寄售最高成交价排名前5的价格"""
max: float | None = Field(..., description='100点券可兑换的最大万金币数量,无有效报价时为null')
min: float | None = Field(..., description='100点券可兑换的最小万金币数量,无有效报价时为null')
+45
View File
@@ -0,0 +1,45 @@
"""随应用生命周期运行的金币缓存定时检查,无需外部请求触发。"""
import asyncio
import logging
from api.utils import official_gold, thirdpart_gold
logger = logging.getLogger('dcalc.gold_refresh')
CHECK_INTERVAL_SECONDS = 60
_periodic_tasks: list[asyncio.Task] = []
async def refresh_realtime():
async with thirdpart_gold.create_redis() as redis:
data = await thirdpart_gold.read_cache(redis)
await thirdpart_gold.schedule_refresh(redis, None, data)
async def refresh_official():
async with thirdpart_gold.create_redis() as redis:
await official_gold.refresh_cache(redis)
async def _periodic(job):
while True:
try:
await job()
except Exception:
logger.exception('periodic gold refresh check failed job=%s', job.__name__)
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def start_periodic_refresh():
if _periodic_tasks:
return
for job in (refresh_realtime, refresh_official):
_periodic_tasks.append(asyncio.create_task(_periodic(job), name=f'gold-periodic-{job.__name__}'))
async def stop_periodic_refresh():
tasks = list(_periodic_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
_periodic_tasks.clear()
+201
View File
@@ -0,0 +1,201 @@
"""官方寄售缓存、认证失败识别及可选BARK通知。"""
import asyncio
from datetime import datetime, timezone
import hashlib
import json
import logging
import math
import time
from uuid import uuid4
import aiohttp
from api.core.Gzip import gzip_unzip, gzip_zip
from api.types.colg import GOLD_REGION_CODES, OfficialGold
from api.utils.thirdpart_gold import RELEASE_LOCK
from config.main import config
logger = logging.getLogger('dcalc.official_gold')
URL = 'https://dzhu.qq.com/zangyi/activity/app'
REGIONS = ('1', '2', '3A', '3B', '4', '5', '6', '7')
CACHE_KEY = 'openapi:colg:real_time_gold_official:v2'
LOCK_KEY = f'{CACHE_KEY}:lock'
COOLDOWN_KEY = f'{CACHE_KEY}:cooldown'
FRESH_SECONDS = 30 * 60
RETENTION_SECONDS = 24 * 60 * 60
ERROR_COOLDOWN_SECONDS = 5 * 60
ALERT_RETENTION_SECONDS = 24 * 60 * 60
class TokenExpired(RuntimeError):
pass
def normalize_row(item: dict) -> dict:
"""原报价为点券/100万金币,换算为每100点券的万金币为10000/报价。"""
ranks = {}
for name in ('goldDealRank', 'goldTopDealRank'):
prices = []
for raw in item[name][:5]:
try:
price = float(raw)
except (TypeError, ValueError, OverflowError):
continue
if math.isfinite(price):
prices.append(price)
ranks[name] = prices
ratios = [10000 / price for prices in ranks.values() for price in prices
if price > 0 and math.isfinite(10000 / price)]
return OfficialGold.model_validate({
**item, **ranks,
'max': round(max(ratios), 4) if ratios else None,
'min': round(min(ratios), 4) if ratios else None,
}).model_dump()
def is_auth_failure(payload: dict) -> bool:
if any(str(payload.get(key)) == '-30003' for key in ('result', 'returnCode')):
return True
message = ' '.join(str(payload.get(key, '')) for key in ('returnMsg', 'message', 'msg')).lower()
return any(marker in message for marker in (
'auth verification failed', 'token expired', 'token invalid', 'invalid token',
'token失效', 'token已过期', '登录失效', '登录已过期', '请重新登录',
))
async def fetch_gold() -> list[dict]:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async def fetch_region(region):
form = {'r': 'godRank/godDealRank', 'userId': config.HELPER_ID,
'token': config.HELPER_TOKEN, 'serverNo': region}
async with session.post(URL, data=form) as resp:
if resp.status == 401:
raise TokenExpired('官方金币接口认证失败')
payload = await resp.json(content_type=None)
if not isinstance(payload, dict):
raise ValueError('官方金币接口返回格式异常')
if is_auth_failure(payload):
raise TokenExpired('官方金币接口认证失败')
resp.raise_for_status()
data = payload['data']
return normalize_row({
'updataTime': str(int(datetime.now(timezone.utc).timestamp())),
'region': f'跨{region}', 'code': GOLD_REGION_CODES[f'跨{region}'],
'goldDealRank': [float(item['price']) for item in data['godDealRank'][:5]],
'goldTopDealRank': [float(item['price']) for item in data['godTopDealRank'][:5]],
})
results = await asyncio.gather(*(fetch_region(region) for region in REGIONS),
return_exceptions=True)
# 任一跨区认证失败只向刷新调用方抛出一次;优先于其他渠道的网络异常。
for result in results:
if isinstance(result, TokenExpired):
raise result
for result in results:
if isinstance(result, BaseException):
raise result
return results
async def notify_token_expired(redis) -> bool:
bark_url = (config.BARK_URL or '').strip()
if not bark_url:
return False
# 不将Token或BARK设备key写入缓存键、通知正文或日志。
fingerprint = hashlib.sha256(config.HELPER_TOKEN.encode()).hexdigest()
key = f'{CACHE_KEY}:token_alert:{fingerprint}'
owner = uuid4().hex
try:
if not await redis.set(key, owner, nx=True, ex=60):
return False
except Exception:
logger.warning('unable to acquire official token notification lock')
return False
sent = False
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(bark_url, json={
'title': '助手Token失效',
'body': '官方接口认证失败,请检查并更新配置',
'group': 'dcalc',
}, allow_redirects=False) as resp:
resp.raise_for_status()
payload = await resp.json(content_type=None)
if not isinstance(payload, dict) or payload.get('code') != 200:
raise ValueError('BARK拒绝推送')
await redis.set(key, 'sent', ex=ALERT_RETENTION_SECONDS)
sent = True
return True
except Exception as exc:
# ClientResponseError包含完整请求URL,不输出异常正文或堆栈以免暴露设备key。
logger.warning('official token BARK notification failed (%s)', type(exc).__name__)
return False
finally:
if not sent:
try:
await redis.eval(RELEASE_LOCK, 1, key, owner)
except Exception:
logger.warning('unable to release official token notification lock')
async def read_cache(redis) -> list[dict]:
value = await redis.get(CACHE_KEY)
if value is None:
return []
try:
rows = [normalize_row(item)
for item in json.loads(gzip_unzip(value))]
if [row['region'] for row in rows] != [f'跨{region}' for region in REGIONS]:
return []
if any(not 0 <= time.time() - int(row['updataTime']) < RETENTION_SECONDS for row in rows):
return []
return rows
except (ValueError, TypeError, KeyError, OSError):
logger.warning('invalid official gold cache')
return []
def is_fresh(rows: list[dict]) -> bool:
return (len(rows) == len(REGIONS)
and all(0 <= time.time() - int(row['updataTime']) < FRESH_SECONDS for row in rows))
async def refresh_cache(redis) -> bool:
if is_fresh(await read_cache(redis)) or await redis.get(COOLDOWN_KEY):
return False
owner = uuid4().hex
if not await redis.set(LOCK_KEY, owner, nx=True, ex=60):
return False
try:
if is_fresh(await read_cache(redis)) or await redis.get(COOLDOWN_KEY):
return False
try:
data = await fetch_gold()
except TokenExpired:
logger.warning('official gold authentication failed; check HELPER_TOKEN and HELPER_ID')
await notify_token_expired(redis)
except Exception:
logger.exception('fetch official gold failed')
else:
if await redis.get(LOCK_KEY) not in (owner, owner.encode()):
return False
await redis.set(CACHE_KEY, gzip_zip(json.dumps(data)), ex=RETENTION_SECONDS)
await redis.delete(COOLDOWN_KEY)
return True
# 网络异常、认证失败均保留上次完整缓存,不写空列表,不改变原更新时间。
await redis.set(COOLDOWN_KEY, '1', ex=ERROR_COOLDOWN_SECONDS)
return False
finally:
await redis.eval(RELEASE_LOCK, 1, LOCK_KEY, owner)
async def get_data(redis) -> list[dict]:
cached = await read_cache(redis)
if cached:
return cached
# 冷启动时允许请求补齐;与定时任务共用刷新锁,不会重复请求上游。
await refresh_cache(redis)
return await read_cache(redis)
+305
View File
@@ -0,0 +1,305 @@
"""YXDR 首次报价均值;Redis 缓存及跨进程刷新锁,所有 HTTP 使用现有 aiohttp。"""
import asyncio
import hashlib
from html.parser import HTMLParser
import json
import logging
import math
import re
from statistics import fmean
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import urljoin, urlsplit
from uuid import uuid4
import aiohttp
from redis.asyncio import Redis
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff
from api.types.colg import GOLD_REGION_CODES, ThirdPartGoldCache
from config.main import config
logger = logging.getLogger('dcalc.thirdpart_gold')
BASE_URL = 'https://www.yxdr.com'
REGIONS = {
'kua1': '跨1', 'kua2': '跨2', 'kua3a': '跨3A', 'kua3b': '跨3B',
'kua4': '跨4', 'kua5': '跨5', 'kua6': '跨6', 'kua7': '跨7', 'kua8': '跨8',
}
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',
}
# 网站公开 DNF 页面已停用/移除的渠道;不请求 VIP 专属渠道。
DISABLED_PLATFORMS = {1, 12, 21, 50, 51, 52, 53}
PAGE_SIZE = 200
FRESH_SECONDS = 30 * 60
RETENTION_SECONDS = 24 * 60 * 60
# 按每分钟最多8次页面访问处理,理论间隔7.5秒,取8秒留出边界余量。
# 渠道在区内并发;上游仍可能限频,遇限频停止本轮并保留已有缓存。
REGION_INTERVAL = 8
LOCK_SECONDS = 15 * 60
ERROR_COOLDOWN_SECONDS = 5 * 60
REDIS_TIMEOUT_SECONDS = 1
CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v1'
LOCK_KEY = f'{CACHE_PREFIX}:lock'
COOLDOWN_KEY = f'{CACHE_PREFIX}:cooldown'
RELEASE_LOCK = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
_refresh_tasks: set[asyncio.Task] = set()
def create_redis():
# 只约束本接口;共享池没有显式超时,不能让缓存故障拖住 HTTP 请求。
# from_url 创建的连接池由客户端持有,aclose 时一并释放。
return Redis.from_url(config.REDIS_URL, protocol=2,
socket_connect_timeout=REDIS_TIMEOUT_SECONDS,
socket_timeout=REDIS_TIMEOUT_SECONDS,
retry=Retry(NoBackoff(), 0))
async def get_redis():
async with create_redis() as redis:
yield redis
def _refresh_done(task):
_refresh_tasks.discard(task)
if not task.cancelled():
error = task.exception()
if error:
logger.error('thirdpart gold background task failed',
exc_info=(type(error), error, error.__traceback__))
async def launch_refresh(token: str):
# BackgroundTasks 只负责启动,HTTP/ASGI 请求不会跟随整轮刷新持续数分钟。
task = asyncio.create_task(refresh_cache(token), name='thirdpart-gold-refresh')
_refresh_tasks.add(task)
task.add_done_callback(_refresh_done)
async def close_refresh_tasks():
tasks = list(_refresh_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
class UpstreamLimited(RuntimeError):
pass
class ConfigScripts(HTMLParser):
def __init__(self):
super().__init__()
self.sources = []
def handle_starttag(self, tag, attrs):
src = dict(attrs).get('src')
if tag == 'script' and src and 'CoinSale_' in src:
self.sources.append(src)
def read_variable(source: str, name: str):
match = re.search(r'\b(?:window\.)?' + re.escape(name) + r'\s*=\s*', source)
if not match:
raise ValueError(f'YXDR 未下发 {name}')
return json.JSONDecoder().raw_decode(source, match.end())[0]
def make_payload(channel: dict, seed: dict, user: dict) -> dict:
platform_id = json.loads(channel['data'])['PfId']
value = f"{seed['secret']}_{platform_id}_{channel['sign']}"
return {
'data': channel['data'], 'sign': channel['sign'],
'price1': 0, 'price2': 0, 'cross': 0, 'dnfCross': user['dnfCross'],
'time': seed['time'], 'secret': hashlib.md5(value.encode('utf-8')).hexdigest(),
}
def first_page_average(offers: list[dict]) -> tuple[float, int]:
"""等权平均每条报价的 1/Price(万金币/元),不是 1/平均单价。"""
valid = []
for offer in offers:
try:
price = float(offer['Price'])
money = float(offer['Money'])
number = float(offer['Number'])
except (KeyError, TypeError, ValueError, OverflowError):
continue
if not all(math.isfinite(value) and value > 0 for value in (price, money, number)):
continue
if not math.isfinite(1 / price):
continue
valid.append({**offer, 'Price': price})
valid.sort(key=lambda item: (item['Price'], item['PfId'], item.get('BuyUrl') or ''))
seen, page = set(), []
for offer in valid:
# 与页面一致,仅对这些平台合并重复商品;7881 同 URL 可有不同规格。
key = (offer['PfId'], offer.get('BuyUrl'))
if offer['PfId'] in {1, 40, 41} and key[1]:
if key in seen:
continue
seen.add(key)
page.append(offer)
if len(page) == PAGE_SIZE:
break
if not page:
raise ValueError('YXDR 未返回有效报价')
return round(fmean(1 / item['Price'] for item in page), 4), len(page)
async def _read_response(resp) -> str:
text = await resp.text()
if resp.status == 429 or 'LimitDeny' in text or '访问频率过高' in text:
raise UpstreamLimited('YXDR 访问频率受限')
resp.raise_for_status()
return text
async def fetch_region(session: aiohttp.ClientSession, region: str) -> dict:
page_url = f'{BASE_URL}/bijiaqi/dnf/youxibi/{region}'
async with session.get(page_url) as resp:
page = await _read_response(resp)
seed = read_variable(page, 'bijiaSecret')
user = read_variable(page, 'bijiaUserInfo')
scripts = ConfigScripts()
scripts.feed(page)
if len(scripts.sources) != 1:
raise ValueError('YXDR 区服配置脚本异常')
config_url = urljoin(page_url, scripts.sources[0])
parts = urlsplit(config_url)
if parts.scheme != 'https' or parts.netloc != 'www.yxdr.com':
raise ValueError('YXDR 区服配置脚本地址异常')
async with session.get(config_url, headers={'Referer': page_url}) as resp:
config = await _read_response(resp)
channels = [channel for channel in read_variable(config, 'bijiaqiChanels')
if json.loads(channel['data'])['PfId'] not in DISABLED_PLATFORMS]
if not channels:
raise ValueError('YXDR 无可用公开渠道')
async def fetch_channel(channel):
platform = json.loads(channel['data'])
if platform['CoinUnit'] != '万金币':
raise ValueError('YXDR 金币单位发生变化')
headers = {'Referer': page_url, 'Origin': BASE_URL,
'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'}
async with session.post(f'{BASE_URL}/bijia/coinsale',
json=make_payload(channel, seed, user), headers=headers) as resp:
result = json.loads(await _read_response(resp))
if not result.get('success') or not isinstance(result.get('data'), list):
raise ValueError(f"YXDR 渠道 {platform['PfId']}/{platform['CoinNo']} 抓取失败")
return [{**offer, 'PfId': platform['PfId']} for offer in result['data']]
# 同区并发,避免短时签名在顺序请求中失效;任何渠道失败都不覆盖旧均值。
results = await asyncio.gather(*(fetch_channel(channel) for channel in channels),
return_exceptions=True)
# 优先传播限频,即使另一个渠道先失败,也不能继续抓下一个区。
for result in results:
if isinstance(result, UpstreamLimited):
raise result
for result in results:
if isinstance(result, BaseException):
raise result
price, count = first_page_average([offer for result in results for offer in result])
now = datetime.now(timezone(timedelta(hours=8)))
return {'region': REGIONS[region], 'code': GOLD_REGION_CODES[REGIONS[region]],
'goldPrice': price, 'sampleCount': count,
'updataTime': int(now.timestamp()), 'fetchedAt': now.timestamp()}
async def read_cache(redis) -> list[dict]:
values = await redis.mget([f'{CACHE_PREFIX}:{region}' for region in REGIONS])
result = []
for region, value in zip(REGIONS, values):
if value is None:
continue
try:
item = json.loads(value)
age = time.time() - item['fetchedAt']
if item['region'] != REGIONS[region] or not 0 <= age < RETENTION_SECONDS:
continue
# 兼容此前缓存的格式化日期及缺少code的记录,无须重新抓取所有跨区。
result.append(ThirdPartGoldCache.model_validate({
**item, 'updataTime': int(item['fetchedAt']),
'code': GOLD_REGION_CODES[REGIONS[region]], 'stale': age >= FRESH_SECONDS,
}).model_dump())
except (KeyError, TypeError, ValueError):
logger.warning('invalid thirdpart gold cache region=%s', region)
return result
async def schedule_refresh(redis, background_tasks, data: list[dict]) -> bool:
# 缓存均新鲜时不访问上游;失败缺区时遵守全局冷却期。
if len(data) == len(REGIONS) and not any(item['stale'] for item in data):
return False
if await redis.get(COOLDOWN_KEY):
return False
token = uuid4().hex
if not await redis.set(LOCK_KEY, token, nx=True, ex=LOCK_SECONDS):
return False
try:
if await redis.get(COOLDOWN_KEY):
await redis.eval(RELEASE_LOCK, 1, LOCK_KEY, token)
return False
if background_tasks is None:
await launch_refresh(token)
else:
background_tasks.add_task(launch_refresh, token)
except BaseException:
await redis.eval(RELEASE_LOCK, 1, LOCK_KEY, token)
raise
return True
async def refresh_cache(token: str):
# BackgroundTasks 中不用请求依赖的 Redis 客户端,它可能已被依赖清理器关闭。
async with create_redis() as redis:
try:
if await redis.get(LOCK_KEY) not in (token, token.encode()):
return
cached = await read_cache(redis)
fresh_regions = {item['region'] for item in cached if not item['stale']}
pending = [region for region, name in REGIONS.items() if name not in fresh_regions]
successful, last_start = 0, None
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(headers=HEADERS, timeout=timeout,
connector=aiohttp.TCPConnector(limit=5)) as session:
for region in pending:
if last_start is not None:
await asyncio.sleep(max(0, REGION_INTERVAL - (time.monotonic() - last_start)))
# 不在租约到期/已被其他刷新取代后继续请求或写缓存。
if await redis.get(LOCK_KEY) not in (token, token.encode()):
return
last_start = time.monotonic()
try:
item = await fetch_region(session, region)
if await redis.get(LOCK_KEY) not in (token, token.encode()):
return
await redis.set(f'{CACHE_PREFIX}:{region}', json.dumps(item),
ex=RETENTION_SECONDS)
successful += 1
except UpstreamLimited:
logger.warning('thirdpart gold rate limited region=%s', region)
break
except Exception:
logger.exception('fetch thirdpart gold failed region=%s', region)
# 新鲜数据由每区 fetchedAt 控制30分钟周期;只对失败设置长冷却。
# 下次重试只补缺失/过期区,避免每次重抓前8区导致最后一区一直受限。
cooldown = 1 if successful == len(pending) else ERROR_COOLDOWN_SECONDS
await redis.set(COOLDOWN_KEY, '1', ex=cooldown)
except Exception:
logger.exception('refresh thirdpart gold cache failed')
finally:
try:
await redis.eval(RELEASE_LOCK, 1, LOCK_KEY, token)
except Exception:
logger.exception('release thirdpart gold refresh lock failed')
+1
View File
@@ -14,6 +14,7 @@ class Config(BaseSettings):
COLG_TOKEN: str = ''
HELPER_TOKEN: str = ''
HELPER_ID: str = ''
BARK_URL: str | None = None
model_config = SettingsConfigDict(
env_file=(
+5
View File
@@ -9,6 +9,8 @@ from fastapi import FastAPI
from api.core import Exceptions, Gzip, Middleware
from api.core.Logging import configure_logging
from api.main import api_router
from api.utils.thirdpart_gold import close_refresh_tasks
from api.utils.gold_refresh import start_periodic_refresh, stop_periodic_refresh
from config.main import config
from database.connect import close_connection_pools
@@ -20,9 +22,12 @@ async def lifespan(_: FastAPI):
print('dcalc is starting...')
print('Application starting...')
print('Application startup complete.')
await start_periodic_refresh()
try:
yield
finally:
await stop_periodic_refresh()
await close_refresh_tasks()
await close_connection_pools()
+262 -12
View File
@@ -1,9 +1,15 @@
import asyncio
import time
import json
from api.routes import colg
import pytest
from api.utils import official_gold as gold
from api.core.Gzip import gzip_zip
class FakeResponse:
status = 200
def __init__(self, region):
self.region = region
@@ -20,8 +26,8 @@ class FakeResponse:
await asyncio.sleep(0)
return {
'data': {
'godDealRank': [{'price': '100'}, {'price': '101'}],
'godTopDealRank': [{'price': '200'}, {'price': '201'}],
'godDealRank': [{'price': value} for value in ['100.5', '101', '102', '103', '104', '105']],
'godTopDealRank': [{'price': value} for value in ['200.25', '201', '202', '203', '204', '205']],
}
}
@@ -51,15 +57,259 @@ def test_fetch_official_gold_uses_async_http_with_isolated_forms(monkeypatch):
sessions.append(session)
return session
monkeypatch.setattr(colg.aiohttp, 'ClientSession', create_session)
monkeypatch.setattr(gold.aiohttp, 'ClientSession', create_session)
result = asyncio.run(colg._fetch_official_gold())
result = asyncio.run(gold.fetch_gold())
assert [data['serverNo'] for _, data in calls] == list(colg.OFFICIAL_GOLD_REGIONS)
assert all(url == colg.OFFICIAL_GOLD_URL for url, _ in calls)
assert len({id(data) for _, data in calls}) == len(colg.OFFICIAL_GOLD_REGIONS)
assert [item['region'] for item in result] == [f'跨{region}' for region in colg.OFFICIAL_GOLD_REGIONS]
assert result[0]['goldDealRank'] == [100, 101]
assert result[0]['goldTopDealRank'] == [200, 201]
assert [data['serverNo'] for _, data in calls] == list(gold.REGIONS)
assert all(url == gold.URL for url, _ in calls)
assert len({id(data) for _, data in calls}) == len(gold.REGIONS)
assert [item['region'] for item in result] == [f'跨{region}' for region in gold.REGIONS]
assert [item['code'] for item in result] == ['k1', 'k2', 'k3a', 'k3b', 'k4', 'k5', 'k6', 'k7']
assert all(isinstance(item['updataTime'], str) and item['updataTime'].isdigit() for item in result)
assert abs(int(result[0]['updataTime']) - time.time()) < 2
assert result[0]['goldDealRank'] == [100.5, 101, 102, 103, 104]
assert result[0]['goldTopDealRank'] == [200.25, 201, 202, 203, 204]
assert result[0]['max'] == round(10000 / 100.5, 4)
assert result[0]['min'] == round(10000 / 204, 4)
assert sessions[0].kwargs['timeout'].total == 10
assert 'serverNo' not in colg.payload_helper
def test_official_route_uses_new_cache_contract_and_numeric_rank_arrays(monkeypatch, client):
calls = []
async def cached(redis):
calls.append(redis)
return [{'updataTime': '1793711716', 'region': '跨3B', 'code': 'k3b',
'goldDealRank': [100.5, 101], 'goldTopDealRank': [200.25, 201],
'max': round(10000 / 100.5, 4), 'min': round(10000 / 201, 4)}]
monkeypatch.setattr(gold, 'get_data', cached)
body = client.get('/api/open/gold/official/').json()
assert set(body) == {'code', 'message', 'data'}
assert body['code'] == 200
assert len(calls) == 1
row = body['data'][0]
assert set(row) == {'updataTime', 'region', 'code', 'goldDealRank', 'goldTopDealRank', 'max', 'min'}
assert isinstance(row['updataTime'], str) and row['updataTime'].isdigit()
assert row['code'] == 'k3b'
assert row['goldDealRank'] == [100.5, 101]
assert row['goldTopDealRank'] == [200.25, 201]
assert row['max'] == round(10000 / 100.5, 4)
assert row['min'] == round(10000 / 201, 4)
for rank in (row['goldDealRank'], row['goldTopDealRank']):
assert len(rank) <= 5
assert all(isinstance(value, (int, float)) for value in rank)
def test_gold_openapi_matches_timestamp_types_and_required_region_codes(client):
schemas = client.app.openapi()['components']['schemas']
realtime = schemas['RealTimeGold']
official = schemas['OfficialGold']
assert realtime['properties']['updataTime']['type'] == 'integer'
assert official['properties']['updataTime']['type'] == 'string'
assert 'code' in realtime['required'] and 'code' in official['required']
assert realtime['properties']['goldPrice']['type'] == 'number'
for name in ('goldDealRank', 'goldTopDealRank'):
assert official['properties'][name]['maxItems'] == 5
assert official['properties'][name]['items']['type'] == 'number'
class FakeRedis:
def __init__(self):
self.values = {}
self.ttls = {}
async def get(self, key):
return self.values.get(key)
async def set(self, key, value, nx=False, ex=None):
if nx and key in self.values:
return False
self.values[key] = value
self.ttls[key] = ex
return True
async def delete(self, key):
return int(self.values.pop(key, None) is not None)
async def eval(self, script, number, key, owner):
if self.values.get(key) == owner:
return await self.delete(key)
return 0
def cached_rows(age=0):
return [{'updataTime': str(int(time.time() - age)), 'region': f'跨{region}',
'code': f'k{region.lower()}', 'goldDealRank': [100.5], 'goldTopDealRank': [200.25]}
for region in gold.REGIONS]
def test_official_converts_point_prices_to_gold_per_100_points():
row = {**cached_rows()[0], 'goldDealRank': [125, 150], 'goldTopDealRank': [200, 250]}
result = gold.normalize_row(row)
assert result['max'] == 80
assert result['min'] == 40
assert result['goldDealRank'] == [125, 150]
assert result['goldTopDealRank'] == [200, 250]
def test_conversion_ignores_nonpositive_invalid_prices_and_uses_null_when_empty():
row = {**cached_rows()[0], 'goldDealRank': [0, -1, float('nan'), float('inf')],
'goldTopDealRank': []}
result = gold.normalize_row(row)
assert result['max'] is None and result['min'] is None
row['goldTopDealRank'] = [125]
result = gold.normalize_row(row)
assert result['max'] == result['min'] == 80
def test_old_official_cache_gets_max_and_min_without_refetch():
redis = FakeRedis()
redis.values[gold.CACHE_KEY] = gzip_zip(json.dumps(cached_rows()))
rows = asyncio.run(gold.read_cache(redis))
assert len(rows) == 8
assert rows[0]['max'] == round(10000 / 100.5, 4)
assert rows[0]['min'] == round(10000 / 200.25, 4)
@pytest.mark.parametrize('payload', [
{'returnCode': -30003, 'returnMsg': 'auth verification failed'},
{'result': '-30003'}, {'message': 'token expired'}, {'msg': '请重新登录'},
])
def test_expired_credentials_are_classified(payload):
assert gold.is_auth_failure(payload)
@pytest.mark.parametrize('payload', [
{'returnCode': 500, 'message': 'server error'}, {'message': 'network timeout'}, {'data': {}},
])
def test_regular_upstream_failures_do_not_trigger_expiry_alert(payload):
assert not gold.is_auth_failure(payload)
def test_fetch_detects_actual_auth_failure_response(monkeypatch):
class Response(FakeResponse):
async def json(self, **kwargs):
return {'result': -30003, 'returnCode': -30003, 'returnMsg': 'auth verification failed'}
class Session(FakeSession):
def post(self, url, data):
return Response(data['serverNo'])
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: Session([], **kwargs))
with pytest.raises(gold.TokenExpired):
asyncio.run(gold.fetch_gold())
def test_refresh_updates_cache_without_http_request_and_skips_when_fresh(monkeypatch):
redis = FakeRedis()
calls = []
async def fetch():
calls.append(1)
return cached_rows()
monkeypatch.setattr(gold, 'fetch_gold', fetch)
assert asyncio.run(gold.refresh_cache(redis)) is True
assert len(asyncio.run(gold.read_cache(redis))) == 8
assert redis.ttls[gold.CACHE_KEY] == gold.RETENTION_SECONDS
assert gold.LOCK_KEY not in redis.values
assert asyncio.run(gold.refresh_cache(redis)) is False
assert calls == [1]
def test_failed_refresh_preserves_cache_and_notifies_once_per_fetch(monkeypatch):
redis = FakeRedis()
old = gzip_zip(json.dumps(cached_rows(age=1900)))
redis.values[gold.CACHE_KEY] = old
alerts = []
async def fetch():
raise gold.TokenExpired()
async def notify(redis):
alerts.append(1)
monkeypatch.setattr(gold, 'fetch_gold', fetch)
monkeypatch.setattr(gold, 'notify_token_expired', notify)
assert asyncio.run(gold.refresh_cache(redis)) is False
assert redis.values[gold.CACHE_KEY] == old
assert alerts == [1]
assert gold.LOCK_KEY not in redis.values
assert redis.ttls[gold.COOLDOWN_KEY] == gold.ERROR_COOLDOWN_SECONDS
assert asyncio.run(gold.refresh_cache(redis)) is False
assert alerts == [1]
def test_network_failure_preserves_cache_without_token_alert(monkeypatch):
redis = FakeRedis()
old = gzip_zip(json.dumps(cached_rows(age=1900)))
redis.values[gold.CACHE_KEY] = old
async def fetch():
raise TimeoutError('upstream unavailable')
async def notify(redis):
raise AssertionError('network failures are not credential failures')
monkeypatch.setattr(gold, 'fetch_gold', fetch)
monkeypatch.setattr(gold, 'notify_token_expired', notify)
assert asyncio.run(gold.refresh_cache(redis)) is False
assert redis.values[gold.CACHE_KEY] == old
@pytest.mark.parametrize('url', [None, '', ' '])
def test_unconfigured_bark_never_creates_http_session(monkeypatch, url):
monkeypatch.setattr(gold.config, 'BARK_URL', url)
def session(**kwargs):
raise AssertionError('BARK must be completely skipped when not configured')
monkeypatch.setattr(gold.aiohttp, 'ClientSession', session)
redis = FakeRedis()
assert asyncio.run(gold.notify_token_expired(redis)) is False
assert not redis.values
def test_bark_alert_is_deduplicated_and_does_not_include_credentials(monkeypatch):
redis = FakeRedis()
posts = []
monkeypatch.setattr(gold.config, 'BARK_URL', 'https://bark.example/device-secret')
monkeypatch.setattr(gold.config, 'HELPER_TOKEN', 'private-token')
class Response(FakeResponse):
async def json(self, **kwargs):
return {'code': 200}
class Session(FakeSession):
def post(self, url, json, **kwargs):
posts.append((url, json))
return Response('push')
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: Session([], **kwargs))
assert asyncio.run(gold.notify_token_expired(redis)) is True
assert asyncio.run(gold.notify_token_expired(redis)) is False
assert len(posts) == 1
assert posts[0][0] == gold.config.BARK_URL
assert 'private-token' not in json.dumps(posts[0][1])
assert 'device-secret' not in json.dumps(posts[0][1])
assert all('private-token' not in key for key in redis.values)
assert next(iter(redis.ttls.values())) == gold.ALERT_RETENTION_SECONDS
monkeypatch.setattr(gold.config, 'HELPER_TOKEN', 'new-token')
assert asyncio.run(gold.notify_token_expired(redis)) is True
assert len(posts) == 2
def test_failed_bark_push_can_retry_without_leaking_device_url(monkeypatch, caplog):
monkeypatch.setattr(gold.config, 'BARK_URL', 'https://bark.example/device-secret')
redis = FakeRedis()
class Session(FakeSession):
def post(self, url, **kwargs):
raise RuntimeError(url)
monkeypatch.setattr(gold.aiohttp, 'ClientSession', lambda **kwargs: Session([], **kwargs))
assert asyncio.run(gold.notify_token_expired(redis)) is False
assert not redis.values
assert 'device-secret' not in caplog.text
+6
View File
@@ -3,3 +3,9 @@ from config.main import Config
def test_config_ignores_extra_environment_fields():
assert Config.model_config.get('extra') == 'ignore'
def test_bark_has_no_default_destination(monkeypatch):
monkeypatch.delenv('BARK_URL', raising=False)
assert Config(_env_file=None).BARK_URL is None
assert Config(_env_file=None, BARK_URL='https://bark.example/my-device').BARK_URL == 'https://bark.example/my-device'
+99
View File
@@ -0,0 +1,99 @@
import asyncio
from api.utils import gold_refresh, thirdpart_gold
def test_both_periodic_jobs_run_without_requests_and_stop_cleanly(monkeypatch):
async def run():
calls = [0, 0]
ready = [asyncio.Event(), asyncio.Event()]
async def realtime():
calls[0] += 1
if calls[0] >= 2:
ready[0].set()
async def official():
calls[1] += 1
if calls[1] >= 2:
ready[1].set()
monkeypatch.setattr(gold_refresh, 'refresh_realtime', realtime)
monkeypatch.setattr(gold_refresh, 'refresh_official', official)
monkeypatch.setattr(gold_refresh, 'CHECK_INTERVAL_SECONDS', .01)
try:
await gold_refresh.start_periodic_refresh()
await gold_refresh.start_periodic_refresh()
assert len(gold_refresh._periodic_tasks) == 2
await asyncio.wait_for(asyncio.gather(*(event.wait() for event in ready)), 1)
finally:
await gold_refresh.stop_periodic_refresh()
assert not gold_refresh._periodic_tasks
snapshot = list(calls)
await asyncio.sleep(.02)
assert calls == snapshot
asyncio.run(run())
def test_periodic_job_survives_transient_failure(monkeypatch):
async def run():
calls = []
ready = asyncio.Event()
async def job():
calls.append(1)
if len(calls) == 1:
raise ConnectionError('Redis unavailable')
ready.set()
monkeypatch.setattr(gold_refresh, 'CHECK_INTERVAL_SECONDS', .01)
task = asyncio.create_task(gold_refresh._periodic(job))
try:
await asyncio.wait_for(ready.wait(), 1)
assert len(calls) >= 2
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(run())
def test_realtime_can_launch_without_request_background_tasks(monkeypatch):
class Redis:
async def get(self, key):
return None
async def set(self, *args, **kwargs):
return True
launched = []
async def launch(token):
launched.append(token)
monkeypatch.setattr(thirdpart_gold, 'launch_refresh', launch)
assert asyncio.run(thirdpart_gold.schedule_refresh(Redis(), None, [])) is True
assert len(launched) == 1
def test_application_lifecycle_starts_and_stops_refresh_jobs(monkeypatch):
import main
calls = []
def callback(name):
async def call():
calls.append(name)
return call
monkeypatch.setattr(main, 'start_periodic_refresh', callback('start'))
monkeypatch.setattr(main, 'stop_periodic_refresh', callback('stop'))
monkeypatch.setattr(main, 'close_refresh_tasks', callback('refresh_closed'))
monkeypatch.setattr(main, 'close_connection_pools', callback('pools_closed'))
async def run():
async with main.lifespan(main.application):
assert calls == ['start']
asyncio.run(run())
assert calls == ['start', 'stop', 'refresh_closed', 'pools_closed']
+418
View File
@@ -0,0 +1,418 @@
import asyncio
import hashlib
import json
import time
import pytest
from fastapi import BackgroundTasks
from api.routes import colg
from api.types.colg import GOLD_REGION_CODES
from api.utils import thirdpart_gold as gold
def offer(price, url='listing', platform=17, money=1):
return {'Price': price, 'Money': money, 'Number': 1, 'PfId': platform, 'BuyUrl': url}
def cached_item(region='跨1', age=0):
fetched_at = time.time() - age
return {'region': region, 'goldPrice': 100.0, 'sampleCount': 10,
'code': GOLD_REGION_CODES[region], 'updataTime': int(fetched_at),
'fetchedAt': fetched_at}
class FakeRedis:
def __init__(self):
self.values = {}
self.writes = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def mget(self, keys):
return [self.values.get(key) for key in keys]
async def get(self, key):
return self.values.get(key)
async def set(self, key, value, nx=False, ex=None):
if nx and key in self.values:
return False
self.values[key] = value
self.writes.append((key, value, ex))
return True
async def eval(self, script, number, key, token):
if self.values.get(key) == token:
del self.values[key]
return 1
return 0
def test_average_is_mean_of_ratios_without_minimum_money_filter():
# 100 and 50 万/元 => 75; inverse mean price would incorrectly give 66.67.
assert gold.first_page_average([offer(.01), offer(.02)]) == (75.0, 2)
invalid = [offer(0), offer(-1), offer(float('nan')), offer(float('inf')),
offer(.01, money=-1), offer(None), offer(.01, money=float('nan'))]
assert gold.first_page_average(invalid + [offer(.01, money=.5)]) == (100.0, 1)
with pytest.raises(ValueError, match='有效报价'):
gold.first_page_average(invalid)
def test_page_deduplicates_before_cutoff_and_preserves_7881_variants():
rows = [offer(.01, 'duplicate', 40), offer(.01, 'duplicate', 40)]
rows += [offer(.02, str(i), 17) for i in range(199)]
rows += [offer(.5, 'outside', 17)]
assert gold.first_page_average(rows) == (50.25, 200)
assert gold.first_page_average([offer(.01, platform=44), offer(.02, platform=44)]) == (75.0, 2)
def test_request_signature_matches_observed_browser_request():
channel = {'data': '{"PfId":17,"CoinNo":"yxb"}',
'sign': 'e33b1f23fdbe4677e5e6f8bc7bdcbf78'}
seed = {'time': '639292586508765510', 'secret': 'a30ad49b1b2532c403de6319ec4f1c1a'}
payload = gold.make_payload(channel, seed, {'dnfCross': 1})
assert payload['secret'] == 'b7ac9fd4e4f15735dcbe1411b627bb4e'
assert payload['data'] == channel['data']
assert payload['time'] == seed['time']
assert payload['price1'] == payload['price2'] == 0
class FakeResponse:
status = 200
def __init__(self, text):
self.body = text
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def text(self):
return self.body
def raise_for_status(self):
pass
def test_region_fetches_its_own_seed_config_and_all_initial_channels():
seed = {'time': 'fresh-time', 'secret': 'fresh-seed'}
channel = {'data': json.dumps({'PfId': 17, 'CoinNo': 'yxb', 'CoinUnit': '万金币'}),
'sign': 'config-sign'}
page = ('window.bijiaSecret=' + json.dumps(seed) + ';'
'window.bijiaUserInfo={"dnfCross":1};'
'<script src="/cate/1838/CoinSale_yxb_254_0.js?ver=935"></script>')
posts, gets = [], []
class Session:
def get(self, url, **kwargs):
gets.append(url)
return FakeResponse(page if '/bijiaqi/' in url else
'window.bijiaqiChanels=' + json.dumps([channel]) + ';')
def post(self, url, json, **kwargs):
posts.append((url, json))
return FakeResponse('{"success":1,"data":[{"Price":0.01,"Money":1,"Number":1}]}')
result = asyncio.run(gold.fetch_region(Session(), 'kua8'))
assert result['region'] == '跨8'
assert result['code'] == 'k8'
assert isinstance(result['updataTime'], int)
assert result['goldPrice'] == 100
assert result['sampleCount'] == 1
assert gets == [gold.BASE_URL + '/bijiaqi/dnf/youxibi/kua8',
gold.BASE_URL + '/cate/1838/CoinSale_yxb_254_0.js?ver=935']
assert posts[0][1]['secret'] == hashlib.md5(b'fresh-seed_17_config-sign').hexdigest()
assert posts[0][1]['time'] == 'fresh-time'
assert 'page' not in posts[0][1]
def test_rate_limited_html_does_not_become_empty_quotes():
with pytest.raises(gold.UpstreamLimited):
asyncio.run(gold._read_response(FakeResponse('<title>LimitDeny</title>')))
def test_cache_omits_missing_regions_marks_old_values_and_retains_order():
redis = FakeRedis()
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = json.dumps(cached_item(age=1900))
redis.values[f'{gold.CACHE_PREFIX}:kua2'] = json.dumps(cached_item('跨2', age=90000))
redis.values[f'{gold.CACHE_PREFIX}:kua8'] = json.dumps(cached_item('跨8'))
rows = asyncio.run(gold.read_cache(redis))
assert [row['region'] for row in rows] == ['跨1', '跨8']
assert [row['stale'] for row in rows] == [True, False]
def test_invalid_cached_average_is_not_served():
redis = FakeRedis()
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = json.dumps({**cached_item(), 'goldPrice': 0})
redis.values[f'{gold.CACHE_PREFIX}:kua8'] = json.dumps({**cached_item('跨8'), 'sampleCount': 0})
assert asyncio.run(gold.read_cache(redis)) == []
def test_legacy_cache_gets_epoch_and_region_code_without_refetching():
redis = FakeRedis()
legacy = cached_item('跨3A')
legacy.pop('code')
legacy['updataTime'] = '2026-11-03 21:13:17'
redis.values[f'{gold.CACHE_PREFIX}:kua3a'] = json.dumps(legacy)
rows = asyncio.run(gold.read_cache(redis))
assert len(rows) == 1
assert rows[0]['code'] == 'k3a'
assert rows[0]['updataTime'] == int(legacy['fetchedAt'])
assert not redis.writes
def test_cold_concurrent_requests_schedule_only_one_refresh():
redis = FakeRedis()
tasks = BackgroundTasks()
async def run():
return await asyncio.gather(*(gold.schedule_refresh(redis, tasks, []) for _ in range(10)))
assert sum(asyncio.run(run())) == 1
assert len(tasks.tasks) == 1
assert tasks.tasks[0].func is gold.launch_refresh
assert next(ttl for key, _, ttl in redis.writes if key == gold.LOCK_KEY) == gold.LOCK_SECONDS
def test_fresh_cache_and_failure_cooldown_do_not_schedule_upstream():
redis = FakeRedis()
tasks = BackgroundTasks()
rows = [{**cached_item(name), 'stale': False} for name in gold.REGIONS.values()]
assert asyncio.run(gold.schedule_refresh(redis, tasks, rows)) is False
redis.values[gold.COOLDOWN_KEY] = '1'
assert asyncio.run(gold.schedule_refresh(redis, tasks, [])) is False
assert not tasks.tasks
assert gold.LOCK_KEY not in redis.values
@pytest.mark.parametrize('limited', [False, True])
def test_refresh_preserves_failed_region_and_releases_its_lock(monkeypatch, limited):
redis = FakeRedis()
redis.values[gold.LOCK_KEY] = 'owner'
old = json.dumps(cached_item(age=1900))
redis.values[f'{gold.CACHE_PREFIX}:kua1'] = old
monkeypatch.setattr(gold, 'create_redis', lambda: redis)
monkeypatch.setattr(gold, 'REGIONS', {'kua1': '跨1', 'kua8': '跨8'})
monkeypatch.setattr(gold, 'REGION_INTERVAL', 0)
calls = []
async def fetch(session, region):
calls.append(region)
if region == 'kua1':
raise gold.UpstreamLimited() if limited else ValueError('channel failed')
return cached_item('跨8')
monkeypatch.setattr(gold, 'fetch_region', fetch)
asyncio.run(gold.refresh_cache('owner'))
assert redis.values[f'{gold.CACHE_PREFIX}:kua1'] == old
assert gold.LOCK_KEY not in redis.values
assert redis.values[gold.COOLDOWN_KEY] == '1'
if limited:
assert calls == ['kua1']
else:
assert calls == ['kua1', 'kua8']
assert f'{gold.CACHE_PREFIX}:kua8' in redis.values
def test_refresh_cannot_delete_another_owners_lock(monkeypatch):
redis = FakeRedis()
redis.values[gold.LOCK_KEY] = 'replacement'
monkeypatch.setattr(gold, 'create_redis', lambda: redis)
asyncio.run(gold.refresh_cache('expired-owner'))
assert redis.values[gold.LOCK_KEY] == 'replacement'
def test_route_serializes_existing_contract_and_hides_internal_timestamp(monkeypatch, client):
async def read(redis):
return [{**cached_item(name), 'stale': False} for name in gold.REGIONS.values()]
async def schedule(*args):
return False
monkeypatch.setattr(gold, 'read_cache', read)
monkeypatch.setattr(gold, 'schedule_refresh', schedule)
body = client.get('/api/open/gold/thirdpart/').json()
assert body['code'] == 200
assert len(body['data']) == 9
for row in body['data']:
assert set(row) == {'updataTime', 'region', 'code', 'goldPrice'}
schema = client.app.openapi()
assert schema['paths']['/api/open/gold/thirdpart/']['get']['operationId'] == 'thirdpartGold'
assert 'ThirdPartGoldCache' not in schema['components']['schemas']
def test_realtime_uses_thirdpart_cache_and_returns_only_defined_fields(monkeypatch, client):
async def read(redis):
return [{**cached_item(name), 'stale': False} for name in gold.REGIONS.values()]
schedules = []
async def schedule(redis, tasks, data):
schedules.append(data)
return False
def old_colg_request(*args, **kwargs):
raise AssertionError('realtime must no longer request COLG')
monkeypatch.setattr(gold, 'read_cache', read)
monkeypatch.setattr(gold, 'schedule_refresh', schedule)
monkeypatch.setattr(colg.requests, 'post', old_colg_request)
body = client.get('/api/open/gold/realtime/').json()
assert set(body) == {'code', 'message', 'data'}
assert body['code'] == 200
assert len(body['data']) == 9
assert len(schedules) == 1
for name, row in zip(gold.REGIONS.values(), body['data']):
assert set(row) == {'updataTime', 'region', 'code', 'goldPrice'}
assert row['region'] == name
assert row['code'] == GOLD_REGION_CODES[name]
assert isinstance(row['updataTime'], int)
assert isinstance(row['goldPrice'], (int, float))
def test_realtime_initialization_still_returns_contract_fields(monkeypatch, client):
async def read(redis):
return [{**cached_item('跨2'), 'stale': False}]
async def schedule(*args):
return False
monkeypatch.setattr(gold, 'read_cache', read)
monkeypatch.setattr(gold, 'schedule_refresh', schedule)
body = client.get('/api/open/gold/realtime/').json()
assert body['code'] == 202
assert len(body['data']) == 1
assert set(body['data'][0]) == {'updataTime', 'region', 'code', 'goldPrice'}
assert body['data'][0]['code'] == 'k2'
def test_route_cold_start_and_redis_failure_do_not_block_or_scrape(monkeypatch):
redis = FakeRedis()
tasks = BackgroundTasks()
result = asyncio.run(colg.get_thirdpart_gold(redis, tasks))
assert result.code == 202 and result.data == []
assert len(tasks.tasks) == 1
async def unavailable(redis):
raise ConnectionError('Redis unavailable')
monkeypatch.setattr(gold, 'read_cache', unavailable)
tasks = BackgroundTasks()
result = asyncio.run(colg.get_thirdpart_gold(redis, tasks))
assert result.code == 503 and result.data == []
assert not tasks.tasks
def test_route_partial_cache_reports_initialization_progress(monkeypatch):
async def read(redis):
return [{**cached_item(), 'stale': False}]
async def schedule(*args):
return False
monkeypatch.setattr(gold, 'read_cache', read)
monkeypatch.setattr(gold, 'schedule_refresh', schedule)
result = asyncio.run(colg.get_thirdpart_gold(None, BackgroundTasks()))
assert result.code == 202
assert '1/9' in result.message
assert len(result.data) == 1
def test_background_launcher_finishes_before_refresh_completes(monkeypatch):
async def run():
started, finish = asyncio.Event(), asyncio.Event()
async def refresh(token):
started.set()
await finish.wait()
monkeypatch.setattr(gold, 'refresh_cache', refresh)
tasks = BackgroundTasks()
tasks.add_task(gold.launch_refresh, 'owner')
try:
await asyncio.wait_for(tasks(), .5)
await asyncio.wait_for(started.wait(), .5)
assert len(gold._refresh_tasks) == 1
assert not next(iter(gold._refresh_tasks)).done()
finally:
finish.set()
await gold.close_refresh_tasks()
assert not gold._refresh_tasks
asyncio.run(run())
def test_shutdown_cancels_refresh_and_releases_redis_lock(monkeypatch):
redis = FakeRedis()
redis.values[gold.LOCK_KEY] = 'owner'
monkeypatch.setattr(gold, 'create_redis', lambda: redis)
async def run():
started = asyncio.Event()
async def fetch(session, region):
started.set()
await asyncio.Event().wait()
monkeypatch.setattr(gold, 'fetch_region', fetch)
try:
await gold.launch_refresh('owner')
await asyncio.wait_for(started.wait(), .5)
finally:
await gold.close_refresh_tasks()
assert gold.LOCK_KEY not in redis.values
assert not gold._refresh_tasks
asyncio.run(run())
def test_retry_only_fetches_missing_regions_instead_of_repeating_fresh_pages(monkeypatch):
redis = FakeRedis()
redis.values[gold.LOCK_KEY] = 'owner'
for region, name in gold.REGIONS.items():
if region != 'kua8':
redis.values[f'{gold.CACHE_PREFIX}:{region}'] = json.dumps(cached_item(name))
monkeypatch.setattr(gold, 'create_redis', lambda: redis)
calls = []
async def fetch(session, region):
calls.append(region)
return cached_item(gold.REGIONS[region])
monkeypatch.setattr(gold, 'fetch_region', fetch)
asyncio.run(gold.refresh_cache('owner'))
assert calls == ['kua8']
assert len(asyncio.run(gold.read_cache(redis))) == 9
def test_unresponsive_redis_returns_prompt_error_instead_of_hanging(monkeypatch):
async def run():
connections = []
async def blackhole(reader, writer):
connections.append(writer)
await reader.read()
server = await asyncio.start_server(blackhole, '127.0.0.1', 0)
port = server.sockets[0].getsockname()[1]
monkeypatch.setattr(gold.config, 'REDIS_URL', f'redis://127.0.0.1:{port}/0')
try:
async with gold.create_redis() as redis:
result = await asyncio.wait_for(colg.get_thirdpart_gold(redis, BackgroundTasks()), 2.5)
assert result.code == 503
assert result.data == []
finally:
server.close()
for writer in connections:
writer.close()
await writer.wait_closed()
await server.wait_closed()
asyncio.run(run())
+311
View File
@@ -0,0 +1,311 @@
#!/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())