Files
sbnews/docker/crawler/src/storage/database.py
T
2026-06-12 22:27:18 +08:00

1437 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
数据库存储模块
- MySQL 异步连接池
- 积分榜、赛程、比赛详情等数据 UPSERT
"""
import json
from datetime import datetime
from typing import Dict, Any, List, Optional
import hashlib
import aiomysql
import redis.asyncio as aioredis
from loguru import logger
from src.core.config import get_config
class Database:
"""MySQL 异步数据库管理"""
def __init__(self):
self._cfg = get_config().database
self._redis_cfg = get_config().redis
self._pool: Optional[aiomysql.Pool] = None
self._redis: Optional[aioredis.Redis] = None
self._prefix = self._cfg.prefix
async def _get_redis(self) -> aioredis.Redis:
if self._redis is None:
self._redis = aioredis.Redis(
host=self._redis_cfg.host,
port=self._redis_cfg.port,
password=self._redis_cfg.password or None,
db=self._redis_cfg.db,
decode_responses=True,
)
return self._redis
async def connect(self):
if self._pool:
return
self._pool = await aiomysql.create_pool(
host=self._cfg.host,
port=self._cfg.port,
user=self._cfg.username,
password=self._cfg.password,
db=self._cfg.database,
charset=self._cfg.charset,
autocommit=True,
minsize=2,
maxsize=self._cfg.pool_size,
)
logger.info(f"数据库连接池已创建: {self._cfg.host}:{self._cfg.port}/{self._cfg.database}")
async def close(self):
if self._redis:
await self._redis.close()
self._redis = None
if self._pool:
self._pool.close()
await self._pool.wait_closed()
self._pool = None
async def execute(self, sql: str, args=None) -> int:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(sql, args)
return cur.rowcount
async def fetchall(self, sql: str, args=None) -> List[Dict]:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(sql, args)
return await cur.fetchall()
async def fetchone(self, sql: str, args=None) -> Optional[Dict]:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(sql, args)
return await cur.fetchone()
# ── 积分榜 ──
async def upsert_standings(self, standings: List[Dict[str, Any]]) -> int:
if not standings:
return 0
await self.connect()
count = 0
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for s in standings:
await cur.execute(f"""
INSERT INTO `{self._prefix}standings`
(season_id, round_number, team_id, team_name, team_logo,
`rank`, points, played, won, drawn, lost,
goals_for, goals_against, goal_diff,
recent_form, deduction_points, deduction_reason,
created_at, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW())
ON DUPLICATE KEY UPDATE
team_name=VALUES(team_name), team_logo=VALUES(team_logo),
`rank`=VALUES(`rank`), points=VALUES(points),
played=VALUES(played), won=VALUES(won), drawn=VALUES(drawn), lost=VALUES(lost),
goals_for=VALUES(goals_for), goals_against=VALUES(goals_against),
goal_diff=VALUES(goal_diff), recent_form=VALUES(recent_form),
deduction_points=VALUES(deduction_points), deduction_reason=VALUES(deduction_reason),
updated_at=NOW()
""", (
s["season_id"], s["round_number"], s["team_id"],
s["team_name"], s.get("team_logo", ""),
s["rank"], s["points"], s["played"],
s["won"], s["drawn"], s["lost"],
s["goals_for"], s["goals_against"], s["goal_diff"],
s.get("recent_form", ""),
s.get("deduction_points", 0), s.get("deduction_reason", ""),
))
count += 1
logger.info(f"积分榜 UPSERT: {count} 条")
return count
# ── 赛程/比赛 ──
_STATUS_MAP = {"fixture": 0, "live": 1, "finished": 2, "postponed": 3, "cancelled": 3}
async def upsert_matches(self, matches: List[Dict[str, Any]], league_name: str = "") -> int:
if not matches:
return 0
await self.connect()
now_ts = int(datetime.now().timestamp())
today_start = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
count = 0
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for m in matches:
status_int = self._STATUS_MAP.get(m.get("status", "fixture"), 0)
md = m.get("match_date")
match_time = int(md.timestamp()) if md else 0
if match_time < today_start:
continue
home_score = m.get("home_score")
away_score = m.get("away_score")
home_score = int(home_score) if home_score is not None and home_score != "" else 0
away_score = int(away_score) if away_score is not None and away_score != "" else 0
half_score = str(m.get("halftime_score", "")) if m.get("halftime_score") else ""
lg_name = league_name or m.get("competition_name", "")
await cur.execute(f"""
INSERT INTO `{self._prefix}match`
(match_id, competition_id,
home_team_id, away_team_id,
league_name, round_name,
home_team, home_icon, home_score,
away_team, away_icon, away_score,
sport_type, status, match_time, half_score,
is_show, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s,%s,1,%s,%s)
ON DUPLICATE KEY UPDATE
status=VALUES(status),
home_score=IF(VALUES(status)=0, home_score, VALUES(home_score)),
away_score=IF(VALUES(status)=0, away_score, VALUES(away_score)),
half_score=IF(VALUES(half_score)='', half_score, VALUES(half_score)),
home_team=VALUES(home_team), away_team=VALUES(away_team),
home_icon=IF(VALUES(home_icon)='', home_icon, VALUES(home_icon)),
away_icon=IF(VALUES(away_icon)='', away_icon, VALUES(away_icon)),
match_time=IF(VALUES(match_time)=0, match_time, VALUES(match_time)),
update_time=VALUES(update_time)
""", (
m["match_id"], m.get("competition_id", 0),
m.get("home_team_id", ""), m.get("away_team_id", ""),
lg_name, m.get("round_name", ""),
m.get("home_team_name", ""), m.get("home_team_logo", ""), home_score,
m.get("away_team_name", ""), m.get("away_team_logo", ""), away_score,
status_int, match_time, half_score,
now_ts, now_ts,
))
count += 1
logger.info(f"赛程 UPSERT: {count} 条")
return count
# ── 比赛类型配置 ──
async def upsert_match_types(self, types: List[Dict[str, Any]]) -> int:
if not types:
return 0
await self.connect()
count = 0
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for t in types:
await cur.execute(f"""
INSERT INTO `{self._prefix}match_type_config`
(id, label, type, sort, api, created_at, updated_at)
VALUES (%s,%s,%s,%s,%s,NOW(),NOW())
ON DUPLICATE KEY UPDATE
label=VALUES(label), type=VALUES(type),
sort=VALUES(sort), api=VALUES(api), updated_at=NOW()
""", (t["id"], t["label"], t["type"], t["sort"], t["api"]))
count += 1
logger.info(f"比赛类型 UPSERT: {count} 条")
return count
# ── 比赛(la_match) ──
STATUS_MAP = {"Fixture": 0, "Playing": 1, "Played": 2}
async def upsert_la_match(self, items: List[Dict[str, Any]]) -> int:
if not items:
return 0
await self.connect()
count = 0
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for m in items:
match_id = int(m["match_id"])
competition_id = int(m.get("competition_id") or 0)
home_team_id = str(m.get("team_A_id", ""))
away_team_id = str(m.get("team_B_id", ""))
status = self.STATUS_MAP.get(m.get("status", ""), 0)
start_play = m.get("start_play", "")
match_time = int(m.get("sort_timestamp") or 0)
if not match_time and start_play:
try:
match_time = int(datetime.strptime(start_play, "%Y-%m-%d %H:%M:%S").timestamp())
except Exception:
pass
home_score = int(m.get("fs_A") or 0) if m.get("fs_A") else 0
away_score = int(m.get("fs_B") or 0) if m.get("fs_B") else 0
hts_a = m.get("hts_A", "")
hts_b = m.get("hts_B", "")
half_score = f"{hts_a}-{hts_b}" if hts_a and hts_b else ""
cmp_type = m.get("cmp_type", "soccer")
sport_type_map = {"soccer": 1, "basketball": 2, "tennis": 3, "esport": 4, "synthesize": 5}
sport_type = sport_type_map.get(cmp_type, 1)
home_odds = float(m.get("home") or 0)
draw_odds = float(m.get("draw") or 0)
away_odds = float(m.get("away") or 0)
await cur.execute(f"""
INSERT INTO `{self._prefix}match`
(match_id, competition_id, home_team_id, away_team_id,
league_name, round_name, league_icon,
home_team, home_icon, home_score,
away_team, away_icon, away_score,
sport_type, status, match_time,
current_minute, half_score,
home_odds, draw_odds, away_odds,
home_corner, away_corner,
home_yellow, away_yellow, home_red, away_red,
is_hot, is_show, sort,
create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
status=VALUES(status),
home_score=VALUES(home_score), away_score=VALUES(away_score),
half_score=VALUES(half_score), current_minute=VALUES(current_minute),
home_odds=VALUES(home_odds), draw_odds=VALUES(draw_odds), away_odds=VALUES(away_odds),
home_corner=VALUES(home_corner), away_corner=VALUES(away_corner),
home_yellow=VALUES(home_yellow), away_yellow=VALUES(away_yellow),
home_red=VALUES(home_red), away_red=VALUES(away_red),
update_time=VALUES(update_time)
""", (
match_id, competition_id, home_team_id, away_team_id,
m.get("competition_name", "")[:100],
m.get("round_name", "")[:50],
m.get("bk_logo", "")[:255],
m.get("team_A_name", "")[:100],
m.get("team_A_logo", "")[:255],
home_score,
m.get("team_B_name", "")[:100],
m.get("team_B_logo", "")[:255],
away_score,
sport_type, status, match_time,
m.get("minute", "")[:20] if m.get("minute") else "",
half_score[:50],
home_odds, draw_odds, away_odds,
int(m.get("corner_A") or 0), int(m.get("corner_B") or 0),
int(m.get("yc_A") or 0), int(m.get("yc_B") or 0),
int(m.get("rc_A") or 0), int(m.get("rc_B") or 0),
int(m.get("key_match") or 0), 1, 0,
now_ts, now_ts,
))
count += 1
logger.info(f"la_match UPSERT: {count} 条")
return count
async def get_existing_match_dates(self) -> set:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT DISTINCT DATE(FROM_UNIXTIME(match_time)) AS d
FROM `{self._prefix}match`
WHERE match_id > 0 AND match_time > 0
""")
rows = await cur.fetchall()
return {str(r[0]) for r in rows if r[0]}
async def get_existing_match_ids(self) -> set:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"SELECT match_id FROM `{self._prefix}match` WHERE match_id > 0")
rows = await cur.fetchall()
return {int(r[0]) for r in rows}
# ── 实时比赛查询/更新 ──
async def get_live_matches(self, now_ts: int, lookback: int = 86400) -> List[Dict]:
"""查询已到开赛时间但未结束的比赛 (status IN(0,1) 且 match_time 在 lookback 秒内)"""
await self.connect()
earliest = now_ts - lookback
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT id, match_id, home_team, away_team, league_name,
match_time, status, home_score, away_score, sport_type
FROM `{self._prefix}match`
WHERE match_id > 0
AND match_time > 0
AND match_time <= %s
AND match_time >= %s
AND status IN (0, 1)
ORDER BY match_time ASC
""", (now_ts, earliest))
return await cur.fetchall()
async def update_match_live(
self,
match_id: int,
status: int,
home_score: int,
away_score: int,
half_score: str = "",
current_minute: str = "",
):
"""更新比赛实时比分和状态"""
await self.connect()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
UPDATE `{self._prefix}match`
SET status=%s, home_score=%s, away_score=%s,
half_score=%s, current_minute=%s, update_time=%s
WHERE match_id=%s
""", (status, home_score, away_score, half_score, current_minute, now_ts, match_id))
logger.debug(f"la_match 更新: match_id={match_id} status={status} {home_score}-{away_score}")
# 允许动态更新的技术统计列白名单
_ALLOWED_STAT_COLS = {
"tech_stats",
}
async def update_match_stats(self, match_id: int, updates: Dict[str, str]):
"""动态更新 la_match 的技术统计字段"""
safe = {k: v for k, v in updates.items() if k in self._ALLOWED_STAT_COLS}
if not safe:
return
await self.connect()
now_ts = int(datetime.now().timestamp())
set_clauses = ", ".join(f"`{col}`=%s" for col in safe)
set_clauses += ", update_time=%s"
vals = list(safe.values()) + [now_ts, match_id]
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"UPDATE `{self._prefix}match` SET {set_clauses} WHERE match_id=%s",
vals,
)
logger.info(f"la_match 技术统计更新: match_id={match_id} fields={list(safe.keys())}")
async def get_league_sessions(self) -> List[Dict[str, Any]]:
"""从 la_league 获取所有配置了 sessionid 或 api 的联赛"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT id, label, sessionid, league_id, sport_type, api
FROM `{self._prefix}league`
WHERE `type` = 'league'
AND is_show = 1
AND (
(sessionid IS NOT NULL AND sessionid != '')
OR (api IS NOT NULL AND api != '')
)
""")
return await cur.fetchall()
async def upsert_match_rounds(self, rounds: List[Dict[str, Any]]) -> int:
"""批量插入/更新轮次到 la_match_round"""
if not rounds:
return 0
await self.connect()
count = 0
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for r in rounds:
await cur.execute(f"""
INSERT INTO `{self._prefix}match_round`
(league_id, season_id, round_id, round_name, gameweek, url, create_time)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
league_id=VALUES(league_id),
round_name=VALUES(round_name),
url=VALUES(url)
""", (
r['league_id'], r['season_id'], r['round_id'],
r['round_name'], r['gameweek'], r['url'], now_ts,
))
count += 1
logger.info(f"la_match_round UPSERT: {count} 条")
return count
async def check_round_skip(self, round_name: str, league_name: str) -> bool:
"""检查某轮次是否可以跳过(查 la_match):
- 已有数据 且 全部已结束(status=2)
- 已有数据 且 全部比赛时间在未来(还未开始)
"""
await self.connect()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT COUNT(*) AS total,
SUM(CASE WHEN `status` = 2 THEN 1 ELSE 0 END) AS finished,
SUM(CASE WHEN match_time > %s THEN 1 ELSE 0 END) AS future
FROM `{self._prefix}match`
WHERE round_name = %s AND league_name = %s
""", (now_ts, round_name, league_name))
row = await cur.fetchone()
if not row or row['total'] == 0:
return False
total = int(row['total'])
finished = int(row['finished'] or 0)
future = int(row['future'] or 0)
return finished == total or future == total
_SCHEDULE_STATUS_MAP = {"Fixture": 0, "Playing": 1, "Played": 2}
@staticmethod
def _safe_int(val, default: int = 0) -> int:
if val is None:
return default
try:
return int(str(val).strip())
except (ValueError, TypeError):
return default
async def upsert_match_data_batch(self, items: List[Dict[str, Any]], league_name: str = "", sport_type: int = 1) -> Dict[str, int]:
"""批量插入/更新比赛数据到 la_match(来自 schedule API 或 tab API 的 matches"""
if not items:
return {"inserted": 0, "updated": 0, "unchanged": 0, "total": 0}
await self.connect()
inserted = 0
updated = 0
unchanged = 0
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for m in items:
match_id = m.get("match_id", "")
if not match_id:
continue
match_id = self._safe_int(match_id)
if not match_id:
continue
competition_id = self._safe_int(m.get("competition_id"))
status = self._SCHEDULE_STATUS_MAP.get(m.get("status", ""), 0)
start_play = m.get("start_play", "")
match_time = 0
if start_play:
try:
match_time = int(datetime.strptime(start_play, "%Y-%m-%d %H:%M:%S").timestamp())
except Exception:
pass
home_score = self._safe_int(m.get("fs_A"))
away_score = self._safe_int(m.get("fs_B"))
half_score = ""
round_name = str(m.get("_round_name", ""))[:50]
await cur.execute(f"""
INSERT INTO `{self._prefix}match`
(match_id, competition_id, home_team_id, away_team_id,
league_name, round_name, league_icon,
home_team, home_icon, home_score,
away_team, away_icon, away_score,
sport_type, status, match_time,
current_minute, half_score,
is_show, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s)
ON DUPLICATE KEY UPDATE
status=VALUES(status),
home_score=VALUES(home_score), away_score=VALUES(away_score),
half_score=IF(VALUES(half_score)='', half_score, VALUES(half_score)),
current_minute=VALUES(current_minute),
round_name=VALUES(round_name),
home_team=VALUES(home_team), away_team=VALUES(away_team),
home_icon=IF(VALUES(home_icon)='', home_icon, VALUES(home_icon)),
away_icon=IF(VALUES(away_icon)='', away_icon, VALUES(away_icon)),
match_time=IF(VALUES(match_time)=0, match_time, VALUES(match_time)),
update_time=VALUES(update_time)
""", (
match_id, competition_id,
str(m.get("team_A_id", "")),
str(m.get("team_B_id", "")),
league_name[:100],
round_name,
"",
str(m.get("team_A_name", ""))[:100],
str(m.get("team_A_logo", ""))[:255],
home_score,
str(m.get("team_B_name", ""))[:100],
str(m.get("team_B_logo", ""))[:255],
away_score,
sport_type, status, match_time,
str(m.get("minute", ""))[:20],
half_score,
now_ts, now_ts,
))
affected = cur.rowcount
if affected == 1:
inserted += 1
elif affected == 2:
updated += 1
else:
unchanged += 1
total = inserted + updated + unchanged
logger.info(f"la_match UPSERT (schedule): 新增 {inserted}, 更新 {updated}, 未变 {unchanged}, 共 {total} 条")
return {"inserted": inserted, "updated": updated, "unchanged": unchanged, "total": total}
async def get_live_match_ids(self) -> List[int]:
"""获取正在进行中(status=1)的比赛match_id列表"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT match_id FROM `{self._prefix}match`
WHERE `status` = 1 AND is_show = 1
""")
rows = await cur.fetchall()
return [int(r[0]) for r in rows]
async def upsert_match_live_text(self, match_id: int, items: List[Dict[str, Any]]) -> int:
"""批量插入文字直播到 la_match_live_text"""
if not items:
return 0
await self.connect()
count = 0
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for m in items:
msg_id = int(m.get("id", 0))
if not msg_id:
continue
await cur.execute(f"""
INSERT INTO `{self._prefix}match_live_text`
(match_id, msg_id, event_type, username, avatar, message, image, `timestamp`, create_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
message=VALUES(message), image=VALUES(image)
""", (
match_id, msg_id,
str(m.get("event_type") or ""),
str(m.get("username", ""))[:50],
str(m.get("avatar", ""))[:500],
str(m.get("message", "")),
str(m.get("image", ""))[:500],
str(m.get("timestamp", ""))[:20],
now_ts,
))
count += 1
logger.info(f"la_match_live_text UPSERT: match_id={match_id}, {count} 条")
return count
async def upsert_match_data(self, match_sample: Dict[str, Any]):
"""将完整 matchSample 快照写入 la_match_data"""
await self.connect()
match_id = str(match_sample.get("match_id", ""))
if not match_id:
return
raw_json = json.dumps(match_sample, ensure_ascii=False, default=str)
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO `{self._prefix}match_data`
(match_id, relate_type, relate_id,
team_A_id, team_A_name, team_A_logo,
team_B_id, team_B_name, team_B_logo,
date_utc, time_utc, start_play, sort_timestamp,
status, fs_A, fs_B,
competition_id, competition_name, round_name,
minute, minute_period,
raw_data, crawl_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
ON DUPLICATE KEY UPDATE
status=VALUES(status), fs_A=VALUES(fs_A), fs_B=VALUES(fs_B),
minute=VALUES(minute), minute_period=VALUES(minute_period),
raw_data=VALUES(raw_data), crawl_time=NOW()
""", (
match_id,
str(match_sample.get("relate_type", "match")),
str(match_sample.get("relate_id", match_id)),
str(match_sample.get("team_A_id", "")),
str(match_sample.get("team_A_name", ""))[:100],
str(match_sample.get("team_A_logo", ""))[:500],
str(match_sample.get("team_B_id", "")),
str(match_sample.get("team_B_name", ""))[:100],
str(match_sample.get("team_B_logo", ""))[:500],
str(match_sample.get("date_utc", "")),
str(match_sample.get("time_utc", "")),
str(match_sample.get("start_play", "")),
int(match_sample.get("sort_timestamp") or 0),
str(match_sample.get("status", "")),
str(match_sample.get("fs_A", "")) or None,
str(match_sample.get("fs_B", "")) or None,
str(match_sample.get("competition_id", "")),
str(match_sample.get("competition_name", ""))[:100],
str(match_sample.get("round_name", ""))[:100],
str(match_sample.get("minute", "")),
str(match_sample.get("minute_period", "")),
raw_json,
))
logger.debug(f"la_match_data UPSERT: match_id={match_id}")
# ── 文章 ──
async def get_existing_article_ids(self) -> set:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"SELECT article_id FROM `{self._prefix}article` WHERE article_id > 0")
rows = await cur.fetchall()
return {int(r[0]) for r in rows}
async def get_article_cate_map(self) -> Dict[str, int]:
"""返回 {分类名: cid} 映射"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"SELECT id, name FROM `{self._prefix}article_cate` WHERE delete_time IS NULL")
rows = await cur.fetchall()
return {r["name"]: r["id"] for r in rows}
async def upsert_articles(self, items: List[Dict[str, Any]], cid: int = 0) -> int:
if not items:
return 0
await self.connect()
count = 0
skip_count = 0
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
titles = []
for a in items:
title = str(a.get("title", "") or "").strip()[:255]
if title:
titles.append(title)
existing_title_article_ids = {}
if titles:
placeholders = ",".join(["%s"] * len(titles))
await cur.execute(
f"SELECT title, article_id FROM `{self._prefix}article` WHERE cid = %s AND title IN ({placeholders})",
[cid] + titles
)
for row in await cur.fetchall():
existing_title_article_ids.setdefault(row[0], set()).add(int(row[1]))
seen_titles = set()
for a in items:
article_id = int(a.get("id", 0) or 0)
if not article_id:
continue
title = str(a.get("title", "") or "").strip()[:255]
if title:
title_article_ids = existing_title_article_ids.get(title, set())
if title in seen_titles or (title_article_ids and article_id not in title_article_ids):
skip_count += 1
continue
seen_titles.add(title)
desc = str(a.get("description", "") or "")[:255]
thumb = str(a.get("thumb", "") or "")[:500]
image = ""
imgs = a.get("match_image_list") or []
if imgs and isinstance(imgs, list) and imgs[0].get("url"):
image = str(imgs[0]["url"])[:128]
if not image and thumb:
image = thumb[:128]
is_video = 1 if a.get("is_video") or a.get("has_video") else 0
duration = str(a.get("video_time", "") or a.get("duration", "") or "")[:20]
video_url = str(a.get("video_src", "") or a.get("video_url", "") or "")[:500]
author_info = a.get("author")
if isinstance(author_info, dict):
author = str(author_info.get("name", ""))[:255]
else:
author = str(a.get("author_name", "") or "")[:255]
published_at = str(a.get("published_at", ""))[:30]
category = str(a.get("category", "") or "")[:50]
source_url = str(a.get("share", "") or "")[:500]
comment_count = int(a.get("comments_total", 0) or 0)
sort_ts = int(a.get("sort_timestamp", 0) or 0)
await cur.execute(f"""
INSERT INTO `{self._prefix}article`
(article_id, cid, title, `desc`, image, thumb, is_video,
duration, video_url,
author, published_at, category, source_url,
comment_count, sort, is_show, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s)
ON DUPLICATE KEY UPDATE
cid=IF(cid=0 AND VALUES(cid)<>0, VALUES(cid), cid),
title=IF(VALUES(title)='', title, VALUES(title)),
`desc`=IF(VALUES(`desc`)='', `desc`, VALUES(`desc`)),
image=IF(VALUES(image)='', image, VALUES(image)),
thumb=IF(VALUES(thumb)='', thumb, VALUES(thumb)),
duration=IF(VALUES(duration)='', duration, VALUES(duration)),
video_url=IF(VALUES(video_url)='', video_url, VALUES(video_url)),
category=IF(VALUES(category)='', category, VALUES(category)),
sort=IF(VALUES(sort)=0, sort, VALUES(sort)),
comment_count=VALUES(comment_count),
update_time=VALUES(update_time)
""", (
article_id, cid, title, desc, image, thumb, is_video,
duration, video_url,
author, published_at, category, source_url,
comment_count, sort_ts, now_ts, now_ts,
))
count += 1
logger.info(f"la_article UPSERT: {count} 条, 标题重复跳过: {skip_count} 条")
return count
async def get_articles_without_content(self, limit: int = 100) -> List[Dict[str, Any]]:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT id, article_id, title, source_url
FROM `{self._prefix}article`
WHERE article_id > 0
AND (
(content IS NULL OR content = '')
OR (is_video = 1 AND (title IS NULL OR title = ''))
)
AND content_retry < 3
ORDER BY id DESC
LIMIT %s
""", (limit,))
return await cur.fetchall()
async def update_article_content(self, article_id: int, detail: dict) -> None:
await self.connect()
now_ts = int(datetime.now().timestamp())
content = detail.get("content", "")
title = detail.get("title", "")
desc = detail.get("description", "")
author = detail.get("author", "")
published_at = detail.get("published_at", "")
video_url = detail.get("video_url", "")
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
UPDATE `{self._prefix}article`
SET content = %s,
title = IF(%s = '', title, %s),
`desc` = IF(%s = '', `desc`, %s),
author = IF(%s = '', author, %s),
published_at = IF(%s = '', published_at, %s),
video_url = IF(%s = '', video_url, %s),
update_time = %s
WHERE article_id = %s
""", (content,
title, title,
desc, desc,
author, author,
published_at, published_at,
video_url, video_url,
now_ts, article_id))
async def increment_content_retry(self, article_id: int) -> None:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
UPDATE `{self._prefix}article`
SET content_retry = content_retry + 1
WHERE article_id = %s
""", (article_id,))
async def set_content_retry_max(self, article_id: int) -> None:
"""页面无可提取内容,直接设为最大重试次数跳过"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
UPDATE `{self._prefix}article`
SET content_retry = 3
WHERE article_id = %s
""", (article_id,))
# ── 六合彩开奖 ──
async def get_lottery_number_mapping(self, year: int) -> dict:
"""获取指定年份的号码映射(生肖/波色/五行),返回 {number: {zodiac, color, element}}"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT type, attr_name, numbers
FROM `{self._prefix}lottery_number_mapping`
WHERE year = %s
""", (year,))
rows = await cur.fetchall()
mapping = {} # {number: {zodiac: '', color: '', element: ''}}
for row in rows:
nums = json.loads(row['numbers']) if isinstance(row['numbers'], str) else row['numbers']
for n in nums:
if n not in mapping:
mapping[n] = {'zodiac': '', 'color': '', 'element': ''}
t = int(row['type'])
if t == 1:
mapping[n]['zodiac'] = row['attr_name']
elif t == 2:
mapping[n]['color'] = row['attr_name']
elif t == 3:
mapping[n]['element'] = row['attr_name']
return mapping
async def get_lottery_draw_exists(self, category_id: int, period: str) -> bool:
"""检查某期开奖是否已存在且已开奖(status=1)"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT id FROM `{self._prefix}lottery_draw`
WHERE category_id = %s AND period = %s AND status = 1
""", (category_id, period))
return await cur.fetchone() is not None
async def upsert_lottery_draw(self, data: dict) -> int:
"""插入或更新六合彩开奖记录"""
await self.connect()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO `{self._prefix}lottery_draw`
(category_id, period, draw_date, draw_time,
numbers, special_number, zodiac, elements, color,
status, is_show, create_time, update_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s, %s)
ON DUPLICATE KEY UPDATE
numbers=VALUES(numbers),
special_number=VALUES(special_number),
zodiac=VALUES(zodiac),
elements=VALUES(elements),
color=VALUES(color),
draw_time=VALUES(draw_time),
status=VALUES(status),
update_time=VALUES(update_time)
""", (
data['category_id'], data['period'], data['draw_date'], data.get('draw_time'),
json.dumps(data['numbers']), str(data['special_number']),
json.dumps(data['zodiac'], ensure_ascii=False),
json.dumps(data['elements'], ensure_ascii=False),
json.dumps(data['color'], ensure_ascii=False),
data['status'], now_ts, now_ts,
))
return cur.rowcount
async def upsert_lottery_next_draw(self, data: dict) -> int:
"""插入下一期待开奖记录(status=0"""
await self.connect()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO `{self._prefix}lottery_draw`
(category_id, period, draw_date, draw_time,
numbers, special_number, zodiac, elements, color,
status, is_show, create_time, update_time)
VALUES (%s, %s, %s, %s, '[]', '', '[]', '[]', '[]', 0, 1, %s, %s)
ON DUPLICATE KEY UPDATE
draw_date=IF(VALUES(draw_date) IS NULL, draw_date, VALUES(draw_date)),
draw_time=IF(VALUES(draw_time) IS NULL, draw_time, VALUES(draw_time)),
update_time=VALUES(update_time)
""", (
data['category_id'], data['period'],
data.get('draw_date'), data.get('draw_time'),
now_ts, now_ts,
))
return cur.rowcount
async def get_pending_lottery_draws(self) -> list:
"""获取所有待开奖记录(status=0),返回 [{category_id, period, draw_date, draw_time}]"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT category_id, period, draw_date, draw_time
FROM `{self._prefix}lottery_draw`
WHERE status = 0
ORDER BY draw_time ASC
""")
return await cur.fetchall()
async def get_latest_lottery_draw(self, category_id: int) -> dict:
"""获取某彩种最新一条已开奖记录"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT category_id, period, draw_date, draw_time, numbers, special_number
FROM `{self._prefix}lottery_draw`
WHERE category_id = %s AND status = 1
ORDER BY id DESC LIMIT 1
""", (category_id,))
return await cur.fetchone() or {}
# ── 爬取日志 ──
async def log_crawl(
self,
crawl_type: str,
target: str,
success: bool,
elapsed: float,
record_count: int = 0,
error: str = "",
channel: str = "",
):
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO `{self._prefix}crawl_log`
(crawl_type, api_url, success, response_time, match_count,
error_message, user_agent, created_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,NOW())
""", (crawl_type, target, success, elapsed, record_count, error, channel))
# ── 建表 DDL ──
async def ensure_tables(self):
"""确保所需数据表存在"""
await self.connect()
ddls = [
f"""CREATE TABLE IF NOT EXISTS `{self._prefix}standings` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`season_id` INT NOT NULL,
`round_number` INT NOT NULL DEFAULT 0,
`team_id` INT NOT NULL,
`team_name` VARCHAR(100) NOT NULL DEFAULT '',
`team_logo` VARCHAR(500) DEFAULT '',
`rank` INT NOT NULL DEFAULT 0,
`points` INT NOT NULL DEFAULT 0,
`played` INT NOT NULL DEFAULT 0,
`won` INT NOT NULL DEFAULT 0,
`drawn` INT NOT NULL DEFAULT 0,
`lost` INT NOT NULL DEFAULT 0,
`goals_for` INT NOT NULL DEFAULT 0,
`goals_against` INT NOT NULL DEFAULT 0,
`goal_diff` INT NOT NULL DEFAULT 0,
`recent_form` VARCHAR(20) DEFAULT '',
`deduction_points` INT DEFAULT 0,
`deduction_reason` VARCHAR(500) DEFAULT '',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_season_round_team` (`season_id`, `round_number`, `team_id`),
KEY `idx_season` (`season_id`),
KEY `idx_team` (`team_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
f"""CREATE TABLE IF NOT EXISTS `{self._prefix}matches` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`match_id` BIGINT NOT NULL,
`season_id` INT NOT NULL,
`round_number` INT DEFAULT 0,
`round_name` VARCHAR(100) DEFAULT '',
`match_date` DATETIME DEFAULT NULL,
`status` VARCHAR(20) DEFAULT 'fixture',
`home_team_id` INT DEFAULT NULL,
`home_team_name` VARCHAR(100) DEFAULT '',
`home_team_logo` VARCHAR(500) DEFAULT '',
`away_team_id` INT DEFAULT NULL,
`away_team_name` VARCHAR(100) DEFAULT '',
`away_team_logo` VARCHAR(500) DEFAULT '',
`home_score` INT DEFAULT NULL,
`away_score` INT DEFAULT NULL,
`halftime_score` VARCHAR(20) DEFAULT '',
`venue` VARCHAR(200) DEFAULT '',
`competition_id` INT DEFAULT NULL,
`competition_name` VARCHAR(100) DEFAULT '',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_match_id` (`match_id`),
KEY `idx_season` (`season_id`),
KEY `idx_status` (`status`),
KEY `idx_date` (`match_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
f"""CREATE TABLE IF NOT EXISTS `{self._prefix}match_type_config` (
`id` INT PRIMARY KEY,
`label` VARCHAR(100) DEFAULT '',
`type` VARCHAR(50) DEFAULT '',
`sort` INT DEFAULT 0,
`api` VARCHAR(500) DEFAULT '',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
f"""CREATE TABLE IF NOT EXISTS `{self._prefix}crawl_log` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`crawl_type` VARCHAR(50) NOT NULL,
`target` VARCHAR(200) DEFAULT '',
`success` TINYINT(1) DEFAULT 0,
`elapsed` FLOAT DEFAULT 0,
`record_count` INT DEFAULT 0,
`error_message` TEXT,
`channel` VARCHAR(50) DEFAULT '',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY `idx_type` (`crawl_type`),
KEY `idx_time` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""",
f"""CREATE TABLE IF NOT EXISTS `{self._prefix}crawl_error_log` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`task_name` VARCHAR(100) NOT NULL DEFAULT '',
`request_url` VARCHAR(1000) NOT NULL DEFAULT '',
`request_method` VARCHAR(10) NOT NULL DEFAULT 'GET',
`request_params` TEXT,
`http_status` INT NOT NULL DEFAULT 0,
`error_type` VARCHAR(100) NOT NULL DEFAULT '',
`error_message` TEXT,
`response_body` TEXT,
`channel` VARCHAR(20) NOT NULL DEFAULT '',
`notified` TINYINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_notified` (`notified`),
KEY `idx_task_created` (`task_name`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='爬虫请求错误日志'""",
]
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for ddl in ddls:
await cur.execute(ddl)
await self.ensure_alert_tables()
logger.info("数据库表检查/创建完成")
async def ensure_alert_tables(self):
await self.connect()
ddl = f"""CREATE TABLE IF NOT EXISTS `{self._prefix}crontab_alert` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`crontab_id` INT NOT NULL DEFAULT 0,
`crontab_log_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`task_name` VARCHAR(100) NOT NULL DEFAULT '',
`command` VARCHAR(100) NOT NULL DEFAULT '',
`params` VARCHAR(255) NOT NULL DEFAULT '',
`alert_type` VARCHAR(50) NOT NULL DEFAULT '',
`http_status` INT NOT NULL DEFAULT 0,
`detail` LONGTEXT NULL,
`dedupe_key` VARCHAR(191) NOT NULL DEFAULT '',
`status` VARCHAR(20) NOT NULL DEFAULT 'open',
`first_seen_at` INT UNSIGNED NOT NULL DEFAULT 0,
`last_seen_at` INT UNSIGNED NOT NULL DEFAULT 0,
`last_notified_at` INT UNSIGNED NOT NULL DEFAULT 0,
`resolved_at` INT UNSIGNED NOT NULL DEFAULT 0,
`notify_count` INT UNSIGNED NOT NULL DEFAULT 0,
`create_time` INT UNSIGNED NOT NULL DEFAULT 0,
`update_time` INT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY `uk_dedupe_key` (`dedupe_key`),
KEY `idx_status_notify` (`status`, `last_notified_at`),
KEY `idx_crontab_status` (`crontab_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定时任务告警'"""
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(ddl)
# ── 彩种开奖结果 (la_lottery_draw_result) ──
async def upsert_lottery_draw_results(self, items: List[Dict[str, Any]]) -> int:
if not items:
return 0
await self.connect()
count = 0
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for item in items:
code = str(item.get('code', ''))
issue = str(item.get('issue', ''))
if not code or not issue:
continue
draw_time = item.get('draw_time') or None
draw_code = str(item.get('draw_code', ''))
draw_ext = str(item.get('draw_ext', ''))
status = int(item.get('status', 0))
if draw_code:
status = 1
idx = int(item.get('index', 0))
next_issue = str(item.get('next_issue', ''))
next_time = item.get('next_time') or None
trend = item.get('trend')
trend_json = json.dumps(trend, ensure_ascii=False) if trend else None
s_time = item.get('sTime') or None
await cur.execute(f"""
INSERT INTO `{self._prefix}lottery_draw_result`
(code, issue, draw_time, draw_code, draw_ext, status, idx,
next_issue, next_time, trend, s_time, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
draw_time=VALUES(draw_time),
draw_code=VALUES(draw_code),
draw_ext=VALUES(draw_ext),
status=VALUES(status),
idx=VALUES(idx),
next_issue=VALUES(next_issue),
next_time=VALUES(next_time),
trend=VALUES(trend),
s_time=VALUES(s_time),
update_time=VALUES(update_time)
""", (
code, issue, draw_time, draw_code, draw_ext,
status, idx, next_issue, next_time, trend_json, s_time,
now_ts, now_ts,
))
count += 1
await conn.commit()
logger.info(f"la_lottery_draw_result UPSERT: {count} 条")
return count
# ── 超时比赛收尾 ──
async def get_overdue_matches(self, hours: int = 6, limit: int = 0) -> List[Dict]:
"""查询比赛时间超过 hours 小时但状态仍非已结束(status!=2)的比赛"""
await self.connect()
now_ts = int(datetime.now().timestamp())
cutoff = now_ts - hours * 3600
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
sql = f"""
SELECT id, match_id, home_team, away_team, league_name,
match_time, status, home_score, away_score, half_score
FROM `{self._prefix}match`
WHERE match_id > 0
AND match_time > 0
AND match_time <= %s
AND status IN (0, 1)
ORDER BY match_time ASC
"""
if limit > 0:
sql += f" LIMIT {limit}"
await cur.execute(sql, (cutoff,))
return await cur.fetchall()
async def finish_match(self, match_id: int, home_score: int, away_score: int, half_score: str = ""):
"""将比赛标记为已结束(status=2),更新比分"""
await self.connect()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
UPDATE `{self._prefix}match`
SET status=2, home_score=%s, away_score=%s,
half_score=IF(%s='', half_score, %s),
current_minute='FT', update_time=%s
WHERE match_id=%s AND status != 2
""", (home_score, away_score, half_score, half_score, now_ts, match_id))
async def has_match_lineup(self, match_id: int) -> bool:
"""检查比赛是否已有阵容数据"""
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT 1 FROM `{self._prefix}match_lineup`
WHERE match_id=%s LIMIT 1
""", (match_id,))
return await cur.fetchone() is not None
async def upsert_match_lineup(self, match_id: int, players: List[Dict[str, Any]]) -> int:
"""批量插入比赛阵容到 la_match_lineup(忽略重复)"""
if not players:
return 0
await self.connect()
now_ts = int(datetime.now().timestamp())
count = 0
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for p in players:
try:
await cur.execute(f"""
INSERT IGNORE INTO `{self._prefix}match_lineup`
(match_id, team_side, is_starter, person_id, person_name,
person_logo, shirt_number, captain, position,
position_x, position_y, formation_place, create_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
match_id,
int(p.get("team_side", 1)),
int(p.get("is_starter", 1)),
int(p.get("person_id", 0)),
str(p.get("person_name", ""))[:50],
str(p.get("person_logo", ""))[:500],
str(p.get("shirt_number", ""))[:10],
int(p.get("captain", 0)),
str(p.get("position", ""))[:20],
str(p.get("position_x", ""))[:10],
str(p.get("position_y", ""))[:10],
int(p.get("formation_place", 0)),
now_ts,
))
count += 1
except Exception:
pass
return count
@staticmethod
def _event_fingerprint(match_id: int, minute: str, etype: int, player: str, team_side: int) -> str:
raw = f"{match_id}:{minute}:{etype}:{player}:{team_side}"
return hashlib.md5(raw.encode()).hexdigest()
async def upsert_match_events(self, match_id: int, events: List[Dict[str, Any]]) -> int:
"""增量写入比赛事件,通过 Redis Set 缓存已有事件指纹,跳过重复"""
if not events:
return 0
await self.connect()
r = await self._get_redis()
now_ts = int(datetime.now().timestamp())
count = 0
EVENT_TYPE_MAP = {
"goal": 1, "assist": 2, "yellow": 3, "red": 4,
"substitution": 5, "penalty": 6, "own_goal": 7, "var": 8,
}
cache_key = f"match_event:{match_id}"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
for ev in events:
minute = str(ev.get("minute", ""))[:10]
etype = EVENT_TYPE_MAP.get(str(ev.get("type", "")).lower(), 0)
team_side = int(ev.get("team_side", 1))
player = str(ev.get("player_name", "") or ev.get("player", ""))[:50]
desc = str(ev.get("description", "") or ev.get("text", ""))[:255]
fp = self._event_fingerprint(match_id, minute, etype, player, team_side)
if await r.sismember(cache_key, fp):
continue
try:
await cur.execute(f"""
INSERT IGNORE INTO `{self._prefix}match_event`
(match_id, minute, event_type, team_side, player_name, description, create_time)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (match_id, minute, etype, team_side, player, desc, now_ts))
await r.sadd(cache_key, fp)
count += 1
except Exception:
pass
if count == 0:
await r.expire(cache_key, 86400)
else:
await r.expire(cache_key, 86400 * 3)
return count
# ── 爬虫错误日志 ──
async def insert_error_log(
self,
task_name: str,
request_url: str,
request_method: str = "GET",
request_params: str = "",
http_status: int = 0,
error_type: str = "",
error_message: str = "",
response_body: str = "",
channel: str = "",
):
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO `{self._prefix}crawl_error_log`
(task_name, request_url, request_method, request_params,
http_status, error_type, error_message, response_body, channel)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
task_name[:100], request_url[:1000], request_method[:10],
request_params[:5000] if request_params else "",
http_status, error_type[:100],
error_message[:5000] if error_message else "",
response_body[:5000] if response_body else "",
channel[:20],
))
async def get_pending_errors(self, limit: int = 100) -> List[Dict]:
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(f"""
SELECT id, task_name, request_url, request_method, request_params,
http_status, error_type, error_message, response_body,
channel, created_at
FROM `{self._prefix}crawl_error_log`
WHERE notified = 0
ORDER BY created_at ASC
LIMIT %s
""", (limit,))
return await cur.fetchall()
async def mark_errors_notified(self, ids: List[int]):
if not ids:
return
await self.connect()
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(ids))
await cur.execute(f"""
UPDATE `{self._prefix}crawl_error_log`
SET notified = 1
WHERE id IN ({placeholders})
""", ids)
async def upsert_crontab_alert(
self,
crontab_id: int,
crontab_log_id: int,
task_name: str,
command: str,
params: str,
alert_type: str,
http_status: int,
detail: Dict[str, Any],
dedupe_key: str,
) -> int:
await self.ensure_alert_tables()
now_ts = int(datetime.now().timestamp())
detail_json = json.dumps(detail, ensure_ascii=False) if isinstance(detail, (dict, list)) else str(detail or "")
existing = await self.fetchone(
f"SELECT id, status FROM `{self._prefix}crontab_alert` WHERE dedupe_key=%s LIMIT 1",
(dedupe_key,),
)
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
if existing:
if existing.get("status") == "resolved":
await cur.execute(
f"""UPDATE `{self._prefix}crontab_alert`
SET crontab_id=%s, crontab_log_id=%s, task_name=%s, `command`=%s, params=%s,
alert_type=%s, http_status=%s, detail=%s,
status='open', first_seen_at=%s, last_seen_at=%s,
last_notified_at=0, resolved_at=0, notify_count=0, update_time=%s
WHERE id=%s""",
(
crontab_id, crontab_log_id, task_name[:100], command[:100], params[:255],
alert_type[:50], int(http_status), detail_json,
now_ts, now_ts, now_ts, existing["id"],
),
)
else:
await cur.execute(
f"""UPDATE `{self._prefix}crontab_alert`
SET crontab_log_id=%s, task_name=%s, `command`=%s, params=%s,
alert_type=%s, http_status=%s, detail=%s,
last_seen_at=%s, update_time=%s
WHERE id=%s""",
(
crontab_log_id, task_name[:100], command[:100], params[:255],
alert_type[:50], int(http_status), detail_json,
now_ts, now_ts, existing["id"],
),
)
return int(existing["id"])
await cur.execute(
f"""INSERT INTO `{self._prefix}crontab_alert`
(crontab_id, crontab_log_id, task_name, `command`, params, alert_type, http_status,
detail, dedupe_key, status, first_seen_at, last_seen_at, create_time, update_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'open',%s,%s,%s,%s)""",
(
crontab_id, crontab_log_id, task_name[:100], command[:100], params[:255],
alert_type[:50], int(http_status), detail_json, dedupe_key[:191],
now_ts, now_ts, now_ts, now_ts,
),
)
return int(cur.lastrowid)
async def resolve_crontab_alerts(self, crontab_id: int):
await self.ensure_alert_tables()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"""UPDATE `{self._prefix}crontab_alert`
SET status='resolved', resolved_at=%s, update_time=%s
WHERE crontab_id=%s AND status='open'""",
(now_ts, now_ts, crontab_id),
)
async def get_dispatchable_crontab_alerts(self, cooldown_seconds: int, limit: int = 50) -> List[Dict]:
await self.ensure_alert_tables()
now_ts = int(datetime.now().timestamp())
cutoff = max(0, now_ts - max(0, int(cooldown_seconds)))
return await self.fetchall(
f"""SELECT a.*, c.expression
FROM `{self._prefix}crontab_alert` a
LEFT JOIN `{self._prefix}dev_crontab` c ON c.id = a.crontab_id
WHERE a.status='open'
AND (a.last_notified_at = 0 OR a.last_notified_at <= %s)
ORDER BY a.first_seen_at ASC
LIMIT %s""",
(cutoff, int(limit)),
)
async def mark_crontab_alert_notified(self, alert_id: int):
await self.ensure_alert_tables()
now_ts = int(datetime.now().timestamp())
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"""UPDATE `{self._prefix}crontab_alert`
SET last_notified_at=%s, notify_count=notify_count + 1, update_time=%s
WHERE id=%s""",
(now_ts, now_ts, int(alert_id)),
)
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, *args):
await self.close()