deploy: auto commit docker changes 2026-07-09 22:54:47

This commit is contained in:
hajimi
2026-07-09 22:54:47 +08:00
parent 6d2302875b
commit 66fd64ef0f
6 changed files with 931 additions and 1 deletions
+8
View File
@@ -21,6 +21,14 @@ AI_KB_REDIS_DB=4
DQD_LOG_LEVEL=INFO
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.
EMBEDDING_API_KEY=change-me
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
+5
View File
@@ -159,3 +159,8 @@ tasks:
action: match_live_stream
cron: "*/1 * * * *"
active: true
- key: match_odds
name: 第三方赛事赔率抓取
action: match_odds
cron: "*/5 * * * *"
active: true
+1 -1
View File
@@ -19,7 +19,7 @@ from pathlib import Path
env_file = Path("/etc/sport-era-crawler/env.sh")
with env_file.open("w", encoding="utf-8") as fp:
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")
env_file.chmod(0o600)
PY
+27
View File
@@ -31,6 +31,7 @@
python main.py cba_news # 采集CBA新闻资讯
python main.py ai_comment_dispatch # Docker AI 评论调度
python main.py match_live_stream # 抓取赛事直播线路播放流
python main.py match_odds # 抓取第三方赛事赔率
python main.py article_content_fetch # 抓取资讯文章正文内容
python main.py all # 采集积分榜 + 赛程
python main.py single <season_id> # 采集单个联赛积分榜
@@ -61,6 +62,7 @@ TASK_ALERT_POLICY = {
"lottery_draw_force": "saved_if_candidates",
"ai_comment_dispatch": "saved_if_candidates",
"match_live_stream": "saved_if_candidates",
"match_odds": "saved_if_candidates",
}
DEFAULT_ACTION_TIMEOUT = 600
@@ -89,6 +91,7 @@ ACTION_TIMEOUTS = {
"dqd_worldcup": 600,
"ai_comment_dispatch": 1800,
"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)
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():
from src.scheduler.task_runner import TaskRunner
async with TaskRunner() as runner:
@@ -859,6 +885,7 @@ def main():
"cba_news": cmd_cba_news,
"ai_comment_dispatch": cmd_ai_comment_dispatch,
"match_live_stream": cmd_match_live_stream,
"match_odds": cmd_match_odds,
"article_content_fetch": cmd_article_content_fetch,
"match_finish": cmd_match_finish,
"live_detail": cmd_live_detail,
+643
View File
@@ -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,
)
)
+247
View File
@@ -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()