API-Football 데이터 수집 로직 변경
parent
8140cd9e02
commit
7625db36a0
|
|
@ -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)보다 앞서 캐시를 채워둔다.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue