Repair official collection and retire exhausted material tasks
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

This commit is contained in:
eezd committed 2026-11-05 13:30:05 +08:00
1 parent d5d350b7ec
commit d0c2beb9b3
23 files changed
+399 -35

No files matched your search

@@ -0,0 +1,18 @@
"""Persist bounded calendar collection failures."""
from alembic import op
import sqlalchemy as sa
revision = "db2345678901"
down_revision = "da1234567890"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("economic_releases", sa.Column("collection_failures", sa.Integer(), nullable=False, server_default="0"))
op.add_column("economic_releases", sa.Column("collection_retired_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
op.drop_column("economic_releases", "collection_retired_at")
op.drop_column("economic_releases", "collection_failures")
+10 -2
View File
@@ -65,6 +65,8 @@ class CalendarSourceOut(BaseModel):
code: str code: str
name: str name: str
region_code: str region_code: str
enabled: bool
retired_count: int
worker_alive: bool worker_alive: bool
heartbeat_at: datetime | None heartbeat_at: datetime | None
schedule_checked_at: datetime | None schedule_checked_at: datetime | None
@@ -124,13 +126,14 @@ async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
now = datetime.now(UTC) now = datetime.now(UTC)
raw = await redis.get(f"{KEY_PREFIX}calendar:status") raw = await redis.get(f"{KEY_PREFIX}calendar:status")
heartbeat = json.loads(raw) if raw else None heartbeat = json.loads(raw) if raw else None
pending = EconomicRelease.first_received_at.is_(None) & (EconomicRelease.schedule_status == "scheduled") 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) published = EconomicRelease.first_received_at.is_not(None)
rows = (await session.execute(select( rows = (await session.execute(select(
EconomicRelease.source, EconomicRelease.source,
func.max(EconomicRelease.schedule_checked_at).label("schedule_success_at"), func.max(EconomicRelease.schedule_checked_at).label("schedule_success_at"),
func.max(EconomicRelease.first_received_at).label("latest_result_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.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.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.min(EconomicRelease.scheduled_at).filter(published).label("history_start"),
func.max(EconomicRelease.scheduled_at).filter(published).label("history_end"), func.max(EconomicRelease.scheduled_at).filter(published).label("history_end"),
@@ -138,7 +141,7 @@ async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
stats = {row["source"]: dict(row) for row in rows} stats = {row["source"]: dict(row) for row in rows}
result_errors = {} result_errors = {}
for source, error in await session.execute(select(EconomicRelease.source, EconomicRelease.error).where( for source, error in await session.execute(select(EconomicRelease.source, EconomicRelease.error).where(
EconomicRelease.error.is_not(None), EconomicRelease.schedule_status == "scheduled", EconomicRelease.error.is_not(None), EconomicRelease.collection_retired_at.is_(None), EconomicRelease.schedule_status == "scheduled",
EconomicRelease.scheduled_at >= now - timedelta(days=45), EconomicRelease.scheduled_at >= now - timedelta(days=45),
).order_by(EconomicRelease.last_checked_at.desc())): ).order_by(EconomicRelease.last_checked_at.desc())):
result_errors.setdefault(source, error) result_errors.setdefault(source, error)
@@ -158,9 +161,12 @@ async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
errors = [s["error"] for s in checks if s["error"]] errors = [s["error"] for s in checks if s["error"]]
if source.code in result_errors: if source.code in result_errors:
errors.append(result_errors[source.code]) errors.append(result_errors[source.code])
if not source.enabled and source.last_error:
errors.append(source.last_error)
data = stats.get(source.code, {}) data = stats.get(source.code, {})
output.append(CalendarSourceOut( output.append(CalendarSourceOut(
code=source.code, name=source.name, region_code=REGIONS[source.code], worker_alive=source_alive, 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, heartbeat_at=current_heartbeat["at"] if current_heartbeat else None,
schedule_checked_at=max((s["checked_at"] for s in checks), default=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")}, **{key: data.get(key) for key in ("schedule_success_at", "latest_result_at", "next_event_at", "history_start", "history_end")},
@@ -197,6 +203,8 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
bills = (source.history_cursor or {}).get('bill_retries', {}) bills = (source.history_cursor or {}).get('bill_retries', {})
bill_pending = sum(value['attempts'] < 3 for value in bills.values()) bill_pending = sum(value['attempts'] < 3 for value in bills.values())
attachment_stats[source.code] = { 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_gaps': len(retries),
'attachment_retry_pending': pending, 'attachment_retry_pending': pending,
'attachment_retry_exhausted': len(retries) - pending, 'attachment_retry_exhausted': len(retries) - pending,
+1 -1
View File
@@ -77,7 +77,7 @@ def archive_links(body: str, start: date, end: date) -> list[str]:
def csv_link(body: str, base: str) -> str: def csv_link(body: str, base: str) -> str:
links = [urljoin(base, link) for link in Selector(text=body).css("a[href]::attr(href)").getall() links = [urljoin(base, link) for link in Selector(text=body).css("a[href]::attr(href)").getall()
if link == "download/table1.csv"] if link in ("download/table1.csv", "csv/table1.csv")]
if len(set(links)) != 1: if len(set(links)) != 1:
raise ValueError("EIA 归档未唯一列示 Table 1 CSV") raise ValueError("EIA 归档未唯一列示 Table 1 CSV")
return links[0] return links[0]
+5 -1
View File
@@ -109,6 +109,8 @@ def save_result(session: AsyncSession, row: EconomicRelease, values: dict, url:
if ((period is not None and row.period is not None and period != row.period) if ((period is not None and row.period is not None and period != row.period)
or (stage is not None and row.stage is not None and stage != row.stage)): or (stage is not None and row.stage is not None and stage != row.stage)):
raise ValueError("公告统计期或发布阶段与日历不符") raise ValueError("公告统计期或发布阶段与日历不符")
row.collection_failures = 0
row.collection_retired_at = None
if row.values != values or row.release_url != url or (period is not None and row.period != period) or (stage is not None and row.stage != stage): if row.values != values or row.release_url != url or (period is not None and row.period != period) or (stage is not None and row.stage != stage):
row.values = values row.values = values
row.release_url = url row.release_url = url
@@ -177,6 +179,8 @@ def release_rows(row: EconomicRelease, now: datetime) -> list[dict]:
state = "withdrawn" state = "withdrawn"
elif row.first_received_at is not None: elif row.first_received_at is not None:
state = "released" state = "released"
elif getattr(row, "collection_retired_at", None) is not None:
state = "retired"
elif now - row.scheduled_at > timedelta(days=45): elif now - row.scheduled_at > timedelta(days=45):
state = "historical" state = "historical"
elif row.error: elif row.error:
@@ -214,7 +218,7 @@ def release_rows(row: EconomicRelease, now: datetime) -> list[dict]:
"impact_basis": "forecast" if forecast is not None else "previous" if previous is not None else None, "impact_basis": "forecast" if forecast is not None else "previous" if previous is not None else None,
"impact": direction, "explanation": explanation, "release_url": row.release_url, "impact": direction, "explanation": explanation, "release_url": row.release_url,
"schedule_url": row.schedule_url, "first_received_at": row.first_received_at, "schedule_url": row.schedule_url, "first_received_at": row.first_received_at,
"last_checked_at": row.last_checked_at, "error": row.error, "revision_no": row.revision_no}) "last_checked_at": row.last_checked_at, "collection_retired_at": getattr(row, "collection_retired_at", None), "error": row.error, "revision_no": row.revision_no})
return result return result
+71 -14
View File
@@ -11,20 +11,21 @@ import httpx
from parsel import Selector from parsel import Selector
from sqlalchemy import select from sqlalchemy import select
from alphapulse.calendar.catalog import SCHEDULE_URLS, UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO from alphapulse.calendar.catalog import SCHEDULE_URLS, UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO, UMICH_REPORTS
from alphapulse.calendar import adp, dol, eia, europe from alphapulse.calendar import adp, dol, eia, europe
from alphapulse.calendar.archives import ArchiveMissing from alphapulse.calendar.archives import ArchiveMissing, parse_umich_report
from alphapulse.calendar.parsers import EASTERN, MONTHS, bea_release_link, parse_fed_calendar, parse_ics, parse_values, release_date, bls_periods, release_metadata from alphapulse.calendar.parsers import EASTERN, MONTHS, bea_release_link, parse_fed_calendar, parse_ics, parse_values, release_date, bls_periods, release_metadata
from alphapulse.calendar.service import apply_bls_periods, record_revision, next_poll, save_raw, save_result, save_schedule, save_series from alphapulse.calendar.service import apply_bls_periods, record_revision, next_poll, save_raw, save_result, save_schedule, save_series
from alphapulse.calendar.umich import current_result, document_links, monthly_series, parse_schedule, pdf_text from alphapulse.calendar.umich import current_result, document_links, monthly_series, parse_schedule, pdf_text, report_links
from alphapulse.core.db import SessionLocal from alphapulse.core.db import SessionLocal
from alphapulse.core.official_sources import FEEDS, HOSTS, feed_entries from alphapulse.core.official_sources import FEEDS, HOSTS, feed_entries
from alphapulse.core.redis import KEY_PREFIX, redis from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import EconomicRelease from alphapulse.models import EconomicRelease, Source
logger = logging.getLogger("alphapulse.calendar") logger = logging.getLogger("alphapulse.calendar")
STATUS_KEY = f"{KEY_PREFIX}calendar:status" STATUS_KEY = f"{KEY_PREFIX}calendar:status"
HISTORY_DAYS = 45 HISTORY_DAYS = 45
BOUNDED_SOURCES = frozenset({"dol", "eia", "umich", "adp"})
def retry_after_at(value: str | None, now: datetime) -> datetime | None: def retry_after_at(value: str | None, now: datetime) -> datetime | None:
@@ -95,12 +96,13 @@ class CalendarWorker:
self.index_cache[source, url] = (datetime.now(UTC) + timedelta(hours=1), page) self.index_cache[source, url] = (datetime.now(UTC) + timedelta(hours=1), page)
return page return page
async def adp_links(self, start, end): async def adp_links(self, start, end, first_page=None):
url, visited = adp.INDEX, set() url, visited = adp.INDEX, set()
for _ in range(8): for _ in range(8):
if url in visited: raise ValueError('ADP 官方分页形成循环') if url in visited: raise ValueError('ADP 官方分页形成循环')
visited.add(url) visited.add(url)
response, _ = await self.index('adp', url) response, _ = first_page if first_page is not None else await self.index('adp', url)
first_page = None
links, following = adp.release_links(response.text, url, start, end) links, following = adp.release_links(response.text, url, start, end)
if links or not following: return links if links or not following: return links
url = following url = following
@@ -112,7 +114,13 @@ class CalendarWorker:
module = dol if source == 'dol' else eia module = dol if source == 'dol' else eia
index_url = dol.SCHEDULE if source == 'dol' else eia.ARCHIVE index_url = dol.SCHEDULE if source == 'dol' else eia.ARCHIVE
response, _ = await self.index(source, index_url) response, _ = await self.index(source, index_url)
links = module.archive_links(response.text, day, day) try:
links = module.archive_links(response.text, day, day)
except ArchiveMissing:
if source != 'dol':
raise
# The index can lag behind published PDFs; validate the PDF date below.
links = [f'https://oui.doleta.gov/press/{day:%Y}/{day:%m%d%y}.pdf']
if len(links) != 1: raise ValueError(f'{source} 当期官方归档不唯一') if len(links) != 1: raise ValueError(f'{source} 当期官方归档不唯一')
url = links[0] url = links[0]
if source == 'eia': if source == 'eia':
@@ -140,7 +148,7 @@ class CalendarWorker:
if delay > 0: await asyncio.sleep(delay) if delay > 0: await asyncio.sleep(delay)
async with self.fetch_slots: async with self.fetch_slots:
client = self.bls if source == "bls" else self.http client = self.bls if source == "bls" else self.http
response = await client.post(url, json=query) if query is not None else await client.get(url) response = await client.post(url, json=query) if query is not None else await client.get(url, **({"timeout": httpx.Timeout(20, connect=8)} if source == "adp" else {}))
now = datetime.now(UTC) now = datetime.now(UTC)
self.host_ready[host] = now + timedelta(seconds=1) self.host_ready[host] = now + timedelta(seconds=1)
if response.status_code in (403, 429, 503): if response.status_code in (403, 429, 503):
@@ -169,6 +177,10 @@ class CalendarWorker:
urls += [("dol", dol.SCHEDULE)] urls += [("dol", dol.SCHEDULE)]
urls += [("eia", eia.SCHEDULE)] urls += [("eia", eia.SCHEDULE)]
urls += [("adp", adp.INDEX)] urls += [("adp", adp.INDEX)]
async with SessionLocal() as session:
disabled = set(await session.scalars(select(Source.code).where(Source.code.in_(BOUNDED_SOURCES), Source.enabled.is_(False))))
urls = [(s, u) for s, u in urls if s not in disabled]
checked = {}
# 启动时明确各来源尚未就绪;月份滚动后移除不再检查的旧月状态。 # 启动时明确各来源尚未就绪;月份滚动后移除不再检查的旧月状态。
self.sources = {url: self.sources.get(url, {"source": source, "checked_at": now.isoformat(), "error": "尚未同步"}) self.sources = {url: self.sources.get(url, {"source": source, "checked_at": now.isoformat(), "error": "尚未同步"})
for source, url in urls} for source, url in urls}
@@ -184,7 +196,7 @@ class CalendarWorker:
response, raw_id = await self.fetch(source, fetch_url, query=europe.INSEE_QUERY if source == "insee" else None) response, raw_id = await self.fetch(source, fetch_url, query=europe.INSEE_QUERY if source == "insee" else None)
response.raise_for_status() response.raise_for_status()
if source == "adp": if source == "adp":
links = await self.adp_links(datetime.min.date(), now.astimezone(EASTERN).date()) links = await self.adp_links(datetime.min.date(), now.astimezone(EASTERN).date(), (response, raw_id))
if not links: if not links:
raise ValueError("ADP 官方新闻索引缺少近期月报") raise ValueError("ADP 官方新闻索引缺少近期月报")
report, evidence = await self.fetch(source, links[0]) report, evidence = await self.fetch(source, links[0])
@@ -223,8 +235,11 @@ class CalendarWorker:
async with SessionLocal() as session: async with SessionLocal() as session:
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC)) await save_schedule(session, source, url, events, raw_id, datetime.now(UTC))
await session.commit() await session.commit()
checked.setdefault(source, []).append(None)
self.sources[url] = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": None} self.sources[url] = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": None}
except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError, StopIteration) as exc: except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError, StopIteration) as exc:
if not isinstance(exc, SourceCooling):
checked.setdefault(source, []).append(str(exc))
state = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": str(exc)} state = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": str(exc)}
retry_at = getattr(exc, 'retry_at', None) retry_at = getattr(exc, 'retry_at', None)
if isinstance(exc, httpx.HTTPStatusError): if isinstance(exc, httpx.HTTPStatusError):
@@ -237,6 +252,24 @@ class CalendarWorker:
self.sources[url] = state self.sources[url] = state
logger.exception("日程采集失败:%s", url) logger.exception("日程采集失败:%s", url)
async with SessionLocal() as session:
for code, outcomes in checked.items():
if code not in BOUNDED_SOURCES:
continue
source = await session.get(Source, code, with_for_update=True)
if source is None:
continue
if None in outcomes:
source.consecutive_failures = 0
source.last_success_at = datetime.now(UTC)
source.last_error = None
else:
source.consecutive_failures += 1
source.last_error = outcomes[0]
if source.consecutive_failures >= 3:
source.enabled = False
await session.commit()
async def umich_schedule(self, info: str, now: datetime) -> None: async def umich_schedule(self, info: str, now: datetime) -> None:
history, _ = await self.fetch("umich", UMICH_HISTORY) history, _ = await self.fetch("umich", UMICH_HISTORY)
history.raise_for_status() history.raise_for_status()
@@ -351,7 +384,17 @@ class CalendarWorker:
return None return None
if row.first_received_at is not None: if row.first_received_at is not None:
return None # 最新页已换期;保留已取得的当期值,不误报历史缺口。 return None # 最新页已换期;保留已取得的当期值,不误报历史缺口。
raise ValueError("密歇根当前页已晚于所选历史期次,未取得当期公告") index, _ = await self.index('umich', f'{UMICH_REPORTS}?year={row.period[:4]}')
url = report_links(index.text, int(row.period[:4])).get((row.period, row.stage))
if url is None:
raise ArchiveMissing("密歇根官方年度归档尚未列出所选期次")
response, raw_id = await self.fetch('umich', url)
response.raise_for_status()
announcement = parse_umich_report(await asyncio.to_thread(pdf_text, response.content))
if (announcement.at, announcement.period, announcement.stage) != (row.scheduled_at, row.period, row.stage):
raise ValueError("密歇根归档日期、月份或阶段与所选期次不符")
response.extensions['calendar_announcement'] = announcement
return response, raw_id
if row.source == "bls": if row.source == "bls":
code = "empsit" if row.family == "jobs" else row.family code = "empsit" if row.family == "jobs" else row.family
# 本机 BLS archive 路径返回 403,最新公告页可访问;下面必须核对正式发布日期。 # 本机 BLS archive 路径返回 403,最新公告页可访问;下面必须核对正式发布日期。
@@ -439,6 +482,8 @@ class CalendarWorker:
async def poll_one(self, release_id: int) -> None: async def poll_one(self, release_id: int) -> None:
async with SessionLocal() as session: async with SessionLocal() as session:
row = await session.scalar(select(EconomicRelease).where(EconomicRelease.id == release_id).with_for_update()) row = await session.scalar(select(EconomicRelease).where(EconomicRelease.id == release_id).with_for_update())
if row is None or row.collection_retired_at is not None:
return
now = datetime.now(UTC) now = datetime.now(UTC)
retry_at = None retry_at = None
try: try:
@@ -458,18 +503,29 @@ class CalendarWorker:
announcement = europe.parse_announcement(row.family, response.text) announcement = europe.parse_announcement(row.family, response.text)
period, stage, values = announcement.period, announcement.stage, announcement.values period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.family == "umich": elif row.family == "umich":
period, stage, values = current_result(response.text) if announcement := response.extensions.get('calendar_announcement'):
period, stage, values = announcement.period, announcement.stage, announcement.values
else:
period, stage, values = current_result(response.text)
else: else:
values = parse_values(row.family, response.text) values = parse_values(row.family, response.text)
text = " ".join(Selector(text=response.text).css("h1,#bodytext").xpath("string(.)").getall()) text = " ".join(Selector(text=response.text).css("h1,#bodytext").xpath("string(.)").getall())
period, stage = release_metadata(row.family, text) period, stage = release_metadata(row.family, text)
save_result(session, row, values, response.extensions.get("calendar_url", str(response.url)), raw_id, datetime.now(UTC), period=period, stage=stage) save_result(session, row, values, response.extensions.get("calendar_url", str(response.url)), raw_id, datetime.now(UTC), period=period, stage=stage)
if page is not None:
row.collection_failures = 0
row.error = None row.error = None
except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError) as exc: except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError) as exc:
row.error = f"{type(exc).__name__}: {exc}" row.error = f"{type(exc).__name__}: {exc}"
retry_at = getattr(exc, 'retry_at', None) if row.source in BOUNDED_SOURCES and not isinstance(exc, SourceCooling):
row.collection_failures += 1
if row.collection_failures >= 3:
row.collection_retired_at = datetime.now(UTC)
if row.source in BOUNDED_SOURCES:
retry_at = datetime.now(UTC) + timedelta(hours=1)
retry_at = max(filter(None, (retry_at, getattr(exc, 'retry_at', None))), default=None)
if isinstance(exc, httpx.HTTPStatusError): if isinstance(exc, httpx.HTTPStatusError):
retry_at = self.cooldowns.get(exc.request.url.host, retry_at) retry_at = max(filter(None, (retry_at, self.cooldowns.get(exc.request.url.host))), default=None)
if retry_at: row.error += f";下次尝试 {retry_at.isoformat()}" if retry_at: row.error += f";下次尝试 {retry_at.isoformat()}"
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503): if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
retry_at = max(retry_at or datetime.now(UTC), retry_after_at(exc.response.headers.get("Retry-After"), datetime.now(UTC)) or datetime.now(UTC)) retry_at = max(retry_at or datetime.now(UTC), retry_after_at(exc.response.headers.get("Retry-After"), datetime.now(UTC)) or datetime.now(UTC))
@@ -483,7 +539,8 @@ class CalendarWorker:
async def results(self) -> None: async def results(self) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
async with SessionLocal() as session: async with SessionLocal() as session:
ids = list(await session.scalars(select(EconomicRelease.id).where( ids = list(await session.scalars(select(EconomicRelease.id).join(Source, Source.code == EconomicRelease.source).where(
Source.enabled.is_(True), EconomicRelease.collection_retired_at.is_(None),
EconomicRelease.source != "jin10", EconomicRelease.source != "jin10",
EconomicRelease.schedule_status == "scheduled", EconomicRelease.scheduled_at <= now, EconomicRelease.schedule_status == "scheduled", EconomicRelease.scheduled_at <= now,
EconomicRelease.scheduled_at >= now - timedelta(days=HISTORY_DAYS), EconomicRelease.next_check_at <= now, EconomicRelease.scheduled_at >= now - timedelta(days=HISTORY_DAYS), EconomicRelease.next_check_at <= now,
@@ -0,0 +1,51 @@
"""本次采集修复初始化:默认只读,--execute 幂等建立一次新预算。运行前停止 crawler。"""
import argparse
import asyncio
import copy
import json
from datetime import UTC, datetime
from sqlalchemy import select, text
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import Source
REPAIR = 'collection-repair-20261105-v1'
CODES = ('sec', 'doj', 'congress')
async def run(execute=False):
output = []
async with SessionLocal() as session:
if not execute:
await session.execute(text('SET TRANSACTION READ ONLY'))
query = select(Source).where(Source.code.in_(CODES)).order_by(Source.code)
if execute:
query = query.with_for_update()
for source in await session.scalars(query):
cursor = copy.deepcopy(source.history_cursor or {})
if cursor.get('collection_repair') == REPAIR:
output.append({'source': source.code, 'already_initialized': True})
continue
affected = []
for key in ('document_retries', 'attachment_retries', 'bill_retries'):
for url, entry in cursor.get(key, {}).items():
affected.append({'kind': key, 'url': url, 'previous_attempts': entry['attempts']})
entry['repair_previous'] = {k: v for k, v in entry.items() if k != 'document'}
entry['attempts'] = 0
entry['next_at'] = datetime.now(UTC).timestamp()
entry.pop('retired_at', None)
cursor['collection_repair'] = REPAIR
if execute:
source.history_cursor = cursor
output.append({'source': source.code, 'items': affected})
if execute:
await session.commit()
await engine.dispose()
return {'mode': 'execute' if execute else 'dry-run', 'sources': output}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--execute', action='store_true')
print(json.dumps(asyncio.run(run(parser.parse_args().execute)), ensure_ascii=False))
@@ -253,16 +253,22 @@ class NewsSpider(RedisSpider):
if history_saved: if history_saved:
source.history_position = run.history_position source.history_position = run.history_position
source.history_done = run.history_done source.history_done = run.history_done
if failed: bounded = self.name in ('sec', 'doj', 'congress')
source_failed = failed and (not bounded or not run.stats.get('source_readable'))
if source_failed:
source.consecutive_failures += 1 source.consecutive_failures += 1
delay = min(BACKOFF_BASE_SECONDS * 2 ** (source.consecutive_failures - 1), BACKOFF_MAX_SECONDS) delay = min(BACKOFF_BASE_SECONDS * 2 ** (source.consecutive_failures - 1), BACKOFF_MAX_SECONDS)
if run.cooldown_until: if run.cooldown_until:
delay = max(delay, int((run.cooldown_until - now).total_seconds()) + 1) delay = max(delay, int((run.cooldown_until - now).total_seconds()) + 1)
if bounded:
delay = max(delay, 3600)
if source.consecutive_failures >= 3:
source.enabled = False
source.paused_until = now + timedelta(seconds=delay) source.paused_until = now + timedelta(seconds=delay)
source.last_error = run.errors[0][:1000] source.last_error = run.errors[0][:1000]
else: else:
source.consecutive_failures = 0 source.consecutive_failures = 0
source.paused_until = None source.paused_until = run.cooldown_until if bounded else None
source.last_error = None source.last_error = None
source.last_success_at = now source.last_success_at = now
await session.commit() await session.commit()
@@ -270,6 +276,7 @@ class NewsSpider(RedisSpider):
elapsed = (now - run.started_at).total_seconds() elapsed = (now - run.started_at).total_seconds()
summary = f"请求 {run.requests},条目 {run.items_seen},新增 {run.items_new},修订 {run.items_revised},用时 {elapsed:.1f}s" summary = f"请求 {run.requests},条目 {run.items_seen},新增 {run.items_new},修订 {run.items_revised},用时 {elapsed:.1f}s"
if failed: if failed:
delay = max(0, int((source.paused_until - now).total_seconds())) if source.paused_until else 0
self.logger.error("本轮失败:%s;错误 %d 个,暂停 %ds", summary, len(run.errors), delay) self.logger.error("本轮失败:%s;错误 %d 个,暂停 %ds", summary, len(run.errors), delay)
else: else:
self.logger.info("本轮完成:%s", summary) self.logger.info("本轮完成:%s", summary)
@@ -6,6 +6,7 @@ import time
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from html import escape, unescape from html import escape, unescape
from email.utils import parsedate_to_datetime
from lxml import etree from lxml import etree
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -155,6 +156,8 @@ class USOfficialSpider(NewsSpider):
def request_for(self, url, callback, lane, **meta): def request_for(self, url, callback, lane, **meta):
url = official_url(self.name, url) url = official_url(self.name, url)
request = self.request(url, callback, lane=lane, official_source=self.name, **meta) request = self.request(url, callback, lane=lane, official_source=self.name, **meta)
if self.name in ('sec', 'doj', 'congress'):
request.meta['dont_retry'] = True
request.priority = 10 if lane == 'realtime' else -10 request.priority = 10 if lane == 'realtime' else -10
# Credentials never travel to document hosts or into persisted URLs. # Credentials never travel to document hosts or into persisted URLs.
if urlsplit(url).hostname == 'api.congress.gov': if urlsplit(url).hostname == 'api.congress.gov':
@@ -317,6 +320,7 @@ class USOfficialSpider(NewsSpider):
docs = list({d['url']: d for d in docs}.values()) docs = list({d['url']: d for d in docs}.values())
if not docs: if not docs:
raise ValueError('官方列表未发现文档,不能认定历史遍历完成') raise ValueError('官方列表未发现文档,不能认定历史遍历完成')
self.run.stats['source_readable'] = 1
if self.name == 'fed': if self.name == 'fed':
begin = self.run.history_target if lane == 'history' else self.run.cutoff begin = self.run.history_target if lane == 'history' else self.run.cutoff
def in_range(doc): def in_range(doc):
@@ -380,11 +384,23 @@ class USOfficialSpider(NewsSpider):
self.run.errors.append(error) self.run.errors.append(error)
self.logger.error('文档缺口:%s;%s', doc['url'], error) self.logger.error('文档缺口:%s;%s', doc['url'], error)
state['pending'].pop(0) state['pending'].pop(0)
self.retire_exhausted()
return self.advance(request.meta['lane']) return self.advance(request.meta['lane'])
def on_request_error(self, failure): def on_request_error(self, failure):
status = failure.value.response.status if failure.check(HttpError) else None status = failure.value.response.status if failure.check(HttpError) else None
if status not in (403, 429): if self.name in ('sec', 'doj', 'congress') and status in (403, 429, 503):
if status in (403, 429):
self.block_until(datetime.now(UTC) + timedelta(hours=1))
value = failure.value.response.headers.get('Retry-After', b'').decode('ascii', errors='ignore')
if value:
try:
until = datetime.now(UTC) + timedelta(seconds=int(value)) if value.isdigit() else parsedate_to_datetime(value)
if until.tzinfo is not None:
self.block_until(until)
except (ValueError, TypeError, OverflowError):
self.logger.warning('忽略无效 Retry-After 响应头')
if status not in (403, 429) or self.name in ('sec', 'doj', 'congress'):
requests = self.document_failed(failure.request, f'请求失败 {failure.request.url}:{status or repr(failure.value)}') requests = self.document_failed(failure.request, f'请求失败 {failure.request.url}:{status or repr(failure.value)}')
if requests is not None: if requests is not None:
return requests return requests
@@ -539,6 +555,10 @@ class USOfficialSpider(NewsSpider):
file_id = attachment['file']['id'] file_id = attachment['file']['id']
if not str(file_id).isdigit(): if not str(file_id).isdigit():
raise ValueError('DOJ API 附件编号无效') raise ValueError('DOJ API 附件编号无效')
if (f'/media/{file_id}' in (record.get('image') or '') and
re.match(r'Photo[-\s]', attachment.get('alt', ''), re.I) and
'left to right' in attachment.get('alt', '').lower()):
continue # Explicitly captioned illustration, not a text document.
api_attachments.append({'url': f'https://www.justice.gov/media/{file_id}/dl', 'title': attachment.get('description') or 'Official attachment', 'status': 'pending'}) api_attachments.append({'url': f'https://www.justice.gov/media/{file_id}/dl', 'title': attachment.get('description') or 'Official attachment', 'status': 'pending'})
attachment_html = '' attachment_html = ''
elif not isinstance(attachment_html, str): elif not isinstance(attachment_html, str):
@@ -623,18 +643,23 @@ class USOfficialSpider(NewsSpider):
retries.pop(context['url'], None) retries.pop(context['url'], None)
if not context['reasons']: if not context['reasons']:
self.run.stats['excluded'] = self.run.stats.get('excluded', 0) + 1 self.run.stats['excluded'] = self.run.stats.get('excluded', 0) + 1
self.retire_exhausted()
self.run.stats['unresolved_bills'] = len(retries) self.run.stats['unresolved_bills'] = len(retries)
self.cursor[lane]['pending'].pop(0) self.cursor[lane]['pending'].pop(0)
if lane == 'realtime': if lane == 'realtime':
self.cursor.setdefault('checked', {})[context['document_url']] = self.run.started_at.timestamp() self.cursor.setdefault('checked', {})[context['document_url']] = self.run.started_at.timestamp()
def attach_or_finish(self, parsed, lane): def attach_or_finish(self, parsed, lane):
if self.cursor.get('attachment_retries', {}).get(parsed['url'], {}).get('retired_at'):
yield from self.complete(lane)
return
pending = next((a for a in parsed['extra']['attachments'] if a['status'] == 'pending'), None) pending = next((a for a in parsed['extra']['attachments'] if a['status'] == 'pending'), None)
if pending: if pending:
request = self.request_for(pending['url'], self.parse_attachment, lane, parsed=parsed) request = self.request_for(pending['url'], self.parse_attachment, lane, parsed=parsed)
request.meta['handle_httpstatus_list'] = [404, 410] request.meta['handle_httpstatus_list'] = [404, 410]
yield request yield request
return return
self.retire_exhausted()
text = html_to_text(parsed['content_html']) text = html_to_text(parsed['content_html'])
reasons = selection(self.name, parsed['title'], text) or parsed['extra'].get('selection_reasons', []) reasons = selection(self.name, parsed['title'], text) or parsed['extra'].get('selection_reasons', [])
incomplete = any(a['status'] != 'complete' for a in parsed['extra']['attachments']) incomplete = any(a['status'] != 'complete' for a in parsed['extra']['attachments'])
@@ -646,6 +671,7 @@ class USOfficialSpider(NewsSpider):
retries[parsed['url']] = {'document': retry_doc, 'attempts': previous.get('attempts', 0) + 1, 'next_at': (datetime.now(UTC) + timedelta(hours=1)).timestamp(), 'error': error} retries[parsed['url']] = {'document': retry_doc, 'attempts': previous.get('attempts', 0) + 1, 'next_at': (datetime.now(UTC) + timedelta(hours=1)).timestamp(), 'error': error}
elif not incomplete: elif not incomplete:
retries.pop(parsed['url'], None) retries.pop(parsed['url'], None)
self.retire_exhausted()
if reasons: if reasons:
parsed['extra']['selection_reasons'] = reasons parsed['extra']['selection_reasons'] = reasons
parsed['extra']['content_scope'] = 'partial' if incomplete else 'article' parsed['extra']['content_scope'] = 'partial' if incomplete else 'article'
@@ -676,6 +702,14 @@ class USOfficialSpider(NewsSpider):
self.logger.warning('附件缺口:%s', response.url) self.logger.warning('附件缺口:%s', response.url)
yield from self.attach_or_finish(parsed, response.meta['lane']) yield from self.attach_or_finish(parsed, response.meta['lane'])
def retire_exhausted(self):
if self.name not in ('sec', 'doj', 'congress'):
return
for key in ('document_retries', 'attachment_retries', 'bill_retries'):
for entry in self.cursor.get(key, {}).values():
if entry['attempts'] >= 3:
entry.setdefault('retired_at', datetime.now(UTC).isoformat())
def complete(self, lane): def complete(self, lane):
doc = self.cursor[lane]['pending'].pop(0) doc = self.cursor[lane]['pending'].pop(0)
self.cursor.get('document_retries', {}).pop(doc['url'], None) self.cursor.get('document_retries', {}).pop(doc['url'], None)
@@ -27,6 +27,8 @@ class EconomicRelease(Base):
first_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) first_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_check_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) next_check_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
collection_failures: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
collection_retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
error: Mapped[str | None] = mapped_column(Text) error: Mapped[str | None] = mapped_column(Text)
# 按指标代码保存实际值、官方报告内前值和引用;数字以十进制字符串存储。 # 按指标代码保存实际值、官方报告内前值和引用;数字以十进制字符串存储。
values: Mapped[dict] = mapped_column(JSONB, default=dict) values: Mapped[dict] = mapped_column(JSONB, default=dict)
+15 -4
View File
@@ -178,7 +178,7 @@ def test_rolling_page_rollover_preserves_received_result_without_source_error(tr
async def check(): async def check():
first_received = datetime(2026, 9, 24, 14, tzinfo=UTC) first_received = datetime(2026, 9, 24, 14, tzinfo=UTC)
values = {"cpi_yoy" if source == "bls" else "umich_sentiment": {"actual": "2.5", "previous": "2.4"}} values = {"cpi_yoy" if source == "bls" else "umich_sentiment": {"actual": "2.5", "previous": "2.4"}}
row = SimpleNamespace(id=1, source=source, family="cpi" if source == "bls" else "umich", row = SimpleNamespace(id=1, collection_failures=0, collection_retired_at=None, source=source, family="cpi" if source == "bls" else "umich",
scheduled_at=first_received, first_received_at=first_received, scheduled_at=first_received, first_received_at=first_received,
period="2026-09", stage="final" if source == "umich" else None, period="2026-09", stage="final" if source == "umich" else None,
values=values.copy(), revision_no=3, error="旧的采集错误") values=values.copy(), revision_no=3, error="旧的采集错误")
@@ -275,7 +275,7 @@ def test_calendar_api_requires_auth_validates_range_and_exposes_unavailable_work
asyncio.run(check()) asyncio.run(check())
def test_worker_initializes_all_sources_and_drops_expired_month_status(trading): def test_worker_initializes_all_sources_and_drops_expired_month_status(trading, schedule_session):
import httpx import httpx
from alphapulse.calendar.worker import CalendarWorker from alphapulse.calendar.worker import CalendarWorker
async def check(): async def check():
@@ -313,7 +313,7 @@ def test_limited_result_preserves_response_and_defers_single_poll(trading, monke
async def check(): async def check():
now = datetime.now(UTC) now = datetime.now(UTC)
row = SimpleNamespace(id=1, source="eurostat", family="ea_hicp", scheduled_at=now, row = SimpleNamespace(id=1, collection_failures=0, collection_retired_at=None, source="eurostat", family="ea_hicp", scheduled_at=now,
first_received_at=None, release_url="https://ec.europa.eu/eurostat/product?code=2-16102026-ap") first_received_at=None, release_url="https://ec.europa.eu/eurostat/product?code=2-16102026-ap")
session = SimpleNamespace(scalar=AsyncMock(return_value=row), commit=AsyncMock()) session = SimpleNamespace(scalar=AsyncMock(return_value=row), commit=AsyncMock())
class Context: class Context:
@@ -344,7 +344,7 @@ def test_limited_result_preserves_response_and_defers_single_poll(trading, monke
asyncio.run(check()) asyncio.run(check())
def test_schedule_honors_server_pause_without_blocking_other_sources(trading): def test_schedule_honors_server_pause_without_blocking_other_sources(trading, schedule_session):
import httpx import httpx
from alphapulse.calendar.worker import CalendarWorker from alphapulse.calendar.worker import CalendarWorker
async def check(): async def check():
@@ -367,3 +367,14 @@ def test_schedule_honors_server_pause_without_blocking_other_sources(trading):
await worker.http.aclose() await worker.http.aclose()
await worker.bls.aclose() await worker.bls.aclose()
asyncio.run(check()) asyncio.run(check())
@pytest.fixture
def schedule_session(trading, monkeypatch):
from alphapulse.calendar import worker as module
session = AsyncMock()
session.scalars.return_value = []
session.get.return_value = None
session.__aenter__.return_value = session
monkeypatch.setattr(module, 'SessionLocal', lambda: session)
return session
+2 -1
View File
@@ -220,7 +220,8 @@ def test_umich_poll_waits_for_final_instead_of_reusing_preliminary(trading):
try: try:
assert await worker.result_page(row) is None assert await worker.result_page(row) is None
row.period = "2026-09" row.period = "2026-09"
with pytest.raises(ValueError, match="历史期次"): worker.index = AsyncMock(return_value=(httpx.Response(200, text='<div id="report_heading"><span class="title">2026 Reports</span></div><a href="fetchdoc.php?docid=1">August Final Results</a>', request=httpx.Request('GET', 'https://data.sca.isr.umich.edu/reports.php?year=2026')), 2))
with pytest.raises(ValueError, match="年度归档"):
await worker.result_page(row) await worker.result_page(row)
finally: finally:
await worker.http.aclose() await worker.http.aclose()
+46
View File
@@ -203,3 +203,49 @@ def test_missing_archive_checks_latest_without_accepting_wrong_week(trading, mon
await worker.http.aclose() await worker.http.aclose()
await worker.bls.aclose() await worker.bls.aclose()
asyncio.run(run()) asyncio.run(run())
def test_result_retirement_is_persistent_and_cooling_does_not_spend_budget(trading, monkeypatch):
from alphapulse.calendar import worker as module
async def run():
row = SimpleNamespace(source='umich', collection_failures=0, collection_retired_at=None,
scheduled_at=datetime(2026, 10, 9, tzinfo=UTC), first_received_at=None,
family='umich', error=None)
session = AsyncMock()
session.scalar.return_value = row
session.__aenter__.return_value = session
monkeypatch.setattr(module, 'SessionLocal', lambda: session)
worker = module.CalendarWorker()
try:
worker.result_page = AsyncMock(side_effect=module.SourceCooling('example', datetime.now(UTC) + timedelta(hours=2)))
await worker.poll_one(1)
assert row.collection_failures == 0
assert row.next_check_at > datetime.now(UTC) + timedelta(hours=1)
worker.result_page.side_effect = ValueError('official archive missing')
for _ in range(3):
await worker.poll_one(1)
assert row.collection_failures == 3 and row.collection_retired_at is not None
calls = worker.result_page.await_count
await worker.poll_one(1)
assert worker.result_page.await_count == calls
finally:
await worker.http.aclose()
await worker.bls.aclose()
asyncio.run(run())
def test_dol_lagging_index_uses_only_expected_official_archive(trading):
from alphapulse.calendar.worker import CalendarWorker
async def run():
worker = CalendarWorker()
response = lambda url, body: httpx.Response(200, text=body, request=httpx.Request('GET', url))
worker.index = AsyncMock(return_value=(response('https://oui.doleta.gov/unemploy/archive.asp', '<a href="/press/2025/010225.pdf">old</a>'), 1))
worker.fetch = AsyncMock(return_value=(response('https://oui.doleta.gov/press/2026/102926.pdf', 'PDF'), 2))
try:
await worker.archive_page(SimpleNamespace(source='dol', scheduled_at=datetime(2026, 10, 29, 12, 30, tzinfo=UTC)))
worker.fetch.assert_awaited_once_with('dol', 'https://oui.doleta.gov/press/2026/102926.pdf')
finally:
await worker.http.aclose()
await worker.bls.aclose()
asyncio.run(run())
+30
View File
@@ -0,0 +1,30 @@
"""Repair initialization never renews an already initialized retry budget."""
import asyncio
import copy
from types import SimpleNamespace
from unittest.mock import AsyncMock
def test_preview_and_repeated_execution_preserve_retry_evidence(trading, monkeypatch):
from alphapulse.crawler import repair_collection as module
original = {'document_retries': {'https://example.invalid/doc': {
'document': {'url': 'https://example.invalid/doc'}, 'attempts': 3,
'next_at': 1, 'error': 'HTTP 503'}}}
source = SimpleNamespace(code='doj', history_cursor=copy.deepcopy(original))
session = AsyncMock()
session.scalars.return_value = [source]
session.__aenter__.return_value = session
monkeypatch.setattr(module, 'SessionLocal', lambda: session)
monkeypatch.setattr(module, 'engine', SimpleNamespace(dispose=AsyncMock()))
async def check():
await module.run()
assert source.history_cursor == original
session.commit.assert_not_awaited()
await module.run(True)
entry = source.history_cursor['document_retries']['https://example.invalid/doc']
assert entry['attempts'] == 0 and entry['repair_previous']['attempts'] == 3
entry['attempts'] = 2
result = await module.run(True)
assert result['sources'][0]['already_initialized']
assert entry['attempts'] == 2
asyncio.run(check())
+7
View File
@@ -123,3 +123,10 @@ def test_history_preview_requires_matching_archive_date_without_writes(trading,
assert result["announcements"].get("eia_stocks", 0) == int(valid) assert result["announcements"].get("eia_stocks", 0) == int(valid)
assert bool(result["errors"]) != valid assert bool(result["errors"]) != valid
save.assert_not_awaited() save.assert_not_awaited()
def test_current_official_archive_csv_directory():
url = 'https://www.eia.gov/petroleum/supply/weekly/archive/2026/2026_10_15/wpsr_2026_10_15.php'
assert eia.csv_link('<a href="csv/table1.csv">CSV</a>', url) == url.rsplit('/', 1)[0] + '/csv/table1.csv'
with pytest.raises(ValueError):
eia.csv_link('<a href="csv/table1.csv">A</a><a href="download/table1.csv">B</a>', url)
+33
View File
@@ -1343,3 +1343,36 @@ def test_real_congress_api_fixture_replays_with_visible_source_gap(trading, tmp_
assert sum(a['status'] == 'complete' for a in attachments) == 14 assert sum(a['status'] == 'complete' for a in attachments) == 14
assert sum(a['status'] == 'unavailable' for a in attachments) == 1 assert sum(a['status'] == 'unavailable' for a in attachments) == 1
assert any(item['extra']['content_scope'] == 'official_action' for item in items) assert any(item['extra']['content_scope'] == 'official_action' for item in items)
def test_terminal_news_gap_survives_rediscovery(trading):
obj = spider(trading, 'doj')
url = 'https://www.justice.gov/opa/pr/example'
obj.cursor['attachment_retries'] = {url: {'attempts': 3, 'error': 'unsupported image'}}
obj.retire_exhausted()
assert obj.cursor['attachment_retries'][url]['retired_at']
obj.complete = lambda lane: []
assert list(obj.attach_or_finish({'url': url}, 'realtime')) == []
def test_selected_news_requests_have_one_retry_owner(trading):
obj = spider(trading, 'doj')
req = obj.request_for(SOURCES['doj'].entries[0], obj.parse_listing, 'realtime')
assert req.meta['dont_retry'] is True
def test_doj_captioned_photo_is_not_a_document_gap(trading):
obj = spider(trading, 'doj')
obj.run.history_deadline = time.monotonic() - 1
identifier = str(uuid.uuid4())
doc = {'url': f'https://www.justice.gov/api/v1/press_releases/{identifier}.json', 'kind': 'doj'}
obj.cursor['history']['pending'] = [doc]
record = {'uuid': identifier, 'url': 'https://www.justice.gov/opa/pr/test', 'date': '1735732800',
'title': 'Cryptocurrency exchange settlement', 'body': '<p>The cryptocurrency exchange pleaded guilty to violations of financial regulations.</p>',
'image': '<a href="/usao-sd/media/1470086">award.png</a>',
'attachment': [{'file': {'id': '1470086'}, 'alt': 'Photo- Left to right: award recipients.'}]}
async def collect():
return [v async for v in obj.parse_document(response(doc['url'], json.dumps({'metadata': {'responseInfo': {'status': 200}}, 'results': [record]}), lane='history', document=doc))]
item, = asyncio.run(collect())
assert item['extra']['attachments'] == []
assert not obj.cursor.get('attachment_retries')
+18
View File
@@ -413,3 +413,21 @@ callback chain finished, including explicit partial packages; it does not mean
full source quality passed. Exhausted candidate/request budgets remain incomplete. full source quality passed. Exhausted candidate/request budgets remain incomplete.
Mocked tests and real captured responses are separate evidence; neither proves Mocked tests and real captured responses are separate evidence; neither proves
complete 2025 coverage or authorizes production activation. complete 2025 coverage or authorizes production activation.
### Bounded collection failures (November 2026)
SEC, DOJ and Congress use one durable retry layer (three failed attempts, at least
one hour apart). EIA, DOL, Michigan and ADP retain the same budget per calendar
release in `collection_failures` / `collection_retired_at`. Cooling does not spend
an attempt. Retirement retains evidence and values; new releases are independent.
A source is disabled only after three failed discovery/schedule checks, not for
one document gap. Explicit official photo captions can exclude illustrations from
text extraction. Missing official dates are never replaced by update timestamps.
Migration `db2345678901` is additive and must precede activation. With the crawler
stopped under the deployment lock, preview `python -m
alphapulse.crawler.repair_collection`; append `--execute` once to establish the
repair budget for existing news gaps. It preserves the previous retry evidence
and records a durable initialization marker. Repeating it cannot renew the budget.
Set the authorized SEC contact in the private instance environment before enabling
SEC; do not put the address or credentials in tracked configuration.
+1 -1
View File
@@ -12,7 +12,7 @@ export interface CalendarItem {
unit: string | null unit: string | null
importance: number importance: number
scheduled_at: string scheduled_at: string
status: "scheduled" | "waiting" | "delayed" | "released" | "withdrawn" | "error" | "historical" | "unavailable" | "recorded" status: "retired" | "scheduled" | "waiting" | "delayed" | "released" | "withdrawn" | "error" | "historical" | "unavailable" | "recorded"
actual: string | null actual: string | null
previous: string | null previous: string | null
forecast: string | null forecast: string | null
+3
View File
@@ -37,6 +37,7 @@ export interface SourceInfo {
} }
export interface CollectionRetry { export interface CollectionRetry {
retired_at?: string
document: { url: string, canonical?: string } document: { url: string, canonical?: string }
attempts: number attempts: number
next_at: number next_at: number
@@ -51,6 +52,8 @@ export interface SourcesInfo {
} }
export interface CalendarSourceInfo { export interface CalendarSourceInfo {
enabled: boolean
retired_count: number
code: string code: string
name: string name: string
region_code: string region_code: string
+1 -1
View File
@@ -26,7 +26,7 @@ const detail = ref<CalendarItem | null>(null)
const country = ref("") const country = ref("")
const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" } const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" }
const countries = computed(() => data.value?.countries ?? []) const countries = computed(() => data.value?.countries ?? [])
const labels = { scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布", recorded: "已收录" } const labels = { retired: "已停止采集", scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布", recorded: "已收录" }
const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss") const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss")
const groups = computed(() => { const groups = computed(() => {
const result = new Map<string, CalendarItem[]>() const result = new Map<string, CalendarItem[]>()
@@ -16,12 +16,13 @@ const date = (at: string | null) => at ? formatTime(at) : "尚无记录"
日历 日历
</el-tag> </el-tag>
<div class="flex-1" /> <div class="flex-1" />
<el-tag :type="!source.worker_alive ? 'danger' : source.errors.length ? 'warning' : source.schedule_success_at ? 'success' : 'info'" size="small"> <el-tag :type="!source.enabled ? 'info' : !source.worker_alive ? 'danger' : source.errors.length ? 'warning' : source.schedule_success_at ? 'success' : 'info'" size="small">
{{ !source.worker_alive ? "进程离线" : source.errors.length ? "采集异常" : source.schedule_success_at ? "同步正常" : "待同步" }} {{ !source.enabled ? "已停用" : !source.worker_alive ? "进程离线" : source.errors.length ? "采集异常" : source.schedule_success_at ? "同步正常" : "待同步" }}
</el-tag> </el-tag>
</div> </div>
</template> </template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs text-slate-500"> <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs text-slate-500">
<dt>已停止采集</dt><dd>{{ source.retired_count }} 场(历史资料保留)</dd>
<dt>进程心跳</dt><dd>{{ date(source.heartbeat_at) }}</dd> <dt>进程心跳</dt><dd>{{ date(source.heartbeat_at) }}</dd>
<dt>日程最近检查</dt><dd>{{ date(source.schedule_checked_at) }}</dd> <dt>日程最近检查</dt><dd>{{ date(source.schedule_checked_at) }}</dd>
<dt>日程最近成功</dt><dd>{{ date(source.schedule_success_at) }}</dd> <dt>日程最近成功</dt><dd>{{ date(source.schedule_success_at) }}</dd>
@@ -144,21 +144,24 @@ onMounted(loadLatest)
</div> </div>
</div> </div>
</div> </div>
<div v-if="source.collection_stats?.unresolved_bills" class="text-xs text-amber-600"> <div v-if="source.collection_stats?.active_gaps !== 0 && source.collection_stats?.unresolved_bills" class="text-xs text-amber-600">
待核验法案 {{ source.collection_stats.unresolved_bills }};待重试 {{ source.collection_stats.bill_retry_pending }};重试已耗尽 {{ source.collection_stats.bill_retry_exhausted }} 待核验法案 {{ source.collection_stats.unresolved_bills }};待重试 {{ source.collection_stats.bill_retry_pending }};重试已耗尽 {{ source.collection_stats.bill_retry_exhausted }}
</div> </div>
<div v-if="source.collection_stats?.attachment_gaps" class="text-xs text-amber-600"> <div v-if="source.collection_stats?.active_gaps !== 0 && source.collection_stats?.attachment_gaps" class="text-xs text-amber-600">
附件待核验文档 {{ source.collection_stats.attachment_gaps }};待重试 {{ source.collection_stats.attachment_retry_pending }};重试已耗尽 {{ source.collection_stats.attachment_retry_exhausted }},请核对原文 附件待核验文档 {{ source.collection_stats.attachment_gaps }};待重试 {{ source.collection_stats.attachment_retry_pending }};重试已耗尽 {{ source.collection_stats.attachment_retry_exhausted }},请核对原文
</div> </div>
<div v-if="source.collection_stats?.document_gaps" class="text-xs text-amber-600"> <div v-if="source.collection_stats?.active_gaps !== 0 && source.collection_stats?.document_gaps" class="text-xs text-amber-600">
正文待核验 {{ source.collection_stats.document_gaps }};待重试 {{ source.collection_stats.document_retry_pending }};重试已耗尽 {{ source.collection_stats.document_retry_exhausted }},请核对来源日志 正文待核验 {{ source.collection_stats.document_gaps }};待重试 {{ source.collection_stats.document_retry_pending }};重试已耗尽 {{ source.collection_stats.document_retry_exhausted }},请核对来源日志
</div> </div>
<p v-if="source.collection_stats?.retired_count" class="text-xs text-slate-500">
已停止采集 {{ source.collection_stats.retired_count }} 条,原因及原文保留在详情中。
</p>
<details v-if="gaps.length" class="text-xs text-amber-700"> <details v-if="gaps.length" class="text-xs text-amber-700">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
缺口详情({{ gaps.length }}) 缺口详情({{ gaps.length }})
</summary> </summary>
<div v-for="gap in gaps" :key="gap.key" class="mt-2 border border-amber-200 rounded border-solid p-2"> <div v-for="gap in gaps" :key="gap.key" class="mt-2 border border-amber-200 rounded border-solid p-2">
<div>{{ gap.label }};已尝试 {{ gap.attempts }} 次;{{ gap.exhausted ? "重试已耗尽" : `下次重试不早于 ${formatTime(gap.nextAt)}` }}</div> <div>{{ gap.label }};已尝试 {{ gap.attempts }} 次;{{ gap.retired ? "已停止采集" : gap.exhausted ? "重试已耗尽" : `下次重试不早于 ${formatTime(gap.nextAt)}` }}</div>
<div class="my-1 whitespace-pre-wrap break-words"> <div class="my-1 whitespace-pre-wrap break-words">
{{ gap.error }} {{ gap.error }}
</div> </div>
+2 -1
View File
@@ -15,7 +15,7 @@ export function sourceStatus(source: SourceInfo, restarting = false, now = Date.
return { type: "primary", text: "采集中" } as const return { type: "primary", text: "采集中" } as const
if (source.consecutive_failures) if (source.consecutive_failures)
return { type: "danger", text: "失败待重试" } as const return { type: "danger", text: "失败待重试" } as const
if (source.collection_stats?.document_gaps || source.collection_stats?.attachment_gaps || source.collection_stats?.unresolved_bills) if ((source.collection_stats?.active_gaps ?? (source.collection_stats?.document_gaps || source.collection_stats?.attachment_gaps || source.collection_stats?.unresolved_bills)))
return { type: "warning", text: "存在采集缺口" } as const return { type: "warning", text: "存在采集缺口" } as const
return { type: "info", text: "等待调度" } as const return { type: "info", text: "等待调度" } as const
} }
@@ -49,6 +49,7 @@ export function collectionGaps(source: Pick<SourceInfo, "history_cursor">) {
url: originalUrl(retry.document.canonical ?? retry.document.url), url: originalUrl(retry.document.canonical ?? retry.document.url),
error: retry.error || "历史记录未保存具体原因,请核对原文", error: retry.error || "历史记录未保存具体原因,请核对原文",
attempts: retry.attempts, attempts: retry.attempts,
retired: !!retry.retired_at,
exhausted: retry.attempts >= 3, exhausted: retry.attempts >= 3,
nextAt: new Date(retry.next_at * 1000).toISOString() nextAt: new Date(retry.next_at * 1000).toISOString()
}))) })))
+29
View File
@@ -102,3 +102,32 @@ test("gap links preserve canonical pages and do not rewrite unrelated URLs", ()
assert.equal(collectionGaps(source)[0].url, url) assert.equal(collectionGaps(source)[0].url, url)
} }
}) })
test("retired gaps retain evidence without an active warning", () => {
const source = {
enabled: true,
consecutive_failures: 0,
code: "doj",
name: "DOJ",
region_code: "US",
list_url: "https://www.justice.gov/",
interval_seconds: 60,
backfill_hours: 72,
last_success_at: null,
last_error: null,
paused_until: null,
supports_history: true,
history_target: null,
history_position: null,
history_done: false,
today_new: 0,
today_revised: 0,
total_items: 0,
process: { state: "running", pid: 1, started_at: null, next_run_at: "2026-11-05T00:00:00Z" },
collection_stats: { active_gaps: 0, document_gaps: 1, retired_count: 1 },
history_cursor: { document_retries: { old: { document: { url: "https://www.justice.gov/old" }, attempts: 3, next_at: 1, error: "HTTP 503", retired_at: "2026-11-05T00:00:00Z" } } }
} as SourceInfo
assert.equal(sourceStatus(source).text, "等待调度")
assert.equal(collectionGaps(source)[0].retired, true)
assert.equal(collectionGaps(source)[0].error, "HTTP 503")
})