API-Football 데이터 수집 로직 변경

develop
jwkim 2026-06-16 09:31:29 +09:00
parent 8140cd9e02
commit 7625db36a0
3 changed files with 42 additions and 29 deletions

View File

@ -116,11 +116,12 @@ class Settings(BaseSettings):
# 키 미설정 시 데이터 수집/주입 전부 no-op → 기존(이름만) 예측으로 폴백.
football_api_key: str = ""
football_api_base: str = "https://v3.football.api-sports.io"
# 캐시 신선도(시간). 이 시간 지난 팀만 워커가 재수집(랭킹/폼은 자주 안 변함).
football_cache_ttl_hours: int = 168 # 7일
# 수집 잡 1회당 갱신할 최대 팀 수. 팀당 ~3콜이라 24팀≈72콜(무료 100/일 안).
# 24면 48팀이 약 2일에 채워짐(8이면 6일). 한도 소진 시 남은 팀은 다음 잡에서.
football_refresh_batch: int = 24
# 팀당 1회만 수집(fetch-once): 과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026
# 진행 결과(승패·스코어)는 build 시 우리 DB 에서 라이브로 읽는다. 한 번 캐시되면
# 다시 받지 않으므로, 전 팀이 채워지면 이후 잡은 호출 0(사실상 수집 자동 종료).
# 하루 호출 예산(무료 100/일 보호). 팀당 ~3콜이라 90이면 하루 ~30팀 →
# 48팀이 약 2일에 채워짐. 예산 소진 시 남은 팀은 다음 날 잡이 이어서 누적 수집.
football_daily_call_budget: int = 90
# API 호출 간 최소 간격(초) — 무료 10req/분 제한 회피.
football_call_interval_sec: float = 7.0
# 수집 잡 시각(KST) — 예측 생성(00:05)보다 앞서 캐시를 채워둔다.

View File

@ -15,13 +15,12 @@ from __future__ import annotations
import asyncio
import logging
from datetime import timedelta
import httpx
from sqlalchemy import or_, select
from ..config import settings
from ..domain import ensure_aware, now_utc
from ..domain import now_utc
from ..models import FootballCache, Match
log = logging.getLogger("triplepick.football")
@ -188,33 +187,47 @@ async def _fetch_h2h(db, client, code_a, id_a, code_b, id_b) -> None:
await _upsert(db, f"h2h:{code_a}-{code_b}", {"rows": rows[-5:]})
# 팀 1건 수집 비용(API 호출 수): 스쿼드 1 + 시즌별 경기. H2H 는 1.
_TEAM_CALLS = 1 + len(_SEASONS)
_H2H_CALLS = 1
# ── 수집 잡 (워커 호출) ─────────────────────────────────────────
async def refresh(db, matches: list[Match], limit: int | None = None) -> int:
"""주어진 경기들의 팀/H2H 캐시를 stale 우선으로 갱신. 갱신한 팀 수 반환."""
async def refresh(db, matches: list[Match]) -> int:
"""경기들의 팀/H2H 캐시를 '아직 없는 것만' 한 번씩 수집한다(fetch-once).
과거 시즌 ·스쿼드·H2H 변하지 않고, 2026 진행 결과는 build_data_block
우리 DB 에서 라이브로 읽으므로 팀당 1 수집이면 충분하다. 무료 한도를 넘지
않도록 하루 호출 예산(football_daily_call_budget) 안에서만 받고, 받은 팀은
다음 잡이 이어서 누적한다. 팀이 캐시되면 이후 호출 0(자동 무동작).
이번 잡에서 새로 수집한 반환."""
if not enabled():
return 0
limit = limit if limit is not None else settings.football_refresh_batch
cutoff = now_utc() - timedelta(hours=settings.football_cache_ttl_hours)
budget = settings.football_daily_call_budget
refreshed = 0
calls = 0
async with httpx.AsyncClient() as client:
seen: set[str] = set()
for m in matches:
for code, name in ((m.team_a_code, m.team_a_name), (m.team_b_code, m.team_b_name)):
if code in seen or refreshed >= limit:
if code in seen:
continue
seen.add(code)
row = await db.get(FootballCache, f"team:{code}")
if row and ensure_aware(row.fetched_at) > cutoff:
continue # 신선 — skip
if await db.get(FootballCache, f"team:{code}"):
continue # 이미 수집됨 — 다시 받지 않음(fetch-once)
if calls + _TEAM_CALLS > budget:
continue # 오늘 호출 예산 소진 — 남은 팀은 다음 잡에서
try:
if await _fetch_team(db, client, code, name):
refreshed += 1
except Exception as e: # noqa: BLE001
log.warning("football: 팀 %s 수집 실패 %s", code, e)
# H2H (양 팀 캐시가 있을 때만)
calls += _TEAM_CALLS # 성공/실패 무관 호출은 소비됨(한도 보호)
# H2H — 양 팀이 캐시됐고 아직 안 받은 쌍만
for m in matches:
h = await db.get(FootballCache, f"h2h:{m.team_a_code}-{m.team_b_code}")
if h and ensure_aware(h.fetched_at) > cutoff:
if calls + _H2H_CALLS > budget:
break
if await db.get(FootballCache, f"h2h:{m.team_a_code}-{m.team_b_code}"):
continue
ta = await db.get(FootballCache, f"team:{m.team_a_code}")
tb = await db.get(FootballCache, f"team:{m.team_b_code}")
@ -225,9 +238,10 @@ async def refresh(db, matches: list[Match], limit: int | None = None) -> int:
m.team_b_code, tb.payload["team_id"])
except Exception as e: # noqa: BLE001
log.warning("football: H2H %s 수집 실패 %s", m.match_id, e)
calls += _H2H_CALLS
await db.commit()
if refreshed:
log.info("football: %d개 캐시 갱신", refreshed)
if refreshed or calls:
log.info("football: 신규 팀 %d개 수집 (호출 ~%d/%d)", refreshed, calls, budget)
return refreshed

View File

@ -138,23 +138,21 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
# ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ──
async def refresh_football_data() -> None:
"""다가오는 미종료 경기의 팀/H2H 축구 데이터를 캐시에 갱신.
"""전 팀의 팀/H2H 축구 데이터를 캐시에 한 번씩 선수집.
FOOTBALL_API_KEY 미설정이면 no-op. 무료 한도(100/·10/) 호출 간격·배치로
분산하므로 번에 일부 팀만 갱신되고, 매일 잡이 누적해 채운다.
예측이 데이터를 쓰려면 미리 캐시에 있어야 하므로, 임박 경기만 기다리지 않고
모든 미종료 경기의 팀을 대상으로 한다. 팀당 1회만 받고(fetch-once), 무료 한도
(100/) 넘지 않게 하루 호출 예산만큼만 받아 며칠에 걸쳐 누적한다. 팀이
캐시되면 이후 잡은 호출 0(자동 무동작). 미설정이면 no-op.
"""
if not football_data.enabled():
return
async with SessionLocal() as db:
horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours)
rows = (
await db.execute(select(Match).where(Match.result_outcome.is_(None)))
).scalars().all()
# 투표 윈도우에 든(곧 들) 경기만, 가장 가까운 경기부터(배치 한도 시 우선 수집).
matches = sorted(
(m for m in rows if ensure_aware(m.opens_at) <= horizon),
key=lambda m: ensure_aware(m.opens_at),
)
# 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서).
matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at))
await football_data.refresh(db, matches)