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

View File

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

View File

@ -138,23 +138,21 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
# ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ── # ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ──
async def refresh_football_data() -> None: 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(): if not football_data.enabled():
return return
async with SessionLocal() as db: async with SessionLocal() as db:
horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours)
rows = ( rows = (
await db.execute(select(Match).where(Match.result_outcome.is_(None))) await db.execute(select(Match).where(Match.result_outcome.is_(None)))
).scalars().all() ).scalars().all()
# 투표 윈도우에 든(곧 들) 경기만, 가장 가까운 경기부터(배치 한도 시 우선 수집). # 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서).
matches = sorted( matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at))
(m for m in rows if ensure_aware(m.opens_at) <= horizon),
key=lambda m: ensure_aware(m.opens_at),
)
await football_data.refresh(db, matches) await football_data.refresh(db, matches)