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

@@ -0,0 +1,74 @@
import hashlib
import json
from typing import Any
from scrapy.crawler import Crawler
from sqlalchemy import func, select
from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import redis
from alphapulse.crawler.events import NEWS_CHANNEL
from alphapulse.models import NewsItem, NewsRevision
UPDATABLE_FIELDS = ("url", "title", "summary", "content_html", "content_text", "author", "tags",
"is_paywalled", "published_at", "source_updated_at", "extra")
def content_hash(item: dict[str, Any]) -> str:
return hashlib.sha256("\0".join((item["title"], item["summary"], item["content_html"])).encode()).hexdigest()
class NewsPipeline:
"""按 (source_code, external_id) 幂等落库;内容变化写一条修订"""
def __init__(self, crawler: Crawler) -> None:
self.crawler = crawler
@classmethod
def from_crawler(cls, crawler: Crawler) -> "NewsPipeline":
return cls(crawler)
async def process_item(self, item: dict[str, Any]) -> dict[str, Any]:
spider = self.crawler.spider
digest = content_hash(item)
raw_response_id = item.pop("raw_response_id")
async with SessionLocal() as session:
news = await session.scalar(
select(NewsItem)
.where(NewsItem.source_code == item["source_code"], NewsItem.external_id == item["external_id"])
.with_for_update()
)
if news is None:
kind = "new"
news = NewsItem(**item, content_hash=digest, revision_no=1)
session.add(news)
await session.flush()
elif news.content_hash != digest:
kind = "revised"
for field in UPDATABLE_FIELDS:
setattr(news, field, item[field])
news.content_hash = digest
news.revision_no += 1
news.last_seen_at = func.now()
else:
kind = None
news.last_seen_at = func.now()
if kind:
session.add(NewsRevision(
news_id=news.id,
revision_no=news.revision_no,
title=item["title"],
summary=item["summary"],
content_html=item["content_html"],
content_hash=digest,
raw_response_id=raw_response_id,
))
news_id = news.id
await session.commit()
spider.run.items_seen += 1
if kind:
setattr(spider.run, f"items_{kind}", getattr(spider.run, f"items_{kind}") + 1)
await redis.publish(NEWS_CHANNEL, json.dumps({"id": news_id, "source_code": item["source_code"], "kind": kind}))
return item