update
This commit is contained in:
+5
-4
@@ -39,10 +39,11 @@ uv run ruff check .
|
|||||||
`updataTime` types remain number for realtime and string for official.
|
`updataTime` types remain number for realtime and string for official.
|
||||||
Redis locks prevent duplicate work across workers. Realtime region pages stay
|
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.
|
8 seconds apart. Failures retain the last successful data for up to 24 hours.
|
||||||
- Realtime uses the arithmetic mean of displayed unit prices from rows 4–10
|
- Realtime selects rows 4–10 (slice `[3:10]`), converts each original unit price
|
||||||
(`i.item1`, slice `[3:10]`), in 元/万金币, rounded to 2 decimals. This replaces
|
with `1 / Price`, then averages those ratios in 万金币/元. Rounding to 2 decimals
|
||||||
the earlier 万金币/元 average and uses a new Redis namespace so old units are
|
happens only after averaging; prices are not rounded before conversion. A new
|
||||||
never returned as new prices. No BeautifulSoup or browser dependency is added.
|
Redis namespace excludes the earlier 元/万金币 cache, whose rounded prices
|
||||||
|
cannot recover the original ratios. No new dependency is added.
|
||||||
- Official authentication uses `HELPER_TOKEN` and `HELPER_ID`. Authentication
|
- Official authentication uses `HELPER_TOKEN` and `HELPER_ID`. Authentication
|
||||||
failures (`returnCode=-30003`, explicit token-expiry messages or HTTP 401) can
|
failures (`returnCode=-30003`, explicit token-expiry messages or HTTP 401) can
|
||||||
notify via Bark. Network errors do not trigger token-expiry notifications.
|
notify via Bark. Network errors do not trigger token-expiry notifications.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def get_date_str(timestamp: int) -> str:
|
|||||||
|
|
||||||
@router.get('/gold/realtime/', operation_id='realtimeGold', response_model=Response[list[RealTimeGold]])
|
@router.get('/gold/realtime/', operation_id='realtimeGold', response_model=Response[list[RealTimeGold]])
|
||||||
async def get_real_time_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
|
async def get_real_time_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
|
||||||
"""获取网站金币单价,取页面第4~10条单价均值,元/万金币,两位小数。
|
"""获取网站金币比例,取页面第4~10条的平均比例,万金币/元,两位小数。
|
||||||
|
|
||||||
updataTime为数据所属半小时刻度的数字秒级时间戳,:25/:55提前采集对应:30/:00数据。与thirdpart接口共用Redis缓存和
|
updataTime为数据所属半小时刻度的数字秒级时间戳,:25/:55提前采集对应:30/:00数据。与thirdpart接口共用Redis缓存和
|
||||||
后台刷新任务:30分钟自动更新,也支持按需补齐,逐区间隔8秒;初始化未齐时响应体code=202。
|
后台刷新任务:30分钟自动更新,也支持按需补齐,逐区间隔8秒;初始化未齐时响应体code=202。
|
||||||
@@ -145,10 +145,10 @@ async def get_official_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.
|
|||||||
|
|
||||||
@router.get('/gold/thirdpart/', operation_id='thirdpartGold', response_model=Response[list[RealTimeGold]])
|
@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]]:
|
async def get_thirdpart_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
|
||||||
"""获取跨1~跨8(含跨3A/3B)的第三方单价均值,单位:元/万金币。
|
"""获取跨1~跨8(含跨3A/3B)的第三方平均比例,单位:万金币/元。
|
||||||
|
|
||||||
不额外限制最低总价;汇总渠道报价并按公开页面展示规则排序、去重,
|
不额外限制最低总价;汇总渠道报价并按公开页面展示规则排序、去重,
|
||||||
对i.item1的第4~10条(Python切片[3:10])显示单价取算术平均,
|
对第4~10条(Python切片[3:10])原始单价逐条计算1/Price后取算术平均,
|
||||||
四舍五入至两位小数,不按库存或份数加权。
|
四舍五入至两位小数,不按库存或份数加权。
|
||||||
|
|
||||||
每30分钟自动后台刷新,跨区页面最短间隔8秒(每分钟不超过8次),Redis锁避免重复刷新。
|
每30分钟自动后台刷新,跨区页面最短间隔8秒(每分钟不超过8次),Redis锁避免重复刷新。
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class RealTimeGold(BaseModel):
|
|||||||
region: str = Field(..., description='跨区')
|
region: str = Field(..., description='跨区')
|
||||||
"""跨区"""
|
"""跨区"""
|
||||||
code: str = Field(..., description='跨区code:k1、k2、k3a、k3b、k4、k5、k6、k7、k8')
|
code: str = Field(..., description='跨区code:k1、k2、k3a、k3b、k4、k5、k6、k7、k8')
|
||||||
goldPrice: float = Field(..., ge=0, allow_inf_nan=False, description='页面第4~10条单价均值,元/万金币,四舍五入至两位小数')
|
goldPrice: float = Field(..., ge=0, allow_inf_nan=False, description='页面第4~10条报价分别换算后求均值,1元对应的万金币数量,两位小数')
|
||||||
"""金价"""
|
"""金价"""
|
||||||
|
|
||||||
@field_validator('updataTime')
|
@field_validator('updataTime')
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ REGION_INTERVAL = 8
|
|||||||
LOCK_SECONDS = 15 * 60
|
LOCK_SECONDS = 15 * 60
|
||||||
ERROR_COOLDOWN_SECONDS = 5 * 60
|
ERROR_COOLDOWN_SECONDS = 5 * 60
|
||||||
REDIS_TIMEOUT_SECONDS = 1
|
REDIS_TIMEOUT_SECONDS = 1
|
||||||
# 统计范围和单位已改变,旧缓存的万金币/元均值不能作为新的元/万金币单价返回。
|
# v2缓存已将元/万金币单价舍入至0.01,无法还原比例,改用新缓存重新采集。
|
||||||
CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v2'
|
CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v3'
|
||||||
LOCK_KEY = f'{CACHE_PREFIX}:lock'
|
LOCK_KEY = f'{CACHE_PREFIX}:lock'
|
||||||
COOLDOWN_KEY = f'{CACHE_PREFIX}:cooldown'
|
COOLDOWN_KEY = f'{CACHE_PREFIX}:cooldown'
|
||||||
RELEASE_LOCK = """
|
RELEASE_LOCK = """
|
||||||
@@ -128,7 +128,7 @@ def make_payload(channel: dict, seed: dict, user: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
||||||
"""匹配公开页面 i.item1 的第4~10条,平均显示单价(元/万金币)。"""
|
"""取页面第4~10条报价,逐条按1/Price换算后平均,单位万金币/元。"""
|
||||||
valid = []
|
valid = []
|
||||||
for offer in offers:
|
for offer in offers:
|
||||||
try:
|
try:
|
||||||
@@ -137,7 +137,9 @@ def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
|||||||
number = float(offer['Number'])
|
number = float(offer['Number'])
|
||||||
except (KeyError, TypeError, ValueError, OverflowError):
|
except (KeyError, TypeError, ValueError, OverflowError):
|
||||||
continue
|
continue
|
||||||
if not all(math.isfinite(value) for value in (price, money, number)) or price < 0:
|
if not all(math.isfinite(value) for value in (price, money, number)) or price <= 0:
|
||||||
|
continue
|
||||||
|
if not math.isfinite(1 / price):
|
||||||
continue
|
continue
|
||||||
# 页面默认展示条件,不额外设置price1/price2价格过滤。
|
# 页面默认展示条件,不额外设置price1/price2价格过滤。
|
||||||
if money != -1 and (money * number < 5 or price == 0):
|
if money != -1 and (money * number < 5 or price == 0):
|
||||||
@@ -160,9 +162,9 @@ def first_page_average(offers: list[dict]) -> tuple[float, int]:
|
|||||||
selected = page[SAMPLE_SLICE]
|
selected = page[SAMPLE_SLICE]
|
||||||
if not selected:
|
if not selected:
|
||||||
raise ValueError('YXDR 未返回有效报价')
|
raise ValueError('YXDR 未返回有效报价')
|
||||||
# 页面 Math.floorDigits(price, 5) 先toFixed(6)再删掉末位。
|
# 对原始单价取倒数后求均值,最后才保留两位,避免先舍入单价丢失比例差异。
|
||||||
prices = [float(f"{item['Price']:.6f}"[:-1]) for item in selected]
|
ratios = [1 / item['Price'] for item in selected]
|
||||||
return round(fmean(prices), 2), len(prices)
|
return round(fmean(ratios), 2), len(ratios)
|
||||||
|
|
||||||
|
|
||||||
async def _read_response(resp) -> str:
|
async def _read_response(resp) -> str:
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def test_refresh_slot_advances_five_minutes_before_boundary(hour, minute, second
|
|||||||
|
|
||||||
|
|
||||||
def test_response_types_round_price_values_and_keep_timestamp_types():
|
def test_response_types_round_price_values_and_keep_timestamp_types():
|
||||||
realtime = RealTimeGold(updataTime=stamp(1, 30), region='跨1', code='k1', goldPrice=.016789)
|
realtime = RealTimeGold(updataTime=stamp(1, 30), region='跨1', code='k1', goldPrice=105.26315789)
|
||||||
assert realtime.goldPrice == .02
|
assert realtime.goldPrice == 105.26
|
||||||
assert realtime.updataTime == stamp(1, 30)
|
assert realtime.updataTime == stamp(1, 30)
|
||||||
assert isinstance(realtime.updataTime, int)
|
assert isinstance(realtime.updataTime, int)
|
||||||
official = OfficialGold(updataTime=str(stamp(1, 30)), region='跨1', code='k1',
|
official = OfficialGold(updataTime=str(stamp(1, 30)), region='跨1', code='k1',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def offer(price, url='listing', platform=17, money=10):
|
|||||||
|
|
||||||
def cached_item(region='跨1', age=0):
|
def cached_item(region='跨1', age=0):
|
||||||
fetched_at = time.time() - age
|
fetched_at = time.time() - age
|
||||||
return {'region': region, 'goldPrice': .01, 'sampleCount': 7,
|
return {'region': region, 'goldPrice': 100.0, 'sampleCount': 7,
|
||||||
'code': GOLD_REGION_CODES[region], 'updataTime': refresh_slot(fetched_at),
|
'code': GOLD_REGION_CODES[region], 'updataTime': refresh_slot(fetched_at),
|
||||||
'fetchedAt': fetched_at}
|
'fetchedAt': fetched_at}
|
||||||
|
|
||||||
@@ -54,16 +54,16 @@ class FakeRedis:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def test_average_uses_displayed_unit_prices_at_indices_three_through_nine():
|
def test_average_uses_gold_per_yuan_ratios_at_indices_three_through_nine():
|
||||||
rows = [offer(i / 100) for i in range(1, 12)]
|
rows = [offer(i / 100) for i in range(1, 12)]
|
||||||
assert gold.first_page_average(rows) == (.07, 7)
|
assert gold.first_page_average(rows) == (15.65, 7)
|
||||||
invalid = [offer(0), offer(-1), offer(float('nan')), offer(float('inf')),
|
invalid = [offer(0), offer(-1), offer(float('nan')), offer(float('inf')),
|
||||||
offer(.01, money=-1), offer(None), offer(.01, money=float('nan'))]
|
offer(.01, money=-1), offer(None), offer(.01, money=float('nan'))]
|
||||||
# 页面允许Money=-1的库存占位报价;另行排除不显示的低额订单。
|
# 页面允许Money=-1的库存占位报价;另行排除不显示的低额订单。
|
||||||
invalid.pop(4)
|
invalid.pop(4)
|
||||||
assert gold.first_page_average(invalid + [offer(.01, money=.5)] + rows) == (.07, 7)
|
assert gold.first_page_average(invalid + [offer(.01, money=.5)] + rows) == (15.65, 7)
|
||||||
assert gold.first_page_average([offer(.016789)] * 10) == (.02, 7)
|
assert gold.first_page_average([offer(.016789)] * 10) == (round(1 / .016789, 2), 7)
|
||||||
assert gold.first_page_average(rows[:4]) == (.04, 1)
|
assert gold.first_page_average(rows[:4]) == (25.0, 1)
|
||||||
with pytest.raises(ValueError, match='有效报价'):
|
with pytest.raises(ValueError, match='有效报价'):
|
||||||
gold.first_page_average(invalid)
|
gold.first_page_average(invalid)
|
||||||
with pytest.raises(ValueError, match='有效报价'):
|
with pytest.raises(ValueError, match='有效报价'):
|
||||||
@@ -73,9 +73,15 @@ def test_average_uses_displayed_unit_prices_at_indices_three_through_nine():
|
|||||||
def test_page_deduplicates_before_cutoff_and_preserves_7881_variants():
|
def test_page_deduplicates_before_cutoff_and_preserves_7881_variants():
|
||||||
rows = [offer(.01, 'duplicate', 40), offer(.01, 'duplicate', 40)]
|
rows = [offer(.01, 'duplicate', 40), offer(.01, 'duplicate', 40)]
|
||||||
rows += [offer(i / 100, str(i), 17) for i in range(2, 12)]
|
rows += [offer(i / 100, str(i), 17) for i in range(2, 12)]
|
||||||
assert gold.first_page_average(rows) == (.07, 7)
|
assert gold.first_page_average(rows) == (15.65, 7)
|
||||||
rows = [offer(.01, platform=44)] * 3 + [offer(.04, platform=44)] * 7
|
rows = [offer(.01, platform=44)] * 3 + [offer(.04, platform=44)] * 7
|
||||||
assert gold.first_page_average(rows) == (.04, 7)
|
assert gold.first_page_average(rows) == (25.0, 7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_quote_is_inverted_before_averaging_without_early_rounding():
|
||||||
|
rows = [offer(.008)] * 3 + [offer(.0095)] * 4 + [offer(.01)] * 3
|
||||||
|
assert gold.first_page_average(rows) == (103.01, 7)
|
||||||
|
assert round(1 / ((.0095 * 4 + .01 * 3) / 7), 2) == 102.94
|
||||||
|
|
||||||
|
|
||||||
def test_request_signature_matches_observed_browser_request():
|
def test_request_signature_matches_observed_browser_request():
|
||||||
@@ -135,7 +141,7 @@ def test_region_fetches_its_own_seed_config_and_all_initial_channels():
|
|||||||
assert result['region'] == '跨8'
|
assert result['region'] == '跨8'
|
||||||
assert result['code'] == 'k8'
|
assert result['code'] == 'k8'
|
||||||
assert isinstance(result['updataTime'], int)
|
assert isinstance(result['updataTime'], int)
|
||||||
assert result['goldPrice'] == .01
|
assert result['goldPrice'] == 100.0
|
||||||
assert result['sampleCount'] == 7
|
assert result['sampleCount'] == 7
|
||||||
assert result['updataTime'] == refresh_slot(started_at)
|
assert result['updataTime'] == refresh_slot(started_at)
|
||||||
assert result['fetchedAt'] == started_at
|
assert result['fetchedAt'] == started_at
|
||||||
@@ -184,6 +190,12 @@ def test_invalid_cached_average_is_not_served():
|
|||||||
assert asyncio.run(gold.read_cache(redis)) == []
|
assert asyncio.run(gold.read_cache(redis)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_yuan_per_gold_cache_cannot_be_returned_as_new_ratio():
|
||||||
|
redis = FakeRedis()
|
||||||
|
redis.values['openapi:colg:thirdpart_gold:v2:kua1'] = json.dumps({**cached_item(), 'goldPrice': .01})
|
||||||
|
assert asyncio.run(gold.read_cache(redis)) == []
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_cache_gets_epoch_and_region_code_without_refetching():
|
def test_legacy_cache_gets_epoch_and_region_code_without_refetching():
|
||||||
redis = FakeRedis()
|
redis = FakeRedis()
|
||||||
legacy = cached_item('跨3A')
|
legacy = cached_item('跨3A')
|
||||||
|
|||||||
Reference in New Issue
Block a user