Files
2026-07-11 11:19:43 +08:00

726 lines
28 KiB
Python

#!/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
from titan_odds import (
DEFAULT_TITAN_COMPANY_ID,
DEFAULT_TITAN_COMPANY_NAME,
DEFAULT_TITAN_CUP_URLS,
DEFAULT_TITAN_EURO_COMPANY_ID,
TitanOddsClient,
)
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 _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(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
items: List[Dict[str, Any]] = []
fetch_errors: List[str] = []
source_urls: List[str] = []
successful_sources = 0
skipped_count = 0
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 username and 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 = _safe_float(
env.get("ODDS_REQUEST_DELAY_SECONDS"),
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"hg {base_url}: {exc}")
if client is None:
fetch_errors.extend(login_errors)
else:
successful_sources += 1
source_urls.append(client.base_url)
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"hg {sport_type}/{show_type}: {exc}")
if request_delay > 0:
time.sleep(request_delay)
titan_enabled = _bool_env(env.get("TITAN_ODDS_ENABLED"), default=True)
if titan_enabled:
titan_urls = [
str(url).strip()
for url in _split_env_list(
env.get("TITAN_ODDS_URLS", ""),
DEFAULT_TITAN_CUP_URLS,
)
]
titan_client = TitanOddsClient(
timeout=_safe_int(env.get("TITAN_ODDS_TIMEOUT"), 20),
verify_tls=_bool_env(env.get("TITAN_ODDS_VERIFY_TLS"), default=True),
company_id=_safe_int(
env.get("TITAN_ODDS_COMPANY_ID"),
DEFAULT_TITAN_COMPANY_ID,
),
euro_company_id=_safe_int(
env.get("TITAN_ODDS_EURO_COMPANY_ID"),
DEFAULT_TITAN_EURO_COMPANY_ID,
),
company_name=(
env.get("TITAN_ODDS_COMPANY_NAME")
or DEFAULT_TITAN_COMPANY_NAME
).strip(),
show_type=(env.get("TITAN_ODDS_SHOW_TYPE") or "early").strip().lower(),
request_delay=_safe_float(
env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"),
0.2,
),
)
for titan_url in titan_urls:
try:
items.extend(titan_client.fetch_cup(titan_url))
successful_sources += 1
source_urls.append(titan_url)
except Exception as exc: # noqa: BLE001
fetch_errors.append(f"titan007 {titan_url}: {exc}")
titan_request_delay = _safe_float(
env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"),
0.2,
)
if titan_request_delay > 0:
time.sleep(titan_request_delay)
if successful_sources == 0:
if not username or not password:
fetch_errors.append("ODDS_USERNAME/ODDS_PASSWORD 未配置")
return _build_result(
success=False,
candidate_count=len(items),
saved_count=0,
source_url=",".join(dict.fromkeys(source_urls)),
skipped_count=skipped_count,
errors=fetch_errors or ["没有可用的赔率来源"],
)
repo = None
source_url = ",".join(dict.fromkeys(source_urls))
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=source_url,
skipped_count=skipped_count,
errors=fetch_errors + [f"database: {exc}"],
)
finally:
if repo is not None:
repo.close()
return _build_result(
success=True,
candidate_count=len(items),
saved_count=saved_count,
source_url=source_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,
)
)