Spaces:
Sleeping
Sleeping
| """ | |
| 短道速滑竞赛成绩爬虫 | |
| 爬取 http://beijing.zhitikeji.com/jscs/ 的数据 | |
| """ | |
| import requests | |
| from bs4 import BeautifulSoup | |
| import re | |
| import time | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime | |
| # 添加项目根目录到路径 | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from database import ( | |
| get_db, init_db, upsert_match, upsert_race_group, | |
| upsert_result, log_update | |
| ) | |
| BASE_URL = "http://beijing.zhitikeji.com/jscs/" | |
| CHAOYANG_KEYWORDS = ["朝阳区", "朝阳", "北京市朝阳区第三少儿业余体校", "北京市朝阳区蓓蕾青少年体育俱乐部", "第三少儿体校"] | |
| DATA_SOURCE = "zhitikeji" | |
| # 需要爬取的赛季 | |
| TARGET_SEASONS = ["2024-2025赛季", "2025-2026赛季", "2026-2027赛季"] | |
| def is_chaoyang_team(team_name): | |
| """判断是否为朝阳队""" | |
| # 排除长春市朝阳区 | |
| if "长春" in team_name and "朝阳" in team_name: | |
| return False | |
| return any(kw in team_name for kw in CHAOYANG_KEYWORDS) | |
| def get_session(): | |
| """创建HTTP会话""" | |
| session = requests.Session() | |
| session.headers.update({ | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", | |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", | |
| "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", | |
| }) | |
| return session | |
| def get_viewstate(session, url): | |
| """获取ASP.NET页面的__VIEWSTATE等隐藏字段""" | |
| r = session.get(url, timeout=15) | |
| soup = BeautifulSoup(r.text, "html.parser") | |
| viewstate = soup.find("input", {"id": "__VIEWSTATE"}) | |
| viewstategen = soup.find("input", {"id": "__VIEWSTATEGENERATOR"}) | |
| eventvalidation = soup.find("input", {"id": "__EVENTVALIDATION"}) | |
| return { | |
| "__VIEWSTATE": viewstate["value"] if viewstate else "", | |
| "__VIEWSTATEGENERATOR": viewstategen["value"] if viewstategen else "", | |
| "__EVENTVALIDATION": eventvalidation["value"] if eventvalidation else "", | |
| } | |
| def get_season_matches(session, season_name): | |
| """获取指定赛季的所有比赛""" | |
| vs = get_viewstate(session, BASE_URL) | |
| data = { | |
| **vs, | |
| "ctl00$ContentPlaceHolder1$DropDownList1": season_name, | |
| } | |
| r = session.post(BASE_URL, data=data, timeout=15) | |
| soup = BeautifulSoup(r.text, "html.parser") | |
| # 从GridView表格提取比赛信息 | |
| matches = [] | |
| seen_ids = set() | |
| gv = soup.find("table", {"id": "ContentPlaceHolder1_GridView1"}) | |
| if gv: | |
| rows = gv.find_all("tr") | |
| for row in rows: | |
| cells = row.find_all("td") | |
| if not cells or len(cells) < 6: | |
| continue | |
| # onclick在td上,取第一个td的onclick获取match_id | |
| onclick = cells[0].get("onclick", "") | |
| m = re.search(r"bsrc1\.aspx\?id=(\d+)", onclick) | |
| if not m: | |
| continue | |
| match_id = int(m.group(1)) | |
| if match_id in seen_ids: | |
| continue | |
| seen_ids.add(match_id) | |
| title = cells[0].get_text(strip=True) | |
| match_type = cells[1].get_text(strip=True) | |
| round_info = cells[2].get_text(strip=True) | |
| date_range = cells[3].get_text(strip=True) | |
| status = cells[4].get_text(strip=True) | |
| venue = cells[5].get_text(strip=True) | |
| matches.append({ | |
| "id": match_id, | |
| "season": season_name, | |
| "title": title, | |
| "match_type": match_type, | |
| "round_info": round_info, | |
| "date_range": date_range, | |
| "venue": venue, | |
| "status": status, | |
| }) | |
| return matches | |
| def get_match_details(session, match_id): | |
| """获取比赛详情页面中的所有分组""" | |
| url = f"{BASE_URL}bsrc1.aspx?id={match_id}" | |
| r = session.get(url, timeout=15) | |
| soup = BeautifulSoup(r.text, "html.parser") | |
| # 获取比赛标题(从详情页的bsxx span获取完整比赛名称) | |
| title_el = soup.find("span", {"id": "ContentPlaceHolder1_bsxx"}) | |
| match_title = title_el.get_text(strip=True) if title_el else "" | |
| # 备用标题 | |
| if not match_title: | |
| title_el2 = soup.find("span", {"id": "ContentPlaceHolder1_Label1"}) | |
| match_title = title_el2.get_text(strip=True) if title_el2 else "" | |
| # 解析分组表格 | |
| groups = [] | |
| current_date = "" | |
| tables = soup.find_all("table") | |
| for table in tables: | |
| rows = table.find_all("tr") | |
| for row in rows: | |
| # 日期行(蓝色背景行) | |
| style = row.get("style", "") | |
| date_cell = row.find("td", colspan=True) | |
| if date_cell and ("3992d0" in style or "507CD1" in style): | |
| current_date = date_cell.get_text(strip=True) | |
| continue | |
| # 表头行 | |
| cells_text = [cell.get_text(strip=True) for cell in row.find_all(["td", "th"])] | |
| if cells_text and "时间" in cells_text[0]: | |
| continue | |
| # 数据行 | |
| onclick = row.get("onclick", "") | |
| if onclick and len(cells_text) >= 5: | |
| m = re.search(r"rcbh=(\d+)", onclick) | |
| m2 = re.search(r"bsbh=(\d+)", onclick) | |
| m3 = re.search(r"id=(\d+)", onclick) | |
| m4 = re.search(r"md=(\w+)", onclick) | |
| if m: | |
| rcbh = int(m.group(1)) | |
| bsbh = int(m2.group(1)) if m2 else match_id | |
| race_id = int(m3.group(1)) if m3 else 0 | |
| md = m4.group(1) if m4 else "si" | |
| groups.append({ | |
| "rcbh": rcbh, | |
| "match_id": match_id, | |
| "race_date": current_date, | |
| "race_time": cells_text[0] if len(cells_text) > 0 else "", | |
| "event": cells_text[1] if len(cells_text) > 1 else "", | |
| "category": cells_text[2] if len(cells_text) > 2 else "", | |
| "gender": cells_text[3] if len(cells_text) > 3 else "", | |
| "round_type": cells_text[4] if len(cells_text) > 4 else "", | |
| "bsbh": bsbh, | |
| "race_id": race_id, | |
| "md": md, | |
| }) | |
| return groups, match_title | |
| def is_valid_time_result(time_str): | |
| """校验成绩时间格式是否有效""" | |
| if not time_str: | |
| return False | |
| # 常见格式:01:23.456, 23.456 | |
| time_pattern = r'^(?:[0-9]{1,2}:)?[0-9]{2,3}\.[0-9]{3}$' | |
| if not re.match(time_pattern, time_str): | |
| return False | |
| # 避免00:00.000 | |
| if time_str == '00:00.000' or time_str == '0.000': | |
| return False | |
| return True | |
| def is_valid_date(date_str): | |
| """校验日期格式是否合理""" | |
| if not date_str: | |
| return False | |
| # 支持格式:2024年1月1日, 2024-01-01 | |
| patterns = [ | |
| r'^\d{4}年\d{1,2}月\d{1,2}日$', | |
| r'^\d{4}-\d{2}-\d{2}$' | |
| ] | |
| for pattern in patterns: | |
| if re.match(pattern, date_str): | |
| return True | |
| return False | |
| def _detect_and_clean_rcbh_change(conn, rcbh, upstream_g): | |
| """检测上游 rcbh 是否被重新分配(event/category/gender 变化)。 | |
| 如果变化,删除旧的小组级 race_groups 和 results,返回 True。 | |
| 上游 unstable rcbh 会导致男女混合等数据污染。 | |
| """ | |
| base_rcbh = rcbh * 1000 | |
| stored = conn.execute( | |
| "SELECT rcbh, event, category, gender FROM race_groups " | |
| "WHERE rcbh BETWEEN ? AND ? AND data_source = ? LIMIT 1", | |
| (base_rcbh, base_rcbh + 999, DATA_SOURCE) | |
| ).fetchone() | |
| if not stored: | |
| return False # no existing data, nothing to clean | |
| # Compare key metadata | |
| if (stored["event"] != upstream_g.get("event", "") or | |
| stored["category"] != upstream_g.get("category", "") or | |
| stored["gender"] != upstream_g.get("gender", "")): | |
| print(f" ⚠ rcbh={rcbh} 上游数据变化: " | |
| f"event {stored['event']}→{upstream_g.get('event','')} " | |
| f"cat {stored['category']}→{upstream_g.get('category','')} " | |
| f"gender {stored['gender']}→{upstream_g.get('gender','')} — 清空旧数据") | |
| # Delete old heat-specific race_groups and results | |
| conn.execute("DELETE FROM results WHERE rcbh BETWEEN ? AND ?", | |
| (base_rcbh, base_rcbh + 999)) | |
| conn.execute("DELETE FROM race_groups WHERE rcbh BETWEEN ? AND ?", | |
| (base_rcbh, base_rcbh + 999)) | |
| conn.commit() | |
| return True | |
| return False | |
| def get_race_results(session, rcbh, bsbh, race_id, md="si"): | |
| """获取具体分组比赛的结果,按每个table分配heat_number(小组编号) | |
| 每次请求使用新 session,避免服务器连接重置导致 Broken pipe 中断整个比赛抓取。 | |
| 返回的每条结果带 heat_specific_rcbh = rcbh * 1000 + heat_number, | |
| 确保每个小组有独立的 rcbh,避免前端查询对手时跨小组混淆。 | |
| """ | |
| url = f"{BASE_URL}jsfz.aspx?ot=1&md={md}&rcbh={rcbh}&bsbh={bsbh}&id={race_id}" | |
| try: | |
| result_session = get_session() | |
| r = result_session.get(url, timeout=15) | |
| except (requests.RequestException, OSError): | |
| return [] | |
| soup = BeautifulSoup(r.text, "html.parser") | |
| all_results = [] | |
| tables = soup.find_all("table") | |
| heat_number = 1 | |
| for table in tables: | |
| rows = table.find_all("tr") | |
| header = None | |
| heat_results = [] | |
| seen_athletes = set() # 去重 | |
| for row in rows: | |
| cells = [cell.get_text(strip=True) for cell in row.find_all(["td", "th"])] | |
| if not cells: | |
| continue | |
| # 判断标题行 | |
| if any("名次" in c or "PlACE" in c for c in cells): | |
| header = cells | |
| continue | |
| if header and len(cells) >= 5: | |
| name = cells[3] if len(cells) > 3 else "" | |
| team = cells[4] if len(cells) > 4 else "" | |
| time_result = cells[5] if len(cells) > 5 else "" | |
| qual = cells[6] if len(cells) > 6 else "" | |
| # 过滤空行 | |
| if not name: | |
| continue | |
| # 去重:同一小组内避免重复数据 | |
| athlete_key = f"{name}_{team}" | |
| if athlete_key in seen_athletes: | |
| continue | |
| seen_athletes.add(athlete_key) | |
| # 校验时间格式:无效时间(未开始/DNS/DNF等)保留运动员但清空成绩 | |
| if time_result and not is_valid_time_result(time_result): | |
| time_result = "" # 保留运动员参赛信息,对手对比用 | |
| heat_results.append({ | |
| "place": cells[0] if len(cells) > 0 else "", | |
| "lane": cells[1] if len(cells) > 1 else "", | |
| "helmet": cells[2] if len(cells) > 2 else "", | |
| "name": name, | |
| "team": team, | |
| "time_result": time_result, | |
| "qual": qual, | |
| "finished": cells[7] if len(cells) > 7 else "", | |
| "is_chaoyang": 1 if is_chaoyang_team(team) else 0, | |
| "heat_number": heat_number, | |
| # 小组级独立 rcbh: 原 rcbh * 1000 + 小组编号 | |
| "rcbh": rcbh * 1000 + heat_number, | |
| }) | |
| # Only increment heat_number if this table had results | |
| if heat_results: | |
| all_results.extend(heat_results) | |
| heat_number += 1 | |
| return all_results | |
| def _upsert_heat_race_groups(conn, original_rcbh, results, group_template): | |
| """为每个小组创建独立的 race_group 条目(rcbh = 原 rcbh * 1000 + heat_number)""" | |
| seen_heats = set() | |
| for r in results: | |
| hn = r.get("heat_number", 1) | |
| if hn in seen_heats: | |
| continue | |
| seen_heats.add(hn) | |
| heat_rcbh = original_rcbh * 1000 + hn | |
| upsert_race_group( | |
| conn, heat_rcbh, group_template["match_id"], group_template["race_date"], | |
| group_template["race_time"], group_template["event"], group_template["category"], | |
| group_template["gender"], group_template["round_type"], | |
| group_template["bsbh"], group_template["race_id"], | |
| data_source=DATA_SOURCE, | |
| ) | |
| def crawl_season(session, season_name, conn, delay=0.5): | |
| """爬取一个赛季的所有数据""" | |
| print(f"\n{'='*60}") | |
| print(f"开始爬取赛季: {season_name}") | |
| print(f"{'='*60}") | |
| # 获取比赛列表 | |
| matches = get_season_matches(session, season_name) | |
| print(f"找到 {len(matches)} 个比赛") | |
| for match_info in matches: | |
| match_id = match_info["id"] | |
| print(f"\n--- 比赛 ID={match_id} ---") | |
| try: | |
| # 获取比赛详情 | |
| groups, match_title = get_match_details(session, match_id) | |
| # 更新比赛信息(优先用详情页获取的完整名称) | |
| title = match_title or match_info.get("title", "") | |
| upsert_match( | |
| conn, match_id, season_name, | |
| title, | |
| match_info.get("match_type", ""), | |
| match_info.get("round_info", ""), | |
| match_info.get("date_range", ""), | |
| match_info.get("venue", ""), | |
| match_info.get("status", ""), | |
| data_source=DATA_SOURCE, | |
| ) | |
| conn.commit() | |
| print(f" 共 {len(groups)} 个分组") | |
| # 获取每个分组的结果(不存原始 race_group,改为存小组级 heat_rcbh) | |
| total_results = 0 | |
| for i, g in enumerate(groups): | |
| if i > 0 and i % 10 == 0: | |
| print(f" 已处理 {i}/{len(groups)} 个分组...") | |
| conn.commit() | |
| results = get_race_results(session, g["rcbh"], g["bsbh"], g["race_id"], g.get("md", "si")) | |
| if results: | |
| # 为每个小组创建独立的 race_group | |
| _upsert_heat_race_groups(conn, g["rcbh"], results, g) | |
| for r in results: | |
| upsert_result( | |
| conn, r["rcbh"], r["place"], r["lane"], r["helmet"], | |
| r["name"], r["team"], r["time_result"], r["qual"], | |
| r["finished"], r["is_chaoyang"], r.get("heat_number", 1), | |
| data_source=DATA_SOURCE, | |
| ) | |
| total_results += 1 | |
| time.sleep(delay) # 避免请求过于频繁 | |
| log_update(conn, season_name, match_id, "success", f"更新了 {len(groups)} 个分组,{total_results} 条结果") | |
| conn.commit() | |
| except Exception as e: | |
| print(f" 错误: {e}") | |
| log_update(conn, season_name, match_id, "error", str(e)) | |
| conn.commit() | |
| print(f"\n赛季 {season_name} 爬取完成") | |
| def crawl_all(target_seasons=None): | |
| """爬取所有目标赛季数据""" | |
| if target_seasons is None: | |
| target_seasons = TARGET_SEASONS | |
| init_db() | |
| conn = get_db() | |
| session = get_session() | |
| try: | |
| for season in target_seasons: | |
| crawl_season(session, season, conn, delay=0.3) | |
| finally: | |
| conn.close() | |
| print("\n全部爬取完成!") | |
| def crawl_update(): | |
| """增量更新 - 爬取新比赛,并检查已有比赛是否有新增分组 | |
| 比赛进行中时(is_live),刷新所有已有小组的结果(成绩可能更新); | |
| 比赛结束后只拉取新出现的小组(新增轮次/项目)。 | |
| 结果按 heat_number 拆分到独立 rcbh(原 rcbh * 1000 + heat_number)。 | |
| """ | |
| init_db() | |
| conn = get_db() | |
| session = get_session() | |
| try: | |
| for season in TARGET_SEASONS: | |
| matches = get_season_matches(session, season) | |
| for match_info in matches: | |
| match_id = match_info["id"] | |
| existing = conn.execute( | |
| "SELECT id, status, title FROM matches WHERE id = ? AND data_source = ?", | |
| (match_id, DATA_SOURCE) | |
| ).fetchone() | |
| # Determine need_update: new match, missing title, or status contains "进行"/"确认" | |
| need_update = False | |
| if not existing: | |
| need_update = True | |
| elif not existing["title"]: | |
| need_update = True | |
| elif "进行" in (existing["status"] or ""): | |
| need_update = True | |
| elif match_info.get("status") and "进行" in match_info["status"]: | |
| need_update = True | |
| # Always update title and status from source | |
| if existing and match_info.get("title"): | |
| old_status = existing["status"] | |
| new_status = match_info.get("status", old_status) | |
| conn.execute("UPDATE matches SET title=?, status=? WHERE id=? AND data_source=?", | |
| (match_info["title"], new_status, match_id, DATA_SOURCE)) | |
| conn.commit() | |
| # Re-trigger need_update if status changed (e.g. 未开始→正在确认 may add groups) | |
| if new_status != old_status: | |
| need_update = True | |
| # Always fetch details and detect new groups | |
| try: | |
| groups, match_title = get_match_details(session, match_id) | |
| title = match_title or match_info.get("title", "") | |
| upsert_match(conn, match_id, season, | |
| title, match_info.get("match_type", ""), | |
| match_info.get("round_info", ""), | |
| match_info.get("date_range", ""), | |
| match_info.get("venue", ""), | |
| match_info.get("status", ""), | |
| data_source=DATA_SOURCE) | |
| conn.commit() | |
| is_live = "已结束" not in (match_info.get("status") or "") | |
| if need_update: | |
| # Full update: fetch all groups' results | |
| print(f"更新比赛 ID={match_id}") | |
| new_group_count = 0 | |
| updated_group_count = 0 | |
| for g in groups: | |
| # Check if any heat results exist for this original rcbh | |
| base_rcbh = g["rcbh"] * 1000 | |
| existing_results = conn.execute( | |
| "SELECT COUNT(*) as cnt FROM race_groups WHERE rcbh BETWEEN ? AND ? AND data_source=?", | |
| (base_rcbh, base_rcbh + 999, DATA_SOURCE) | |
| ).fetchone() | |
| has_results = existing_results and existing_results["cnt"] > 0 | |
| if not is_live and has_results: | |
| continue | |
| if has_results: | |
| updated_group_count += 1 | |
| else: | |
| new_group_count += 1 | |
| print(f" 获取分组 rcbh={g['rcbh']} {g['category']} {g['event']} {g['round_type']}") | |
| _detect_and_clean_rcbh_change(conn, g["rcbh"], g) | |
| results = get_race_results(session, g["rcbh"], g["bsbh"], g["race_id"], g.get("md", "si")) | |
| if results: | |
| _upsert_heat_race_groups(conn, g["rcbh"], results, g) | |
| for r in results: | |
| upsert_result(conn, r["rcbh"], r["place"], r["lane"], r["helmet"], | |
| r["name"], r["team"], r["time_result"], r["qual"], | |
| r["finished"], r["is_chaoyang"], r.get("heat_number", 1), | |
| data_source=DATA_SOURCE) | |
| time.sleep(0.3) | |
| log_update(conn, season, match_id, "updated", | |
| f"更新了 {len(groups)} 个分组(新获取 {new_group_count} 个,刷新 {updated_group_count} 个)") | |
| conn.commit() | |
| else: | |
| # Check for new groups only | |
| new_rcbhs = [] | |
| for g in groups: | |
| base_rcbh = g["rcbh"] * 1000 | |
| existing_heat = conn.execute( | |
| "SELECT rcbh FROM race_groups WHERE rcbh BETWEEN ? AND ? AND data_source=? LIMIT 1", | |
| (base_rcbh, base_rcbh + 999, DATA_SOURCE) | |
| ).fetchone() | |
| if not existing_heat: | |
| new_rcbhs.append(g["rcbh"]) | |
| groups_to_fetch = list(groups) if is_live else [g for g in groups if g["rcbh"] in new_rcbhs] | |
| if groups_to_fetch: | |
| label = "刷新" if is_live else "发现" | |
| print(f"比赛 ID={match_id} {label} {len(groups_to_fetch)} 个分组") | |
| for g in groups: | |
| if g in groups_to_fetch: | |
| print(f" 获取分组 rcbh={g['rcbh']} {g['category']} {g['event']} {g['round_type']}") | |
| _detect_and_clean_rcbh_change(conn, g["rcbh"], g) | |
| results = get_race_results(session, g["rcbh"], g["bsbh"], g["race_id"], g.get("md", "si")) | |
| if results: | |
| _upsert_heat_race_groups(conn, g["rcbh"], results, g) | |
| for r in results: | |
| upsert_result(conn, r["rcbh"], r["place"], r["lane"], r["helmet"], | |
| r["name"], r["team"], r["time_result"], r["qual"], | |
| r["finished"], r["is_chaoyang"], r.get("heat_number", 1), | |
| data_source=DATA_SOURCE) | |
| time.sleep(0.3) | |
| log_update(conn, season, match_id, "updated", | |
| f"{label} {len(groups_to_fetch)} 个分组,共 {len(groups)} 个分组") | |
| conn.commit() | |
| else: | |
| print(f"跳过比赛 ID={match_id}(无新增分组)") | |
| except Exception as e: | |
| print(f" 错误: {e}") | |
| log_update(conn, season, match_id, "error", str(e)) | |
| conn.commit() | |
| finally: | |
| conn.close() | |
| print("更新完成!") | |
| if __name__ == "__main__": | |
| import argparse | |
| parser = argparse.ArgumentParser(description="短道速滑竞赛成绩爬虫") | |
| parser.add_argument("--update", action="store_true", help="增量更新模式") | |
| parser.add_argument("--season", type=str, help="指定赛季(如 2024-2025赛季)") | |
| args = parser.parse_args() | |
| if args.update: | |
| crawl_update() | |
| elif args.season: | |
| crawl_all([args.season]) | |
| else: | |
| crawl_all() | |