添加赔率
This commit is contained in:
@@ -29,6 +29,17 @@ ODDS_SPORT_TYPES=FT,BK,ES,TN,VB,BM,TT,BS,SK,OP
|
||||
ODDS_SHOW_TYPES=live,today,early
|
||||
ODDS_VERIFY_TLS=0
|
||||
|
||||
# Titan007 public cup odds. Multiple cup URLs can be comma-separated.
|
||||
TITAN_ODDS_ENABLED=1
|
||||
TITAN_ODDS_URLS=https://zq.titan007.com/cn/CupMatch/75.html
|
||||
TITAN_ODDS_COMPANY_ID=3
|
||||
TITAN_ODDS_EURO_COMPANY_ID=545
|
||||
TITAN_ODDS_COMPANY_NAME=Crown
|
||||
TITAN_ODDS_SHOW_TYPE=early
|
||||
TITAN_ODDS_VERIFY_TLS=1
|
||||
TITAN_ODDS_TIMEOUT=20
|
||||
TITAN_ODDS_REQUEST_DELAY_SECONDS=0.2
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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_", "ODDS_", "HG_ODDS_")) or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}:
|
||||
if key.startswith(("DQD_", "ODDS_", "HG_ODDS_", "TITAN_ODDS_")) or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}:
|
||||
fp.write(f"export {key}={shlex.quote(value)}\n")
|
||||
env_file.chmod(0o600)
|
||||
PY
|
||||
|
||||
+134
-52
@@ -15,6 +15,14 @@ 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/",
|
||||
@@ -70,6 +78,13 @@ def _safe_int(value: Any, default: int = 0) -> int:
|
||||
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()
|
||||
|
||||
@@ -538,62 +553,129 @@ def _build_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] = []
|
||||
source_urls: List[str] = []
|
||||
successful_sources = 0
|
||||
skipped_count = 0
|
||||
for sport_type in sport_types:
|
||||
for show_type in show_types:
|
||||
|
||||
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:
|
||||
items.extend(client.fetch_game_list(sport_type, show_type))
|
||||
except OddsGameListUnavailable:
|
||||
skipped_count += 1
|
||||
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
|
||||
fetch_errors.append(f"{sport_type}/{show_type}: {exc}")
|
||||
if request_delay > 0:
|
||||
time.sleep(request_delay)
|
||||
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()
|
||||
@@ -603,7 +685,7 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict
|
||||
success=False,
|
||||
candidate_count=len(items),
|
||||
saved_count=0,
|
||||
source_url=client.base_url,
|
||||
source_url=source_url,
|
||||
skipped_count=skipped_count,
|
||||
errors=fetch_errors + [f"database: {exc}"],
|
||||
)
|
||||
@@ -612,10 +694,10 @@ def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict
|
||||
repo.close()
|
||||
|
||||
return _build_result(
|
||||
success=len(fetch_errors) == 0,
|
||||
success=True,
|
||||
candidate_count=len(items),
|
||||
saved_count=saved_count,
|
||||
source_url=client.base_url,
|
||||
source_url=source_url,
|
||||
skipped_count=skipped_count,
|
||||
errors=fetch_errors,
|
||||
)
|
||||
|
||||
@@ -146,7 +146,7 @@ class MatchOddsTest(unittest.TestCase):
|
||||
self.assertEqual(json.loads(params[-2])["moneyline"]["draw"], "3.90")
|
||||
|
||||
def test_run_skips_without_credentials(self):
|
||||
result = run({"prefix": "la_"}, env={})
|
||||
result = run({"prefix": "la_"}, env={"TITAN_ODDS_ENABLED": "0"})
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["candidate_count"], 0)
|
||||
@@ -182,6 +182,7 @@ class MatchOddsTest(unittest.TestCase):
|
||||
"ODDS_SPORT_TYPES": "FT",
|
||||
"ODDS_SHOW_TYPES": "today",
|
||||
"ODDS_REQUEST_DELAY_SECONDS": "0",
|
||||
"TITAN_ODDS_ENABLED": "0",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -234,6 +235,7 @@ class MatchOddsTest(unittest.TestCase):
|
||||
"ODDS_SPORT_TYPES": "FT",
|
||||
"ODDS_SHOW_TYPES": "today,early",
|
||||
"ODDS_REQUEST_DELAY_SECONDS": "0",
|
||||
"TITAN_ODDS_ENABLED": "0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from titan_odds import (
|
||||
build_titan_odds_items,
|
||||
parse_titan_odds_script,
|
||||
parse_titan_schedule_script,
|
||||
)
|
||||
|
||||
|
||||
SCHEDULE_SCRIPT = r"""
|
||||
var arrCup = [75,'世界杯','世界盃','World Cup','世界杯','世界盃','World Cup','2026'];
|
||||
jh["G27976"] = [[2907402,75,0,'2026-07-12 05:00',640,744,'','','19','4',-0.5,-0.25,'2.5','1']];
|
||||
var arrTeam = [
|
||||
[640,'挪威','挪威','Norway','','images/norway.png'],
|
||||
[744,'英格兰','英格蘭','England','','images/england.png']
|
||||
];
|
||||
var lastUpdateTime = '2026-07-11 10:00:12';
|
||||
"""
|
||||
|
||||
ODDS_SCRIPT = """
|
||||
oddsData["O_2907402"]=[[545,3.5,3.6,2.01],[16,3.3,3.55,2.13]];
|
||||
oddsData["L_2907402"]=[[3,0.88,-0.5,0.94],[16,0.86,-0.5,0.98]];
|
||||
oddsData["T_2907402"]=[[3,0.92,2.5,0.88],[16,0.93,2.5,0.87]];
|
||||
"""
|
||||
|
||||
|
||||
class TitanOddsTest(unittest.TestCase):
|
||||
def test_parse_schedule_and_odds_builds_normalized_item(self):
|
||||
schedule = parse_titan_schedule_script(SCHEDULE_SCRIPT)
|
||||
odds = parse_titan_odds_script(ODDS_SCRIPT)
|
||||
|
||||
items = build_titan_odds_items(
|
||||
schedule,
|
||||
odds,
|
||||
source_url="https://zq.titan007.com/cn/CupMatch/75.html",
|
||||
company_id=3,
|
||||
euro_company_id=545,
|
||||
company_name="Crown",
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
item = items[0]
|
||||
self.assertEqual(item["source_site"], "titan007")
|
||||
self.assertEqual(item["source_match_id"], "2907402")
|
||||
self.assertEqual(item["league_name"], "世界杯")
|
||||
self.assertEqual(item["home_team"], "挪威")
|
||||
self.assertEqual(item["away_team"], "英格兰")
|
||||
self.assertEqual(item["show_type"], "early")
|
||||
self.assertEqual(item["strong_side"], "C")
|
||||
self.assertEqual(item["handicap_line"], "0.5")
|
||||
self.assertEqual(item["handicap_home_odds"], "0.88")
|
||||
self.assertEqual(item["handicap_away_odds"], "0.94")
|
||||
self.assertEqual(item["total_line"], "2.5")
|
||||
self.assertEqual(item["home_win_odds"], "3.5")
|
||||
self.assertEqual(item["draw_odds"], "3.6")
|
||||
self.assertEqual(item["away_win_odds"], "2.01")
|
||||
self.assertEqual(item["raw_data"]["selected_company_id"], 3)
|
||||
self.assertEqual(item["raw_data"]["selected_euro_company_id"], 545)
|
||||
|
||||
def test_sparse_schedule_rows_are_parsed_without_eval(self):
|
||||
script = r"""
|
||||
var arrCup = [75,'世界杯','','','','','','2026'];
|
||||
jh["G27977"] = [[2907405,75,0,'2026-07-16 03:00',77068,77069,'','','','',,,'','']];
|
||||
var arrTeam = [[77068,'99胜者'],[77069,'100胜者']];
|
||||
"""
|
||||
|
||||
parsed = parse_titan_schedule_script(script)
|
||||
|
||||
self.assertIn("2907405", parsed["matches"])
|
||||
self.assertEqual(parsed["matches"]["2907405"]["home_team"], "99胜者")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
DEFAULT_TITAN_CUP_URLS = [
|
||||
"https://zq.titan007.com/cn/CupMatch/75.html",
|
||||
]
|
||||
DEFAULT_TITAN_COMPANY_ID = 3
|
||||
DEFAULT_TITAN_EURO_COMPANY_ID = 545
|
||||
DEFAULT_TITAN_COMPANY_NAME = "Crown"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def _limit(value: Any, size: int) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()[:size]
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _number_text(value: Any, *, absolute: bool = False) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
if absolute:
|
||||
number = abs(number)
|
||||
text = format(number.normalize(), "f")
|
||||
return "0" if text in {"-0", ""} else text
|
||||
except (InvalidOperation, ValueError):
|
||||
return _limit(value, 32)
|
||||
|
||||
|
||||
def _extract_array_expression(script: str, start_at: int) -> Tuple[str, int]:
|
||||
start = script.find("[", start_at)
|
||||
if start < 0:
|
||||
raise ValueError("JavaScript array start not found")
|
||||
|
||||
depth = 0
|
||||
quote = ""
|
||||
escaped = False
|
||||
for index in range(start, len(script)):
|
||||
char = script[index]
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = ""
|
||||
continue
|
||||
if char in {"'", '"'}:
|
||||
quote = char
|
||||
elif char == "[":
|
||||
depth += 1
|
||||
elif char == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return script[start : index + 1], index + 1
|
||||
raise ValueError("JavaScript array end not found")
|
||||
|
||||
|
||||
def _fill_sparse_array_holes(expression: str) -> str:
|
||||
output: List[str] = []
|
||||
quote = ""
|
||||
escaped = False
|
||||
length = len(expression)
|
||||
for index, char in enumerate(expression):
|
||||
output.append(char)
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = ""
|
||||
continue
|
||||
if char in {"'", '"'}:
|
||||
quote = char
|
||||
continue
|
||||
if char not in {"[", ","}:
|
||||
continue
|
||||
next_index = index + 1
|
||||
while next_index < length and expression[next_index].isspace():
|
||||
next_index += 1
|
||||
if next_index < length and expression[next_index] == ",":
|
||||
output.append("None")
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def _parse_js_array(expression: str) -> List[Any]:
|
||||
normalized = _fill_sparse_array_holes(expression)
|
||||
value = ast.literal_eval(normalized)
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("JavaScript assignment is not an array")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_named_array(script: str, marker: str) -> List[Any]:
|
||||
marker_index = script.find(marker)
|
||||
if marker_index < 0:
|
||||
raise ValueError(f"JavaScript marker not found: {marker}")
|
||||
expression, _ = _extract_array_expression(script, marker_index + len(marker))
|
||||
return _parse_js_array(expression)
|
||||
|
||||
|
||||
def _is_match_row(value: Any) -> bool:
|
||||
if not isinstance(value, list) or len(value) < 6:
|
||||
return False
|
||||
if _safe_int(value[0]) <= 0:
|
||||
return False
|
||||
return isinstance(value[3], str) and bool(re.match(r"^\d{4}-\d{2}-\d{2}\s", value[3]))
|
||||
|
||||
|
||||
def _flatten_match_rows(value: Any) -> Iterable[List[Any]]:
|
||||
if _is_match_row(value):
|
||||
yield value
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
yield from _flatten_match_rows(item)
|
||||
|
||||
|
||||
def parse_titan_schedule_script(script: str) -> Dict[str, Any]:
|
||||
script = (script or "").lstrip("\ufeff")
|
||||
cup = _parse_named_array(script, "var arrCup =")
|
||||
teams = _parse_named_array(script, "var arrTeam =")
|
||||
team_map = {
|
||||
str(row[0]): str(row[1]).strip()
|
||||
for row in teams
|
||||
if isinstance(row, list) and len(row) >= 2
|
||||
}
|
||||
|
||||
matches: Dict[str, Dict[str, Any]] = {}
|
||||
assignment_pattern = re.compile(r'jh\["(G[^"]+)"\]\s*=')
|
||||
for assignment in assignment_pattern.finditer(script):
|
||||
expression, _ = _extract_array_expression(script, assignment.end())
|
||||
event_key = assignment.group(1)
|
||||
for row in _flatten_match_rows(_parse_js_array(expression)):
|
||||
match_id = str(row[0])
|
||||
home_team_id = str(row[4] or "")
|
||||
away_team_id = str(row[5] or "")
|
||||
matches[match_id] = {
|
||||
"source_match_id": match_id,
|
||||
"source_event_id": event_key,
|
||||
"match_time_text": str(row[3] or "").strip(),
|
||||
"home_team_id": home_team_id,
|
||||
"away_team_id": away_team_id,
|
||||
"home_team": team_map.get(home_team_id, home_team_id),
|
||||
"away_team": team_map.get(away_team_id, away_team_id),
|
||||
"state": row[2] if len(row) > 2 else 0,
|
||||
"schedule": row,
|
||||
}
|
||||
|
||||
last_update_match = re.search(r"var\s+lastUpdateTime\s*=\s*'([^']*)'", script)
|
||||
return {
|
||||
"source_league_id": str(cup[0] if cup else ""),
|
||||
"league_name": str(cup[1] if len(cup) > 1 else "球探赛事").strip(),
|
||||
"season": str(cup[7] if len(cup) > 7 else "").strip(),
|
||||
"last_update_time": last_update_match.group(1) if last_update_match else "",
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
def parse_titan_odds_script(script: str) -> Dict[str, List[List[Any]]]:
|
||||
script = (script or "").lstrip("\ufeff")
|
||||
odds: Dict[str, List[List[Any]]] = {}
|
||||
assignment_pattern = re.compile(r'oddsData\["([OLT]_\d+)"\]\s*=')
|
||||
for assignment in assignment_pattern.finditer(script):
|
||||
expression, _ = _extract_array_expression(script, assignment.end())
|
||||
value = json.loads(expression)
|
||||
if isinstance(value, list):
|
||||
odds[assignment.group(1)] = value
|
||||
return odds
|
||||
|
||||
|
||||
def _find_company_row(rows: List[List[Any]], company_id: int) -> Optional[List[Any]]:
|
||||
for row in rows:
|
||||
if isinstance(row, list) and row and _safe_int(row[0], -1) == company_id:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _select_company_id(market_rows: Dict[str, List[List[Any]]], preferred_id: int) -> int:
|
||||
coverage: Dict[int, int] = {}
|
||||
for rows in market_rows.values():
|
||||
seen = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, list) or not row:
|
||||
continue
|
||||
company_id = _safe_int(row[0], -1)
|
||||
if company_id < 0 or company_id in seen:
|
||||
continue
|
||||
seen.add(company_id)
|
||||
coverage[company_id] = coverage.get(company_id, 0) + 1
|
||||
if preferred_id in coverage:
|
||||
return preferred_id
|
||||
if not coverage:
|
||||
return preferred_id
|
||||
return sorted(coverage, key=lambda company_id: (-coverage[company_id], company_id))[0]
|
||||
|
||||
|
||||
def build_titan_odds_items(
|
||||
schedule_data: Dict[str, Any],
|
||||
odds_data: Dict[str, List[List[Any]]],
|
||||
*,
|
||||
source_url: str,
|
||||
company_id: int = DEFAULT_TITAN_COMPANY_ID,
|
||||
euro_company_id: int = DEFAULT_TITAN_EURO_COMPANY_ID,
|
||||
company_name: str = DEFAULT_TITAN_COMPANY_NAME,
|
||||
show_type: str = "early",
|
||||
) -> List[Dict[str, Any]]:
|
||||
items: List[Dict[str, Any]] = []
|
||||
matches = schedule_data.get("matches") or {}
|
||||
for match_id, match in matches.items():
|
||||
market_rows = {
|
||||
"moneyline": odds_data.get(f"O_{match_id}", []),
|
||||
"handicap": odds_data.get(f"L_{match_id}", []),
|
||||
"total": odds_data.get(f"T_{match_id}", []),
|
||||
}
|
||||
selected_company_id = _select_company_id(
|
||||
{
|
||||
"handicap": market_rows["handicap"],
|
||||
"total": market_rows["total"],
|
||||
},
|
||||
company_id,
|
||||
)
|
||||
selected_euro_company_id = _select_company_id(
|
||||
{"moneyline": market_rows["moneyline"]},
|
||||
euro_company_id,
|
||||
)
|
||||
moneyline = _find_company_row(
|
||||
market_rows["moneyline"],
|
||||
selected_euro_company_id,
|
||||
)
|
||||
handicap = _find_company_row(market_rows["handicap"], selected_company_id)
|
||||
total = _find_company_row(market_rows["total"], selected_company_id)
|
||||
if not any((moneyline, handicap, total)):
|
||||
continue
|
||||
|
||||
handicap_value = handicap[2] if handicap and len(handicap) > 2 else ""
|
||||
handicap_number: Optional[Decimal] = None
|
||||
try:
|
||||
handicap_number = Decimal(str(handicap_value))
|
||||
except (InvalidOperation, ValueError):
|
||||
pass
|
||||
strong_side = ""
|
||||
if handicap_number is not None:
|
||||
if handicap_number > 0:
|
||||
strong_side = "H"
|
||||
elif handicap_number < 0:
|
||||
strong_side = "C"
|
||||
|
||||
company_ids = {
|
||||
_safe_int(row[0], -1)
|
||||
for rows in market_rows.values()
|
||||
for row in rows
|
||||
if isinstance(row, list) and row
|
||||
}
|
||||
company_ids.discard(-1)
|
||||
markets = {
|
||||
"handicap": {
|
||||
"line": _number_text(handicap_value, absolute=True),
|
||||
"home": _number_text(handicap[1]) if handicap and len(handicap) > 1 else "",
|
||||
"away": _number_text(handicap[3]) if handicap and len(handicap) > 3 else "",
|
||||
"strong": strong_side,
|
||||
},
|
||||
"total": {
|
||||
"over_line": _number_text(total[2]) if total and len(total) > 2 else "",
|
||||
"under_line": _number_text(total[2]) if total and len(total) > 2 else "",
|
||||
"over": _number_text(total[1]) if total and len(total) > 1 else "",
|
||||
"under": _number_text(total[3]) if total and len(total) > 3 else "",
|
||||
},
|
||||
"moneyline": {
|
||||
"home": _number_text(moneyline[1]) if moneyline and len(moneyline) > 1 else "",
|
||||
"draw": _number_text(moneyline[2]) if moneyline and len(moneyline) > 2 else "",
|
||||
"away": _number_text(moneyline[3]) if moneyline and len(moneyline) > 3 else "",
|
||||
},
|
||||
"first_half": {
|
||||
"handicap_line": "",
|
||||
"handicap_home": "",
|
||||
"handicap_away": "",
|
||||
"total_over_line": "",
|
||||
"total_under_line": "",
|
||||
"total_over": "",
|
||||
"total_under": "",
|
||||
"home_win": "",
|
||||
"away_win": "",
|
||||
"draw": "",
|
||||
},
|
||||
"both_teams_to_score": {"yes": "", "no": ""},
|
||||
"kickoff": {"home": "", "away": ""},
|
||||
}
|
||||
selected_company_name = company_name if selected_company_id == company_id else str(selected_company_id)
|
||||
raw_data = {
|
||||
"schedule": match.get("schedule") or [],
|
||||
"odds": {
|
||||
"moneyline": market_rows["moneyline"],
|
||||
"handicap": market_rows["handicap"],
|
||||
"total": market_rows["total"],
|
||||
},
|
||||
"selected_company_id": selected_company_id,
|
||||
"selected_euro_company_id": selected_euro_company_id,
|
||||
"selected_company_name": selected_company_name,
|
||||
"last_update_time": schedule_data.get("last_update_time") or "",
|
||||
}
|
||||
items.append(
|
||||
{
|
||||
"source_site": "titan007",
|
||||
"source_url": _limit(source_url, 255),
|
||||
"source_match_id": _limit(match_id, 64),
|
||||
"source_parent_id": "",
|
||||
"source_league_id": _limit(schedule_data.get("source_league_id"), 64),
|
||||
"source_event_id": _limit(match.get("source_event_id"), 64),
|
||||
"sport_type": "FT",
|
||||
"show_type": _limit(show_type, 20),
|
||||
"odds_type": "H",
|
||||
"league_name": _limit(schedule_data.get("league_name"), 150),
|
||||
"match_time_text": _limit(match.get("match_time_text"), 50),
|
||||
"home_team_id": _limit(match.get("home_team_id"), 64),
|
||||
"away_team_id": _limit(match.get("away_team_id"), 64),
|
||||
"home_team": _limit(match.get("home_team"), 150),
|
||||
"away_team": _limit(match.get("away_team"), 150),
|
||||
"strong_side": strong_side,
|
||||
"more_count": len(company_ids),
|
||||
"is_running": 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": "",
|
||||
"half_home_win_odds": "",
|
||||
"half_draw_odds": "",
|
||||
"half_away_win_odds": "",
|
||||
"raw_data": raw_data,
|
||||
"markets": markets,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
class TitanOddsClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: int = 20,
|
||||
verify_tls: bool = True,
|
||||
company_id: int = DEFAULT_TITAN_COMPANY_ID,
|
||||
euro_company_id: int = DEFAULT_TITAN_EURO_COMPANY_ID,
|
||||
company_name: str = DEFAULT_TITAN_COMPANY_NAME,
|
||||
show_type: str = "early",
|
||||
request_delay: float = 0.2,
|
||||
) -> None:
|
||||
self.timeout = int(timeout)
|
||||
self.verify_tls = bool(verify_tls)
|
||||
self.company_id = int(company_id)
|
||||
self.euro_company_id = int(euro_company_id)
|
||||
self.company_name = company_name
|
||||
self.show_type = show_type
|
||||
self.request_delay = float(request_delay)
|
||||
self.session = requests.Session()
|
||||
self.session.verify = self.verify_tls
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/javascript,*/*;q=0.8",
|
||||
}
|
||||
)
|
||||
|
||||
def fetch_cup(self, cup_url: str) -> List[Dict[str, Any]]:
|
||||
page_response = self.session.get(cup_url, timeout=self.timeout)
|
||||
page_response.raise_for_status()
|
||||
page_html = page_response.text or ""
|
||||
season_match = re.search(r"selectSeason\s*=\s*'([^']+)'", page_html)
|
||||
league_match = re.search(r"SclassID\s*=\s*(\d+)", page_html)
|
||||
script_match = re.search(
|
||||
r"""src=["']([^"']*/jsData/matchResult/[^"']+\.js(?:\?[^"']*)?)["']""",
|
||||
page_html,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not season_match or not league_match or not script_match:
|
||||
raise ValueError("球探杯赛页面缺少赛季、联赛或赛程脚本")
|
||||
|
||||
season = season_match.group(1)
|
||||
league_id = league_match.group(1)
|
||||
schedule_url = urljoin(page_response.url, html.unescape(script_match.group(1)))
|
||||
schedule_response = self.session.get(
|
||||
schedule_url,
|
||||
headers={"Referer": page_response.url},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
schedule_response.raise_for_status()
|
||||
schedule_data = parse_titan_schedule_script(schedule_response.text)
|
||||
|
||||
if self.request_delay > 0:
|
||||
time.sleep(self.request_delay)
|
||||
|
||||
odds_url = urljoin(page_response.url, "/League/LeagueOddsAjax")
|
||||
odds_response = self.session.get(
|
||||
odds_url,
|
||||
params={
|
||||
"sclassId": league_id,
|
||||
"subSclassId": "",
|
||||
"matchSeason": season,
|
||||
"round": 1,
|
||||
},
|
||||
headers={
|
||||
"Referer": page_response.url,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
odds_response.raise_for_status()
|
||||
odds_data = parse_titan_odds_script(odds_response.text)
|
||||
return build_titan_odds_items(
|
||||
schedule_data,
|
||||
odds_data,
|
||||
source_url=page_response.url,
|
||||
company_id=self.company_id,
|
||||
euro_company_id=self.euro_company_id,
|
||||
company_name=self.company_name,
|
||||
show_type=self.show_type,
|
||||
)
|
||||
Reference in New Issue
Block a user