deploy: auto commit docker changes 2026-07-09 22:54:47
This commit is contained in:
@@ -21,6 +21,14 @@ AI_KB_REDIS_DB=4
|
|||||||
DQD_LOG_LEVEL=INFO
|
DQD_LOG_LEVEL=INFO
|
||||||
DQD_LOG_FILE=/app/logs/crawler.log
|
DQD_LOG_FILE=/app/logs/crawler.log
|
||||||
|
|
||||||
|
# Third-party match odds crawler. Keep the real account in docker/.env.docker only.
|
||||||
|
ODDS_USERNAME=change-me
|
||||||
|
ODDS_PASSWORD=change-me
|
||||||
|
ODDS_BASE_URLS=https://205.201.0.120/,https://mos022.com/,https://hga039.com/,https://mos100.com/
|
||||||
|
ODDS_SPORT_TYPES=FT,BK,ES,TN,VB,BM,TT,BS,SK,OP
|
||||||
|
ODDS_SHOW_TYPES=live,today,early
|
||||||
|
ODDS_VERIFY_TLS=0
|
||||||
|
|
||||||
# AI KB worker embedding config. Keep real key in docker/.env.docker only.
|
# AI KB worker embedding config. Keep real key in docker/.env.docker only.
|
||||||
EMBEDDING_API_KEY=change-me
|
EMBEDDING_API_KEY=change-me
|
||||||
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
|
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
|
||||||
|
|||||||
@@ -159,3 +159,8 @@ tasks:
|
|||||||
action: match_live_stream
|
action: match_live_stream
|
||||||
cron: "*/1 * * * *"
|
cron: "*/1 * * * *"
|
||||||
active: true
|
active: true
|
||||||
|
- key: match_odds
|
||||||
|
name: 第三方赛事赔率抓取
|
||||||
|
action: match_odds
|
||||||
|
cron: "*/5 * * * *"
|
||||||
|
active: true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
|||||||
env_file = Path("/etc/sport-era-crawler/env.sh")
|
env_file = Path("/etc/sport-era-crawler/env.sh")
|
||||||
with env_file.open("w", encoding="utf-8") as fp:
|
with env_file.open("w", encoding="utf-8") as fp:
|
||||||
for key, value in sorted(os.environ.items()):
|
for key, value in sorted(os.environ.items()):
|
||||||
if key.startswith("DQD_") or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}:
|
if key.startswith(("DQD_", "ODDS_", "HG_ODDS_")) or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}:
|
||||||
fp.write(f"export {key}={shlex.quote(value)}\n")
|
fp.write(f"export {key}={shlex.quote(value)}\n")
|
||||||
env_file.chmod(0o600)
|
env_file.chmod(0o600)
|
||||||
PY
|
PY
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
python main.py cba_news # 采集CBA新闻资讯
|
python main.py cba_news # 采集CBA新闻资讯
|
||||||
python main.py ai_comment_dispatch # Docker AI 评论调度
|
python main.py ai_comment_dispatch # Docker AI 评论调度
|
||||||
python main.py match_live_stream # 抓取赛事直播线路播放流
|
python main.py match_live_stream # 抓取赛事直播线路播放流
|
||||||
|
python main.py match_odds # 抓取第三方赛事赔率
|
||||||
python main.py article_content_fetch # 抓取资讯文章正文内容
|
python main.py article_content_fetch # 抓取资讯文章正文内容
|
||||||
python main.py all # 采集积分榜 + 赛程
|
python main.py all # 采集积分榜 + 赛程
|
||||||
python main.py single <season_id> # 采集单个联赛积分榜
|
python main.py single <season_id> # 采集单个联赛积分榜
|
||||||
@@ -61,6 +62,7 @@ TASK_ALERT_POLICY = {
|
|||||||
"lottery_draw_force": "saved_if_candidates",
|
"lottery_draw_force": "saved_if_candidates",
|
||||||
"ai_comment_dispatch": "saved_if_candidates",
|
"ai_comment_dispatch": "saved_if_candidates",
|
||||||
"match_live_stream": "saved_if_candidates",
|
"match_live_stream": "saved_if_candidates",
|
||||||
|
"match_odds": "saved_if_candidates",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_ACTION_TIMEOUT = 600
|
DEFAULT_ACTION_TIMEOUT = 600
|
||||||
@@ -89,6 +91,7 @@ ACTION_TIMEOUTS = {
|
|||||||
"dqd_worldcup": 600,
|
"dqd_worldcup": 600,
|
||||||
"ai_comment_dispatch": 1800,
|
"ai_comment_dispatch": 1800,
|
||||||
"match_live_stream": 180,
|
"match_live_stream": 180,
|
||||||
|
"match_odds": 300,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -543,6 +546,29 @@ async def cmd_match_live_stream():
|
|||||||
return await asyncio.to_thread(run, db_config)
|
return await asyncio.to_thread(run, db_config)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_match_odds():
|
||||||
|
import asyncio
|
||||||
|
from match_odds import run
|
||||||
|
from src.core.config import load_config
|
||||||
|
|
||||||
|
cfg = load_config().database
|
||||||
|
db_config = {
|
||||||
|
"host": cfg.host,
|
||||||
|
"port": cfg.port,
|
||||||
|
"user": cfg.username,
|
||||||
|
"password": cfg.password,
|
||||||
|
"database": cfg.database,
|
||||||
|
"charset": cfg.charset,
|
||||||
|
"prefix": cfg.prefix,
|
||||||
|
}
|
||||||
|
result = await asyncio.to_thread(run, db_config)
|
||||||
|
prefix = "✅" if result.get("success") else "❌"
|
||||||
|
print(f"{prefix} {result.get('summary_text', '第三方赛事赔率抓取完成')}")
|
||||||
|
if result.get("error"):
|
||||||
|
print(f" {result['error']}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def cmd_match_data():
|
async def cmd_match_data():
|
||||||
from src.scheduler.task_runner import TaskRunner
|
from src.scheduler.task_runner import TaskRunner
|
||||||
async with TaskRunner() as runner:
|
async with TaskRunner() as runner:
|
||||||
@@ -859,6 +885,7 @@ def main():
|
|||||||
"cba_news": cmd_cba_news,
|
"cba_news": cmd_cba_news,
|
||||||
"ai_comment_dispatch": cmd_ai_comment_dispatch,
|
"ai_comment_dispatch": cmd_ai_comment_dispatch,
|
||||||
"match_live_stream": cmd_match_live_stream,
|
"match_live_stream": cmd_match_live_stream,
|
||||||
|
"match_odds": cmd_match_odds,
|
||||||
"article_content_fetch": cmd_article_content_fetch,
|
"article_content_fetch": cmd_article_content_fetch,
|
||||||
"match_finish": cmd_match_finish,
|
"match_finish": cmd_match_finish,
|
||||||
"live_detail": cmd_live_detail,
|
"live_detail": cmd_live_detail,
|
||||||
|
|||||||
@@ -0,0 +1,643 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BASE_URLS = [
|
||||||
|
"https://205.201.0.120/",
|
||||||
|
"https://mos022.com/",
|
||||||
|
"https://hga039.com/",
|
||||||
|
"https://mos100.com/",
|
||||||
|
]
|
||||||
|
DEFAULT_SPORT_TYPES = ["FT", "BK", "ES", "TN", "VB", "BM", "TT", "BS", "SK", "OP"]
|
||||||
|
DEFAULT_SHOW_TYPES = ["live", "today", "early"]
|
||||||
|
SHOW_TYPE_RTYPE = {
|
||||||
|
"live": "rb",
|
||||||
|
"today": "r",
|
||||||
|
"early": "r",
|
||||||
|
}
|
||||||
|
DEFAULT_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"
|
||||||
|
)
|
||||||
|
REQUEST_DELAY_SECONDS = 0.4
|
||||||
|
|
||||||
|
|
||||||
|
class OddsGameListUnavailable(Exception):
|
||||||
|
"""Raised when the account/menu combination has no accessible game list."""
|
||||||
|
|
||||||
|
|
||||||
|
def _split_env_list(value: str, default: Iterable[str]) -> List[str]:
|
||||||
|
if not str(value or "").strip():
|
||||||
|
return list(default)
|
||||||
|
parts = re.split(r"[\s,]+", str(value).strip())
|
||||||
|
return [item.strip() for item in parts if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_base_url(url: str) -> str:
|
||||||
|
normalized = str(url or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("empty odds base url")
|
||||||
|
if not normalized.startswith(("http://", "https://")):
|
||||||
|
normalized = "https://" + normalized
|
||||||
|
return normalized.rstrip("/") + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_env(value: Any, default: bool = False) -> bool:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _text(raw: Dict[str, str], key: str) -> str:
|
||||||
|
return str(raw.get(key) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _limit(value: Any, size: int) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(value or "")).strip()[:size]
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_json(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_version(html: str) -> str:
|
||||||
|
match = re.search(r"top\.ver\s*=\s*'([^']+)'", html or "")
|
||||||
|
if not match:
|
||||||
|
raise ValueError("odds site version not found")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_login_xml(body: str) -> Dict[str, str]:
|
||||||
|
root = ET.fromstring((body or "").strip())
|
||||||
|
status = (root.findtext("status") or "").strip()
|
||||||
|
msg = (root.findtext("msg") or "").strip()
|
||||||
|
uid = (root.findtext("uid") or "").strip()
|
||||||
|
if status != "200" or not uid:
|
||||||
|
code_message = (root.findtext("code_message") or root.findtext("msg") or "login failed").strip()
|
||||||
|
raise ValueError(f"odds login failed: {code_message or msg or status}")
|
||||||
|
return {child.tag: (child.text or "").strip() for child in list(root)}
|
||||||
|
|
||||||
|
|
||||||
|
def _skippable_game_list_reason(body: str) -> str:
|
||||||
|
prefix = re.sub(r"\s+", " ", str(body or "").strip())[:120]
|
||||||
|
if prefix.startswith("CheckEMNU"):
|
||||||
|
return "菜单不可用"
|
||||||
|
if prefix.startswith("coupon_date error"):
|
||||||
|
return "早盘日期不可用"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_markets(raw: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"handicap": {
|
||||||
|
"line": _text(raw, "RATIO_R"),
|
||||||
|
"home": _text(raw, "IOR_RH"),
|
||||||
|
"away": _text(raw, "IOR_RC"),
|
||||||
|
"strong": _text(raw, "STRONG"),
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"over_line": _text(raw, "RATIO_OUO"),
|
||||||
|
"under_line": _text(raw, "RATIO_OUU"),
|
||||||
|
"over": _text(raw, "IOR_OUC"),
|
||||||
|
"under": _text(raw, "IOR_OUH"),
|
||||||
|
},
|
||||||
|
"moneyline": {
|
||||||
|
"home": _text(raw, "IOR_MH"),
|
||||||
|
"away": _text(raw, "IOR_MC"),
|
||||||
|
"draw": _text(raw, "IOR_MN"),
|
||||||
|
},
|
||||||
|
"first_half": {
|
||||||
|
"handicap_line": _text(raw, "RATIO_HR"),
|
||||||
|
"handicap_home": _text(raw, "IOR_HRH"),
|
||||||
|
"handicap_away": _text(raw, "IOR_HRC"),
|
||||||
|
"total_over_line": _text(raw, "RATIO_HOUO"),
|
||||||
|
"total_under_line": _text(raw, "RATIO_HOUU"),
|
||||||
|
"total_over": _text(raw, "IOR_HOUC"),
|
||||||
|
"total_under": _text(raw, "IOR_HOUH"),
|
||||||
|
"home_win": _text(raw, "IOR_HMH"),
|
||||||
|
"away_win": _text(raw, "IOR_HMC"),
|
||||||
|
"draw": _text(raw, "IOR_HMN"),
|
||||||
|
},
|
||||||
|
"both_teams_to_score": {
|
||||||
|
"yes": _text(raw, "IOR_TSY"),
|
||||||
|
"no": _text(raw, "IOR_TSN"),
|
||||||
|
},
|
||||||
|
"kickoff": {
|
||||||
|
"home": _text(raw, "IOR_TKH"),
|
||||||
|
"away": _text(raw, "IOR_TKC"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_game_list_xml(
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
source_site: str,
|
||||||
|
source_url: str,
|
||||||
|
sport_type: str,
|
||||||
|
show_type: str,
|
||||||
|
odds_type: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
root = ET.fromstring((body or "").strip())
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
ec_nodes = root.findall(".//ec")
|
||||||
|
if not ec_nodes:
|
||||||
|
ec_nodes = [root]
|
||||||
|
|
||||||
|
for ec_node in ec_nodes:
|
||||||
|
ec_id = str(ec_node.attrib.get("id") or "").replace("ec", "", 1)
|
||||||
|
for game in ec_node.findall(".//game"):
|
||||||
|
raw = {child.tag: (child.text or "").strip() for child in list(game)}
|
||||||
|
source_match_id = _text(raw, "GID") or str(game.attrib.get("id") or "").strip()
|
||||||
|
if not source_match_id:
|
||||||
|
continue
|
||||||
|
if not _text(raw, "ECID") and ec_id:
|
||||||
|
raw["ECID"] = ec_id
|
||||||
|
markets = _build_markets(raw)
|
||||||
|
item = {
|
||||||
|
"source_site": _limit(source_site, 50),
|
||||||
|
"source_url": _limit(source_url, 255),
|
||||||
|
"source_match_id": _limit(source_match_id, 64),
|
||||||
|
"source_parent_id": _limit(_text(raw, "GIDM"), 64),
|
||||||
|
"source_league_id": _limit(_text(raw, "LID"), 64),
|
||||||
|
"source_event_id": _limit(_text(raw, "ECID") or ec_id, 64),
|
||||||
|
"sport_type": _limit(str(sport_type).upper(), 20),
|
||||||
|
"show_type": _limit(str(show_type).lower(), 20),
|
||||||
|
"odds_type": _limit(str(odds_type).upper(), 20),
|
||||||
|
"league_name": _limit(_text(raw, "LEAGUE"), 150),
|
||||||
|
"match_time_text": _limit(_text(raw, "DATETIME"), 50),
|
||||||
|
"home_team_id": _limit(_text(raw, "TEAM_H_ID"), 64),
|
||||||
|
"away_team_id": _limit(_text(raw, "TEAM_C_ID"), 64),
|
||||||
|
"home_team": _limit(_text(raw, "TEAM_H"), 150),
|
||||||
|
"away_team": _limit(_text(raw, "TEAM_C"), 150),
|
||||||
|
"strong_side": _limit(_text(raw, "STRONG"), 10),
|
||||||
|
"more_count": _safe_int(_text(raw, "MORE")),
|
||||||
|
"is_running": 1 if _text(raw, "IS_RB").upper() == "Y" or str(show_type).lower() == "live" else 0,
|
||||||
|
"handicap_line": _limit(markets["handicap"]["line"], 32),
|
||||||
|
"handicap_home_odds": _limit(markets["handicap"]["home"], 32),
|
||||||
|
"handicap_away_odds": _limit(markets["handicap"]["away"], 32),
|
||||||
|
"total_line": _limit(markets["total"]["over_line"], 32),
|
||||||
|
"total_over_odds": _limit(markets["total"]["over"], 32),
|
||||||
|
"total_under_odds": _limit(markets["total"]["under"], 32),
|
||||||
|
"home_win_odds": _limit(markets["moneyline"]["home"], 32),
|
||||||
|
"draw_odds": _limit(markets["moneyline"]["draw"], 32),
|
||||||
|
"away_win_odds": _limit(markets["moneyline"]["away"], 32),
|
||||||
|
"half_handicap_line": _limit(markets["first_half"]["handicap_line"], 32),
|
||||||
|
"half_home_win_odds": _limit(markets["first_half"]["home_win"], 32),
|
||||||
|
"half_draw_odds": _limit(markets["first_half"]["draw"], 32),
|
||||||
|
"half_away_win_odds": _limit(markets["first_half"]["away_win"], 32),
|
||||||
|
"raw_data": raw,
|
||||||
|
"markets": markets,
|
||||||
|
}
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OddsLoginSession:
|
||||||
|
base_url: str
|
||||||
|
transform_url: str
|
||||||
|
version: str
|
||||||
|
uid: str
|
||||||
|
ltype: str
|
||||||
|
odds_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class HgOddsClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
user_agent: str = DEFAULT_USER_AGENT,
|
||||||
|
timeout: int = 20,
|
||||||
|
verify_tls: bool = False,
|
||||||
|
langx: str = "zh-cn",
|
||||||
|
odds_type: str = "H",
|
||||||
|
) -> None:
|
||||||
|
self.base_url = _normalize_base_url(base_url)
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self.timeout = int(timeout)
|
||||||
|
self.verify_tls = bool(verify_tls)
|
||||||
|
self.langx = langx
|
||||||
|
self.odds_type = odds_type
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.verify = self.verify_tls
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"User-Agent": self.user_agent,
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Referer": self.base_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not self.verify_tls:
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
self.login_session: Optional[OddsLoginSession] = None
|
||||||
|
|
||||||
|
def login(self) -> OddsLoginSession:
|
||||||
|
bootstrap = self.session.post(
|
||||||
|
self.base_url,
|
||||||
|
data={"detection": "Y", "sub_doubleLogin": "", "isapp": "", "q": "", "appversion": ""},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
bootstrap.raise_for_status()
|
||||||
|
version = _extract_version(bootstrap.text)
|
||||||
|
transform_url = urljoin(self.base_url, "transform_nl.php") + f"?ver={version}"
|
||||||
|
user_agent_b64 = base64.b64encode(self.user_agent.encode("utf-8")).decode("ascii")
|
||||||
|
login_response = self.session.post(
|
||||||
|
transform_url,
|
||||||
|
data={
|
||||||
|
"p": "chk_login",
|
||||||
|
"langx": self.langx,
|
||||||
|
"ver": version,
|
||||||
|
"username": self.username,
|
||||||
|
"password": self.password,
|
||||||
|
"app": "N",
|
||||||
|
"auto": "EDACFD",
|
||||||
|
"userAgent": user_agent_b64,
|
||||||
|
},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
login_response.raise_for_status()
|
||||||
|
parsed = _parse_login_xml(login_response.text)
|
||||||
|
self.login_session = OddsLoginSession(
|
||||||
|
base_url=self.base_url,
|
||||||
|
transform_url=transform_url,
|
||||||
|
version=version,
|
||||||
|
uid=parsed["uid"],
|
||||||
|
ltype=parsed.get("ltype") or "3",
|
||||||
|
odds_type=parsed.get("odd_f_type") or self.odds_type,
|
||||||
|
)
|
||||||
|
return self.login_session
|
||||||
|
|
||||||
|
def fetch_game_list(self, sport_type: str, show_type: str) -> List[Dict[str, Any]]:
|
||||||
|
login_session = self.login_session or self.login()
|
||||||
|
normalized_show_type = str(show_type).lower()
|
||||||
|
normalized_sport_type = str(sport_type).upper()
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
response = self.session.post(
|
||||||
|
login_session.transform_url,
|
||||||
|
data={
|
||||||
|
"uid": login_session.uid,
|
||||||
|
"ver": login_session.version,
|
||||||
|
"langx": self.langx,
|
||||||
|
"p": "get_game_list",
|
||||||
|
"p3type": "",
|
||||||
|
"date": "",
|
||||||
|
"gtype": normalized_sport_type.lower(),
|
||||||
|
"showtype": normalized_show_type,
|
||||||
|
"rtype": SHOW_TYPE_RTYPE.get(normalized_show_type, "r"),
|
||||||
|
"ltype": login_session.ltype,
|
||||||
|
"filter": normalized_sport_type,
|
||||||
|
"cupFantasy": "N",
|
||||||
|
"sorttype": "L",
|
||||||
|
"specialClick": "",
|
||||||
|
"isFantasy": "N",
|
||||||
|
"ts": str(now_ms),
|
||||||
|
"chgSortTS": str(now_ms),
|
||||||
|
},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.text or ""
|
||||||
|
skip_reason = _skippable_game_list_reason(body)
|
||||||
|
if skip_reason:
|
||||||
|
raise OddsGameListUnavailable(skip_reason)
|
||||||
|
if not body.lstrip().startswith("<"):
|
||||||
|
prefix = re.sub(r"\s+", " ", body.strip())[:120]
|
||||||
|
raise ValueError(f"unexpected odds response: {prefix}")
|
||||||
|
return parse_game_list_xml(
|
||||||
|
body,
|
||||||
|
source_site="hg",
|
||||||
|
source_url=login_session.base_url,
|
||||||
|
sport_type=normalized_sport_type,
|
||||||
|
show_type=normalized_show_type,
|
||||||
|
odds_type=login_session.odds_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchOddsRepository:
|
||||||
|
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 ensure_table(self) -> None:
|
||||||
|
sql = f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `{self.prefix}match_odds` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`source_site` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '赔率来源',
|
||||||
|
`source_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '来源线路',
|
||||||
|
`source_match_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源比赛ID',
|
||||||
|
`source_parent_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源主比赛ID',
|
||||||
|
`source_league_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源联赛ID',
|
||||||
|
`source_event_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源事件ID',
|
||||||
|
`sport_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '运动类型',
|
||||||
|
`show_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '盘口类型 live/today/early',
|
||||||
|
`odds_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '赔率格式',
|
||||||
|
`league_name` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '联赛名称',
|
||||||
|
`match_time_text` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '来源比赛时间文本',
|
||||||
|
`home_team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主队来源ID',
|
||||||
|
`away_team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客队来源ID',
|
||||||
|
`home_team` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '主队名称',
|
||||||
|
`away_team` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '客队名称',
|
||||||
|
`strong_side` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '让球强弱方',
|
||||||
|
`more_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更多玩法数量',
|
||||||
|
`is_running` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否滚球',
|
||||||
|
`handicap_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '让球盘口',
|
||||||
|
`handicap_home_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主队让球赔率',
|
||||||
|
`handicap_away_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客队让球赔率',
|
||||||
|
`total_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '大小球盘口',
|
||||||
|
`total_over_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '大球赔率',
|
||||||
|
`total_under_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '小球赔率',
|
||||||
|
`home_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主胜赔率',
|
||||||
|
`draw_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平局赔率',
|
||||||
|
`away_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客胜赔率',
|
||||||
|
`half_handicap_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场让球盘口',
|
||||||
|
`half_home_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场主胜赔率',
|
||||||
|
`half_draw_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场平局赔率',
|
||||||
|
`half_away_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场客胜赔率',
|
||||||
|
`create_time` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`raw_data` JSON NULL COMMENT '来源原始game节点',
|
||||||
|
`markets_json` JSON NULL COMMENT '规范化盘口JSON',
|
||||||
|
`update_time` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_source_match` (`source_site`, `source_match_id`),
|
||||||
|
KEY `idx_sport_show` (`sport_type`, `show_type`),
|
||||||
|
KEY `idx_league` (`source_league_id`),
|
||||||
|
KEY `idx_update_time` (`update_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方赛事赔率表'
|
||||||
|
"""
|
||||||
|
with self.conn.cursor() as cur:
|
||||||
|
cur.execute(sql)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def upsert_latest(self, items: List[Dict[str, Any]], now_ts: Optional[int] = None) -> int:
|
||||||
|
if not items:
|
||||||
|
return 0
|
||||||
|
now_ts = int(now_ts or time.time())
|
||||||
|
sql = (
|
||||||
|
f"INSERT INTO `{self.prefix}match_odds` ("
|
||||||
|
"`source_site`, `source_url`, `source_match_id`, `source_parent_id`, `source_league_id`, `source_event_id`, "
|
||||||
|
"`sport_type`, `show_type`, `odds_type`, `league_name`, `match_time_text`, "
|
||||||
|
"`home_team_id`, `away_team_id`, `home_team`, `away_team`, `strong_side`, `more_count`, `is_running`, "
|
||||||
|
"`handicap_line`, `handicap_home_odds`, `handicap_away_odds`, "
|
||||||
|
"`total_line`, `total_over_odds`, `total_under_odds`, "
|
||||||
|
"`home_win_odds`, `draw_odds`, `away_win_odds`, "
|
||||||
|
"`half_handicap_line`, `half_home_win_odds`, `half_draw_odds`, `half_away_win_odds`, "
|
||||||
|
"`create_time`, `raw_data`, `markets_json`, `update_time`"
|
||||||
|
") VALUES ("
|
||||||
|
+ ", ".join(["%s"] * 35)
|
||||||
|
+ ") ON DUPLICATE KEY UPDATE "
|
||||||
|
"`source_url`=VALUES(`source_url`), "
|
||||||
|
"`source_parent_id`=VALUES(`source_parent_id`), "
|
||||||
|
"`source_league_id`=VALUES(`source_league_id`), "
|
||||||
|
"`source_event_id`=VALUES(`source_event_id`), "
|
||||||
|
"`sport_type`=VALUES(`sport_type`), "
|
||||||
|
"`show_type`=VALUES(`show_type`), "
|
||||||
|
"`odds_type`=VALUES(`odds_type`), "
|
||||||
|
"`league_name`=VALUES(`league_name`), "
|
||||||
|
"`match_time_text`=VALUES(`match_time_text`), "
|
||||||
|
"`home_team_id`=VALUES(`home_team_id`), "
|
||||||
|
"`away_team_id`=VALUES(`away_team_id`), "
|
||||||
|
"`home_team`=VALUES(`home_team`), "
|
||||||
|
"`away_team`=VALUES(`away_team`), "
|
||||||
|
"`strong_side`=VALUES(`strong_side`), "
|
||||||
|
"`more_count`=VALUES(`more_count`), "
|
||||||
|
"`is_running`=VALUES(`is_running`), "
|
||||||
|
"`handicap_line`=VALUES(`handicap_line`), "
|
||||||
|
"`handicap_home_odds`=VALUES(`handicap_home_odds`), "
|
||||||
|
"`handicap_away_odds`=VALUES(`handicap_away_odds`), "
|
||||||
|
"`total_line`=VALUES(`total_line`), "
|
||||||
|
"`total_over_odds`=VALUES(`total_over_odds`), "
|
||||||
|
"`total_under_odds`=VALUES(`total_under_odds`), "
|
||||||
|
"`home_win_odds`=VALUES(`home_win_odds`), "
|
||||||
|
"`draw_odds`=VALUES(`draw_odds`), "
|
||||||
|
"`away_win_odds`=VALUES(`away_win_odds`), "
|
||||||
|
"`half_handicap_line`=VALUES(`half_handicap_line`), "
|
||||||
|
"`half_home_win_odds`=VALUES(`half_home_win_odds`), "
|
||||||
|
"`half_draw_odds`=VALUES(`half_draw_odds`), "
|
||||||
|
"`half_away_win_odds`=VALUES(`half_away_win_odds`), "
|
||||||
|
"`raw_data`=VALUES(`raw_data`), "
|
||||||
|
"`markets_json`=VALUES(`markets_json`), "
|
||||||
|
"`update_time`=VALUES(`update_time`)"
|
||||||
|
)
|
||||||
|
saved_count = 0
|
||||||
|
with self.conn.cursor() as cur:
|
||||||
|
for item in items:
|
||||||
|
params = (
|
||||||
|
item["source_site"],
|
||||||
|
item["source_url"],
|
||||||
|
item["source_match_id"],
|
||||||
|
item["source_parent_id"],
|
||||||
|
item["source_league_id"],
|
||||||
|
item["source_event_id"],
|
||||||
|
item["sport_type"],
|
||||||
|
item["show_type"],
|
||||||
|
item["odds_type"],
|
||||||
|
item["league_name"],
|
||||||
|
item["match_time_text"],
|
||||||
|
item["home_team_id"],
|
||||||
|
item["away_team_id"],
|
||||||
|
item["home_team"],
|
||||||
|
item["away_team"],
|
||||||
|
item["strong_side"],
|
||||||
|
int(item["more_count"]),
|
||||||
|
int(item["is_running"]),
|
||||||
|
item["handicap_line"],
|
||||||
|
item["handicap_home_odds"],
|
||||||
|
item["handicap_away_odds"],
|
||||||
|
item["total_line"],
|
||||||
|
item["total_over_odds"],
|
||||||
|
item["total_under_odds"],
|
||||||
|
item["home_win_odds"],
|
||||||
|
item["draw_odds"],
|
||||||
|
item["away_win_odds"],
|
||||||
|
item["half_handicap_line"],
|
||||||
|
item["half_home_win_odds"],
|
||||||
|
item["half_draw_odds"],
|
||||||
|
item["half_away_win_odds"],
|
||||||
|
now_ts,
|
||||||
|
_dump_json(item["raw_data"]),
|
||||||
|
_dump_json(item["markets"]),
|
||||||
|
now_ts,
|
||||||
|
)
|
||||||
|
cur.execute(sql, params)
|
||||||
|
saved_count += 1
|
||||||
|
self.conn.commit()
|
||||||
|
return saved_count
|
||||||
|
|
||||||
|
|
||||||
|
def _build_result(
|
||||||
|
*,
|
||||||
|
success: bool,
|
||||||
|
candidate_count: int,
|
||||||
|
saved_count: int,
|
||||||
|
source_url: str = "",
|
||||||
|
skipped_count: int = 0,
|
||||||
|
errors: Optional[List[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
errors = errors or []
|
||||||
|
summary_text = (
|
||||||
|
f"第三方赛事赔率抓取完成: 来源 {source_url or '-'}, 待写入 {candidate_count} 场, "
|
||||||
|
f"入库 {saved_count} 场, 跳过 {skipped_count} 项, 失败 {len(errors)} 项"
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"success": bool(success),
|
||||||
|
"candidate_count": int(candidate_count),
|
||||||
|
"saved_count": int(saved_count),
|
||||||
|
"count": int(saved_count),
|
||||||
|
"source_url": source_url,
|
||||||
|
"skipped_count": int(skipped_count),
|
||||||
|
"failed_count": len(errors),
|
||||||
|
"summary_text": summary_text,
|
||||||
|
}
|
||||||
|
if errors:
|
||||||
|
result["error"] = "; ".join(errors[:5])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||||
|
env = env if env is not None else os.environ
|
||||||
|
username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip()
|
||||||
|
password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip()
|
||||||
|
if not username or not password:
|
||||||
|
return _build_result(
|
||||||
|
success=False,
|
||||||
|
candidate_count=0,
|
||||||
|
saved_count=0,
|
||||||
|
errors=["ODDS_USERNAME/ODDS_PASSWORD 未配置"],
|
||||||
|
)
|
||||||
|
|
||||||
|
base_urls = [_normalize_base_url(url) for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS)]
|
||||||
|
sport_types = [item.upper() for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES)]
|
||||||
|
show_types = [item.lower() for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_SHOW_TYPES)]
|
||||||
|
timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20)
|
||||||
|
verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False)
|
||||||
|
langx = (env.get("ODDS_LANGX") or "zh-cn").strip()
|
||||||
|
odds_type = (env.get("ODDS_TYPE") or "H").strip().upper()
|
||||||
|
request_delay = float(env.get("ODDS_REQUEST_DELAY_SECONDS") or REQUEST_DELAY_SECONDS)
|
||||||
|
|
||||||
|
client: Optional[HgOddsClient] = None
|
||||||
|
login_errors: List[str] = []
|
||||||
|
for base_url in base_urls:
|
||||||
|
try:
|
||||||
|
candidate = HgOddsClient(
|
||||||
|
base_url=base_url,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=timeout,
|
||||||
|
verify_tls=verify_tls,
|
||||||
|
langx=langx,
|
||||||
|
odds_type=odds_type,
|
||||||
|
)
|
||||||
|
candidate.login()
|
||||||
|
client = candidate
|
||||||
|
break
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
login_errors.append(f"{base_url}: {exc}")
|
||||||
|
|
||||||
|
if client is None:
|
||||||
|
return _build_result(success=False, candidate_count=0, saved_count=0, errors=login_errors)
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
fetch_errors: List[str] = []
|
||||||
|
skipped_count = 0
|
||||||
|
for sport_type in sport_types:
|
||||||
|
for show_type in show_types:
|
||||||
|
try:
|
||||||
|
items.extend(client.fetch_game_list(sport_type, show_type))
|
||||||
|
except OddsGameListUnavailable:
|
||||||
|
skipped_count += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
fetch_errors.append(f"{sport_type}/{show_type}: {exc}")
|
||||||
|
if request_delay > 0:
|
||||||
|
time.sleep(request_delay)
|
||||||
|
|
||||||
|
repo = None
|
||||||
|
try:
|
||||||
|
repo = MatchOddsRepository(db_config)
|
||||||
|
repo.ensure_table()
|
||||||
|
saved_count = repo.upsert_latest(items, now_ts=int(time.time()))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return _build_result(
|
||||||
|
success=False,
|
||||||
|
candidate_count=len(items),
|
||||||
|
saved_count=0,
|
||||||
|
source_url=client.base_url,
|
||||||
|
skipped_count=skipped_count,
|
||||||
|
errors=fetch_errors + [f"database: {exc}"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if repo is not None:
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
return _build_result(
|
||||||
|
success=len(fetch_errors) == 0,
|
||||||
|
candidate_count=len(items),
|
||||||
|
saved_count=saved_count,
|
||||||
|
source_url=client.base_url,
|
||||||
|
skipped_count=skipped_count,
|
||||||
|
errors=fetch_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from src.core.config import load_config
|
||||||
|
|
||||||
|
cfg = load_config().database
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
run(
|
||||||
|
{
|
||||||
|
"host": cfg.host,
|
||||||
|
"port": cfg.port,
|
||||||
|
"user": cfg.username,
|
||||||
|
"password": cfg.password,
|
||||||
|
"database": cfg.database,
|
||||||
|
"charset": cfg.charset,
|
||||||
|
"prefix": cfg.prefix,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import match_odds
|
||||||
|
from match_odds import MatchOddsRepository, OddsGameListUnavailable, parse_game_list_xml, run
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_GAME_LIST_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<serverresponse sip="14.39">
|
||||||
|
<dataCount>1</dataCount>
|
||||||
|
<totalDataCount>1</totalDataCount>
|
||||||
|
<pageCount>1</pageCount>
|
||||||
|
<ec id="ec11290788" hasEC="Y" myGame="ft">
|
||||||
|
<game id="8892295">
|
||||||
|
<GID>8892295</GID>
|
||||||
|
<DATETIME>07-09 04:00p</DATETIME>
|
||||||
|
<LID>104036</LID>
|
||||||
|
<LEAGUE>世界杯2026(美加墨)</LEAGUE>
|
||||||
|
<GIDM>7972917</GIDM>
|
||||||
|
<GNUM_H>402008</GNUM_H>
|
||||||
|
<GNUM_C>402007</GNUM_C>
|
||||||
|
<TEAM_H_ID>122821</TEAM_H_ID>
|
||||||
|
<TEAM_C_ID>122841</TEAM_C_ID>
|
||||||
|
<TEAM_H>法国</TEAM_H>
|
||||||
|
<TEAM_C>摩洛哥</TEAM_C>
|
||||||
|
<STRONG>H</STRONG>
|
||||||
|
<MORE>162</MORE>
|
||||||
|
<ECID>11290788</ECID>
|
||||||
|
<RATIO_R>1</RATIO_R>
|
||||||
|
<IOR_RH>1.030</IOR_RH>
|
||||||
|
<IOR_RC>0.860</IOR_RC>
|
||||||
|
<RATIO_OUO>2.5</RATIO_OUO>
|
||||||
|
<RATIO_OUU>2.5</RATIO_OUU>
|
||||||
|
<IOR_OUH>0.850</IOR_OUH>
|
||||||
|
<IOR_OUC>1.030</IOR_OUC>
|
||||||
|
<IOR_MH>1.57</IOR_MH>
|
||||||
|
<IOR_MC>6.60</IOR_MC>
|
||||||
|
<IOR_MN>3.90</IOR_MN>
|
||||||
|
<RATIO_HR>0 / 0.5</RATIO_HR>
|
||||||
|
<IOR_HRH>0.810</IOR_HRH>
|
||||||
|
<IOR_HRC>1.070</IOR_HRC>
|
||||||
|
<IOR_HMH>2.28</IOR_HMH>
|
||||||
|
<IOR_HMC>5.90</IOR_HMC>
|
||||||
|
<IOR_HMN>2.11</IOR_HMN>
|
||||||
|
<SYSTIME>2026-07-09 10:29:39</SYSTIME>
|
||||||
|
<IS_RB>N</IS_RB>
|
||||||
|
</game>
|
||||||
|
</ec>
|
||||||
|
</serverresponse>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __init__(self):
|
||||||
|
self.executed = []
|
||||||
|
self.rowcount = 1
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
self.executed.append((sql, params))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.cursor_instance = FakeCursor()
|
||||||
|
self.commit_count = 0
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return self.cursor_instance
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.commit_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
class MatchOddsTest(unittest.TestCase):
|
||||||
|
def _build_repo(self, prefix="la_"):
|
||||||
|
repo = object.__new__(MatchOddsRepository)
|
||||||
|
repo.prefix = prefix
|
||||||
|
repo.conn = FakeConnection()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
def test_parse_game_list_xml_extracts_main_markets_and_raw_data(self):
|
||||||
|
items = parse_game_list_xml(
|
||||||
|
SAMPLE_GAME_LIST_XML,
|
||||||
|
source_site="hg",
|
||||||
|
source_url="https://mos022.com/",
|
||||||
|
sport_type="FT",
|
||||||
|
show_type="today",
|
||||||
|
odds_type="H",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
item = items[0]
|
||||||
|
self.assertEqual(item["source_match_id"], "8892295")
|
||||||
|
self.assertEqual(item["league_name"], "世界杯2026(美加墨)")
|
||||||
|
self.assertEqual(item["home_team"], "法国")
|
||||||
|
self.assertEqual(item["away_team"], "摩洛哥")
|
||||||
|
self.assertEqual(item["match_time_text"], "07-09 04:00p")
|
||||||
|
self.assertEqual(item["handicap_line"], "1")
|
||||||
|
self.assertEqual(item["handicap_home_odds"], "1.030")
|
||||||
|
self.assertEqual(item["handicap_away_odds"], "0.860")
|
||||||
|
self.assertEqual(item["total_line"], "2.5")
|
||||||
|
self.assertEqual(item["total_over_odds"], "1.030")
|
||||||
|
self.assertEqual(item["total_under_odds"], "0.850")
|
||||||
|
self.assertEqual(item["home_win_odds"], "1.57")
|
||||||
|
self.assertEqual(item["away_win_odds"], "6.60")
|
||||||
|
self.assertEqual(item["draw_odds"], "3.90")
|
||||||
|
self.assertEqual(item["raw_data"]["GID"], "8892295")
|
||||||
|
self.assertEqual(item["markets"]["first_half"]["home_win"], "2.28")
|
||||||
|
|
||||||
|
def test_repository_upserts_latest_odds_by_source_match_id(self):
|
||||||
|
items = parse_game_list_xml(
|
||||||
|
SAMPLE_GAME_LIST_XML,
|
||||||
|
source_site="hg",
|
||||||
|
source_url="https://mos022.com/",
|
||||||
|
sport_type="FT",
|
||||||
|
show_type="today",
|
||||||
|
odds_type="H",
|
||||||
|
)
|
||||||
|
repo = self._build_repo()
|
||||||
|
|
||||||
|
saved = repo.upsert_latest(items, now_ts=1783607379)
|
||||||
|
|
||||||
|
self.assertEqual(saved, 1)
|
||||||
|
self.assertEqual(repo.conn.commit_count, 1)
|
||||||
|
sql, params = repo.conn.cursor_instance.executed[0]
|
||||||
|
self.assertIn("INSERT INTO `la_match_odds`", sql)
|
||||||
|
self.assertIn("ON DUPLICATE KEY UPDATE", sql)
|
||||||
|
self.assertIn("`source_match_id`", sql)
|
||||||
|
self.assertEqual(params[0], "hg")
|
||||||
|
self.assertEqual(params[2], "8892295")
|
||||||
|
self.assertEqual(json.loads(params[-3])["GID"], "8892295")
|
||||||
|
self.assertEqual(json.loads(params[-2])["moneyline"]["draw"], "3.90")
|
||||||
|
|
||||||
|
def test_run_skips_without_credentials(self):
|
||||||
|
result = run({"prefix": "la_"}, env={})
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["candidate_count"], 0)
|
||||||
|
self.assertIn("ODDS_USERNAME", result["error"])
|
||||||
|
|
||||||
|
def test_run_returns_structured_failure_when_database_is_unavailable(self):
|
||||||
|
class FakeClient:
|
||||||
|
base_url = "https://mos022.com/"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_game_list(self, sport_type, show_type):
|
||||||
|
return parse_game_list_xml(
|
||||||
|
SAMPLE_GAME_LIST_XML,
|
||||||
|
source_site="hg",
|
||||||
|
source_url="https://mos022.com/",
|
||||||
|
sport_type=sport_type,
|
||||||
|
show_type=show_type,
|
||||||
|
odds_type="H",
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(match_odds, "HgOddsClient", FakeClient):
|
||||||
|
with mock.patch.object(match_odds, "MatchOddsRepository", side_effect=RuntimeError("db down")):
|
||||||
|
result = run(
|
||||||
|
{"prefix": "la_"},
|
||||||
|
env={
|
||||||
|
"ODDS_USERNAME": "u",
|
||||||
|
"ODDS_PASSWORD": "p",
|
||||||
|
"ODDS_SPORT_TYPES": "FT",
|
||||||
|
"ODDS_SHOW_TYPES": "today",
|
||||||
|
"ODDS_REQUEST_DELAY_SECONDS": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["candidate_count"], 1)
|
||||||
|
self.assertIn("db down", result["error"])
|
||||||
|
|
||||||
|
def test_run_skips_unavailable_game_list_combinations_without_failing(self):
|
||||||
|
class FakeClient:
|
||||||
|
base_url = "https://mos022.com/"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_game_list(self, sport_type, show_type):
|
||||||
|
if show_type == "early":
|
||||||
|
raise OddsGameListUnavailable("早盘日期不可用")
|
||||||
|
return parse_game_list_xml(
|
||||||
|
SAMPLE_GAME_LIST_XML,
|
||||||
|
source_site="hg",
|
||||||
|
source_url="https://mos022.com/",
|
||||||
|
sport_type=sport_type,
|
||||||
|
show_type=show_type,
|
||||||
|
odds_type="H",
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeRepo:
|
||||||
|
def __init__(self, db_config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ensure_table(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def upsert_latest(self, items, now_ts=None):
|
||||||
|
return len(items)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with mock.patch.object(match_odds, "HgOddsClient", FakeClient):
|
||||||
|
with mock.patch.object(match_odds, "MatchOddsRepository", FakeRepo):
|
||||||
|
result = run(
|
||||||
|
{"prefix": "la_"},
|
||||||
|
env={
|
||||||
|
"ODDS_USERNAME": "u",
|
||||||
|
"ODDS_PASSWORD": "p",
|
||||||
|
"ODDS_SPORT_TYPES": "FT",
|
||||||
|
"ODDS_SHOW_TYPES": "today,early",
|
||||||
|
"ODDS_REQUEST_DELAY_SECONDS": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(result["candidate_count"], 1)
|
||||||
|
self.assertEqual(result["saved_count"], 1)
|
||||||
|
self.assertEqual(result["skipped_count"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user