拆分: douyu_runner.py (-3186行) → core/bind/manual/gold/donate/goods/xpd 七个 Mixin + 入口聚合类, douyu_batch_registry 供 routers 重导出 精英手册兑换对齐 HAR 抓包 (activity_client +379 行): - csrf 复用 Cookie 值不再每次 generateCsrf (HAR 实测全程零 csrf 请求), 仅服务端报 csrf 错误时 force_refresh 重试一次 - pay Referer 补 roomId 对齐浏览器 - 新增手册链路接口: elite_user_info/storedetail/pre_exchange check+confirm/ subscribe/batch_limit/赠品兑换/自动转换 - resolve_exchange_plan: 浏览器兑换按钮状态机路由 (normal/subscribe/ pre_exchange/wait/blocked) - 兑换执行: 人类节奏抖动 → 锁单(火爆重试1次) → 支付分级退避 (频控 30/60/90s 4次上限, 普错 2/5/10/20s 5次上限, 300s 锁单期 15s 余量, 可中断睡眠) 测试: 新增 17 项 (csrf 复用语义/状态机路由/Referer), 全量 94 通过; 未夹带代理改动
280 lines
9.7 KiB
Python
280 lines
9.7 KiB
Python
"""斗鱼任务执行器:公共基础(由 douyu_runner.py 按功能域拆分)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from loguru import logger
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.douyu import DouyuActivityClient
|
|
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot
|
|
from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload
|
|
|
|
# 支付/到账轮询(手册与充值共用)
|
|
DOUYU_PAYMENT_POLL_SECONDS = 600
|
|
DOUYU_PAYMENT_POLL_INTERVAL = 5
|
|
|
|
class DouyuBatchRunnerCore:
|
|
"""斗鱼任务执行器公共基础:批次状态、日志、任务落库与客户端构造。"""
|
|
def __init__(
|
|
self,
|
|
db: Session,
|
|
batch_id: str,
|
|
task_type: str,
|
|
payload: Optional[dict] = None,
|
|
log_queue: Optional[asyncio.Queue] = None,
|
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
|
concurrency: int = 3,
|
|
):
|
|
self.db = db
|
|
self.batch_id = batch_id
|
|
self.task_type = task_type
|
|
self.payload = payload or {}
|
|
self.log_queue = log_queue
|
|
self.loop = loop
|
|
self.concurrency = max(1, min(concurrency, 10))
|
|
self._stop = threading.Event()
|
|
self._counter_lock = threading.Lock()
|
|
self._started = 0
|
|
|
|
def stop(self):
|
|
self._stop.set()
|
|
|
|
def _push_log(self, level: str, message: str):
|
|
if level == "result":
|
|
try:
|
|
douyu_batch_registry.mark_finished(self.batch_id)
|
|
except NameError:
|
|
pass
|
|
if level != "result" and message:
|
|
log_func = getattr(logger, level, logger.info)
|
|
log_func(f"[douyu] {message}")
|
|
if self.log_queue and self.loop:
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.log_queue.put({"level": level, "message": message}),
|
|
self.loop,
|
|
)
|
|
|
|
@staticmethod
|
|
def _account_name(account: Account) -> str:
|
|
return account.nickname or account.username or account.uid or f"#{account.id}"
|
|
|
|
@staticmethod
|
|
def _to_int(value) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _format_wait_time(seconds: int | None) -> str:
|
|
if seconds is None:
|
|
return ""
|
|
seconds = max(0, int(seconds))
|
|
days, rem = divmod(seconds, 86400)
|
|
hours, rem = divmod(rem, 3600)
|
|
minutes, sec = divmod(rem, 60)
|
|
if days:
|
|
return f"{days}天{hours}小时{minutes}分"
|
|
if hours:
|
|
return f"{hours}小时{minutes}分{sec}秒"
|
|
return f"{minutes}分{sec}秒"
|
|
|
|
def _push_task_event(self, task: DouyuTask) -> None:
|
|
"""向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。"""
|
|
if not self.log_queue or not self.loop:
|
|
return
|
|
try:
|
|
payload = douyu_task_payload(task)
|
|
except Exception:
|
|
logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id)
|
|
return
|
|
event = {
|
|
"level": "task",
|
|
"message": "",
|
|
"task": payload,
|
|
}
|
|
asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop)
|
|
|
|
def _mark_task(
|
|
self,
|
|
db: Session,
|
|
task: DouyuTask,
|
|
status: str,
|
|
message: str,
|
|
result: dict | None = None,
|
|
) -> None:
|
|
task.status = status
|
|
task.message = message[:512]
|
|
if result is not None:
|
|
task.result = result
|
|
task.finished_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
self._push_task_event(task)
|
|
|
|
def _update_task_progress(
|
|
self,
|
|
db: Session,
|
|
task: DouyuTask,
|
|
status: str,
|
|
message: str,
|
|
result: dict | None = None,
|
|
) -> None:
|
|
task.status = status
|
|
task.message = message[:512]
|
|
if result is not None:
|
|
task.result = result
|
|
db.commit()
|
|
self._push_task_event(task)
|
|
|
|
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
|
|
now = datetime.now(timezone.utc)
|
|
for raw in goods:
|
|
commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "")
|
|
if not commodity_id:
|
|
continue
|
|
row = (
|
|
db.query(DouyuGoodsSnapshot)
|
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
|
.first()
|
|
)
|
|
score = self._to_int(raw.get("score"))
|
|
if row is None:
|
|
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
|
|
db.add(row)
|
|
row.name = str(raw.get("commodityName") or raw.get("name") or "")
|
|
row.score = score
|
|
row.status = str(raw.get("status") or "")
|
|
row.raw = raw
|
|
row.updated_at = now
|
|
db.commit()
|
|
|
|
def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None:
|
|
now = datetime.now(timezone.utc)
|
|
for raw in goods:
|
|
commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "")
|
|
if not commodity_id:
|
|
continue
|
|
row = (
|
|
db.query(DouyuEsportsGoodsSnapshot)
|
|
.filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id)
|
|
.first()
|
|
)
|
|
if row is None:
|
|
row = DouyuEsportsGoodsSnapshot(commodity_id=commodity_id)
|
|
db.add(row)
|
|
row.name = str(raw.get("commodityName") or raw.get("name") or "")
|
|
row.score = self._to_int(raw.get("score"))
|
|
row.status = str(raw.get("status") or "")
|
|
row.raw = raw
|
|
row.updated_at = now
|
|
db.commit()
|
|
|
|
def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None:
|
|
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
|
|
now = datetime.now(timezone.utc)
|
|
commodity_ids = {
|
|
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
|
for raw in goods
|
|
}
|
|
commodity_ids.discard("")
|
|
query = db.query(DouyuXpdGoodsSnapshot)
|
|
if commodity_ids:
|
|
query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete(
|
|
synchronize_session=False,
|
|
)
|
|
else:
|
|
query.delete(synchronize_session=False)
|
|
for raw in goods:
|
|
commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
|
if not commodity_id:
|
|
continue
|
|
row = (
|
|
db.query(DouyuXpdGoodsSnapshot)
|
|
.filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id)
|
|
.first()
|
|
)
|
|
if row is None:
|
|
row = DouyuXpdGoodsSnapshot(commodity_id=commodity_id)
|
|
db.add(row)
|
|
row.name = str(raw.get("name") or raw.get("sGoodsName") or "")
|
|
row.price = self._to_int(raw.get("price") or raw.get("iPrice"))
|
|
row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice"))
|
|
row.category = str(raw.get("category") or raw.get("iCategoryId") or "")
|
|
goods_left = raw.get("goods_left")
|
|
if goods_left is None:
|
|
goods_left = raw.get("iGoodsLeft")
|
|
row.goods_left = self._to_int(goods_left)
|
|
row.raw = raw
|
|
row.updated_at = now
|
|
db.commit()
|
|
|
|
def _config_info(self, db: Session) -> dict:
|
|
config = ensure_douyu_config(db)
|
|
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS}
|
|
|
|
def _task_payload(self, task: DouyuTask) -> dict:
|
|
result = task.result if isinstance(task.result, dict) else {}
|
|
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {}
|
|
return {**payload, **self.payload}
|
|
|
|
def _client(self, cookie: str) -> DouyuActivityClient:
|
|
return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg))
|
|
|
|
def _sleep_interruptible(self, seconds: float) -> bool:
|
|
"""分段睡眠,任务停止时提前返回;返回 False 表示已被停止。"""
|
|
waited = 0.0
|
|
step = 0.5
|
|
while waited < seconds:
|
|
if self._stop.is_set():
|
|
return False
|
|
time.sleep(min(step, seconds - waited))
|
|
waited += step
|
|
return not self._stop.is_set()
|
|
|
|
|
|
class DouyuBatchRegistry:
|
|
"""管理运行中的斗鱼任务批次。"""
|
|
|
|
def __init__(self):
|
|
self._batches: dict[str, dict] = {}
|
|
|
|
def register(self, batch_id: str, log_queue: asyncio.Queue,
|
|
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
|
|
self._batches[batch_id] = {
|
|
"log_queue": log_queue,
|
|
"loop": loop,
|
|
"runner": runner,
|
|
"finished": False,
|
|
"updated_at": time.time(),
|
|
}
|
|
|
|
def get(self, batch_id: str):
|
|
return self._batches.get(batch_id)
|
|
|
|
def pop(self, batch_id: str):
|
|
return self._batches.pop(batch_id, None)
|
|
|
|
def mark_finished(self, batch_id: str):
|
|
if batch_id in self._batches:
|
|
self._batches[batch_id]["finished"] = True
|
|
self._batches[batch_id]["updated_at"] = time.time()
|
|
|
|
def active_ids(self) -> set[str]:
|
|
return {
|
|
batch_id
|
|
for batch_id, info in self._batches.items()
|
|
if not info.get("finished")
|
|
}
|
|
|
|
|
|
douyu_batch_registry = DouyuBatchRegistry()
|
|
|