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

+10 -2
View File
@@ -65,6 +65,8 @@ class CalendarSourceOut(BaseModel):
code: str
name: str
region_code: str
enabled: bool
retired_count: int
worker_alive: bool
heartbeat_at: datetime | None
schedule_checked_at: datetime | None
@@ -124,13 +126,14 @@ async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
now = datetime.now(UTC)
raw = await redis.get(f"{KEY_PREFIX}calendar:status")
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)
rows = (await session.execute(select(
EconomicRelease.source,
func.max(EconomicRelease.schedule_checked_at).label("schedule_success_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.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.min(EconomicRelease.scheduled_at).filter(published).label("history_start"),
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}
result_errors = {}
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),
).order_by(EconomicRelease.last_checked_at.desc())):
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"]]
if source.code in result_errors:
errors.append(result_errors[source.code])
if not source.enabled and source.last_error:
errors.append(source.last_error)
data = stats.get(source.code, {})
output.append(CalendarSourceOut(
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,
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")},
@@ -197,6 +203,8 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
bills = (source.history_cursor or {}).get('bill_retries', {})
bill_pending = sum(value['attempts'] < 3 for value in bills.values())
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_retry_pending': 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:
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:
raise ValueError("EIA 归档未唯一列示 Table 1 CSV")
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)
or (stage is not None and row.stage is not None and stage != row.stage)):
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):
row.values = values
row.release_url = url
@@ -177,6 +179,8 @@ def release_rows(row: EconomicRelease, now: datetime) -> list[dict]:
state = "withdrawn"
elif row.first_received_at is not None:
state = "released"
elif getattr(row, "collection_retired_at", None) is not None:
state = "retired"
elif now - row.scheduled_at > timedelta(days=45):
state = "historical"
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": direction, "explanation": explanation, "release_url": row.release_url,
"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
+71 -14
View File
@@ -11,20 +11,21 @@ import httpx
from parsel import Selector
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.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.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.official_sources import FEEDS, HOSTS, feed_entries
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import EconomicRelease
from alphapulse.models import EconomicRelease, Source
logger = logging.getLogger("alphapulse.calendar")
STATUS_KEY = f"{KEY_PREFIX}calendar:status"
HISTORY_DAYS = 45
BOUNDED_SOURCES = frozenset({"dol", "eia", "umich", "adp"})
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)
return page
async def adp_links(self, start, end):
async def adp_links(self, start, end, first_page=None):
url, visited = adp.INDEX, set()
for _ in range(8):
if url in visited: raise ValueError('ADP 官方分页形成循环')
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)
if links or not following: return links
url = following
@@ -112,7 +114,13 @@ class CalendarWorker:
module = dol if source == 'dol' else eia
index_url = dol.SCHEDULE if source == 'dol' else eia.ARCHIVE
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} 当期官方归档不唯一')
url = links[0]
if source == 'eia':
@@ -140,7 +148,7 @@ class CalendarWorker:
if delay > 0: await asyncio.sleep(delay)
async with self.fetch_slots:
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)
self.host_ready[host] = now + timedelta(seconds=1)
if response.status_code in (403, 429, 503):
@@ -169,6 +177,10 @@ class CalendarWorker:
urls += [("dol", dol.SCHEDULE)]
urls += [("eia", eia.SCHEDULE)]
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": "尚未同步"})
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.raise_for_status()
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:
raise ValueError("ADP 官方新闻索引缺少近期月报")
report, evidence = await self.fetch(source, links[0])
@@ -223,8 +235,11 @@ class CalendarWorker:
async with SessionLocal() as session:
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC))
await session.commit()
checked.setdefault(source, []).append(None)
self.sources[url] = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": None}
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)}
retry_at = getattr(exc, 'retry_at', None)
if isinstance(exc, httpx.HTTPStatusError):
@@ -237,6 +252,24 @@ class CalendarWorker:
self.sources[url] = state
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:
history, _ = await self.fetch("umich", UMICH_HISTORY)
history.raise_for_status()
@@ -351,7 +384,17 @@ class CalendarWorker:
return None
if row.first_received_at is not 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":
code = "empsit" if row.family == "jobs" else row.family
# 本机 BLS archive 路径返回 403,最新公告页可访问;下面必须核对正式发布日期。
@@ -439,6 +482,8 @@ class CalendarWorker:
async def poll_one(self, release_id: int) -> None:
async with SessionLocal() as session:
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)
retry_at = None
try:
@@ -458,18 +503,29 @@ class CalendarWorker:
announcement = europe.parse_announcement(row.family, response.text)
period, stage, values = announcement.period, announcement.stage, announcement.values
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:
values = parse_values(row.family, response.text)
text = " ".join(Selector(text=response.text).css("h1,#bodytext").xpath("string(.)").getall())
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)
if page is not None:
row.collection_failures = 0
row.error = None
except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError) as 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):
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 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))
@@ -483,7 +539,8 @@ class CalendarWorker:
async def results(self) -> None:
now = datetime.now(UTC)
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.schedule_status == "scheduled", EconomicRelease.scheduled_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:
source.history_position = run.history_position
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
delay = min(BACKOFF_BASE_SECONDS * 2 ** (source.consecutive_failures - 1), BACKOFF_MAX_SECONDS)
if run.cooldown_until:
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.last_error = run.errors[0][:1000]
else:
source.consecutive_failures = 0
source.paused_until = None
source.paused_until = run.cooldown_until if bounded else None
source.last_error = None
source.last_success_at = now
await session.commit()
@@ -270,6 +276,7 @@ class NewsSpider(RedisSpider):
elapsed = (now - run.started_at).total_seconds()
summary = f"请求 {run.requests},条目 {run.items_seen},新增 {run.items_new},修订 {run.items_revised},用时 {elapsed:.1f}s"
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)
else:
self.logger.info("本轮完成:%s", summary)
@@ -6,6 +6,7 @@ import time
import uuid
from datetime import UTC, datetime, timedelta
from html import escape, unescape
from email.utils import parsedate_to_datetime
from lxml import etree
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -155,6 +156,8 @@ class USOfficialSpider(NewsSpider):
def request_for(self, url, callback, lane, **meta):
url = official_url(self.name, url)
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
# Credentials never travel to document hosts or into persisted URLs.
if urlsplit(url).hostname == 'api.congress.gov':
@@ -317,6 +320,7 @@ class USOfficialSpider(NewsSpider):
docs = list({d['url']: d for d in docs}.values())
if not docs:
raise ValueError('官方列表未发现文档,不能认定历史遍历完成')
self.run.stats['source_readable'] = 1
if self.name == 'fed':
begin = self.run.history_target if lane == 'history' else self.run.cutoff
def in_range(doc):
@@ -380,11 +384,23 @@ class USOfficialSpider(NewsSpider):
self.run.errors.append(error)
self.logger.error('文档缺口:%s;%s', doc['url'], error)
state['pending'].pop(0)
self.retire_exhausted()
return self.advance(request.meta['lane'])
def on_request_error(self, failure):
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)}')
if requests is not None:
return requests
@@ -539,6 +555,10 @@ class USOfficialSpider(NewsSpider):
file_id = attachment['file']['id']
if not str(file_id).isdigit():
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'})
attachment_html = ''
elif not isinstance(attachment_html, str):
@@ -623,18 +643,23 @@ class USOfficialSpider(NewsSpider):
retries.pop(context['url'], None)
if not context['reasons']:
self.run.stats['excluded'] = self.run.stats.get('excluded', 0) + 1
self.retire_exhausted()
self.run.stats['unresolved_bills'] = len(retries)
self.cursor[lane]['pending'].pop(0)
if lane == 'realtime':
self.cursor.setdefault('checked', {})[context['document_url']] = self.run.started_at.timestamp()
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)
if pending:
request = self.request_for(pending['url'], self.parse_attachment, lane, parsed=parsed)
request.meta['handle_httpstatus_list'] = [404, 410]
yield request
return
self.retire_exhausted()
text = html_to_text(parsed['content_html'])
reasons = selection(self.name, parsed['title'], text) or parsed['extra'].get('selection_reasons', [])
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}
elif not incomplete:
retries.pop(parsed['url'], None)
self.retire_exhausted()
if reasons:
parsed['extra']['selection_reasons'] = reasons
parsed['extra']['content_scope'] = 'partial' if incomplete else 'article'
@@ -676,6 +702,14 @@ class USOfficialSpider(NewsSpider):
self.logger.warning('附件缺口:%s', response.url)
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):
doc = self.cursor[lane]['pending'].pop(0)
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))
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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)
# 按指标代码保存实际值、官方报告内前值和引用;数字以十进制字符串存储。
values: Mapped[dict] = mapped_column(JSONB, default=dict)