Add resumable US policy news collectors and provenance
Development CI / check-and-build (push) Successful in 2m54s
GitHub Git mirror / mirror (push) Successful in 9s
Development CI / deploy-development (push) Failing after 4m24s

Collect official crypto and US policy documents with deterministic screening, full bodies and attachments, bounded retries, and durable history cursors. Preserve publication precision and source identity while keeping new collectors disabled.

Add real Congress, GovInfo, DOJ and Federal Register fixtures, offline capture replay, isolated database validation, and rollout instructions. Validate 982 backend tests, frontend checks, and 57 deployment control tests.
This commit is contained in:
eezd committed 2026-11-04 21:41:04 +08:00
1 parent dc8a283571
commit 89c729092c
31 files changed
+2199 -23

No files matched your search

+12
View File
@@ -19,6 +19,7 @@ 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)])
@@ -46,10 +47,13 @@ class SourceOut(BaseModel):
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
@@ -180,6 +184,8 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
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 for run in latest_runs}
statuses = await redis.hgetall(STATUS_KEY)
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
return success(SourcesOut(
@@ -191,6 +197,8 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
**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, {}),
"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],
@@ -206,6 +214,8 @@ async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSes
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:
@@ -214,6 +224,8 @@ async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSes
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()
+5 -2
View File
@@ -55,6 +55,8 @@ class NewsBrief(BaseModel):
first_seen_at: datetime
revision_no: int
risk: NewsRiskBrief | None = None
published_at_precision: str | None = None
published_date: str | None = None
class NewsPage(BaseModel):
@@ -71,6 +73,7 @@ class NewsDetail(NewsBrief):
class Revision(BaseModel):
extra: dict | None = None
revision_no: int
title: str
summary: str
@@ -131,7 +134,7 @@ async def list_news(
items = []
for news, excerpt, package in rows:
risk = {'state': package.state} if package else None
items.append(NewsBrief.model_validate({**news.__dict__, 'excerpt': excerpt, 'risk': risk}))
items.append(NewsBrief.model_validate({**news.__dict__, 'excerpt': excerpt, 'risk': risk, 'published_at_precision': news.extra.get('published_at_precision'), 'published_date': news.extra.get('published_date')}))
return success(NewsPage(items=items, total=total))
@@ -154,7 +157,7 @@ async def get_news(news_id: int, session: Annotated[AsyncSession, Depends(get_se
news = await session.get(NewsItem, news_id)
if news is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "新闻不存在")
return success(NewsDetail.model_validate({**news.__dict__, "excerpt": news.content_text[:EXCERPT_LENGTH]}))
return success(NewsDetail.model_validate({**news.__dict__, "excerpt": news.content_text[:EXCERPT_LENGTH], "published_at_precision": news.extra.get("published_at_precision"), "published_date": news.extra.get("published_date")}))
@router.get("/{news_id}/revisions")