cydd / scraper.py
chaofenghui's picture
Upload folder using huggingface_hub
dd92d97 verified
Raw
History Blame Contribute Delete
23.8 kB
"""
短道速滑竞赛成绩爬虫
爬取 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()