Initial AlphaPulse implementation: news crawler and Binance klines

- Backend (FastAPI, SQLAlchemy 2, Alembic): single-admin cookie auth,
  news/crawler/market APIs with SSE and WebSocket streaming
- Crawler (Scrapy + Scrapy-Redis): wscn_news, wscn_live, cls_depth,
  cls_telegraph spiders, idempotent upserts with revisions, raw response
  archiving, per-source subprocess supervision, restart and backoff
- Market worker: full-history Binance spot/USD-M kline backfill,
  realtime WS stream, hourly and on-reconnect catch-up
- Frontend (Vue 3, Element Plus, UnoCSS, KLineChart): news feed,
  crawler console with live logs, Binance-style chart page
- Deploy: docker compose, nginx site, migrate and daily backup scripts
This commit is contained in:
eezd committed 2026-10-13 13:32:55 +08:00
commit aabc81ba5b
88 files changed
+12603

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
import json
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
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 BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.crawler.events import CONTROL_CHANNEL, LOG_CHANNEL, STATUS_KEY
from alphapulse.models import CrawlLog, CrawlRun, 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
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
# 管理进程未运行时为 None
process: ProcessStatus | None
today_new: int
today_revised: int
total_items: int
class SourcesOut(BaseModel):
manager_alive: bool
db_size_bytes: int
sources: list[SourceOut]
class SourcePatch(BaseModel):
enabled: bool | None = None
interval_seconds: Annotated[int | None, Field(ge=5, le=86400)] = 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
@router.get("/sources")
async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -> SourcesOut:
sources = (await session.scalars(select(Source).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())
statuses = await redis.hgetall(STATUS_KEY)
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
return SourcesOut(
manager_alive=bool(statuses),
db_size_bytes=db_size,
sources=[
SourceOut.model_validate({
**s.__dict__,
"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)]) -> None:
source = await session.get(Source, code)
if source is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
for field, value in body.model_dump(exclude_none=True).items():
setattr(source, field, value)
await session.commit()
@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,
) -> list[RunOut]:
stmt = select(CrawlRun).order_by(CrawlRun.started_at.desc()).limit(limit)
if source:
stmt = stmt.where(CrawlRun.source_code == source)
return [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,
) -> 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 rows[::-1]
@router.get("/logs/stream", response_class=EventSourceResponse)
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)]) -> None:
if body.source != "all" and await session.get(Source, body.source) is None:
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, "采集管理进程未运行")