Files
AlphaPulse/backend/src/alphapulse/api/crawler.py
T
eezd d0c2beb9b3
Development CI / check-and-build (push) Successful in 2m59s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 5s
Repair official collection and retire exhausted material tasks
2026-11-05 13:30:05 +08:00

336 lines
15 KiB
Python

import json
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Annotated, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import AwareDatetime, BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.api.response import ApiResponse, success
from alphapulse.core.auth import AuthSession, require_admin
from alphapulse.core.streams import session_events
from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.core.official_sources import REGIONS
from alphapulse.calendar.catalog import FAMILIES
from alphapulse.crawler.events import CONTROL_CHANNEL, HISTORY_SOURCES, LOG_CHANNEL, STATUS_KEY
from alphapulse.crawler.spiders.us_official import configuration_error
from alphapulse.models import CrawlLog, CrawlRun, EconomicRelease, NewsItem, Source
router = APIRouter(prefix="/api/crawler", tags=["crawler"], dependencies=[Depends(require_admin)])
# “今日”按北京时间计算
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
class ProcessStatus(BaseModel):
state: Literal["running", "stopped", "restarting"]
pid: int | None
started_at: datetime | None
next_run_at: datetime
class SourceOut(BaseModel):
code: str
name: str
region_code: str | None
list_url: str
enabled: bool
interval_seconds: int
backfill_hours: int
consecutive_failures: int
last_success_at: datetime | None
last_error: str | None
paused_until: datetime | None
configuration_error: str | None = None
history_cursor: dict = Field(default_factory=dict)
supports_history: bool
history_target: datetime | None
history_position: datetime | None
history_done: bool
collection_stats: dict = Field(default_factory=dict)
# 管理进程未运行时为 None
process: ProcessStatus | None
today_new: int
today_revised: int
total_items: int
class CalendarSourceOut(BaseModel):
code: str
name: str
region_code: str
enabled: bool
retired_count: int
worker_alive: bool
heartbeat_at: datetime | None
schedule_checked_at: datetime | None
schedule_success_at: datetime | None
latest_result_at: datetime | None
next_event_at: datetime | None
overdue_count: int
history_start: datetime | None
history_end: datetime | None
errors: list[str]
class SourcesOut(BaseModel):
manager_alive: bool
db_size_bytes: int
sources: list[SourceOut]
calendar_sources: list[CalendarSourceOut]
class SourcePatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
enabled: bool | None = None
interval_seconds: Annotated[int | None, Field(ge=5, le=86400)] = None
# 设为时间表示开始或继续回补到该时间;显式传 null 表示取消
history_target: AwareDatetime | None = None
class RunOut(BaseModel):
id: uuid.UUID
source_code: str
trigger: str
started_at: datetime
finished_at: datetime | None
status: str
requests: int
items_seen: int
items_new: int
items_revised: int
error: str | None
class LogOut(BaseModel):
id: int
source_code: str
run_id: uuid.UUID | None
ts: datetime
level: str
message: str
class RestartIn(BaseModel):
# 来源 code 或 "all"
source: str
async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
now = datetime.now(UTC)
raw = await redis.get(f"{KEY_PREFIX}calendar:status")
heartbeat = json.loads(raw) if raw else None
pending = EconomicRelease.first_received_at.is_(None) & (EconomicRelease.schedule_status == "scheduled") & EconomicRelease.collection_retired_at.is_(None)
published = EconomicRelease.first_received_at.is_not(None)
rows = (await session.execute(select(
EconomicRelease.source,
func.max(EconomicRelease.schedule_checked_at).label("schedule_success_at"),
func.max(EconomicRelease.first_received_at).label("latest_result_at"),
func.min(EconomicRelease.scheduled_at).filter(pending & (EconomicRelease.scheduled_at > now)).label("next_event_at"),
func.count().filter(EconomicRelease.collection_retired_at.is_not(None)).label("retired_count"),
func.count().filter(pending & EconomicRelease.scheduled_at.between(now - timedelta(days=45), now)).label("overdue_count"),
func.min(EconomicRelease.scheduled_at).filter(published).label("history_start"),
func.max(EconomicRelease.scheduled_at).filter(published).label("history_end"),
).group_by(EconomicRelease.source))).mappings().all()
stats = {row["source"]: dict(row) for row in rows}
result_errors = {}
for source, error in await session.execute(select(EconomicRelease.source, EconomicRelease.error).where(
EconomicRelease.error.is_not(None), EconomicRelease.collection_retired_at.is_(None), EconomicRelease.schedule_status == "scheduled",
EconomicRelease.scheduled_at >= now - timedelta(days=45),
).order_by(EconomicRelease.last_checked_at.desc())):
result_errors.setdefault(source, error)
codes = {family.source for family in FAMILIES.values()} | {"jin10"}
raw_jin10 = await redis.get(f"{KEY_PREFIX}calendar:jin10")
jin10_heartbeat = json.loads(raw_jin10) if raw_jin10 else None
sources = list(await session.scalars(select(Source).where(Source.code.in_(codes)).order_by(Source.code)))
output = []
for source in sources:
current_heartbeat = jin10_heartbeat if source.code == "jin10" else heartbeat
source_alive = bool(current_heartbeat and now - datetime.fromisoformat(current_heartbeat["at"]) < timedelta(seconds=30))
if source.code == "jin10":
checks = [{"source": "jin10", "checked_at": jin10_heartbeat["at"],
"error": jin10_heartbeat.get("error") or (None if jin10_heartbeat.get("state") in ("ready", "syncing") else "金十同步尚未就绪")}] if jin10_heartbeat else []
else:
checks = [s for s in heartbeat["sources"].values() if s["source"] == source.code] if heartbeat else []
errors = [s["error"] for s in checks if s["error"]]
if source.code in result_errors:
errors.append(result_errors[source.code])
if not source.enabled and source.last_error:
errors.append(source.last_error)
data = stats.get(source.code, {})
output.append(CalendarSourceOut(
code=source.code, name=source.name, region_code=REGIONS[source.code], worker_alive=source_alive,
enabled=source.enabled, retired_count=data.get("retired_count", 0),
heartbeat_at=current_heartbeat["at"] if current_heartbeat else None,
schedule_checked_at=max((s["checked_at"] for s in checks), default=None),
**{key: data.get(key) for key in ("schedule_success_at", "latest_result_at", "next_event_at", "history_start", "history_end")},
overdue_count=data.get("overdue_count", 0), errors=list(dict.fromkeys(errors)),
))
return output
@router.get("/sources")
async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[SourcesOut]:
sources = (await session.scalars(select(Source).where(Source.collector == "news").order_by(Source.code))).all()
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
today_counts = {
code: (new or 0, revised or 0)
for code, new, revised in await session.execute(
select(CrawlRun.source_code, func.sum(CrawlRun.items_new), func.sum(CrawlRun.items_revised))
.where(CrawlRun.started_at >= today)
.group_by(CrawlRun.source_code)
)
}
totals = dict((await session.execute(
select(NewsItem.source_code, func.count()).group_by(NewsItem.source_code)
)).all())
latest_runs = (await session.scalars(select(CrawlRun).order_by(CrawlRun.source_code, CrawlRun.started_at.desc()).distinct(CrawlRun.source_code))).all()
run_stats = {run.source_code: {**run.stats, 'round_running': int(run.status == 'running')} for run in latest_runs}
statuses = await redis.hgetall(STATUS_KEY)
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
attachment_stats = {}
for source in sources:
retries = (source.history_cursor or {}).get('attachment_retries', {})
pending = sum(value['attempts'] < 3 for value in retries.values())
documents = (source.history_cursor or {}).get('document_retries', {})
document_pending = sum(value['attempts'] < 3 for value in documents.values())
bills = (source.history_cursor or {}).get('bill_retries', {})
bill_pending = sum(value['attempts'] < 3 for value in bills.values())
attachment_stats[source.code] = {
'retired_count': sum(bool(v.get('retired_at')) for group in (retries, documents, bills) for v in group.values()),
'active_gaps': sum(not v.get('retired_at') for group in (retries, documents, bills) for v in group.values()),
'attachment_gaps': len(retries),
'attachment_retry_pending': pending,
'attachment_retry_exhausted': len(retries) - pending,
'document_gaps': len(documents),
'document_retry_pending': document_pending,
'document_retry_exhausted': len(documents) - document_pending,
'unresolved_bills': len(bills),
'bill_retry_pending': bill_pending,
'bill_retry_exhausted': len(bills) - bill_pending,
}
return success(SourcesOut(
manager_alive=bool(statuses),
db_size_bytes=db_size,
calendar_sources=await calendar_sources(session),
sources=[
SourceOut.model_validate({
**s.__dict__,
"region_code": REGIONS.get(s.code),
"supports_history": s.code in HISTORY_SOURCES,
"configuration_error": configuration_error(s.code),
"collection_stats": {**run_stats.get(s.code, {}), **attachment_stats[s.code]},
"process": json.loads(statuses[s.code]) if s.code in statuses else None,
"today_new": today_counts.get(s.code, (0, 0))[0],
"today_revised": today_counts.get(s.code, (0, 0))[1],
"total_items": totals.get(s.code, 0),
})
for s in sources
],
))
@router.patch("/sources/{code}")
async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
source = await session.get(Source, code)
if source is None or source.collector != "news":
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
if body.enabled and (error := configuration_error(code)):
raise HTTPException(status.HTTP_409_CONFLICT, error)
for field, value in body.model_dump(exclude_none=True, exclude={"history_target"}).items():
setattr(source, field, value)
if "history_target" in body.model_fields_set:
if code not in HISTORY_SOURCES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "该来源的接口不支持历史翻页,无法回补")
if body.history_target is not None and body.history_target >= datetime.now(LOCAL_TZ):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "回补目标时间必须早于现在")
# 保留断点:改目标或重新开始时从已回补的位置继续
if body.history_target is not None and (source.history_cursor or {}).get("history", {}).get("scanned"):
source.history_cursor = {k: v for k, v in source.history_cursor.items() if k != "history"}
source.history_target = body.history_target
source.history_done = False
await session.commit()
return success(None)
@router.get("/runs")
async def list_runs(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> ApiResponse[list[RunOut]]:
stmt = select(CrawlRun).order_by(CrawlRun.started_at.desc()).limit(limit)
if source:
stmt = stmt.where(CrawlRun.source_code == source)
return success([RunOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)])
@router.get("/logs")
async def list_logs(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
before_id: int | None = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
) -> ApiResponse[list[LogOut]]:
"""按时间正序返回 before_id 之前最近的 limit 条"""
stmt = select(CrawlLog).order_by(CrawlLog.id.desc()).limit(limit)
if source:
stmt = stmt.where(CrawlLog.source_code == source)
if before_id:
stmt = stmt.where(CrawlLog.id < before_id)
rows = [LogOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)]
return success(rows[::-1])
@router.get("/logs/stream", response_class=EventSourceResponse)
async def stream_logs(principal: Annotated[AuthSession, Depends(require_admin)], source: str | None = None) -> AsyncIterator[ServerSentEvent]:
async for item in session_events(_stream_logs(source), principal):
yield item
async def _stream_logs(source: str | None = None) -> AsyncIterator[ServerSentEvent]:
async with redis.pubsub() as pubsub:
await pubsub.subscribe(LOG_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
if source and json.loads(message["data"])["source_code"] != source:
continue
yield ServerSentEvent(raw_data=message["data"], event="log")
@router.post("/restart")
async def restart(body: RestartIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
if body.source != "all":
source = await session.get(Source, body.source)
if source is None or source.collector != "news":
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
receivers = await redis.publish(CONTROL_CHANNEL, json.dumps({"action": "restart", "source": body.source}))
if receivers == 0:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "采集管理进程未运行")
return success(None)
class RssIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
url: str = Field(min_length=1, max_length=255)
interval_seconds: int = Field(default=300, ge=60, le=86400)
@router.post('/sources/rss', status_code=201)
async def create_rss(body: RssIn, session: Annotated[AsyncSession, Depends(get_session)]):
from alphapulse.news_risk.research import public_address
try: await public_address(body.url)
except ValueError as error: raise HTTPException(422, str(error)) from error
except OSError as error: raise HTTPException(422, '无法解析订阅来源地址') from error
source = Source(code='rss_'+uuid.uuid4().hex[:24], name=body.name, list_url=body.url,
adapter='rss', collector='news', enabled=False, interval_seconds=body.interval_seconds, backfill_hours=72)
session.add(source); await session.commit()
return success({'code': source.code})