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:
commit
aabc81ba5b
88 files changed
+12603
No files matched your search
@@ -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, "采集管理进程未运行")
|
||||
Reference in new issue
Block a user