feat:添加colg对接功能
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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=(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user