迁移目录
This commit is contained in:
@@ -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 缩略图替换为原始 GIF(data-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'<[^&]*>', '', 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user