#!/usr/bin/env python3 from __future__ import annotations import json import os import re import time from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List import pymysql import requests DETAIL_URL_TEMPLATE = "https://json.ncctrials.com/room/{room_id}/detail.json?v={ts}" FETCH_BATCH_SIZE = 30 FETCH_TIMEOUT = 15 FETCH_ERROR_LIMIT = 500 ROOM_ID_RE = re.compile(r"/room/(\d+)") DETAIL_JSONP_RE = re.compile(r"detail\((\{.*\})\)\s*;?\s*$", re.S) DEFAULT_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/137.0.0.0 Safari/537.36" ), "Accept": "application/json,text/javascript,*/*;q=0.1", "Referer": "https://yyzb1.tv/", } def _should_use_internal_lock() -> bool: flag = str(os.environ.get("DQD_RUNNING_UNDER_TASK_RUNNER") or "").strip().lower() return flag not in {"1", "true", "yes", "on"} def parse_match_live_detail_html(body: str) -> dict: match = DETAIL_JSONP_RE.search((body or "").strip()) if not match: raise ValueError("detail jsonp not found") payload = json.loads(match.group(1)) stream = payload.get("data", {}).get("stream", {}) parsed = { "play_url_flv": str(stream.get("flv") or "").strip(), "play_url_hd_flv": str(stream.get("hdFlv") or "").strip(), "play_url_m3u8": str(stream.get("m3u8") or "").strip(), "play_url_hd_m3u8": str(stream.get("hdM3u8") or "").strip(), } if not any(parsed.values()): raise ValueError("stream urls empty") return parsed def choose_next_fetch_at(now_ts: int, has_existing_stream: bool) -> int: return int(now_ts) + (600 if has_existing_stream else 60) def _safe_int(value: Any, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default def _trim_error(message: Any) -> str: return str(message or "").strip()[:FETCH_ERROR_LIMIT] def _derive_room_id(source_url: str) -> str: match = ROOM_ID_RE.search(str(source_url or "").strip()) if not match: raise ValueError("room id not found in source_url") return match.group(1) def _build_detail_url(source_url: str, now_ts: int) -> str: room_id = _derive_room_id(source_url) return DETAIL_URL_TEMPLATE.format(room_id=room_id, ts=int(now_ts)) def _has_existing_stream(row: Dict[str, Any]) -> bool: return any( str(row.get(field) or "").strip() for field in ("play_url_m3u8", "play_url_hd_m3u8", "play_url_flv", "play_url_hd_flv") ) def _fetch_stream_payload(source_url: str, now_ts: int) -> Dict[str, str]: detail_url = _build_detail_url(source_url, now_ts) response = requests.get(detail_url, timeout=FETCH_TIMEOUT, headers=DEFAULT_HEADERS) response.raise_for_status() return parse_match_live_detail_html(response.text) def _build_run_result(candidate_count: int, success_count: int, failed_count: int) -> Dict[str, Any]: summary_text = ( f"赛事直播流抓取完成: 待处理 {candidate_count} 条, " f"成功 {success_count} 条, 失败 {failed_count} 条" ) result = { "success": failed_count == 0, "candidate_count": candidate_count, "saved_count": success_count, "count": success_count, "failed_count": failed_count, "summary_text": summary_text, } if failed_count > 0: result["error"] = f"赛事直播流抓取存在失败线路: 失败 {failed_count} 条" return result class TaskFileLock: def __init__(self, name: str, lock_dir: Path | None = None): safe_name = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(name or "task")) self.lock_dir = Path(lock_dir or os.environ.get("DQD_LOCK_DIR") or (Path(__file__).resolve().parent / "data" / "locks")) self.path = self.lock_dir / f"{safe_name}.lock" self._fp = None self._locked = False def acquire(self) -> bool: self.lock_dir.mkdir(parents=True, exist_ok=True) self._fp = open(self.path, "a+b") try: self._fp.seek(0) if os.name == "nt": import msvcrt msvcrt.locking(self._fp.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) self._locked = True self._fp.seek(0) self._fp.truncate() self._fp.write(f"pid={os.getpid()} started_at={int(time.time())}\n".encode("utf-8")) self._fp.flush() return True except (BlockingIOError, OSError): self.release() return False def release(self) -> None: if not self._fp: return try: if self._locked and os.name == "nt": import msvcrt self._fp.seek(0) msvcrt.locking(self._fp.fileno(), msvcrt.LK_UNLCK, 1) elif self._locked: import fcntl fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) finally: self._fp.close() self._fp = None self._locked = False @dataclass class MatchLiveStreamRepository: db_config: Dict[str, Any] def __post_init__(self) -> None: self.prefix = str(self.db_config.get("prefix") or "la_") self.conn = pymysql.connect( host=self.db_config["host"], port=int(self.db_config["port"]), user=self.db_config["user"], password=self.db_config["password"], database=self.db_config["database"], charset=self.db_config.get("charset") or "utf8mb4", cursorclass=pymysql.cursors.DictCursor, autocommit=False, ) def close(self) -> None: self.conn.close() def load_due_rows(self, now_ts: int, limit: int = FETCH_BATCH_SIZE) -> List[Dict[str, Any]]: sql = ( f"SELECT id, match_id, title, source_url, play_url_flv, play_url_hd_flv, play_url_m3u8, play_url_hd_m3u8 " f"FROM `{self.prefix}match_live` " f"WHERE `delete_time` IS NULL AND `next_fetch_at` <= %s " f"ORDER BY `next_fetch_at` ASC, `id` ASC LIMIT %s" ) with self.conn.cursor() as cur: cur.execute(sql, (int(now_ts), int(limit))) return list(cur.fetchall() or []) def mark_success(self, row_id: int, stream_urls: Dict[str, str], now_ts: int) -> None: sql = ( f"UPDATE `{self.prefix}match_live` SET " "`play_url_flv`=%s, `play_url_hd_flv`=%s, `play_url_m3u8`=%s, `play_url_hd_m3u8`=%s, " "`fetch_status`=1, `fetch_error`='', `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s " "WHERE `id`=%s" ) next_fetch_at = choose_next_fetch_at(now_ts, has_existing_stream=True) with self.conn.cursor() as cur: cur.execute( sql, ( stream_urls["play_url_flv"], stream_urls["play_url_hd_flv"], stream_urls["play_url_m3u8"], stream_urls["play_url_hd_m3u8"], int(now_ts), next_fetch_at, int(now_ts), int(row_id), ), ) self.conn.commit() def mark_failed(self, row_id: int, error_message: str, now_ts: int, has_existing_stream: bool) -> None: sql = ( f"UPDATE `{self.prefix}match_live` SET " "`fetch_status`=2, `fetch_error`=%s, `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s " "WHERE `id`=%s" ) with self.conn.cursor() as cur: cur.execute( sql, ( _trim_error(error_message), int(now_ts), choose_next_fetch_at(now_ts, has_existing_stream=has_existing_stream), int(now_ts), int(row_id), ), ) self.conn.commit() def run(db_config: Dict[str, Any]) -> Dict[str, Any]: lock = None if _should_use_internal_lock(): lock = TaskFileLock("match_live_stream") if not lock.acquire(): return { "success": True, "candidate_count": 0, "saved_count": 0, "summary_text": "match_live_stream 已在执行中,跳过本次", } repo = MatchLiveStreamRepository(db_config) now_ts = int(time.time()) success_count = 0 failed_count = 0 rows: List[Dict[str, Any]] = [] try: rows = repo.load_due_rows(now_ts, limit=FETCH_BATCH_SIZE) for row in rows: fetch_ts = int(time.time()) has_existing_stream = _has_existing_stream(row) try: stream_urls = _fetch_stream_payload(str(row.get("source_url") or ""), fetch_ts) repo.mark_success(_safe_int(row.get("id")), stream_urls, fetch_ts) success_count += 1 except Exception as exc: # noqa: BLE001 repo.mark_failed(_safe_int(row.get("id")), str(exc), fetch_ts, has_existing_stream) failed_count += 1 candidate_count = len(rows) return _build_run_result(candidate_count, success_count, failed_count) finally: repo.close() if lock is not None: lock.release()