no message
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
懂球帝文章正文采集脚本
|
||||
- 从数据库读取已入库但无正文的文章
|
||||
- 请求文章详情页HTML,提取 div.con 正文内容
|
||||
- 将正文HTML写入 la_article.content
|
||||
用法:
|
||||
python scripts/crawl_article_detail.py # 采集最多50篇
|
||||
python scripts/crawl_article_detail.py --limit 200 # 采集最多200篇
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from curl_cffi import requests as curl_requests
|
||||
from loguru import logger
|
||||
|
||||
from src.core.config import get_config
|
||||
from src.storage.database import Database
|
||||
|
||||
DETAIL_URL = "https://www.dongqiudi.com/articles/{article_id}"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Referer": "https://www.dongqiudi.com/",
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
def fetch_article_html(article_id: int) -> str:
|
||||
url = DETAIL_URL.format(article_id=article_id)
|
||||
resp = curl_requests.get(
|
||||
url, headers=HEADERS,
|
||||
impersonate="chrome", timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def extract_content(html: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
con_div = soup.select_one("div.con")
|
||||
if not con_div:
|
||||
return ""
|
||||
inner = con_div.find("div", style=lambda v: v and "display:none" in v.replace(" ", ""))
|
||||
target = inner if inner else con_div
|
||||
for img in target.find_all("img"):
|
||||
src = img.get("data-src") or img.get("orig-src") or img.get("src", "")
|
||||
if src:
|
||||
img["src"] = src
|
||||
for attr in ["data-src", "orig-src", "data-width", "data-height"]:
|
||||
if img.has_attr(attr):
|
||||
del img[attr]
|
||||
return str(target.decode_contents()).strip()
|
||||
|
||||
|
||||
async def run(limit: int = 50):
|
||||
cfg = get_config()
|
||||
db = Database()
|
||||
|
||||
try:
|
||||
articles = await db.get_articles_without_content(limit=limit)
|
||||
logger.info(f"待采集正文: {len(articles)} 篇")
|
||||
|
||||
if not articles:
|
||||
logger.info("所有文章已有正文,无需采集")
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for i, art in enumerate(articles, 1):
|
||||
article_id = art["article_id"]
|
||||
title = art["title"][:30]
|
||||
logger.info(f"[{i}/{len(articles)}] article_id={article_id} {title}...")
|
||||
|
||||
try:
|
||||
html = fetch_article_html(article_id)
|
||||
content = extract_content(html)
|
||||
|
||||
if not content:
|
||||
logger.warning(f" 正文为空,跳过")
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
await db.update_article_content(article_id, content)
|
||||
success_count += 1
|
||||
logger.info(f" 正文长度: {len(content)} 字符 ✓")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" 采集失败: {e}")
|
||||
fail_count += 1
|
||||
|
||||
delay = random.uniform(1.5, 3.0)
|
||||
time.sleep(delay)
|
||||
|
||||
logger.info(f"✅ 正文采集完成: 成功 {success_count}, 失败 {fail_count}")
|
||||
|
||||
await db.log_crawl(
|
||||
crawl_type="article_detail",
|
||||
target="dongqiudi_article_html",
|
||||
success=True,
|
||||
elapsed=0,
|
||||
record_count=success_count,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"采集失败: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="懂球帝文章正文采集")
|
||||
parser.add_argument("--limit", type=int, default=50, help="最多采集篇数 (默认50)")
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("懂球帝文章正文采集")
|
||||
logger.info(f"最多采集 {args.limit} 篇")
|
||||
logger.info("=" * 60)
|
||||
|
||||
asyncio.run(run(args.limit))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
懂球帝文章资讯采集脚本
|
||||
- 通过 /api/app/tabs/iphone/{tab_id}.json 接口获取文章列表
|
||||
- 支持多Tab采集 + 分页翻页
|
||||
- 按 article_id 去重,已有数据跳过
|
||||
用法:
|
||||
python scripts/crawl_articles.py # 采集所有Tab第1页
|
||||
python scripts/crawl_articles.py --pages 5 # 每个Tab采集5页
|
||||
python scripts/crawl_articles.py --tabs 1,3,5 # 只采集指定Tab
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from curl_cffi import requests as curl_requests
|
||||
from loguru import logger
|
||||
|
||||
from src.core.config import get_config
|
||||
from src.storage.database import Database
|
||||
|
||||
API_URL = "https://www.dongqiudi.com/api/app/tabs/iphone/{tab_id}.json"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Referer": "https://www.dongqiudi.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
TAB_CONFIG = {
|
||||
1: "头条",
|
||||
3: "英超",
|
||||
4: "意甲",
|
||||
5: "西甲",
|
||||
6: "德甲",
|
||||
}
|
||||
|
||||
|
||||
def fetch_tab_page(tab_id: int, page_url: str = "") -> dict:
|
||||
if not page_url:
|
||||
page_url = API_URL.format(tab_id=tab_id)
|
||||
resp = curl_requests.get(
|
||||
page_url, headers=HEADERS,
|
||||
impersonate="chrome", timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def collect_tab(tab_id: int, max_pages: int = 1) -> list:
|
||||
all_articles = []
|
||||
|
||||
page_url = ""
|
||||
for page_idx in range(1, max_pages + 1):
|
||||
label = TAB_CONFIG.get(tab_id, f"Tab{tab_id}")
|
||||
logger.info(f"[{label}] 第{page_idx}页 请求...")
|
||||
|
||||
try:
|
||||
data = fetch_tab_page(tab_id, page_url)
|
||||
except Exception as e:
|
||||
logger.error(f"[{label}] 第{page_idx}页 请求失败: {e}")
|
||||
break
|
||||
|
||||
articles = data.get("articles", [])
|
||||
logger.info(f"[{label}] 第{page_idx}页 返回 {len(articles)} 条文章")
|
||||
|
||||
if not articles:
|
||||
break
|
||||
|
||||
all_articles.extend(articles)
|
||||
|
||||
next_url = data.get("next", "")
|
||||
if not next_url:
|
||||
logger.info(f"[{label}] 无下一页,停止")
|
||||
break
|
||||
page_url = next_url
|
||||
time.sleep(2)
|
||||
|
||||
return all_articles
|
||||
|
||||
|
||||
async def run(tab_ids: list, max_pages: int = 1):
|
||||
cfg = get_config()
|
||||
db = Database()
|
||||
|
||||
try:
|
||||
existing_ids = await db.get_existing_article_ids()
|
||||
cate_map = await db.get_article_cate_map()
|
||||
logger.info(f"数据库已有 {len(existing_ids)} 条文章, {len(cate_map)} 个分类")
|
||||
|
||||
total_new = 0
|
||||
total_skip = 0
|
||||
|
||||
for tab_id in tab_ids:
|
||||
label = TAB_CONFIG.get(tab_id, f"Tab{tab_id}")
|
||||
cid = cate_map.get(label, 0)
|
||||
if not cid:
|
||||
logger.warning(f"[{label}] 未找到对应分类,使用 cid=0")
|
||||
|
||||
all_articles = collect_tab(tab_id, max_pages)
|
||||
|
||||
unique = {}
|
||||
for a in all_articles:
|
||||
aid = int(a.get("id", 0))
|
||||
if aid and aid not in unique:
|
||||
unique[aid] = a
|
||||
|
||||
new_items = [a for aid, a in unique.items() if aid not in existing_ids]
|
||||
skip_count = len(unique) - len(new_items)
|
||||
logger.info(f"[{label}] 去重: {len(unique)} 条唯一, 新增: {len(new_items)}, 跳过: {skip_count}")
|
||||
|
||||
if new_items:
|
||||
count = await db.upsert_articles(new_items, cid=cid)
|
||||
total_new += count
|
||||
existing_ids.update(int(a["id"]) for a in new_items)
|
||||
|
||||
total_skip += skip_count
|
||||
time.sleep(1)
|
||||
|
||||
logger.info(f"✅ 采集完成: 新增 {total_new} 条, 跳过 {total_skip} 条")
|
||||
|
||||
await db.log_crawl(
|
||||
crawl_type="articles",
|
||||
target="dongqiudi_tabs_api",
|
||||
success=True,
|
||||
elapsed=0,
|
||||
record_count=total_new,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"采集失败: {e}", exc_info=True)
|
||||
try:
|
||||
await db.log_crawl(
|
||||
crawl_type="articles",
|
||||
target="dongqiudi_tabs_api",
|
||||
success=False,
|
||||
elapsed=0,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description="懂球帝文章资讯采集",
|
||||
epilog="示例:\n"
|
||||
" python crawl_articles.py # 所有Tab各1页\n"
|
||||
" python crawl_articles.py --pages 5 # 每Tab采5页\n"
|
||||
" python crawl_articles.py --tabs 1,3,5 # 指定Tab\n",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--pages", type=int, default=1, help="每个Tab采集页数 (默认1)")
|
||||
parser.add_argument("--tabs", type=str, default="", help="Tab ID列表,逗号分隔 (默认全部)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.tabs:
|
||||
tab_ids = [int(t.strip()) for t in args.tabs.split(",") if t.strip()]
|
||||
else:
|
||||
tab_ids = list(TAB_CONFIG.keys())
|
||||
|
||||
tab_names = [TAB_CONFIG.get(t, f"Tab{t}") for t in tab_ids]
|
||||
logger.info("=" * 60)
|
||||
logger.info("懂球帝文章资讯采集")
|
||||
logger.info(f"Tabs: {', '.join(tab_names)}, 每Tab {args.pages} 页")
|
||||
logger.info("=" * 60)
|
||||
|
||||
asyncio.run(run(tab_ids, args.pages))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
懂球帝比赛实时详情采集脚本
|
||||
- 每5秒轮询 la_match 表,找出已到开赛时间但未结束的比赛
|
||||
- 异步并发采集每场比赛的 liveDetail 页面
|
||||
- 解析 __NUXT__ 数据,更新 la_match 比分/状态,写入 la_match_data
|
||||
- 每个异步任务间隔5-10秒随机
|
||||
- 比赛结束后自动停止该场采集
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from curl_cffi import requests as curl_requests
|
||||
from loguru import logger
|
||||
|
||||
from src.core.config import get_config
|
||||
from src.storage.database import Database
|
||||
from src.parser.nuxt_parser import parse_nuxt_match_sample
|
||||
|
||||
LIVE_DETAIL_URL = "https://www.dongqiudi.com/liveDetail/{match_id}"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Referer": "https://www.dongqiudi.com/",
|
||||
"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",
|
||||
}
|
||||
|
||||
STATUS_MAP = {"Fixture": 0, "Playing": 1, "Played": 2}
|
||||
|
||||
# 正在采集中的 match_id 集合,防止重复调度
|
||||
_active_tasks: dict = {} # match_id -> asyncio.Task
|
||||
|
||||
# 全局停止信号
|
||||
_stop_event = asyncio.Event()
|
||||
|
||||
|
||||
def fetch_live_detail(match_id: int) -> str:
|
||||
"""同步请求 liveDetail 页面(在线程池中执行)"""
|
||||
url = LIVE_DETAIL_URL.format(match_id=match_id)
|
||||
resp = curl_requests.get(url, headers=HEADERS, impersonate="chrome", timeout=20)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
async def crawl_single_match(match_id: int, match_info: dict, db: Database):
|
||||
"""采集单场比赛的实时数据,循环直到比赛结束"""
|
||||
home = match_info.get("home_team", "")
|
||||
away = match_info.get("away_team", "")
|
||||
league = match_info.get("league_name", "")
|
||||
logger.info(f"[开始采集] match_id={match_id} {league} {home} vs {away}")
|
||||
|
||||
consecutive_errors = 0
|
||||
max_errors = 5
|
||||
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
html = await loop.run_in_executor(None, fetch_live_detail, match_id)
|
||||
|
||||
match_sample = parse_nuxt_match_sample(html)
|
||||
if not match_sample:
|
||||
logger.warning(f"[{match_id}] 解析 __NUXT__ 失败,跳过本轮")
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= max_errors:
|
||||
logger.error(f"[{match_id}] 连续 {max_errors} 次失败,停止采集")
|
||||
break
|
||||
await asyncio.sleep(random.uniform(5, 10))
|
||||
continue
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
# 提取关键数据
|
||||
status_str = str(match_sample.get("status", ""))
|
||||
status_int = STATUS_MAP.get(status_str, 0)
|
||||
fs_a = match_sample.get("fs_A", "")
|
||||
fs_b = match_sample.get("fs_B", "")
|
||||
home_score = int(fs_a) if fs_a and str(fs_a).isdigit() else 0
|
||||
away_score = int(fs_b) if fs_b and str(fs_b).isdigit() else 0
|
||||
hts_a = str(match_sample.get("hts_A", ""))
|
||||
hts_b = str(match_sample.get("hts_B", ""))
|
||||
half_score = f"{hts_a}-{hts_b}" if hts_a and hts_b and hts_a != "" and hts_b != "" else ""
|
||||
minute = str(match_sample.get("minute", ""))
|
||||
minute_period = str(match_sample.get("minute_period", ""))
|
||||
current_minute = minute_period if minute_period else minute
|
||||
|
||||
logger.info(
|
||||
f"[{match_id}] {league} {home} {home_score}-{away_score} {away} | "
|
||||
f"status={status_str} minute={current_minute}"
|
||||
)
|
||||
|
||||
# 更新 la_match
|
||||
await db.update_match_live(
|
||||
match_id=match_id,
|
||||
status=status_int,
|
||||
home_score=home_score,
|
||||
away_score=away_score,
|
||||
half_score=half_score,
|
||||
current_minute=current_minute,
|
||||
)
|
||||
|
||||
# 写入 la_match_data
|
||||
await db.upsert_match_data(match_sample)
|
||||
|
||||
# 比赛结束
|
||||
if status_str == "Played":
|
||||
logger.info(f"[{match_id}] 比赛已结束: {home} {home_score}-{away_score} {away}")
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"[{match_id}] 任务被取消")
|
||||
break
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
logger.error(f"[{match_id}] 采集异常: {e}")
|
||||
if consecutive_errors >= max_errors:
|
||||
logger.error(f"[{match_id}] 连续 {max_errors} 次异常,停止采集")
|
||||
break
|
||||
|
||||
# 随机间隔 5-10 秒
|
||||
delay = random.uniform(5, 10)
|
||||
try:
|
||||
await asyncio.wait_for(_stop_event.wait(), timeout=delay)
|
||||
break # stop_event 被设置,退出
|
||||
except asyncio.TimeoutError:
|
||||
pass # 正常超时,继续下一轮
|
||||
|
||||
logger.info(f"[结束采集] match_id={match_id} {league} {home} vs {away}")
|
||||
|
||||
|
||||
async def poll_loop(db: Database, interval: int = 5):
|
||||
"""主轮询循环:每 interval 秒检查一次需要采集的比赛"""
|
||||
logger.info(f"启动轮询循环,间隔 {interval} 秒")
|
||||
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
now_ts = int(time.time())
|
||||
matches = await db.get_live_matches(now_ts)
|
||||
|
||||
# 清理已完成的任务
|
||||
done_ids = [mid for mid, task in _active_tasks.items() if task.done()]
|
||||
for mid in done_ids:
|
||||
del _active_tasks[mid]
|
||||
|
||||
if matches:
|
||||
new_count = 0
|
||||
for m in matches:
|
||||
mid = int(m["match_id"])
|
||||
if mid in _active_tasks:
|
||||
continue # 已在采集中
|
||||
task = asyncio.create_task(crawl_single_match(mid, m, db))
|
||||
_active_tasks[mid] = task
|
||||
new_count += 1
|
||||
# 每启动一个任务间隔 1-2 秒,避免瞬间并发太多
|
||||
if new_count > 0:
|
||||
await asyncio.sleep(random.uniform(1, 2))
|
||||
|
||||
if new_count > 0:
|
||||
logger.info(f"新启动 {new_count} 个采集任务,当前活跃: {len(_active_tasks)}")
|
||||
else:
|
||||
active_count = len(_active_tasks)
|
||||
if active_count > 0:
|
||||
logger.debug(f"无新比赛,当前活跃任务: {active_count}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"轮询异常: {e}")
|
||||
|
||||
# 等待 interval 秒或停止信号
|
||||
try:
|
||||
await asyncio.wait_for(_stop_event.wait(), timeout=interval)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
# 停止所有活跃任务
|
||||
logger.info(f"停止所有活跃任务 ({len(_active_tasks)} 个)...")
|
||||
for mid, task in _active_tasks.items():
|
||||
task.cancel()
|
||||
if _active_tasks:
|
||||
await asyncio.gather(*_active_tasks.values(), return_exceptions=True)
|
||||
_active_tasks.clear()
|
||||
|
||||
|
||||
async def run(interval: int = 5):
|
||||
db = Database()
|
||||
try:
|
||||
await db.connect()
|
||||
logger.info("数据库连接成功")
|
||||
await poll_loop(db, interval)
|
||||
finally:
|
||||
await db.close()
|
||||
logger.info("数据库连接已关闭")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="懂球帝比赛实时详情采集")
|
||||
parser.add_argument("--interval", type=int, default=5, help="轮询间隔秒数 (默认5)")
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("懂球帝比赛实时详情采集 (liveDetail)")
|
||||
logger.info(f"轮询间隔: {args.interval} 秒")
|
||||
logger.info("按 Ctrl+C 停止")
|
||||
logger.info("=" * 60)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
def handle_signal():
|
||||
logger.info("收到停止信号...")
|
||||
_stop_event.set()
|
||||
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, handle_signal)
|
||||
loop.add_signal_handler(signal.SIGTERM, handle_signal)
|
||||
except NotImplementedError:
|
||||
pass # Windows 不支持 add_signal_handler
|
||||
|
||||
try:
|
||||
loop.run_until_complete(run(interval=args.interval))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到 Ctrl+C,正在停止...")
|
||||
_stop_event.set()
|
||||
loop.run_until_complete(asyncio.sleep(1))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
懂球帝比赛数据采集脚本
|
||||
- 通过 /api/data/tab/new/important 接口获取比赛记录
|
||||
- 按周循环翻页 (start + 7天)
|
||||
- 按天匹配,已有数据跳过
|
||||
- 每天执行一次
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from curl_cffi import requests as curl_requests
|
||||
from loguru import logger
|
||||
|
||||
from src.core.config import get_config
|
||||
from src.storage.database import Database
|
||||
|
||||
API_URL = "https://www.dongqiudi.com/api/data/tab/new/important"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Referer": "https://www.dongqiudi.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
|
||||
def fetch_page(start_str: str) -> dict:
|
||||
params = {
|
||||
"start": f"{start_str}next",
|
||||
"init": "1",
|
||||
"platform": "www",
|
||||
}
|
||||
resp = curl_requests.get(
|
||||
API_URL, params=params, headers=HEADERS,
|
||||
impersonate="chrome", timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def collect_range(start_date: datetime, end_date: datetime) -> list:
|
||||
all_items = []
|
||||
current = start_date
|
||||
page = 0
|
||||
|
||||
while current < end_date:
|
||||
page += 1
|
||||
start_str = current.strftime("%Y-%m-%d") + " 00:00:00"
|
||||
logger.info(f"[第{page}页] 请求: start={start_str}")
|
||||
|
||||
try:
|
||||
data = fetch_page(start_str)
|
||||
except Exception as e:
|
||||
logger.error(f"请求失败: {e}")
|
||||
current += timedelta(days=7)
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
items = data.get("list", [])
|
||||
logger.info(f" 返回 {len(items)} 条记录")
|
||||
|
||||
for item in items:
|
||||
all_items.append(item)
|
||||
|
||||
current += timedelta(days=7)
|
||||
time.sleep(3)
|
||||
|
||||
return all_items
|
||||
|
||||
|
||||
async def run(start_date: datetime, end_date: datetime):
|
||||
cfg = get_config()
|
||||
db = Database()
|
||||
|
||||
try:
|
||||
existing_ids = await db.get_existing_match_ids()
|
||||
logger.info(f"数据库已有 {len(existing_ids)} 条比赛记录(有match_id)")
|
||||
|
||||
days = (end_date - start_date).days
|
||||
logger.info(f"采集范围: {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')} ({days}天)")
|
||||
|
||||
all_items = collect_range(start_date, end_date)
|
||||
|
||||
unique_items = {}
|
||||
for item in all_items:
|
||||
mid = int(item["match_id"])
|
||||
if mid not in unique_items:
|
||||
unique_items[mid] = item
|
||||
|
||||
logger.info(f"去重后共 {len(unique_items)} 条唯一比赛")
|
||||
|
||||
new_items = [item for mid, item in unique_items.items() if mid not in existing_ids]
|
||||
logger.info(f"新增比赛: {new_items.__len__()} 条 (跳过已有 {len(unique_items) - len(new_items)} 条)")
|
||||
|
||||
if not new_items:
|
||||
logger.info("没有新比赛需要入库")
|
||||
return
|
||||
|
||||
fixture_items = [i for i in new_items if i.get("status") == "Fixture"]
|
||||
played_items = [i for i in new_items if i.get("status") == "Played"]
|
||||
logger.info(f" 未开始: {len(fixture_items)} 条, 已结束: {len(played_items)} 条")
|
||||
|
||||
count = await db.upsert_la_match(new_items)
|
||||
logger.info(f"✅ 入库完成: {count} 条比赛记录")
|
||||
|
||||
await db.log_crawl(
|
||||
crawl_type="match_important",
|
||||
target=API_URL,
|
||||
success=True,
|
||||
elapsed=0,
|
||||
record_count=count,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"采集失败: {e}", exc_info=True)
|
||||
try:
|
||||
await db.log_crawl(
|
||||
crawl_type="match_important",
|
||||
target=API_URL,
|
||||
success=False,
|
||||
elapsed=0,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def parse_date(s: str) -> datetime:
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"无法解析日期: {s},支持格式: YYYY-MM-DD / YYYY/MM/DD / YYYYMMDD")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description="懂球帝比赛数据采集",
|
||||
epilog="示例:\n"
|
||||
" python crawl_matches.py --weeks 4 # 从今天起采集4周\n"
|
||||
" python crawl_matches.py --start 2026-01-01 --end 2026-02-01 # 指定时间段\n"
|
||||
" python crawl_matches.py --start 2026-01-01 # 从指定日期到今天\n",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--weeks", type=int, default=None, help="从今天起采集未来N周 (默认4)")
|
||||
parser.add_argument("--start", type=str, default=None, help="起始日期 (YYYY-MM-DD)")
|
||||
parser.add_argument("--end", type=str, default=None, help="结束日期 (YYYY-MM-DD),不指定则到今天")
|
||||
args = parser.parse_args()
|
||||
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if args.start:
|
||||
start_date = parse_date(args.start)
|
||||
if args.end:
|
||||
end_date = parse_date(args.end)
|
||||
else:
|
||||
end_date = today + timedelta(days=1)
|
||||
else:
|
||||
weeks = args.weeks or 4
|
||||
start_date = today
|
||||
end_date = today + timedelta(weeks=weeks)
|
||||
|
||||
if start_date >= end_date:
|
||||
logger.error(f"起始日期 {start_date.strftime('%Y-%m-%d')} >= 结束日期 {end_date.strftime('%Y-%m-%d')}")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("懂球帝比赛数据采集 (important)")
|
||||
logger.info(f"{start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
asyncio.run(run(start_date, end_date))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
获取单场比赛详情
|
||||
用法: python scripts/fetch_match_detail.py <match_id> [--save]
|
||||
--save 将数据写入 la_match_data 并更新 la_match
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from curl_cffi import requests as curl_requests
|
||||
from loguru import logger
|
||||
|
||||
from src.parser.nuxt_parser import parse_nuxt_match_sample
|
||||
from src.storage.database import Database
|
||||
|
||||
LIVE_DETAIL_URL = "https://www.dongqiudi.com/liveDetail/{match_id}"
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Referer": "https://www.dongqiudi.com/",
|
||||
"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",
|
||||
}
|
||||
|
||||
STATUS_MAP = {"Fixture": 0, "Playing": 1, "Played": 2}
|
||||
|
||||
|
||||
def fetch_html(match_id: int) -> str:
|
||||
url = LIVE_DETAIL_URL.format(match_id=match_id)
|
||||
resp = curl_requests.get(url, headers=HEADERS, impersonate="chrome", timeout=20)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def display(ms: dict):
|
||||
"""格式化输出比赛详情"""
|
||||
status = ms.get("status", "")
|
||||
fs_a = ms.get("fs_A", "")
|
||||
fs_b = ms.get("fs_B", "")
|
||||
hts_a = ms.get("hts_A", "")
|
||||
hts_b = ms.get("hts_B", "")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" 比赛ID: {ms.get('match_id')}")
|
||||
print(f" 赛事: {ms.get('competition_name', '')} {ms.get('match_title', '')}")
|
||||
print(f" 轮次: {ms.get('gameweek', '') or ms.get('round_name', '') or '-'}")
|
||||
print("-" * 60)
|
||||
print(f" 主队: {ms.get('team_A_name', '')} (ID: {ms.get('team_A_id', '')})")
|
||||
print(f" 客队: {ms.get('team_B_name', '')} (ID: {ms.get('team_B_id', '')})")
|
||||
print("-" * 60)
|
||||
|
||||
if status == "Played":
|
||||
print(f" 比分: {fs_a} - {fs_b} (全场)")
|
||||
if hts_a and hts_b:
|
||||
print(f" 半场: {hts_a} - {hts_b}")
|
||||
print(f" 状态: 已结束")
|
||||
elif status == "Playing":
|
||||
minute = ms.get("minute", "")
|
||||
period = ms.get("minute_period", "")
|
||||
print(f" 比分: {fs_a or 0} - {fs_b or 0} (进行中)")
|
||||
print(f" 进行: {period} {minute}")
|
||||
else:
|
||||
print(f" 状态: 未开始")
|
||||
|
||||
print(f" 开赛时间: {ms.get('start_play', '')}")
|
||||
print(f" 类型: {ms.get('cmp_type', '')}")
|
||||
|
||||
# 赔率
|
||||
home_odds = ms.get("home", "")
|
||||
draw_odds = ms.get("draw", "")
|
||||
away_odds = ms.get("away", "")
|
||||
if home_odds or draw_odds or away_odds:
|
||||
print(f" 赔率: 主{home_odds} 平{draw_odds} 客{away_odds}")
|
||||
|
||||
# 直播源
|
||||
tv = ms.get("livingTv", "")
|
||||
if tv:
|
||||
print(f" 直播: {tv}")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
# 额外信息
|
||||
ht_info = ms.get("ht_info", "")
|
||||
score_info = ms.get("score_info", "")
|
||||
if ht_info:
|
||||
print(f" 半场信息: {ht_info}")
|
||||
if score_info:
|
||||
print(f" 比分信息: {score_info}")
|
||||
|
||||
# Logo URLs
|
||||
print(f"\n 赛事Logo: {ms.get('competition_bk_logo', '')}")
|
||||
print(f" 主队Logo: {ms.get('team_A_logo', '')}")
|
||||
print(f" 客队Logo: {ms.get('team_B_logo', '')}")
|
||||
print()
|
||||
|
||||
|
||||
async def save_to_db(ms: dict):
|
||||
"""将数据写入数据库"""
|
||||
db = Database()
|
||||
try:
|
||||
match_id = int(ms.get("match_id", 0))
|
||||
status_str = str(ms.get("status", ""))
|
||||
status_int = STATUS_MAP.get(status_str, 0)
|
||||
fs_a = ms.get("fs_A", "")
|
||||
fs_b = ms.get("fs_B", "")
|
||||
home_score = int(fs_a) if fs_a and str(fs_a).isdigit() else 0
|
||||
away_score = int(fs_b) if fs_b and str(fs_b).isdigit() else 0
|
||||
hts_a = str(ms.get("hts_A", ""))
|
||||
hts_b = str(ms.get("hts_B", ""))
|
||||
half_score = f"{hts_a}-{hts_b}" if hts_a and hts_b else ""
|
||||
minute_period = str(ms.get("minute_period", ""))
|
||||
|
||||
await db.update_match_live(
|
||||
match_id=match_id,
|
||||
status=status_int,
|
||||
home_score=home_score,
|
||||
away_score=away_score,
|
||||
half_score=half_score,
|
||||
current_minute=minute_period,
|
||||
)
|
||||
await db.upsert_match_data(ms)
|
||||
logger.info(f"已保存到数据库: match_id={match_id}")
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="获取单场比赛详情")
|
||||
parser.add_argument("match_id", type=int, help="懂球帝 match_id")
|
||||
parser.add_argument("--save", action="store_true", help="写入数据库")
|
||||
parser.add_argument("--json", action="store_true", help="输出原始 JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info(f"获取比赛详情: match_id={args.match_id}")
|
||||
|
||||
html = fetch_html(args.match_id)
|
||||
ms = parse_nuxt_match_sample(html)
|
||||
|
||||
if not ms:
|
||||
logger.error("解析失败,无法提取比赛数据")
|
||||
sys.exit(1)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(ms, ensure_ascii=False, indent=2, default=str))
|
||||
else:
|
||||
display(ms)
|
||||
|
||||
if args.save:
|
||||
asyncio.run(save_to_db(ms))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user