1538 lines
69 KiB
Python
1538 lines
69 KiB
Python
"""
|
||
任务运行器
|
||
- 多联赛批量采集
|
||
- 单次采集 / 定时采集
|
||
"""
|
||
import asyncio
|
||
import time
|
||
from typing import Dict, Any, List, Optional
|
||
|
||
from loguru import logger
|
||
|
||
from src.core.config import get_config
|
||
from src.core.error_collector import ErrorCollector
|
||
from src.engine.hybrid_engine import HybridEngine
|
||
from src.parser.data_parser import DongqiudiParser
|
||
from src.storage.database import Database
|
||
|
||
|
||
class TaskRunner:
|
||
"""采集任务运行器"""
|
||
|
||
def __init__(self, engine: Optional[HybridEngine] = None, db: Optional[Database] = None):
|
||
self._engine = engine or HybridEngine()
|
||
self._parser = DongqiudiParser()
|
||
self._db = db or Database()
|
||
self._cfg = get_config()
|
||
self._task_locks: Dict[str, asyncio.Lock] = {}
|
||
self._task_running: Dict[str, bool] = {}
|
||
|
||
async def initialize(self):
|
||
await self._engine.initialize()
|
||
await self._db.connect()
|
||
await self._db.ensure_tables()
|
||
|
||
async def close(self):
|
||
await self._engine.close()
|
||
await self._db.close()
|
||
|
||
def _try_lock_task(self, task_name: str) -> bool:
|
||
"""尝试获取任务锁(非阻塞),成功返回 True,已在运行返回 False"""
|
||
if self._task_running.get(task_name):
|
||
logger.warning(f"[LOCK] 任务 {task_name} 正在执行中,跳过本次触发")
|
||
return False
|
||
self._task_running[task_name] = True
|
||
return True
|
||
|
||
def _unlock_task(self, task_name: str):
|
||
"""释放任务锁"""
|
||
self._task_running[task_name] = False
|
||
|
||
# ── 积分榜采集 ──
|
||
|
||
async def crawl_standings(self, season_id: int, league_name: str = "") -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("standings")
|
||
start = time.time()
|
||
result = {"league": league_name, "season_id": season_id, "success": False}
|
||
|
||
try:
|
||
logger.info(f"开始采集积分榜: {league_name} (season_id={season_id})")
|
||
raw = await self._engine.get_standings(season_id)
|
||
standings = self._parser.parse_standings(raw, season_id)
|
||
|
||
if standings:
|
||
count = await self._db.upsert_standings(standings)
|
||
result.update({"success": True, "count": count, "candidate_count": len(standings), "saved_count": count, "elapsed": time.time() - start})
|
||
else:
|
||
result["error"] = "无数据"
|
||
|
||
await self._db.log_crawl(
|
||
"standings", league_name, result["success"],
|
||
time.time() - start, len(standings),
|
||
)
|
||
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"积分榜采集失败 [{league_name}]: {e}")
|
||
await self._db.log_crawl(
|
||
"standings", league_name, False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
|
||
return result
|
||
|
||
# ── 赛程采集 ──
|
||
|
||
async def crawl_schedule(self, season_id: int, league_name: str = "") -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("schedule")
|
||
start = time.time()
|
||
result = {"league": league_name, "season_id": season_id, "success": False}
|
||
|
||
try:
|
||
logger.info(f"开始采集赛程: {league_name} (season_id={season_id})")
|
||
raw = await self._engine.get_schedule(season_id)
|
||
matches = self._parser.parse_schedule(raw, season_id)
|
||
|
||
if matches:
|
||
count = await self._db.upsert_matches(matches, league_name)
|
||
result.update({"success": True, "count": count, "candidate_count": len(matches), "saved_count": count, "elapsed": time.time() - start})
|
||
else:
|
||
result["error"] = "无数据"
|
||
|
||
await self._db.log_crawl(
|
||
"schedule", league_name, result["success"],
|
||
time.time() - start, len(matches),
|
||
)
|
||
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"赛程采集失败 [{league_name}]: {e}")
|
||
await self._db.log_crawl(
|
||
"schedule", league_name, False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
|
||
return result
|
||
|
||
# ── 比赛菜单采集 ──
|
||
|
||
async def crawl_match_menu(self) -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("match_menu")
|
||
start = time.time()
|
||
try:
|
||
logger.info("采集比赛类型菜单")
|
||
raw = await self._engine.get_match_menu()
|
||
types = self._parser.parse_match_menu(raw)
|
||
if types:
|
||
count = await self._db.upsert_match_types(types)
|
||
return {"success": True, "count": count, "candidate_count": len(types), "saved_count": count, "elapsed": time.time() - start}
|
||
return {"success": False, "error": "无数据"}
|
||
except Exception as e:
|
||
logger.error(f"比赛菜单采集失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
# ── 新闻资讯采集 ──
|
||
|
||
NEWS_TABS = {
|
||
1: "推荐", 56: "热门", 3: "英超", 5: "西甲", 4: "意甲",
|
||
6: "德甲", 57: "法甲", 232: "中超", 37: "NBA", 55: "CBA",
|
||
}
|
||
|
||
NEWS_TAB_CIDS = {
|
||
3: 10, 5: 12, 4: 11, 6: 13, 57: 14, 232: 15, 37: 17, 55: 18,
|
||
}
|
||
|
||
async def crawl_news(self, tab_ids: list = None) -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("news")
|
||
start = time.time()
|
||
result = {"task": "news", "success": False}
|
||
tabs = tab_ids or list(self.NEWS_TABS.keys())
|
||
try:
|
||
total_count = 0
|
||
for tab_id in tabs:
|
||
tab_name = self.NEWS_TABS.get(tab_id, str(tab_id))
|
||
logger.info(f"采集资讯频道: {tab_name} (tab_id={tab_id})")
|
||
raw = await self._engine.get_news(tab_id=tab_id, size=30)
|
||
articles = self._parser.parse_news(raw, tab_name=tab_name)
|
||
if articles:
|
||
cid = self.NEWS_TAB_CIDS.get(tab_id, 0)
|
||
count = await self._db.upsert_articles(articles, cid=cid)
|
||
total_count += count
|
||
logger.info(f" {tab_name}: {count} 条入库 (cid={cid})")
|
||
|
||
result.update({"success": True, "count": total_count, "candidate_count": total_count, "saved_count": total_count, "elapsed": time.time() - start})
|
||
await self._db.log_crawl(
|
||
"news", "dongqiudi", True,
|
||
time.time() - start, total_count,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"新闻采集失败: {e}")
|
||
await self._db.log_crawl(
|
||
"news", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# ── 联赛资讯采集(iPhone API,支持分页)──
|
||
|
||
LEAGUE_NEWS_TABS = {
|
||
56: {"name": "中超", "cid": 15, "pages": 3},
|
||
3: {"name": "英超", "cid": 10, "pages": 3},
|
||
5: {"name": "西甲", "cid": 12, "pages": 3},
|
||
6: {"name": "德甲", "cid": 13, "pages": 3},
|
||
12: {"name": "法甲", "cid": 14, "pages": 3},
|
||
4: {"name": "意甲", "cid": 11, "pages": 3},
|
||
}
|
||
|
||
async def crawl_league_news(self, tab_ids: list = None) -> Dict[str, Any]:
|
||
"""从懂球帝 iPhone tabs API 采集联赛资讯,写入 article 表并设置正确的 cid"""
|
||
ErrorCollector.get().set_task_name("league_news")
|
||
start = time.time()
|
||
result = {"task": "league_news", "success": False}
|
||
tabs = tab_ids or list(self.LEAGUE_NEWS_TABS.keys())
|
||
try:
|
||
total_count = 0
|
||
for tab_id in tabs:
|
||
cfg = self.LEAGUE_NEWS_TABS.get(tab_id, {"name": str(tab_id), "cid": 0, "pages": 1})
|
||
tab_name = cfg["name"]
|
||
cid = cfg["cid"]
|
||
max_pages = cfg["pages"]
|
||
after = 0
|
||
for page in range(1, max_pages + 1):
|
||
logger.info(f"[league_news] 采集 {tab_name} tab_id={tab_id} page={page}")
|
||
raw = await self._engine.get_league_news(tab_id=tab_id, page=page, after=after)
|
||
articles = self._parser.parse_news(raw, tab_name=tab_name)
|
||
if not articles:
|
||
logger.info(f" {tab_name} page={page}: 无数据,停止翻页")
|
||
break
|
||
count = await self._db.upsert_articles(articles, cid=cid)
|
||
total_count += count
|
||
logger.info(f" {tab_name} page={page}: {count} 条入库 (cid={cid})")
|
||
after = raw.get("min", 0)
|
||
if not after or not raw.get("next"):
|
||
break
|
||
|
||
result.update({"success": True, "count": total_count, "candidate_count": total_count, "saved_count": total_count, "elapsed": time.time() - start})
|
||
summary = f"✅ 联赛资讯采集成功: {total_count} 条入库, {time.time()-start:.1f}s"
|
||
logger.info(summary)
|
||
result["summary"] = summary
|
||
await self._db.log_crawl("league_news", "dongqiudi", True, time.time() - start, total_count)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"联赛资讯采集失败: {e}")
|
||
await self._db.log_crawl("league_news", "dongqiudi", False, time.time() - start, error=str(e))
|
||
return result
|
||
|
||
# ── 视频列表采集 ──
|
||
|
||
VIDEO_TABS = {
|
||
233: "中超", 239: "英超", 234: "西甲", 235: "意甲",
|
||
236: "德甲", 237: "更多", 238: "闲情",
|
||
}
|
||
|
||
VIDEO_TAB_CIDS = {
|
||
233: 15, 239: 10, 234: 12, 235: 11, 236: 13,
|
||
}
|
||
|
||
async def crawl_videos(self, tab_ids: list = None) -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("video")
|
||
start = time.time()
|
||
result = {"task": "video", "success": False}
|
||
tabs = tab_ids or list(self.VIDEO_TABS.keys())
|
||
try:
|
||
total_count = 0
|
||
for tab_id in tabs:
|
||
tab_name = self.VIDEO_TABS.get(tab_id, str(tab_id))
|
||
logger.info(f"采集视频频道: {tab_name} (tab_id={tab_id})")
|
||
raw = await self._engine.get_video_list(tab_id=tab_id)
|
||
videos = self._parser.parse_video_list(raw, tab_name=tab_name)
|
||
if videos:
|
||
cid = self.VIDEO_TAB_CIDS.get(tab_id, 0)
|
||
count = await self._db.upsert_articles(videos, cid=cid)
|
||
total_count += count
|
||
logger.info(f" {tab_name}: {count} 条入库 (cid={cid})")
|
||
|
||
result.update({"success": True, "count": total_count, "candidate_count": total_count, "saved_count": total_count, "elapsed": time.time() - start})
|
||
await self._db.log_crawl(
|
||
"video", "dongqiudi", True,
|
||
time.time() - start, total_count,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"视频采集失败: {e}")
|
||
await self._db.log_crawl(
|
||
"video", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# ── 文章详情补全 ──
|
||
|
||
async def crawl_article_content(self, batch_size: int = 10) -> Dict[str, Any]:
|
||
"""从 m.dongqiudi.com 获取文章 HTML 内容,每次取 batch_size 篇,逐篇随机睡眠 1-3s"""
|
||
import random, asyncio
|
||
ErrorCollector.get().set_task_name("article_content")
|
||
start = time.time()
|
||
result = {"task": "article_content", "success": False}
|
||
total_ok = 0
|
||
total_fail = 0
|
||
try:
|
||
articles = await self._db.get_articles_without_content(batch_size)
|
||
if not articles:
|
||
result.update({"success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "elapsed": time.time() - start, "msg": "无待补全文章"})
|
||
await self._db.log_crawl("article_content", "dongqiudi", True, time.time() - start, 0)
|
||
return result
|
||
|
||
logger.info(f"[article_content] 本次获取 {len(articles)} 篇待补全文章")
|
||
for art in articles:
|
||
aid = art["article_id"]
|
||
try:
|
||
html = await self._engine.get_article_html(aid)
|
||
detail = self._parser.parse_article_html(html)
|
||
if detail:
|
||
await self._db.update_article_content(aid, detail)
|
||
total_ok += 1
|
||
logger.info(f" 文章 {aid} 内容补全成功: {len(detail.get('content',''))} chars")
|
||
else:
|
||
await self._db.set_content_retry_max(aid)
|
||
total_fail += 1
|
||
logger.info(f" 文章 {aid} 页面无可提取内容,跳过")
|
||
except Exception as e:
|
||
await self._db.increment_content_retry(aid)
|
||
total_fail += 1
|
||
logger.warning(f" 文章 {aid} 抓取失败: {e} (retry+1)")
|
||
|
||
sleep_sec = random.uniform(1, 3)
|
||
await asyncio.sleep(sleep_sec)
|
||
|
||
result.update({"success": True, "count": total_ok, "candidate_count": len(articles), "saved_count": total_ok, "fail": total_fail, "elapsed": time.time() - start})
|
||
summary = f"✅ 文章内容补全: {total_ok} 成功, {total_fail} 失败, {time.time()-start:.1f}s"
|
||
logger.info(summary)
|
||
result["summary"] = summary
|
||
await self._db.log_crawl("article_content", "dongqiudi", True, time.time() - start, total_ok)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"文章内容补全失败: {e}")
|
||
await self._db.log_crawl("article_content", "dongqiudi", False, time.time() - start, error=str(e))
|
||
return result
|
||
|
||
async def crawl_article_details(self, batch_size: int = 3) -> Dict[str, Any]:
|
||
import random, asyncio
|
||
ErrorCollector.get().set_task_name("content")
|
||
start = time.time()
|
||
result = {"task": "content", "success": False}
|
||
total_ok = 0
|
||
total_processed = 0
|
||
consecutive_fail_batches = 0
|
||
try:
|
||
while True:
|
||
articles = await self._db.get_articles_without_content(batch_size)
|
||
if not articles:
|
||
if total_processed == 0:
|
||
result.update({"success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "elapsed": time.time() - start, "msg": "无待补全文章"})
|
||
else:
|
||
result.update({"success": True, "count": total_ok, "candidate_count": total_processed, "saved_count": total_ok, "total": total_processed, "elapsed": time.time() - start})
|
||
break
|
||
|
||
logger.info(f"补全文章详情: 本批 {len(articles)} 篇 (已完成 {total_ok}/{total_processed})")
|
||
batch_ok = 0
|
||
for art in articles:
|
||
aid = art["article_id"]
|
||
total_processed += 1
|
||
try:
|
||
raw = await self._engine.get_article_detail(aid)
|
||
detail = self._parser.parse_article_detail(raw)
|
||
if detail:
|
||
await self._db.update_article_content(aid, detail)
|
||
total_ok += 1
|
||
batch_ok += 1
|
||
logger.debug(f" 文章 {aid} 已补全: title={detail.get('title','')[:20]}, content={len(detail.get('content',''))} chars")
|
||
else:
|
||
await self._db.increment_content_retry(aid)
|
||
logger.warning(f" 文章 {aid} 未提取到正文 (retry+1)")
|
||
except Exception as e:
|
||
await self._db.increment_content_retry(aid)
|
||
logger.warning(f" 文章 {aid} 抓取失败: {e} (retry+1)")
|
||
|
||
if batch_ok > 0:
|
||
consecutive_fail_batches = 0
|
||
else:
|
||
consecutive_fail_batches += 1
|
||
if consecutive_fail_batches >= 3:
|
||
logger.warning(f"连续 {consecutive_fail_batches} 批全部失败,停止本轮")
|
||
result.update({"success": True, "count": total_ok, "candidate_count": total_processed, "saved_count": total_ok, "total": total_processed, "elapsed": time.time() - start, "msg": "连续失败过多,已停止"})
|
||
break
|
||
|
||
sleep_sec = random.uniform(1, 5)
|
||
logger.info(f" 本批完成({batch_ok}/{len(articles)}成功),随机睡眠 {sleep_sec:.1f}s")
|
||
await asyncio.sleep(sleep_sec)
|
||
|
||
await self._db.log_crawl(
|
||
"content", "dongqiudi", True,
|
||
time.time() - start, total_ok,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"文章详情补全失败: {e}")
|
||
await self._db.log_crawl(
|
||
"content", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# ── 实时比赛采集 ──
|
||
|
||
async def crawl_live(self) -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("live")
|
||
start = time.time()
|
||
result = {"task": "live", "success": False}
|
||
try:
|
||
logger.info("开始采集实时比赛数据")
|
||
now_ts = int(time.time())
|
||
pending = await self._db.get_live_matches(now_ts)
|
||
if not pending:
|
||
result.update({"success": True, "count": 0, "elapsed": time.time() - start, "msg": "无进行中比赛"})
|
||
return result
|
||
|
||
raw = await self._engine.get_live_matches()
|
||
live_data = raw.get("data", {}).get("matches", []) if isinstance(raw, dict) else []
|
||
|
||
live_map = {}
|
||
for m in live_data:
|
||
mid = m.get("match_id")
|
||
if mid:
|
||
live_map[int(mid)] = m
|
||
|
||
updated = 0
|
||
expired = 0
|
||
for match in pending:
|
||
mid = int(match["match_id"])
|
||
if mid in live_map:
|
||
lm = live_map[mid]
|
||
status_raw = str(lm.get("status", ""))
|
||
if "Played" in status_raw or "finished" in status_raw.lower():
|
||
status = 2
|
||
elif "Playing" in status_raw or "live" in status_raw.lower():
|
||
status = 1
|
||
else:
|
||
status = match["status"]
|
||
await self._db.update_match_live(
|
||
mid, status,
|
||
int(lm.get("fs_A") or 0),
|
||
int(lm.get("fs_B") or 0),
|
||
f"{lm.get('hts_A', '')}-{lm.get('hts_B', '')}",
|
||
str(lm.get("minute", "")),
|
||
)
|
||
updated += 1
|
||
else:
|
||
# API中找不到该比赛,通过文字直播判断是否已结束
|
||
if match["status"] == 1:
|
||
is_ended = await self._check_match_ended_by_live_text(mid)
|
||
if is_ended:
|
||
await self._db.update_match_live(
|
||
mid, 2,
|
||
int(match.get("home_score") or 0),
|
||
int(match.get("away_score") or 0),
|
||
str(match.get("half_score") or ""),
|
||
"FT",
|
||
)
|
||
expired += 1
|
||
else:
|
||
# 兜底:开赛超过6小时仍无法确认,强制标记已结束
|
||
elapsed_h = (now_ts - int(match["match_time"])) / 3600
|
||
if elapsed_h >= 6:
|
||
await self._db.update_match_live(
|
||
mid, 2,
|
||
int(match.get("home_score") or 0),
|
||
int(match.get("away_score") or 0),
|
||
str(match.get("half_score") or ""),
|
||
"FT",
|
||
)
|
||
expired += 1
|
||
logger.warning(f"兜底: match_id={mid} 开赛超6小时,强制标记已结束")
|
||
if expired:
|
||
logger.info(f"超时自动标记已结束: {expired} 场")
|
||
|
||
# 采集进行中比赛的文字直播
|
||
live_text_count = 0
|
||
live_ids = [int(m["match_id"]) for m in pending if m["status"] == 1]
|
||
for mid in live_ids:
|
||
try:
|
||
c = await self._crawl_match_live_text(mid)
|
||
live_text_count += c
|
||
except Exception as e:
|
||
logger.warning(f"[live_text] match_id={mid} 失败: {e}")
|
||
if live_text_count:
|
||
logger.info(f"文字直播采集: {live_text_count} 条, {len(live_ids)} 场")
|
||
|
||
result.update({"success": True, "count": updated, "pending": len(pending), "live_text": live_text_count, "elapsed": time.time() - start})
|
||
await self._db.log_crawl(
|
||
"live", "dongqiudi", True,
|
||
time.time() - start, updated,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"实时比赛采集失败: {e}")
|
||
await self._db.log_crawl(
|
||
"live", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# ── 文字直播采集(由 crawl_live 调用) ──
|
||
|
||
async def _check_match_ended_by_live_text(self, match_id: int) -> bool:
|
||
"""通过文字直播最新消息判断比赛是否已结束"""
|
||
try:
|
||
raw = await self._engine.get_live_text(match_id, num=5)
|
||
data = raw.get("data", {})
|
||
items = data.get("data", [])
|
||
end_keywords = ["比赛结束", "全场结束", "已结束"]
|
||
for item in items:
|
||
msg = str(item.get("message", ""))
|
||
if any(kw in msg for kw in end_keywords):
|
||
logger.info(f" [live_text] match_id={match_id} 检测到结束: {msg}")
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f" [live_text] match_id={match_id} 检查结束状态失败: {e}")
|
||
return False
|
||
|
||
async def _crawl_match_live_text(self, match_id: int) -> int:
|
||
"""采集单场比赛的全部文字直播(自动翻页)"""
|
||
total = 0
|
||
raw = await self._engine.get_live_text(match_id, num=200)
|
||
data = raw.get("data", {})
|
||
items = data.get("data", [])
|
||
if items:
|
||
count = await self._db.upsert_match_live_text(match_id, items)
|
||
total += count
|
||
logger.info(f" [live_text] match_id={match_id}: 首页 {count} 条")
|
||
|
||
# 向前翻页获取更早的消息
|
||
next_url = data.get("next")
|
||
page = 1
|
||
while next_url and page < 50:
|
||
try:
|
||
page_raw = await self._engine.get_live_text_page(next_url)
|
||
page_data = page_raw.get("data", {})
|
||
page_items = page_data.get("data", [])
|
||
if not page_items:
|
||
break
|
||
count = await self._db.upsert_match_live_text(match_id, page_items)
|
||
total += count
|
||
next_url = page_data.get("next")
|
||
page += 1
|
||
await asyncio.sleep(0.2)
|
||
except Exception as e:
|
||
logger.warning(f" [live_text] match_id={match_id} 翻页失败: {e}")
|
||
break
|
||
|
||
logger.info(f" [live_text] match_id={match_id}: 共 {total} 条")
|
||
return total
|
||
|
||
# ── 懂球帝赛程页面通用采集(SSR 页面提取 matchList) ──
|
||
|
||
async def _crawl_page_matches(self, task_name: str, tab_id: int, league_name: str, sport_type: int = 1) -> Dict[str, Any]:
|
||
"""通用:从懂球帝移动端赛程页面采集数据(m.dongqiudi.com/match/{tab_id})"""
|
||
ErrorCollector.get().set_task_name(task_name)
|
||
start = time.time()
|
||
result = {"task": task_name, "success": False}
|
||
try:
|
||
logger.info(f"[{task_name}] 开始采集{league_name}赛程页面 tab_id={tab_id}")
|
||
match_list = await self._engine.fetch_match_list_from_page(tab_id)
|
||
|
||
if not match_list:
|
||
result.update({"success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "elapsed": time.time() - start, "msg": "页面无比赛数据"})
|
||
await self._db.log_crawl(task_name, league_name, True, time.time() - start, 0)
|
||
return result
|
||
|
||
for m in match_list:
|
||
m["_round_name"] = m.get("round_name", "")
|
||
m["_gameweek"] = m.get("gameweek", 0)
|
||
|
||
stats = await self._db.upsert_match_data_batch(match_list, league_name, sport_type=sport_type)
|
||
result.update({"success": True, "count": stats["total"], "candidate_count": len(match_list), "saved_count": stats["total"], "inserted": stats["inserted"], "updated": stats["updated"], "unchanged": stats["unchanged"], "total": len(match_list), "elapsed": time.time() - start})
|
||
logger.info(f"[{task_name}] 采集完成: 新增 {stats['inserted']}, 更新 {stats['updated']}, 未变 {stats['unchanged']}, 共 {stats['total']}/{len(match_list)} 场, {time.time() - start:.1f}s")
|
||
await self._db.log_crawl(task_name, league_name, True, time.time() - start, stats["total"])
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"[{task_name}] 采集失败: {e}")
|
||
await self._db.log_crawl(task_name, league_name, False, time.time() - start, error=str(e))
|
||
return result
|
||
|
||
async def crawl_csl_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("csl_match", tab_id=5, league_name="中超", sport_type=1)
|
||
|
||
async def crawl_nba_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("nba_match", tab_id=68, league_name="NBA", sport_type=2)
|
||
|
||
async def crawl_cba_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("cba_match", tab_id=67, league_name="CBA", sport_type=2)
|
||
|
||
async def crawl_epl_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("epl_match", tab_id=6, league_name="英超", sport_type=1)
|
||
|
||
async def crawl_bundesliga_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("bundesliga_match", tab_id=8, league_name="德甲", sport_type=1)
|
||
|
||
async def crawl_laliga_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("laliga_match", tab_id=7, league_name="西甲", sport_type=1)
|
||
|
||
async def crawl_seriea_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("seriea_match", tab_id=9, league_name="意甲", sport_type=1)
|
||
|
||
async def crawl_ligue1_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("ligue1_match", tab_id=30, league_name="法甲", sport_type=1)
|
||
|
||
async def crawl_ucl_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("ucl_match", tab_id=31, league_name="欧冠", sport_type=1)
|
||
|
||
async def crawl_uel_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("uel_match", tab_id=34, league_name="欧联", sport_type=1)
|
||
|
||
async def crawl_tennis_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("tennis_match", tab_id=107, league_name="网球", sport_type=3)
|
||
|
||
async def crawl_esports_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("esports_match", tab_id=65, league_name="电竞", sport_type=4)
|
||
|
||
async def crawl_sports_matches(self) -> Dict[str, Any]:
|
||
return await self._crawl_page_matches("sports_match", tab_id=106, league_name="体坛", sport_type=5)
|
||
|
||
# ── 赛事比赛数据采集(从 la_league 的 sessionid 触发) ──
|
||
|
||
async def crawl_match_data(self) -> Dict[str, Any]:
|
||
ErrorCollector.get().set_task_name("match_data")
|
||
start = time.time()
|
||
result = {"task": "match_data", "success": False}
|
||
try:
|
||
leagues = await self._db.get_league_sessions()
|
||
if not leagues:
|
||
result.update({"success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "elapsed": time.time() - start, "msg": "无配置sessionid的联赛"})
|
||
return result
|
||
|
||
logger.info(f"[match_data] 开始同步,共 {len(leagues)} 个联赛")
|
||
total_matches = 0
|
||
total_rounds = 0
|
||
|
||
for league in leagues:
|
||
try:
|
||
sid = league.get('sessionid')
|
||
api_url = league.get('api') or ''
|
||
if sid:
|
||
# 有 sessionid:通过赛程轮次方式采集
|
||
r_count, m_count = await self._crawl_league_match_data(league)
|
||
total_rounds += r_count
|
||
total_matches += m_count
|
||
elif api_url:
|
||
# 无 sessionid,有 api:通过 tab API 直接获取
|
||
m_count = await self._crawl_league_by_api(league)
|
||
total_matches += m_count
|
||
except Exception as e:
|
||
logger.error(f"[match_data] {league['label']} 同步失败: {e}")
|
||
|
||
result.update({
|
||
"success": True,
|
||
"count": total_matches,
|
||
"candidate_count": total_matches,
|
||
"saved_count": total_matches,
|
||
"rounds": total_rounds,
|
||
"elapsed": time.time() - start,
|
||
})
|
||
await self._db.log_crawl(
|
||
"match_data", "dongqiudi", True,
|
||
time.time() - start, total_matches,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"[match_data] 采集失败: {e}")
|
||
await self._db.log_crawl(
|
||
"match_data", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
async def _crawl_league_by_api(self, league: dict) -> int:
|
||
"""通过 la_league.api (tab API) 直接获取比赛列表,返回 match_count"""
|
||
label = league['label']
|
||
api_url = league['api']
|
||
logger.info(f"[{label}] 通过 tab API 获取比赛...")
|
||
|
||
raw = await self._engine.get_league_tab_matches(api_url)
|
||
matches = raw.get("list", []) if isinstance(raw, dict) else []
|
||
if not matches:
|
||
logger.info(f"[{label}] tab API 无比赛数据")
|
||
return 0
|
||
|
||
# 过滤:只保留未结束的比赛(Fixture/Playing),已结束的跳过避免重复写入
|
||
pending = [m for m in matches if m.get("status") != "Played"]
|
||
if not pending:
|
||
logger.info(f"[{label}] 全部已结束,跳过")
|
||
return 0
|
||
skipped = len(matches) - len(pending)
|
||
if skipped:
|
||
logger.info(f"[{label}] 过滤 {skipped} 场已结束,剩余 {len(pending)} 场")
|
||
|
||
for m in pending:
|
||
m["_round_name"] = m.get("round_name", "")
|
||
m["_gameweek"] = m.get("gameweek", 0)
|
||
|
||
st = int(league.get('sport_type') or 1)
|
||
stats = await self._db.upsert_match_data_batch(pending, label, sport_type=st)
|
||
logger.info(f"[{label}] tab API 同步完成: 新增 {stats['inserted']}, 更新 {stats['updated']}, 未变 {stats['unchanged']}, 共 {stats['total']} 场")
|
||
return stats["total"]
|
||
|
||
async def _crawl_league_match_data(self, league: dict) -> tuple:
|
||
"""采集单个联赛的所有轮次比赛数据,返回 (round_count, match_count)"""
|
||
season_id = int(league['sessionid'])
|
||
league_id = int(league['id'])
|
||
label = league['label']
|
||
|
||
logger.info(f"[{label}] season_id={season_id} 获取赛程...")
|
||
raw = await self._engine.get_schedule(season_id)
|
||
|
||
content = raw.get("content", {})
|
||
rounds = content.get("rounds", [])
|
||
logger.info(f"[{label}] 共 {len(rounds)} 轮次")
|
||
|
||
# 保存轮次
|
||
round_rows = []
|
||
for r in rounds:
|
||
params = r.get("params", {})
|
||
round_rows.append({
|
||
"league_id": league_id,
|
||
"season_id": season_id,
|
||
"round_id": params.get("round_id", 0),
|
||
"round_name": r.get("name", ""),
|
||
"gameweek": params.get("gameweek", 0),
|
||
"url": r.get("url", ""),
|
||
})
|
||
r_count = await self._db.upsert_match_rounds(round_rows)
|
||
|
||
# 遍历每个轮次获取比赛(跳过已完结或未开始的轮次)
|
||
m_count = 0
|
||
skipped = 0
|
||
for r in rounds:
|
||
round_url = r.get("url", "")
|
||
if not round_url:
|
||
continue
|
||
|
||
round_name = r.get("name", "")
|
||
|
||
# 检查是否可跳过:该轮已有数据且全部 Played 或全部 Fixture
|
||
try:
|
||
if await self._db.check_round_skip(round_name, label):
|
||
skipped += 1
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
round_raw = await self._engine.get_schedule_round(round_url)
|
||
matches = round_raw.get("content", {}).get("matches", [])
|
||
if not matches:
|
||
continue
|
||
|
||
params = r.get("params", {})
|
||
gameweek = params.get("gameweek", 0)
|
||
|
||
for m in matches:
|
||
m["_round_name"] = round_name
|
||
m["_gameweek"] = gameweek
|
||
|
||
st = int(league.get('sport_type') or 1)
|
||
stats = await self._db.upsert_match_data_batch(matches, label, sport_type=st)
|
||
m_count += stats["total"]
|
||
logger.info(f" [{label}] {round_name}: 新增 {stats['inserted']}, 更新 {stats['updated']}, 未变 {stats['unchanged']}, 共 {stats['total']} 场")
|
||
except Exception as e:
|
||
logger.warning(f" [{label}] 轮次 {r.get('name','')} 失败: {e}")
|
||
|
||
await asyncio.sleep(0.2)
|
||
|
||
if skipped:
|
||
logger.info(f"[{label}] 跳过 {skipped} 个已完结/未开始轮次")
|
||
|
||
logger.info(f"[{label}] 同步完成: {r_count} 轮, {m_count} 场比赛")
|
||
return r_count, m_count
|
||
|
||
# ── 超时比赛收尾(采集详情 + 标记已结束) ──
|
||
|
||
async def crawl_match_finish(self) -> Dict[str, Any]:
|
||
"""
|
||
分层兜底处理超时未结束比赛(每次处理1场):
|
||
从 m.dongqiudi.com 页面抓取SSR数据获取比赛详情
|
||
- 6~12h: 拉详情,有确认结束才标记,否则跳过
|
||
- 12~24h: 拉详情后无论如何标记结束
|
||
- 24h+: 拉详情后强制标记结束
|
||
"""
|
||
ErrorCollector.get().set_task_name("match_finish")
|
||
start = time.time()
|
||
result = {"task": "match_finish", "success": False}
|
||
try:
|
||
overdue = await self._db.get_overdue_matches(hours=6, limit=1)
|
||
if not overdue:
|
||
result.update({"success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "elapsed": time.time() - start, "msg": "无超时未结束比赛"})
|
||
return result
|
||
|
||
now_ts = int(time.time())
|
||
finished = 0
|
||
detail_ok = 0
|
||
skipped = 0
|
||
|
||
for match in overdue:
|
||
mid = int(match["match_id"])
|
||
match_time = int(match.get("match_time") or 0)
|
||
elapsed_h = (now_ts - match_time) / 3600 if match_time else 999
|
||
home_score = int(match.get("home_score") or 0)
|
||
away_score = int(match.get("away_score") or 0)
|
||
half_score = str(match.get("half_score") or "")
|
||
got_detail = False
|
||
api_confirmed_ended = False
|
||
|
||
logger.info(
|
||
f" [match_finish] 处理 match_id={mid} "
|
||
f"{match.get('home_team','')}-{match.get('away_team','')} "
|
||
f"league={match.get('league_name','')} "
|
||
f"db_status={match.get('status')} db_score={home_score}-{away_score} "
|
||
f"elapsed={elapsed_h:.1f}h"
|
||
)
|
||
|
||
# 从懂球帝移动端页面抓取比赛详情
|
||
try:
|
||
header_data = await self._engine.fetch_match_detail_from_page(mid)
|
||
match_obj = header_data.get("match", {})
|
||
info_obj = header_data.get("info", {})
|
||
|
||
if match_obj:
|
||
got_detail = True
|
||
# 判断页面返回的状态是否已结束
|
||
page_status = str(match_obj.get("status", "")).lower()
|
||
if "played" in page_status or "finished" in page_status or "ended" in page_status:
|
||
api_confirmed_ended = True
|
||
logger.info(f" [match_finish] page_status='{match_obj.get('status','')}' confirmed_ended={api_confirmed_ended}")
|
||
# 更新比分: team_A.fs / team_B.fs
|
||
team_a = match_obj.get("team_A", {})
|
||
team_b = match_obj.get("team_B", {})
|
||
fs_a = team_a.get("fs") or team_a.get("score")
|
||
fs_b = team_b.get("fs") or team_b.get("score")
|
||
if fs_a:
|
||
home_score = int(fs_a)
|
||
if fs_b:
|
||
away_score = int(fs_b)
|
||
hts_a = team_a.get("hts", "")
|
||
hts_b = team_b.get("hts", "")
|
||
if hts_a or hts_b:
|
||
half_score = f"{hts_a}-{hts_b}"
|
||
# 保存事件
|
||
events = info_obj.get("events") or []
|
||
if events:
|
||
ec = await self._db.upsert_match_events(mid, events)
|
||
if ec:
|
||
detail_ok += 1
|
||
logger.info(f" [match_finish] match_id={mid} 保存 {ec} 条事件")
|
||
else:
|
||
logger.info(f" [match_finish] match_id={mid} 页面match数据为空")
|
||
except Exception as e:
|
||
logger.warning(f" [match_finish] match_id={mid} 获取详情失败: {type(e).__name__}: {e}")
|
||
|
||
# 决策:是否标记结束
|
||
should_finish = False
|
||
reason = ""
|
||
if elapsed_h >= 24:
|
||
should_finish = True
|
||
reason = "超过24h强制结束"
|
||
elif elapsed_h >= 12:
|
||
should_finish = True
|
||
reason = "超过12h强制结束"
|
||
else:
|
||
# 6~12h: 仅在API确认结束 或 拉不到详情时标记
|
||
if api_confirmed_ended:
|
||
should_finish = True
|
||
reason = "API确认已结束"
|
||
elif not got_detail:
|
||
should_finish = True
|
||
reason = "API无数据/请求失败"
|
||
else:
|
||
skipped += 1
|
||
reason = "API有数据但未结束(可能延期)"
|
||
logger.info(f" [match_finish] 决策: 跳过 reason={reason}")
|
||
|
||
if should_finish:
|
||
logger.info(f" [match_finish] 决策: 标记结束 reason={reason} score={home_score}-{away_score} half={half_score}")
|
||
await self._db.finish_match(mid, home_score, away_score, half_score)
|
||
finished += 1
|
||
logger.info(f" [match_finish] ✔ match_id={mid} 已写入数据库 status=2")
|
||
|
||
result.update({
|
||
"success": True,
|
||
"count": finished,
|
||
"candidate_count": len(overdue),
|
||
"saved_count": finished,
|
||
"detail_ok": detail_ok,
|
||
"skipped": skipped,
|
||
"elapsed": time.time() - start,
|
||
})
|
||
await self._db.log_crawl(
|
||
"match_finish", "dongqiudi", True,
|
||
time.time() - start, finished,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"[match_finish] 失败: {e}")
|
||
await self._db.log_crawl(
|
||
"match_finish", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# ── 实时比赛详情更新(通用,覆盖所有赛事) ──
|
||
|
||
_SPORT_CMP_TYPE = {1: "soccer", 2: "basketball", 3: "tennis", 4: "esports", 5: "sports"}
|
||
|
||
async def crawl_live_detail(self) -> Dict[str, Any]:
|
||
"""
|
||
从 la_match 表查询已到开赛时间且未结束的比赛,
|
||
通过 situation API 获取结构化 JSON 数据,更新比分、状态和事件。
|
||
"""
|
||
ErrorCollector.get().set_task_name("live_detail")
|
||
start = time.time()
|
||
result = {"task": "live_detail", "success": False}
|
||
|
||
try:
|
||
now_ts = int(time.time())
|
||
pending = await self._db.get_live_matches(now_ts)
|
||
if not pending:
|
||
result.update({"success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "elapsed": time.time() - start, "msg": "无进行中比赛"})
|
||
return result
|
||
|
||
logger.info(f"[live_detail] 待更新比赛: {len(pending)} 场")
|
||
|
||
updated = 0
|
||
finished = 0
|
||
failed = 0
|
||
live_text_total = 0
|
||
|
||
for match in pending:
|
||
mid = int(match["match_id"])
|
||
|
||
try:
|
||
raw = await self._engine.get_match_situation(mid)
|
||
logger.debug(f"[live_detail] match_id={mid} raw keys={list(raw.keys())}")
|
||
match_obj = raw.get("match", {})
|
||
if not match_obj:
|
||
logger.warning(f"[live_detail] match_id={mid} situation 返回无 match 数据")
|
||
failed += 1
|
||
continue
|
||
|
||
info_obj_dbg = raw.get("info", {})
|
||
logger.info(f"[live_detail] match_id={mid} info keys={list(info_obj_dbg.keys()) if info_obj_dbg else []}, match keys={list(match_obj.keys())[:20]}")
|
||
|
||
# 阵容:只采集一次(通过独立 lineup API)
|
||
if not await self._db.has_match_lineup(mid):
|
||
try:
|
||
lu_raw = await self._engine.get_match_lineup(mid)
|
||
logger.debug(f"[live_detail] match_id={mid} lineup raw keys={list(lu_raw.keys())}")
|
||
lu_info = lu_raw.get("info", lu_raw)
|
||
if isinstance(lu_info, dict):
|
||
logger.debug(f"[live_detail] match_id={mid} lineup info keys={list(lu_info.keys())}")
|
||
lineup_list = lu_info.get("lineup", [])
|
||
sub_list = lu_info.get("sub", [])
|
||
logger.info(f"[live_detail] match_id={mid} lineup={len(lineup_list)} sub={len(sub_list)}")
|
||
if lineup_list:
|
||
flat_lineup = self._flatten_situation_lineup(lineup_list, sub_list)
|
||
if flat_lineup:
|
||
lc = await self._db.upsert_match_lineup(mid, flat_lineup)
|
||
if lc:
|
||
logger.info(f"[live_detail] match_id={mid} 保存阵容 {lc} 人")
|
||
except Exception as lu_e:
|
||
logger.warning(f"[live_detail] match_id={mid} 阵容采集失败: {lu_e}")
|
||
|
||
# 解析状态
|
||
page_status = str(match_obj.get("status", "")).lower()
|
||
if "played" in page_status or "finished" in page_status or "ended" in page_status:
|
||
new_status = 2
|
||
elif "playing" in page_status or "live" in page_status:
|
||
new_status = 1
|
||
else:
|
||
new_status = match["status"]
|
||
|
||
# 解析比分
|
||
team_a = match_obj.get("team_A", {})
|
||
team_b = match_obj.get("team_B", {})
|
||
home_score = int(team_a.get("fs") or team_a.get("score") or match.get("home_score") or 0)
|
||
away_score = int(team_b.get("fs") or team_b.get("score") or match.get("away_score") or 0)
|
||
|
||
# 半场比分
|
||
hts_a = str(team_a.get("hts", ""))
|
||
hts_b = str(team_b.get("hts", ""))
|
||
half_score = f"{hts_a}-{hts_b}" if (hts_a or hts_b) else str(match.get("half_score") or "")
|
||
|
||
# 比赛分钟 / 阶段
|
||
period = str(match_obj.get("period", ""))
|
||
minute = str(match_obj.get("minute", ""))
|
||
if new_status == 2:
|
||
minute = "FT"
|
||
elif period == "HT":
|
||
minute = "HT"
|
||
|
||
await self._db.update_match_live(
|
||
mid, new_status, home_score, away_score, half_score, minute,
|
||
)
|
||
updated += 1
|
||
if new_status == 2:
|
||
finished += 1
|
||
|
||
logger.info(
|
||
f"[live_detail] match_id={mid} "
|
||
f"{match.get('home_team','')}-{match.get('away_team','')} "
|
||
f"status={new_status} score={home_score}-{away_score} min={minute}"
|
||
)
|
||
|
||
# 保存事件(进行中和结束都采集)
|
||
info_obj = raw.get("info", {})
|
||
situation_events = info_obj.get("events", {})
|
||
if situation_events:
|
||
flat_events = self._flatten_situation_events(situation_events)
|
||
if flat_events:
|
||
ec = await self._db.upsert_match_events(mid, flat_events)
|
||
if ec:
|
||
logger.info(f"[live_detail] match_id={mid} 保存 {ec} 条事件")
|
||
|
||
# 提取技术统计并更新
|
||
tech_stats = info_obj.get("statistics") or info_obj.get("tech_stats") or info_obj.get("stats") or {}
|
||
if not tech_stats:
|
||
tech_stats = match_obj.get("statistics") or match_obj.get("tech_stats") or match_obj.get("stats") or {}
|
||
if tech_stats:
|
||
await self._update_match_tech_stats(mid, tech_stats)
|
||
|
||
# 进行中的比赛采集文字直播
|
||
if new_status == 1:
|
||
try:
|
||
lt_count = await self._crawl_match_live_text(mid)
|
||
if lt_count:
|
||
live_text_total += lt_count
|
||
except Exception as lt_e:
|
||
logger.warning(f"[live_detail] match_id={mid} 文字直播采集失败: {lt_e}")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[live_detail] match_id={mid} 失败: {type(e).__name__}: {e}")
|
||
failed += 1
|
||
|
||
await asyncio.sleep(0.5)
|
||
|
||
result.update({
|
||
"success": True,
|
||
"count": updated,
|
||
"candidate_count": len(pending),
|
||
"saved_count": updated,
|
||
"finished": finished,
|
||
"failed": failed,
|
||
"pending": len(pending),
|
||
"live_text": live_text_total,
|
||
"elapsed": time.time() - start,
|
||
})
|
||
logger.info(
|
||
f"[live_detail] 完成: 更新 {updated} 场, 结束 {finished} 场, "
|
||
f"失败 {failed} 场, 文字直播 {live_text_total} 条, "
|
||
f"共 {len(pending)} 场, {time.time() - start:.1f}s"
|
||
)
|
||
await self._db.log_crawl(
|
||
"live_detail", "dongqiudi", True,
|
||
time.time() - start, updated,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"[live_detail] 任务失败: {e}")
|
||
await self._db.log_crawl(
|
||
"live_detail", "dongqiudi", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# 进球图标关键字 → 事件类型映射
|
||
_EVENT_PIC_MAP = {
|
||
"rBUC6GcM116": "goal", # 进球图标
|
||
"ChMf8FxtCcu": "assist", # 助攻图标
|
||
}
|
||
|
||
async def _update_match_tech_stats(self, match_id: int, tech_stats: dict):
|
||
"""从 situation API 的 statistics 中提取技术统计,更新到 la_match。
|
||
statistics 格式: {"team_A": {...}, "team_B": {...}, "list": [{"type": "控球率", "team_A": {"value": 57}, "team_B": {"value": 43}}, ...]}
|
||
"""
|
||
try:
|
||
stat_list = tech_stats.get("list", []) if isinstance(tech_stats, dict) else []
|
||
if not stat_list:
|
||
logger.debug(f"[live_detail] match_id={match_id} statistics 无 list 字段")
|
||
return
|
||
|
||
import json
|
||
compact_list = []
|
||
for item in stat_list:
|
||
compact_list.append({
|
||
"type": item.get("type", ""),
|
||
"home": item.get("team_A", {}).get("value", ""),
|
||
"away": item.get("team_B", {}).get("value", ""),
|
||
})
|
||
updates = {"tech_stats": json.dumps(compact_list, ensure_ascii=False)}
|
||
await self._db.update_match_stats(match_id, updates)
|
||
logger.info(f"[live_detail] match_id={match_id} 更新 {len(compact_list)} 项技术统计")
|
||
except Exception as e:
|
||
logger.warning(f"[live_detail] match_id={match_id} 技术统计更新失败: {e}")
|
||
|
||
def _flatten_situation_events(self, events_dict: dict) -> list:
|
||
"""
|
||
将 situation API 的按分钟分组事件展开为 upsert_match_events 期望的扁平列表。
|
||
events_dict 格式: {"3": {"minute": "3", "teamAEvents": [...], "teamBEvents": [...]}, ...}
|
||
"""
|
||
flat = []
|
||
for minute_key, evt_group in events_dict.items():
|
||
minute = str(evt_group.get("minute", minute_key))
|
||
for side, team_events in [("1", evt_group.get("teamAEvents", [])), ("2", evt_group.get("teamBEvents", []))]:
|
||
for ev in team_events:
|
||
pic_url = str(ev.get("event_pic", ""))
|
||
event_type = "goal"
|
||
for pic_key, etype in self._EVENT_PIC_MAP.items():
|
||
if pic_key in pic_url:
|
||
event_type = etype
|
||
break
|
||
flat.append({
|
||
"minute": minute,
|
||
"type": event_type,
|
||
"team_side": int(side),
|
||
"player_name": ev.get("person", ""),
|
||
"description": "",
|
||
})
|
||
return flat
|
||
|
||
@staticmethod
|
||
def _flatten_situation_lineup(lineup: list, sub: list) -> list:
|
||
"""
|
||
将 situation API 的 lineup + sub 数据展平为 upsert_match_lineup 期望的扁平列表。
|
||
lineup 中每个元素含 team_A / team_B 对象(首发),sub 中同理(替补)。
|
||
"""
|
||
flat = []
|
||
for row in lineup:
|
||
for side_key, team_side in [("team_A", 1), ("team_B", 2)]:
|
||
p = row.get(side_key)
|
||
if not p or not isinstance(p, dict) or not p.get("person_id"):
|
||
continue
|
||
flat.append({
|
||
"team_side": team_side,
|
||
"is_starter": 1,
|
||
"person_id": p.get("person_id", 0),
|
||
"person_name": p.get("person", ""),
|
||
"person_logo": p.get("person_logo", ""),
|
||
"shirt_number": str(p.get("shirtnumber", "")),
|
||
"captain": int(p.get("captain", 0)),
|
||
"position": p.get("position", ""),
|
||
"position_x": p.get("position_x", "") or "",
|
||
"position_y": p.get("position_y", "") or "",
|
||
"formation_place": int(p.get("formation_place", 0)),
|
||
})
|
||
for row in sub:
|
||
for side_key, team_side in [("team_A", 1), ("team_B", 2)]:
|
||
p = row.get(side_key)
|
||
if not p or not isinstance(p, dict) or not p.get("person_id"):
|
||
continue
|
||
flat.append({
|
||
"team_side": team_side,
|
||
"is_starter": 0,
|
||
"person_id": p.get("person_id", 0),
|
||
"person_name": p.get("person", ""),
|
||
"person_logo": p.get("person_logo", ""),
|
||
"shirt_number": str(p.get("shirtnumber", "")),
|
||
"captain": int(p.get("captain", 0)),
|
||
"position": p.get("position", ""),
|
||
"position_x": p.get("position_x", "") or "",
|
||
"position_y": p.get("position_y", "") or "",
|
||
"formation_place": int(p.get("formation_place", 0)),
|
||
})
|
||
return flat
|
||
|
||
# ── 六合彩开奖采集 ──
|
||
|
||
LOTTERY_LOCK_FILE = "/tmp/lottery_crawler.lock"
|
||
LOTTERY_POLL_INTERVAL = 3 # 开奖中轮询间隔(秒)
|
||
LOTTERY_POLL_TIMEOUT = 600 # 单期最长轮询时间(秒)
|
||
|
||
async def crawl_lottery(self) -> Dict[str, Any]:
|
||
"""
|
||
定时任务入口(每分钟被 cron 触发):
|
||
1. 无待开奖记录 → 首次拉取当前期 + 创建下期占位 → 退出
|
||
2. 有待开奖记录但未到开奖时间 → 直接退出
|
||
3. 已到开奖时间 → 文件锁防并发 → 内部每3秒轮询直到获取完整号码+下期信息 → 退出
|
||
"""
|
||
import fcntl
|
||
from datetime import datetime as _dt
|
||
|
||
ErrorCollector.get().set_task_name("lottery")
|
||
start = time.time()
|
||
result = {"task": "lottery", "success": False}
|
||
sources = self._cfg.lottery.sources
|
||
if not sources:
|
||
result.update({"success": True, "count": 0, "msg": "无六合彩数据源配置"})
|
||
return result
|
||
|
||
now = _dt.now()
|
||
current_year = now.year
|
||
num_mapping = await self._db.get_lottery_number_mapping(current_year)
|
||
if not num_mapping:
|
||
logger.warning(f"[lottery] {current_year}年号码映射表为空")
|
||
|
||
src_map = {s.category_id: s for s in sources}
|
||
|
||
# 获取待开奖记录(status=0)
|
||
pending_draws = await self._db.get_pending_lottery_draws()
|
||
|
||
# 无待开奖记录 → 首次拉取
|
||
if not pending_draws:
|
||
logger.info("[lottery] 无待开奖记录,首次拉取...")
|
||
count = await self._lottery_first_fetch(sources, num_mapping, current_year)
|
||
result.update({"success": True, "count": count, "elapsed": time.time() - start})
|
||
if count > 0:
|
||
await self._db.log_crawl("lottery", "lottery", True, time.time() - start, count)
|
||
return result
|
||
|
||
# 检查是否有到了开奖时间的记录
|
||
need_poll = []
|
||
for draw in pending_draws:
|
||
src = src_map.get(draw['category_id'])
|
||
if not src:
|
||
continue
|
||
draw_time = draw.get('draw_time')
|
||
if not draw_time:
|
||
logger.warning(f"[lottery] {src.name} 第{draw['period']}期 无开奖时间,跳过")
|
||
continue
|
||
if isinstance(draw_time, str):
|
||
draw_time = _dt.strptime(draw_time, '%Y-%m-%d %H:%M:%S')
|
||
if now < draw_time:
|
||
remain = (draw_time - now).total_seconds()
|
||
logger.info(f"[lottery] {src.name} 第{draw['period']}期 距开奖还有{remain:.0f}秒,跳过")
|
||
continue
|
||
need_poll.append((draw, src))
|
||
|
||
if not need_poll:
|
||
result.update({"success": True, "count": 0, "msg": "未到开奖时间"})
|
||
return result
|
||
|
||
# 有需要采集的 → 获取文件锁防并发
|
||
lock_fd = None
|
||
try:
|
||
lock_fd = open(self.LOTTERY_LOCK_FILE, 'w')
|
||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except (IOError, OSError):
|
||
logger.info("[lottery] 另一个采集进程正在运行,退出")
|
||
result.update({"success": True, "count": 0, "msg": "已有采集进程运行中"})
|
||
if lock_fd:
|
||
lock_fd.close()
|
||
return result
|
||
|
||
try:
|
||
total_count = 0
|
||
for draw, src in need_poll:
|
||
count = await self._lottery_poll_one(src, draw['period'], num_mapping, current_year)
|
||
total_count += count
|
||
result.update({"success": True, "count": total_count, "elapsed": time.time() - start})
|
||
if total_count > 0:
|
||
await self._db.log_crawl("lottery", "lottery", True, time.time() - start, total_count)
|
||
except Exception as e:
|
||
logger.error(f"[lottery] 采集异常: {e}")
|
||
result.update({"success": False, "error": str(e)})
|
||
finally:
|
||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||
lock_fd.close()
|
||
|
||
return result
|
||
|
||
async def _lottery_poll_one(self, src, expected_period: str, num_mapping: dict, year: int) -> int:
|
||
"""对单个彩种轮询,每3秒请求一次API,直到获取完整号码+下期信息或超时"""
|
||
poll_start = time.time()
|
||
attempt = 0
|
||
period_num = expected_period[len(str(year)):]
|
||
|
||
logger.info(f"[lottery] {src.name} 第{expected_period}期 已到开奖时间,开始轮询...")
|
||
|
||
while (time.time() - poll_start) < self.LOTTERY_POLL_TIMEOUT:
|
||
attempt += 1
|
||
try:
|
||
raw_text = await self._engine.get_lottery_result(src.url)
|
||
if attempt <= 3 or attempt % 20 == 0:
|
||
logger.info(f"[lottery] {src.name} 第{attempt}次请求 响应: {raw_text[:150]}")
|
||
|
||
parsed = self._parse_lottery_result(raw_text)
|
||
if not parsed:
|
||
await asyncio.sleep(self.LOTTERY_POLL_INTERVAL)
|
||
continue
|
||
|
||
api_period = parsed['period'].strip()
|
||
if api_period.zfill(3) != period_num.zfill(3):
|
||
await asyncio.sleep(self.LOTTERY_POLL_INTERVAL)
|
||
continue
|
||
|
||
numbers = parsed['numbers']
|
||
if len(numbers) < 7:
|
||
await asyncio.sleep(self.LOTTERY_POLL_INTERVAL)
|
||
continue
|
||
|
||
has_next = parsed.get('next_period') and parsed.get('next_month') and parsed.get('next_day')
|
||
if not has_next:
|
||
await asyncio.sleep(self.LOTTERY_POLL_INTERVAL)
|
||
continue
|
||
|
||
# 完整数据,入库
|
||
count = await self._lottery_save_result(src, parsed, num_mapping, year)
|
||
elapsed = time.time() - poll_start
|
||
logger.info(f"[lottery] ✅ {src.name} 第{expected_period}期采集完成 "
|
||
f"(轮询{attempt}次, 耗时{elapsed:.1f}s)")
|
||
return count
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[lottery] {src.name} 请求失败: {e}")
|
||
|
||
await asyncio.sleep(self.LOTTERY_POLL_INTERVAL)
|
||
|
||
logger.warning(f"[lottery] {src.name} 第{expected_period}期轮询超时({self.LOTTERY_POLL_TIMEOUT}s)")
|
||
return 0
|
||
|
||
async def _lottery_first_fetch(self, sources, num_mapping: dict, year: int) -> int:
|
||
"""首次拉取(无待开奖记录时),获取最新结果并创建下期占位"""
|
||
total_count = 0
|
||
for src in sources:
|
||
try:
|
||
logger.info(f"[lottery] 首次拉取 {src.name}...")
|
||
raw_text = await self._engine.get_lottery_result(src.url)
|
||
logger.info(f"[lottery] {src.name} 原始响应: {raw_text[:200]}")
|
||
|
||
parsed = self._parse_lottery_result(raw_text)
|
||
if not parsed:
|
||
logger.warning(f"[lottery] {src.name} 解析失败")
|
||
continue
|
||
|
||
period = str(year) + parsed['period'].zfill(3)
|
||
exists = await self._db.get_lottery_draw_exists(src.category_id, period)
|
||
if exists:
|
||
logger.info(f"[lottery] {src.name} 第{period}期已存在,跳过")
|
||
await self._lottery_ensure_next(src, parsed, year)
|
||
continue
|
||
|
||
count = await self._lottery_save_result(src, parsed, num_mapping, year)
|
||
total_count += count
|
||
except Exception as e:
|
||
logger.error(f"[lottery] {src.name} 首次拉取失败: {e}")
|
||
return total_count
|
||
|
||
async def _lottery_save_result(self, src, parsed: dict, num_mapping: dict, year: int) -> int:
|
||
"""保存解析后的开奖结果到数据库"""
|
||
from datetime import datetime as _dt
|
||
|
||
period = str(year) + parsed['period'].zfill(3)
|
||
numbers = parsed['numbers']
|
||
special = numbers[-1]
|
||
all_nums = numbers
|
||
|
||
color_code_map = {'红': 'red', '蓝': 'blue', '绿': 'green'}
|
||
zodiac_list, element_list, color_codes = [], [], []
|
||
for n in all_nums:
|
||
m = num_mapping.get(n, {})
|
||
zodiac_list.append(m.get('zodiac', ''))
|
||
element_list.append(m.get('element', ''))
|
||
c = m.get('color', '')
|
||
key = c[0] if c else ''
|
||
color_codes.append(color_code_map.get(key, ''))
|
||
|
||
draw_data = {
|
||
'category_id': src.category_id,
|
||
'period': period,
|
||
'draw_date': _dt.now().strftime('%Y-%m-%d'),
|
||
'draw_time': None,
|
||
'numbers': numbers[:-1],
|
||
'special_number': special,
|
||
'zodiac': zodiac_list,
|
||
'elements': element_list,
|
||
'color': color_codes,
|
||
'status': 1,
|
||
}
|
||
|
||
await self._db.upsert_lottery_draw(draw_data)
|
||
logger.info(f"[lottery] {src.name} 第{period}期入库: 号码={numbers}, 特码={special}")
|
||
|
||
# 插入下一期待开奖记录
|
||
await self._lottery_ensure_next(src, parsed, year)
|
||
return 1
|
||
|
||
async def _lottery_ensure_next(self, src, parsed: dict, year: int):
|
||
"""确保下一期待开奖记录存在"""
|
||
if not parsed.get('next_period'):
|
||
return
|
||
period_prefix = str(year)
|
||
next_period = period_prefix + parsed['next_period'].zfill(3)
|
||
next_draw_date = None
|
||
next_draw_time = None
|
||
if parsed.get('next_month') and parsed.get('next_day'):
|
||
next_draw_date = f"{year}-{parsed['next_month'].zfill(2)}-{parsed['next_day'].zfill(2)}"
|
||
if parsed.get('next_hour') and parsed.get('next_minute'):
|
||
if next_draw_date:
|
||
next_draw_time = f"{next_draw_date} {parsed['next_hour'].zfill(2)}:{parsed['next_minute'].zfill(2)}:00"
|
||
|
||
next_data = {
|
||
'category_id': src.category_id,
|
||
'period': next_period,
|
||
'draw_date': next_draw_date,
|
||
'draw_time': next_draw_time,
|
||
}
|
||
await self._db.upsert_lottery_next_draw(next_data)
|
||
logger.info(f"[lottery] {src.name} 下期{next_period} 日期={next_draw_date} 时间={next_draw_time}")
|
||
|
||
@staticmethod
|
||
def _parse_lottery_result(raw_text: str) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
解析六合彩API响应
|
||
格式: {"k":"039,14,42,40,11,17,28,02,040,04,14,二,21时32分","t":3000}
|
||
k字段逗号分隔:
|
||
[0]=当期期数, [1-7]=开奖号码(最后一个是特码),
|
||
[8]=下期期数, [9]=下期月, [10]=下期日, [11]=星期, [12]=下期开奖时间
|
||
"""
|
||
import json as _json, re
|
||
|
||
try:
|
||
data = _json.loads(raw_text)
|
||
except _json.JSONDecodeError:
|
||
# 尝试从文本中提取JSON
|
||
match = re.search(r'\{.*\}', raw_text)
|
||
if match:
|
||
try:
|
||
data = _json.loads(match.group())
|
||
except _json.JSONDecodeError:
|
||
return None
|
||
else:
|
||
return None
|
||
|
||
k = data.get('k', '')
|
||
if not k:
|
||
return None
|
||
|
||
parts = k.split(',')
|
||
if len(parts) < 9:
|
||
return None
|
||
|
||
period = parts[0].strip()
|
||
numbers = []
|
||
for p in parts[1:8]:
|
||
p = p.strip()
|
||
try:
|
||
numbers.append(int(p))
|
||
except ValueError:
|
||
return None
|
||
|
||
result = {
|
||
'period': period,
|
||
'numbers': numbers,
|
||
}
|
||
|
||
# 下一期信息
|
||
if len(parts) >= 9:
|
||
result['next_period'] = parts[8].strip()
|
||
if len(parts) >= 10:
|
||
result['next_month'] = parts[9].strip()
|
||
if len(parts) >= 11:
|
||
result['next_day'] = parts[10].strip()
|
||
|
||
# 解析下期开奖时间 "21时32分" 或 "21:32" 等
|
||
if len(parts) >= 13:
|
||
time_str = parts[12].strip()
|
||
time_match = re.search(r'(\d+)\D+(\d+)', time_str)
|
||
if time_match:
|
||
result['next_hour'] = time_match.group(1)
|
||
result['next_minute'] = time_match.group(2)
|
||
|
||
return result
|
||
|
||
# ── 彩种开奖数据采集 (kai8.us API) ──
|
||
|
||
LOTTERY_DRAW_API = "https://data.kai8.us/api/lottery/peroids/KAI166"
|
||
|
||
async def crawl_lottery_draw(self, force: bool = False) -> Dict[str, Any]:
|
||
"""请求一次 kai8 API,把返回的所有彩种 UPSERT 入库。
|
||
|
||
外部 cron(如 la_dev_crontab)按需要的频率反复调用本方法,
|
||
每次调用都会真实发起一次请求并写库,不做"是否到开奖时间"的预检。
|
||
force 参数保留以兼容 main.py 的 cmd_lottery_draw_force 调用方,
|
||
当前实现下与默认调用完全等价。
|
||
"""
|
||
ErrorCollector.get().set_task_name("lottery_draw")
|
||
start = time.time()
|
||
result = {"task": "lottery_draw", "success": False}
|
||
try:
|
||
raw = await self._engine.get_lottery_draw_data(self.LOTTERY_DRAW_API)
|
||
if not isinstance(raw, dict) or raw.get('status') != 0:
|
||
api_status = raw.get('status') if isinstance(raw, dict) else type(raw).__name__
|
||
err_msg = f"API返回异常: status={api_status}"
|
||
logger.warning(f"[lottery_draw] {err_msg}")
|
||
result["error"] = err_msg
|
||
await self._db.log_crawl(
|
||
"lottery_draw", "kai8", False,
|
||
time.time() - start, error=err_msg,
|
||
)
|
||
return result
|
||
|
||
items = raw.get('data', []) or []
|
||
count = await self._db.upsert_lottery_draw_results(items)
|
||
elapsed = time.time() - start
|
||
result.update({"success": True, "count": count, "candidate_count": len(items), "saved_count": count, "elapsed": elapsed})
|
||
logger.info(f"[lottery_draw] 采集完成: {count} 条, {elapsed:.2f}s")
|
||
if count > 0:
|
||
await self._db.log_crawl(
|
||
"lottery_draw", "kai8", True,
|
||
elapsed, count,
|
||
)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
logger.error(f"[lottery_draw] 采集失败: {e}")
|
||
await self._db.log_crawl(
|
||
"lottery_draw", "kai8", False,
|
||
time.time() - start, error=str(e),
|
||
)
|
||
return result
|
||
|
||
# ── 批量采集所有联赛 ──
|
||
|
||
async def crawl_all_leagues(
|
||
self, task_type: str = "standings",
|
||
) -> List[Dict[str, Any]]:
|
||
targets = sorted(
|
||
[t for t in self._cfg.dongqiudi.targets if t.active],
|
||
key=lambda t: t.priority,
|
||
)
|
||
|
||
if not targets:
|
||
logger.warning("无激活的采集目标")
|
||
return []
|
||
|
||
logger.info(f"开始批量采集 {len(targets)} 个联赛 ({task_type})")
|
||
results = []
|
||
|
||
for idx, target in enumerate(targets):
|
||
if task_type == "standings":
|
||
r = await self.crawl_standings(target.season_id, target.league)
|
||
elif task_type == "schedule":
|
||
r = await self.crawl_schedule(target.season_id, target.league)
|
||
else:
|
||
logger.warning(f"未知任务类型: {task_type}")
|
||
continue
|
||
|
||
results.append(r)
|
||
status = "✅" if r.get("success") else "❌"
|
||
logger.info(f" {status} {target.league}: {r.get('count', 0)} 条, {r.get('elapsed', 0):.1f}s")
|
||
|
||
success_count = sum(1 for r in results if r.get("success"))
|
||
logger.info(f"批量采集完成: {success_count}/{len(results)} 成功")
|
||
return results
|
||
|
||
async def __aenter__(self):
|
||
await self.initialize()
|
||
return self
|
||
|
||
async def __aexit__(self, *args):
|
||
await self.close()
|