迁移目录

This commit is contained in:
hajimi
2026-06-12 22:27:18 +08:00
parent 7381097582
commit cd44cd6e47
92 changed files with 1839 additions and 0 deletions
+560
View File
@@ -0,0 +1,560 @@
"""
懂球帝数据解析器
- 积分榜、赛程、比赛详情、新闻等数据解析
- 数据校验和清洗
"""
import re
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple
from loguru import logger
from src.core.exceptions import ParsingError
class DongqiudiParser:
"""懂球帝数据解析器"""
# ── 积分榜 ──
def parse_standings(self, response: Dict[str, Any], season_id: int) -> List[Dict[str, Any]]:
"""解析积分榜 API 响应"""
if not isinstance(response, dict):
raise ParsingError(f"积分榜响应类型错误: {type(response)}")
content = response.get("content", {})
rounds = content.get("rounds", [])
if not rounds:
logger.warning(f"积分榜无 rounds 数据 (season_id={season_id})")
return []
standings = []
for round_data in rounds:
rc = round_data.get("content", {})
round_info = rc.get("info", {})
round_number = round_info.get("round", 0)
teams = rc.get("data", [])
for team in teams:
parsed = self._parse_team_standing(team, season_id, round_number)
if parsed:
standings.append(parsed)
logger.info(f"积分榜解析完成: season_id={season_id}, {len(standings)} 条记录")
return standings
def _parse_team_standing(self, team: Dict, season_id: int, round_number: int) -> Optional[Dict[str, Any]]:
team_id = team.get("team_id")
if not team_id:
return None
deduction_pts, deduction_reason = self._parse_deduction(team.get("instruction", {}))
recent = self._parse_recent_form(team.get("recent_record", ""))
gf = int(team.get("goals_pro", 0))
ga = int(team.get("goals_against", 0))
return {
"season_id": season_id,
"round_number": round_number,
"team_id": team_id,
"team_name": team.get("team_name", ""),
"team_logo": team.get("team_logo", ""),
"rank": int(team.get("rank", 0)),
"points": int(team.get("points", 0)),
"played": int(team.get("matches_total", 0)),
"won": int(team.get("matches_won", 0)),
"drawn": int(team.get("matches_draw", 0)),
"lost": int(team.get("matches_lost", 0)),
"goals_for": gf,
"goals_against": ga,
"goal_diff": gf - ga,
"recent_form": recent,
"deduction_points": deduction_pts,
"deduction_reason": deduction_reason,
}
# ── 赛程 ──
def parse_schedule(self, response: Dict[str, Any], season_id: int) -> List[Dict[str, Any]]:
"""解析赛程 API 响应"""
if not isinstance(response, dict):
raise ParsingError(f"赛程响应类型错误: {type(response)}")
content = response.get("content", {})
matches_raw = content.get("matches", [])
if not matches_raw:
logger.warning(f"赛程无 matches 数据 (season_id={season_id})")
return []
matches = []
for m in matches_raw:
parsed = self._parse_match(m, season_id)
if parsed:
matches.append(parsed)
logger.info(f"赛程解析完成: season_id={season_id}, {len(matches)} 场比赛")
return matches
def _parse_match(self, m: Dict, season_id: int) -> Optional[Dict[str, Any]]:
match_id = m.get("match_id")
if not match_id:
return None
start_play = self._parse_datetime(m.get("start_play"))
status = self._normalize_status(m.get("status", ""))
return {
"match_id": match_id,
"season_id": season_id,
"round_number": m.get("round_id", 0),
"round_name": m.get("round_name", ""),
"match_date": start_play,
"status": status,
"home_team_id": m.get("team_A_id"),
"home_team_name": m.get("team_A_name", ""),
"home_team_logo": m.get("team_A_logo", ""),
"away_team_id": m.get("team_B_id"),
"away_team_name": m.get("team_B_name", ""),
"away_team_logo": m.get("team_B_logo", ""),
"home_score": m.get("score_A") if status != "fixture" else None,
"away_score": m.get("score_B") if status != "fixture" else None,
"halftime_score": m.get("half_score", ""),
"venue": m.get("stadium", ""),
"competition_id": m.get("competition_id"),
"competition_name": m.get("competition_name", ""),
}
# ── 比赛详情 ──
def parse_match_detail(self, response: Dict[str, Any]) -> Dict[str, Any]:
"""解析比赛详情"""
if not isinstance(response, dict):
raise ParsingError(f"比赛详情响应类型错误: {type(response)}")
data = response.get("data", response)
return {
"match_id": data.get("match_id"),
"status": self._normalize_status(data.get("status", "")),
"home_team": data.get("team_A", {}),
"away_team": data.get("team_B", {}),
"home_score": data.get("score_A"),
"away_score": data.get("score_B"),
"events": data.get("events", []),
"statistics": data.get("statistics", {}),
"lineups": data.get("lineups", {}),
}
# ── 比赛菜单 / 联赛列表 ──
def parse_match_menu(self, response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""解析比赛类型菜单"""
if response.get("errCode") != 0:
return []
items = response.get("data", {}).get("list", [])
result = []
for item in items:
result.append({
"id": item.get("id"),
"label": item.get("label", ""),
"type": item.get("type", ""),
"sort": item.get("sort", 0),
"api": item.get("api", ""),
})
return result
# ── 新闻 ──
def parse_news(self, response: Dict[str, Any], tab_name: str = "") -> List[Dict[str, Any]]:
"""解析 /api/app/tabs/web/{id}.json 返回的文章列表"""
articles = response.get("articles", [])
if not articles:
return []
import time
now_ts = int(time.time())
result = []
for art in articles:
aid = art.get("id")
if not aid:
continue
title = art.get("title", "")
if not title:
continue
author_info = art.get("author")
if isinstance(author_info, dict):
author = author_info.get("name", "")
else:
author = str(art.get("author_name", "") or "")
result.append({
"id": aid,
"title": title,
"description": art.get("description", "") or art.get("b_description", ""),
"thumb": art.get("thumb", ""),
"author_name": author,
"published_at": art.get("published_at", ""),
"share": art.get("share", ""),
"comments_total": art.get("comments_total", 0),
"category": tab_name or "news",
"is_video": art.get("is_video", False),
"sort_timestamp": now_ts,
})
logger.info(f"从tabs API解析到 {len(result)} 篇文章 (tab={tab_name})")
return result
# ── 视频列表 ──
def parse_video_list(self, response: Dict[str, Any], tab_name: str = "") -> List[Dict[str, Any]]:
"""从视频列表页 HTML 的 __NUXT__ 中提取视频条目列表"""
import json as _json
html = response.get("html", "")
if not html:
return []
nuxt_m = re.search(r'window\.__NUXT__\s*=\s*(.+?);\s*</script>', html, re.DOTALL)
if not nuxt_m:
return []
nuxt_raw = nuxt_m.group(1)
var_map = self._parse_iife_var_map(nuxt_raw)
ids = re.findall(r'\bid:(\d{5,})', nuxt_raw)
shares = re.findall(r'\bshare:"([^"]*)"', nuxt_raw)
thumbs = re.findall(r'\bthumb:"([^"]*)"', nuxt_raw)
video_srcs = re.findall(r'\bvideo_src:"([^"]*)"', nuxt_raw)
video_times = re.findall(r'\bvideo_time:"([^"]*)"', nuxt_raw)
title_refs = re.findall(r'\btitle:([a-zA-Z]\w*)', nuxt_raw)
desc_refs = re.findall(r'\bdescription:([a-zA-Z]\w*)', nuxt_raw)
# published_at 可能是字符串值或变量引用,统一按顺序提取
pub_matches = re.findall(r'\bpublished_at:(?:"([^"]*)"|([a-zA-Z]\w*))', nuxt_raw)
count = min(len(ids), len(shares), len(thumbs))
if count == 0:
return []
def resolve(refs: list, idx: int) -> str:
if idx >= len(refs):
return ""
val = var_map.get(refs[idx])
return str(val) if val and val is not None else ""
def resolve_pub(idx: int) -> str:
if idx >= len(pub_matches):
return ""
str_val, ref_val = pub_matches[idx]
if str_val:
return str_val
if ref_val:
val = var_map.get(ref_val)
return str(val) if val and val is not None else ""
return ""
now_ts = int(datetime.now().timestamp())
videos = []
for i in range(count):
try:
article_id = int(ids[i])
share_url = _json.loads(f'"{shares[i]}"')
thumb_url = _json.loads(f'"{thumbs[i]}"')
video_src = _json.loads(f'"{video_srcs[i]}"') if i < len(video_srcs) else ""
video_time = video_times[i] if i < len(video_times) else ""
except Exception:
continue
videos.append({
"id": article_id,
"title": resolve(title_refs, i),
"description": resolve(desc_refs, i),
"thumb": thumb_url,
"share": share_url,
"is_video": True,
"video_src": video_src,
"video_time": video_time,
"category": tab_name,
"published_at": resolve_pub(i),
"sort_timestamp": now_ts,
})
return videos
# ── 文章详情 ──
def parse_article_detail(self, response: Dict[str, Any]) -> Optional[Dict[str, str]]:
"""从文章详情页 HTML 的 __NUXT__ 中提取正文及元信息"""
import json as _json
html = response.get("html", "")
if not html:
return None
nuxt_m = re.search(r'window\.__NUXT__\s*=\s*(.+?);\s*</script>', html, re.DOTALL)
if not nuxt_m:
return None
nuxt_raw = nuxt_m.group(1)
body_m = re.search(r'\bbody:"((?:[^"\\]|\\.)*)"', nuxt_raw)
if not body_m:
return None
try:
content = _json.loads(f'"{body_m.group(1)}"')
except Exception:
return None
if not content or len(content) < 10:
return None
result = {"content": content}
for field in ("title", "description", "published_at"):
m = re.search(rf'\b{field}:"((?:[^"\\]|\\.)*)"', nuxt_raw)
if m:
try:
result[field] = _json.loads(f'"{m.group(1)}"')
except Exception:
pass
author_m = re.search(r'\bauthor_name:"((?:[^"\\]|\\.)*)"', nuxt_raw)
if author_m:
try:
result["author"] = _json.loads(f'"{author_m.group(1)}"')
except Exception:
pass
return result
def parse_article_html(self, html: str) -> Optional[Dict[str, str]]:
"""从 m.dongqiudi.com 文章 HTML 中提取 <article> 标签内的富文本内容"""
if not html:
return None
article_m = re.search(r'<article[^>]*>(.*?)</article>', html, re.DOTALL)
if not article_m:
return None
article_html = article_m.group(1)
title = ""
h1_m = re.search(r'<h1[^>]*>(.*?)</h1>', article_html, re.DOTALL)
if h1_m:
title = re.sub(r'<[^>]+>', '', h1_m.group(1)).strip()
author = ""
writer_m = re.search(r'<span[^>]*class="writer"[^>]*>(.*?)</span>', article_html, re.DOTALL)
if writer_m:
author = re.sub(r'<[^>]+>', '', writer_m.group(1)).strip()
con_m = re.search(r'<div[^>]*class="con"[^>]*>(.*)', article_html, re.DOTALL)
if con_m:
content = con_m.group(1).strip()
content = re.sub(r'</div>\s*$', '', content, count=1).strip()
else:
content = ""
content = re.sub(r'data-src="([^"]*)"', r'src="\1"', content)
# 将 GIF 缩略图替换为原始 GIFdata-gif-src 属性中存储了可播放的原图)
content = re.sub(
r'<img([^>]*)\bsrc="[^"]*"([^>]*)\bdata-gif-src="([^"]*)"',
r'<img\1src="\3"\2data-gif-src="\3"',
content
)
if not content or len(content) < 20:
parts = []
if title:
parts.append(f'<h1>{title}</h1>')
time_text = ""
time_m = re.search(r'<time[^>]*>(.*?)</time>', article_html, re.DOTALL)
if time_m:
time_text = re.sub(r'<[^>]+>', '', time_m.group(1)).strip()
if author or time_text:
meta = []
if author:
meta.append(author)
if time_text:
meta.append(time_text)
parts.append(f'<p>{" · ".join(meta)}</p>')
desc_m = re.search(r'<meta[^>]*name="description"[^>]*content="([^"]*)"', html)
if desc_m:
desc_text = desc_m.group(1).strip()
desc_text = re.sub(r'&lt;[^&]*&gt;', '', desc_text)
if desc_text:
parts.append(f'<p>{desc_text}</p>')
if not parts:
return None
content = "\n".join(parts)
result = {"content": content}
if title:
result["title"] = title
if author:
result["author"] = author
video_urls = re.findall(r'<video[^>]*\bsrc="([^"]+)"', article_html)
if not video_urls:
video_urls = re.findall(r'<source[^>]*\bsrc="([^"]+)"', article_html)
if video_urls:
result["video_url"] = video_urls[0]
return result
# ── IIFE __NUXT__ 解析 ──
@staticmethod
def _parse_iife_var_map(nuxt_raw: str) -> Dict[str, Any]:
"""解析 (function(a,b,...){...}(val_a,val_b,...)) 压缩格式,返回变量名->值映射"""
import json as _json
param_m = re.match(r'^\(function\(([^)]*)\)', nuxt_raw)
if not param_m:
return {}
param_names = [p.strip() for p in param_m.group(1).split(',')]
last_pos = nuxt_raw.rfind('})(')
if last_pos >= 0:
args_raw = nuxt_raw[last_pos + 3:-1]
else:
last_pos = nuxt_raw.rfind('}(')
if last_pos >= 0:
args_raw = nuxt_raw[last_pos + 2:-2]
else:
return {}
args: list = []
i = 0
n = len(args_raw)
while i < n:
c = args_raw[i]
if c in ' \n\r\t,':
i += 1
continue
if c == '"':
j = i + 1
while j < n:
if args_raw[j] == '\\':
j += 2
elif args_raw[j] == '"':
break
else:
j += 1
try:
args.append(_json.loads(args_raw[i:j + 1]))
except Exception:
args.append(args_raw[i + 1:j])
i = j + 1
elif c in ('{', '['):
depth, j, close_c = 0, i, '}' if c == '{' else ']'
in_str = False
while j < n:
ch = args_raw[j]
if in_str:
if ch == '\\':
j += 1
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == c:
depth += 1
elif ch == close_c:
depth -= 1
if depth == 0:
break
j += 1
args.append(args_raw[i:j + 1])
i = j + 1
else:
j = i
while j < n and args_raw[j] not in ',)\n':
j += 1
token = args_raw[i:j].strip()
if token == 'true':
args.append(True)
elif token == 'false':
args.append(False)
elif token in ('null', 'void 0'):
args.append(None)
else:
try:
args.append(int(token))
except ValueError:
try:
args.append(float(token))
except ValueError:
args.append(token)
i = j
return {name: args[idx] for idx, name in enumerate(param_names) if idx < len(args)}
# ── 工具方法 ──
def _parse_deduction(self, instruction: Dict) -> Tuple[int, str]:
if not instruction:
return 0, ""
desc = instruction.get("description", "")
if not desc:
return 0, ""
for pattern in [r"扣(\d+)分", r"扣除(\d+)分", r"罚(\d+)分"]:
match = re.search(pattern, desc)
if match:
return int(match.group(1)), desc
return 0, desc
def _parse_recent_form(self, record: str) -> str:
if not record:
return ""
mapping = {"": "W", "": "W", "": "D", "": "L", "": "L"}
return "".join(mapping.get(c, "?") for c in record)[-5:]
def _normalize_status(self, status: str) -> str:
if not status:
return "fixture"
s = status.lower()
if "finished" in s or "完场" in s:
return "finished"
elif "live" in s or "进行" in s:
return "live"
elif "postponed" in s or "延期" in s:
return "postponed"
elif "cancelled" in s or "取消" in s:
return "cancelled"
return "fixture"
def _parse_datetime(self, dt_str: Optional[str]) -> Optional[datetime]:
if not dt_str:
return None
for fmt in [
"%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M", "%Y-%m-%d",
]:
try:
return datetime.strptime(dt_str, fmt)
except ValueError:
continue
return None
def validate_standing(self, data: Dict) -> List[str]:
errors = []
for field in ["season_id", "team_id", "rank", "points"]:
if field not in data:
errors.append(f"缺少字段: {field}")
if all(k in data for k in ("played", "won", "drawn", "lost")):
if data["played"] != data["won"] + data["drawn"] + data["lost"]:
errors.append(f"场次不匹配: {data['played']} != {data['won']}+{data['drawn']}+{data['lost']}")
return errors
def validate_match(self, data: Dict) -> List[str]:
errors = []
for field in ["match_id", "season_id", "home_team_id", "away_team_id"]:
if field not in data:
errors.append(f"缺少字段: {field}")
if data.get("status") == "finished":
if data.get("home_score") is None or data.get("away_score") is None:
errors.append("已完场比赛缺少比分")
return errors
+179
View File
@@ -0,0 +1,179 @@
"""
从懂球帝 liveDetail 页面的 window.__NUXT__ IIFE 中解析比赛详情数据
"""
import re
from typing import Optional, Dict, Any
def parse_js_args(args_str: str) -> list:
"""解析 JS IIFE 的实参列表,返回 Python 值列表"""
results = []
i = 0
n = len(args_str)
while i < n:
c = args_str[i]
if c in (' ', '\n', '\r', '\t'):
i += 1
continue
if c == ',':
i += 1
continue
if c == '"':
j = i + 1
while j < n:
if args_str[j] == '\\':
j += 2
continue
if args_str[j] == '"':
break
j += 1
raw = args_str[i:j+1]
inner = raw[1:-1]
if '\\u' in inner:
val = inner.encode('utf-8').decode('unicode_escape')
else:
val = inner.replace('\\n', '\n').replace('\\t', '\t').replace('\\"', '"').replace('\\\\', '\\')
results.append(val)
i = j + 1
continue
if args_str[i:i+4] == 'true':
results.append(True)
i += 4
continue
if args_str[i:i+5] == 'false':
results.append(False)
i += 5
continue
if args_str[i:i+4] == 'null':
results.append(None)
i += 4
continue
if args_str[i:i+6] == 'void 0':
results.append(None)
i += 6
continue
if c in '0123456789.-':
j = i + 1
while j < n and args_str[j] in '0123456789.eE+-':
j += 1
num_str = args_str[i:j]
try:
val = int(num_str)
except ValueError:
val = float(num_str)
results.append(val)
i = j
continue
if c == '{':
depth = 1
j = i + 1
while j < n and depth > 0:
if args_str[j] == '{':
depth += 1
elif args_str[j] == '}':
depth -= 1
elif args_str[j] == '"':
j += 1
while j < n and args_str[j] != '"':
if args_str[j] == '\\':
j += 1
j += 1
j += 1
results.append(args_str[i:j])
i = j
continue
if c == '[':
depth = 1
j = i + 1
while j < n and depth > 0:
if args_str[j] == '[':
depth += 1
elif args_str[j] == ']':
depth -= 1
elif args_str[j] == '"':
j += 1
while j < n and args_str[j] != '"':
if args_str[j] == '\\':
j += 1
j += 1
j += 1
results.append(args_str[i:j])
i = j
continue
j = i
while j < n and args_str[j] not in ',)]}':
j += 1
token = args_str[i:j].strip()
results.append(token)
i = j
return results
def _decode_str(val_expr: str) -> str:
"""解码 JS 字符串字面量(去掉引号)"""
inner = val_expr[1:-1]
if '\\u' in inner:
try:
return inner.encode('utf-8').decode('unicode_escape')
except Exception:
return inner
return inner.replace('\\n', '\n').replace('\\t', '\t').replace('\\"', '"').replace('\\\\', '\\')
def _resolve_value(val_expr: str, var_map: dict):
"""将 JS 赋值右侧表达式解析为 Python 值"""
if val_expr in var_map:
return var_map[val_expr]
if val_expr.startswith('"') and val_expr.endswith('"'):
return _decode_str(val_expr)
if val_expr == 'true':
return True
if val_expr == 'false':
return False
if val_expr == 'null':
return None
try:
return int(val_expr)
except ValueError:
pass
try:
return float(val_expr)
except ValueError:
pass
return val_expr
def parse_nuxt_match_sample(html: str) -> Optional[Dict[str, Any]]:
"""从 liveDetail HTML 中解析 matchSample 对象"""
pattern = r'window\.__NUXT__\s*=\s*\(function\(([^)]+)\)\s*\{(.+)\}\((.+)\)\);'
m = re.search(pattern, html, re.DOTALL)
if not m:
return None
params = [p.strip() for p in m.group(1).split(',')]
body = m.group(2)
args_str = m.group(3)
args = parse_js_args(args_str)
var_map = {}
for i, p in enumerate(params):
if i < len(args):
var_map[p] = args[i]
ms_var_match = re.search(r'(\w+)\.match_id\s*=\s*(\w+)', body)
if not ms_var_match:
return None
ms_var = ms_var_match.group(1)
assign_pattern = re.compile(rf'\b{re.escape(ms_var)}\.(\w+)\s*=\s*(.+?)\s*;', re.MULTILINE)
match_sample = {}
for am in assign_pattern.finditer(body):
field = am.group(1)
val_expr = am.group(2).strip()
match_sample[field] = _resolve_value(val_expr, var_map)
return match_sample if match_sample else None