update
This commit is contained in:
@@ -9,7 +9,6 @@ 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
|
||||
|
||||
@@ -19,6 +18,7 @@ from redis.asyncio.retry import Retry
|
||||
from redis.backoff import NoBackoff
|
||||
|
||||
from api.types.colg import GOLD_REGION_CODES, ThirdPartGoldCache
|
||||
from api.utils.gold_time import refresh_slot
|
||||
from config.main import config
|
||||
|
||||
logger = logging.getLogger('dcalc.thirdpart_gold')
|
||||
@@ -36,6 +36,7 @@ HEADERS = {
|
||||
# 网站公开 DNF 页面已停用/移除的渠道;不请求 VIP 专属渠道。
|
||||
DISABLED_PLATFORMS = {1, 12, 21, 50, 51, 52, 53}
|
||||
PAGE_SIZE = 200
|
||||
SAMPLE_SLICE = slice(3, 10)
|
||||
FRESH_SECONDS = 30 * 60
|
||||
RETENTION_SECONDS = 24 * 60 * 60
|
||||
# 按每分钟最多8次页面访问处理,理论间隔7.5秒,取8秒留出边界余量。
|
||||
@@ -44,7 +45,8 @@ REGION_INTERVAL = 8
|
||||
LOCK_SECONDS = 15 * 60
|
||||
ERROR_COOLDOWN_SECONDS = 5 * 60
|
||||
REDIS_TIMEOUT_SECONDS = 1
|
||||
CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v1'
|
||||
# 统计范围和单位已改变,旧缓存的万金币/元均值不能作为新的元/万金币单价返回。
|
||||
CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v2'
|
||||
LOCK_KEY = f'{CACHE_PREFIX}:lock'
|
||||
COOLDOWN_KEY = f'{CACHE_PREFIX}:cooldown'
|
||||
RELEASE_LOCK = """
|
||||
@@ -126,7 +128,7 @@ def make_payload(channel: dict, seed: dict, user: dict) -> dict:
|
||||
|
||||
|
||||
def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
||||
"""等权平均每条报价的 1/Price(万金币/元),不是 1/平均单价。"""
|
||||
"""匹配公开页面 i.item1 的第4~10条,平均显示单价(元/万金币)。"""
|
||||
valid = []
|
||||
for offer in offers:
|
||||
try:
|
||||
@@ -135,9 +137,12 @@ def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
||||
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)):
|
||||
if not all(math.isfinite(value) for value in (price, money, number)) or price < 0:
|
||||
continue
|
||||
if not math.isfinite(1 / price):
|
||||
# 页面默认展示条件,不额外设置price1/price2价格过滤。
|
||||
if money != -1 and (money * number < 5 or price == 0):
|
||||
continue
|
||||
if money >= 1_000_000 or (offer.get('Seller') or '').startswith('智莹科技'):
|
||||
continue
|
||||
valid.append({**offer, 'Price': price})
|
||||
valid.sort(key=lambda item: (item['Price'], item['PfId'], item.get('BuyUrl') or ''))
|
||||
@@ -152,9 +157,12 @@ def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
||||
page.append(offer)
|
||||
if len(page) == PAGE_SIZE:
|
||||
break
|
||||
if not page:
|
||||
selected = page[SAMPLE_SLICE]
|
||||
if not selected:
|
||||
raise ValueError('YXDR 未返回有效报价')
|
||||
return round(fmean(1 / item['Price'] for item in page), 4), len(page)
|
||||
# 页面 Math.floorDigits(price, 5) 先toFixed(6)再删掉末位。
|
||||
prices = [float(f"{item['Price']:.6f}"[:-1]) for item in selected]
|
||||
return round(fmean(prices), 2), len(prices)
|
||||
|
||||
|
||||
async def _read_response(resp) -> str:
|
||||
@@ -165,7 +173,9 @@ async def _read_response(resp) -> str:
|
||||
return text
|
||||
|
||||
|
||||
async def fetch_region(session: aiohttp.ClientSession, region: str) -> dict:
|
||||
async def fetch_region(session: aiohttp.ClientSession, region: str, *, started_at: float | None = None) -> dict:
|
||||
if started_at is None:
|
||||
started_at = time.time()
|
||||
page_url = f'{BASE_URL}/bijiaqi/dnf/youxibi/{region}'
|
||||
async with session.get(page_url) as resp:
|
||||
page = await _read_response(resp)
|
||||
@@ -210,10 +220,9 @@ async def fetch_region(session: aiohttp.ClientSession, region: str) -> dict:
|
||||
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()}
|
||||
'updataTime': refresh_slot(started_at), 'fetchedAt': started_at}
|
||||
|
||||
|
||||
async def read_cache(redis) -> list[dict]:
|
||||
@@ -229,8 +238,9 @@ async def read_cache(redis) -> list[dict]:
|
||||
continue
|
||||
# 兼容此前缓存的格式化日期及缺少code的记录,无须重新抓取所有跨区。
|
||||
result.append(ThirdPartGoldCache.model_validate({
|
||||
**item, 'updataTime': int(item['fetchedAt']),
|
||||
'code': GOLD_REGION_CODES[REGIONS[region]], 'stale': age >= FRESH_SECONDS,
|
||||
**item, 'updataTime': refresh_slot(item['fetchedAt']),
|
||||
'code': GOLD_REGION_CODES[REGIONS[region]],
|
||||
'stale': refresh_slot(item['fetchedAt']) < refresh_slot(),
|
||||
}).model_dump())
|
||||
except (KeyError, TypeError, ValueError):
|
||||
logger.warning('invalid thirdpart gold cache region=%s', region)
|
||||
@@ -270,6 +280,7 @@ async def refresh_cache(token: str):
|
||||
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
|
||||
started_at = time.time()
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(headers=HEADERS, timeout=timeout,
|
||||
connector=aiohttp.TCPConnector(limit=5)) as session:
|
||||
@@ -281,7 +292,7 @@ async def refresh_cache(token: str):
|
||||
return
|
||||
last_start = time.monotonic()
|
||||
try:
|
||||
item = await fetch_region(session, region)
|
||||
item = await fetch_region(session, region, started_at=started_at)
|
||||
if await redis.get(LOCK_KEY) not in (token, token.encode()):
|
||||
return
|
||||
await redis.set(f'{CACHE_PREFIX}:{region}', json.dumps(item),
|
||||
|
||||
Reference in New Issue
Block a user