Files
AlphaPulse/backend/src/alphapulse/calendar/worker.py
T
eezd 655813d4d9
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / check-and-build (push) Successful in 2m14s
Development CI / deploy-development (push) Failing after 8s
Preserve calendar failure cooldown across worker restarts
2026-11-05 13:32:19 +08:00

592 lines
35 KiB
Python

"""独立日历采集进程:日程每小时刷新,公布窗口按 5/15/60 秒检查官方结果。"""
import asyncio
import json
import logging
import ssl
from datetime import UTC, datetime, timedelta
from email.utils import parsedate_to_datetime
from urllib.parse import urljoin, urlparse
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, UMICH_REPORTS
from alphapulse.calendar import adp, dol, eia, europe
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, 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, 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:
if value is None:
return None
try:
value = value.strip()
if value.isdecimal():
return now + timedelta(seconds=int(value))
at = parsedate_to_datetime(value)
if at.tzinfo is None:
raise ValueError("Retry-After 日期缺少时区")
return at.astimezone(UTC)
except (ValueError, OverflowError, TypeError) as exc:
logger.warning("官方 Retry-After 无法解析,沿用既有轮询间隔:%s", exc)
return None
class SourceCooling(ValueError):
def __init__(self, host, retry_at):
self.retry_at = retry_at
super().__init__(f'{host} 请求冷却,下次尝试 {retry_at.isoformat()}')
def fed_month_urls(now: datetime) -> list[str]:
month = now.astimezone(EASTERN).replace(day=1)
following = (month + timedelta(days=32)).replace(day=1)
names = {v: k.lower() for k, v in MONTHS.items()}
return [f"https://www.federalreserve.gov/newsevents/{d.year}-{names[d.month]}.htm" for d in (month, following)]
class CalendarWorker:
def __init__(self) -> None:
# 沿用已验证的 BLS TLS 设置;其余来源保持默认配置。
ctx = ssl.create_default_context()
ctx.set_ciphers("DEFAULT")
self.http = httpx.AsyncClient(timeout=8, follow_redirects=True, headers={"User-Agent": "AlphaPulse economic calendar"})
self.bls = httpx.AsyncClient(timeout=8, follow_redirects=True, verify=ctx, headers={"User-Agent": "AlphaPulse economic calendar"})
self.sources: dict[str, dict] = {}
self.eia_schedule_body: str | None = None
self.fetch_slots = asyncio.Semaphore(2)
self.host_locks = {}
self.host_ready = {}
self.cooldowns = {}
self.failures = {}
self.index_cache = {}
self.poll_cache = None
async def fetch(self, source: str, url: str, *, query: dict | None = None) -> tuple[httpx.Response, int]:
key = (source, url, json.dumps(query, sort_keys=True))
if self.poll_cache is None:
return await self._fetch(source, url, query=query)
if key not in self.poll_cache:
self.poll_cache[key] = asyncio.create_task(self._fetch(source, url, query=query))
return await self.poll_cache[key]
async def index(self, source, url):
# Live ADP discovery must follow result polling rather than archive cache TTL.
if source == 'adp':
page = await self.fetch(source, url)
page[0].raise_for_status()
return page
cached = self.index_cache.get((source, url))
if cached and cached[0] > datetime.now(UTC):
return cached[1]
page = await self.fetch(source, url)
page[0].raise_for_status()
self.index_cache[source, url] = (datetime.now(UTC) + timedelta(hours=1), page)
return page
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, _ = 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
raise ValueError('ADP 在线索引已扫描8页,目标月报尚未取得')
async def archive_page(self, row):
day = row.scheduled_at.astimezone(EASTERN).date()
source = row.source
module = dol if source == 'dol' else eia
index_url = dol.SCHEDULE if source == 'dol' else eia.ARCHIVE
response, _ = await self.index(source, index_url)
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':
page, _ = await self.fetch(source, url)
page.raise_for_status()
url = eia.csv_link(page.text, url)
response, raw_id = await self.fetch(source, url)
response.raise_for_status()
response.extensions['calendar_url'] = url
return response, raw_id
async def _fetch(self, source: str, url: str, *, query: dict | None = None) -> tuple[httpx.Response, int]:
# 链接来自公开响应,限制到对应官方域名,避免外部内容扩大访问范围。
allowed = {"bls": {"www.bls.gov"}, "bea": {"www.bea.gov", "apps.bea.gov"}, "fed": {"www.federalreserve.gov"},
"umich": {"www.sca.isr.umich.edu", "data.sca.isr.umich.edu"}, "dol": {"www.dol.gov", "oui.doleta.gov"},
"eia": {"www.eia.gov", "ir.eia.gov"}, "adp": {"mediacenter.adp.com"}, **HOSTS}
if urlparse(url).scheme != "https" or urlparse(url).hostname not in allowed[source]:
raise ValueError("公告链接不是对应机构的官方 HTTPS 地址")
host = urlparse(url).hostname
async with self.host_locks.setdefault(host, asyncio.Lock()):
now = datetime.now(UTC)
if self.cooldowns.get(host, now) > now:
raise SourceCooling(host, self.cooldowns[host])
delay = (self.host_ready.get(host, now) - now).total_seconds()
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, **({"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):
self.failures[host] = min(self.failures.get(host, 0) + 1, 7)
retry = retry_after_at(response.headers.get('Retry-After'), now)
seconds = 3600 if response.status_code == 403 else min(3600, 60 * 2 ** (self.failures[host] - 1))
self.cooldowns[host] = max(retry or now, now + timedelta(seconds=seconds))
elif response.is_success:
self.failures.pop(host, None)
if urlparse(str(response.url)).hostname not in allowed[source] or response.url.scheme != "https":
raise ValueError("官方响应重定向到了不受支持的地址")
now = datetime.now(UTC)
async with SessionLocal() as session:
raw_id = await save_raw(session, source, str(response.url), response.status_code,
{k: v for k, v in response.headers.items() if k in ("content-type", "date", "last-modified", "etag", "retry-after")},
response.content, now, method=response.request.method)
await session.commit()
return response, raw_id
async def schedules(self) -> None:
now = datetime.now(UTC)
urls = [(s, u) for s, u in SCHEDULE_URLS.items() if s != "fed"] + [("fed", u) for u in fed_month_urls(now)]
urls += [("bls", f"https://www.bls.gov/schedule/news_release/{code}.htm") for code in ("cpi", "ppi", "empsit")]
urls += [("umich", UMICH_INFO), ("umich", UMICH_CSV)]
urls += list(europe.SCHEDULES.items())
urls += [("dol", dol.SCHEDULE)]
urls += [("eia", eia.SCHEDULE)]
urls += [("adp", adp.INDEX)]
async with SessionLocal() as session:
controls = list(await session.scalars(select(Source).where(Source.code.in_(BOUNDED_SOURCES))))
disabled = {source.code for source in controls if not source.enabled}
paused = {source.code for source in controls if source.paused_until and source.paused_until > now}
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}
for source, url in urls:
if source in paused:
continue
retry_at = self.sources[url].get("retry_at")
if retry_at and datetime.fromisoformat(retry_at) > datetime.now(UTC):
continue
try:
fetch_url = europe.eurostat_calendar_url(now) if source == "eurostat" else url
if source == "adp":
response, raw_id = await self.index(source, fetch_url)
else:
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(), (response, raw_id))
if not links:
raise ValueError("ADP 官方新闻索引缺少近期月报")
report, evidence = await self.fetch(source, links[0])
report.raise_for_status()
events = adp.schedule(report.text, str(report.url))
async with SessionLocal() as session:
await save_schedule(session, source, url, events, evidence, datetime.now(UTC), withdraw_missing=False)
await session.commit()
elif source in ("dol", "eia"):
events = (dol if source == "dol" else eia).schedule(response.text, now)
async with SessionLocal() as session:
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC), withdraw_missing=False)
await session.commit()
if source == "eia":
self.eia_schedule_body = response.text
elif source in europe.SCHEDULES:
events = europe.parse_schedule(source, response.text)
async with SessionLocal() as session:
# 欧洲公开日程是滚动/分页窗口,缺失不等于撤回。
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC), withdraw_missing=False)
await session.commit()
elif url == UMICH_INFO:
await self.umich_schedule(response.text, now)
elif url == UMICH_CSV:
points = [(p, v) for p, v in monthly_series(response.text) if p >= "2024-12" and p <= now.strftime("%Y-%m")]
async with SessionLocal() as session:
await save_series(session, "umich", "umich_sentiment", points, "final", raw_id, datetime.now(UTC))
await session.commit()
elif source == "bls" and url.endswith(".htm"):
code = url.rsplit("/", 1)[1].split(".")[0]
async with SessionLocal() as session:
await apply_bls_periods(session, "jobs" if code == "empsit" else code, bls_periods(response.text), raw_id)
await session.commit()
else:
events = parse_fed_calendar(response.text) if source == "fed" else parse_ics(response.text, source)
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):
retry_at = self.cooldowns.get(exc.request.url.host)
if retry_at is None and exc.response.status_code in (429, 503):
retry_at = retry_after_at(exc.response.headers.get('Retry-After'), datetime.now(UTC))
if retry_at is not None:
state['retry_at'] = retry_at.isoformat()
state['error'] += f";下次尝试 {retry_at.isoformat()}"
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
source.paused_until = None
else:
source.consecutive_failures += 1
source.last_error = outcomes[0]
deadlines = [datetime.fromisoformat(state['retry_at']) for state in self.sources.values()
if state['source'] == code and state.get('retry_at')]
source.paused_until = max([datetime.now(UTC) + timedelta(hours=1), *deadlines])
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()
links = document_links(history.text, UMICH_HISTORY, {"Historical Prelim/Final Release Dates"})
if "Historical Prelim/Final Release Dates" not in links:
raise ValueError("密歇根官方资料缺少历史发布日期 PDF")
documents = list(links.values())
yearly = document_links(info, UMICH_INFO, {f"{year} Release Dates" for year in (now.year, now.year + 1)})
if f"{now.year} Release Dates" not in yearly:
raise ValueError("密歇根官方资料缺少当年日程 PDF")
documents.extend(yearly.values())
events = {}
raw_id = None
for url in documents:
response, raw_id = await self.fetch("umich", url)
response.raise_for_status()
parsed = await asyncio.to_thread(parse_schedule, await asyncio.to_thread(pdf_text, response.content))
# 年度日程在历史表之后处理,以官方最新年度安排为准;同一稳定身份不创建重复事件。
for event in parsed:
events[event.external_id] = (event, raw_id)
async with SessionLocal() as session:
# 不按单份 PDF 撤回其他年度事件;合并日程一次同步,修订引用对应 PDF。
existing = {r.external_id: r for r in await session.scalars(select(EconomicRelease).where(
EconomicRelease.source == "umich"))}
batches = {}
for event, evidence in events.values():
if event.at < datetime(2024, 12, 31, 16, tzinfo=UTC):
continue
# 每份 PDF 是部分日程,不能让 save_schedule 的撤回逻辑作用于其他事件。
batches.setdefault(evidence, []).append(event)
for evidence, batch in batches.items():
await save_schedule(session, "umich", UMICH_INFO, batch, evidence, now, withdraw_missing=False)
if raw_id is not None:
for row in existing.values():
if row.external_id not in events and row.scheduled_at > now and row.first_received_at is None and row.schedule_status != "withdrawn":
row.schedule_status = "withdrawn"
row.revision_no += 1
record_revision(session, row, raw_id)
await session.commit()
async def result_page(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
if row.source == "adp":
if row.release_url:
url = row.release_url
else:
links = await self.adp_links(row.scheduled_at.astimezone(EASTERN).date(), row.scheduled_at.astimezone(EASTERN).date())
if not links:
return None
if len(links) != 1:
raise ValueError("ADP 同日月度公告不唯一")
url = links[0]
response, raw_id = await self.fetch("adp", url)
response.raise_for_status()
announcement = adp.report(response.text)
if announcement.at != row.scheduled_at or announcement.period != row.period:
raise ValueError("ADP 公告时间或统计月与所选日程不符")
return response, raw_id
if row.source == "eia":
if self.eia_schedule_body is None:
schedule, _ = await self.fetch("eia", eia.SCHEDULE)
schedule.raise_for_status()
eia.exceptions(schedule.text)
self.eia_schedule_body = schedule.text
fixed_url = row.release_url if row.release_url and "/archive/" in urlparse(row.release_url).path else None
if row.scheduled_at.astimezone(EASTERN).date() < datetime.now(EASTERN).date() and not fixed_url:
try:
response, raw_id = await self.archive_page(row)
except ArchiveMissing:
response, raw_id = await self.fetch("eia", eia.LATEST)
response.extensions["calendar_url"] = eia.LATEST
else:
response, raw_id = await self.fetch("eia", fixed_url or eia.LATEST)
response.extensions["calendar_url"] = fixed_url or eia.LATEST
response.raise_for_status()
announcement = eia.report(response.text, self.eia_schedule_body)
if announcement.at < row.scheduled_at or (announcement.at > row.scheduled_at and row.first_received_at is not None):
return None
if announcement.at != row.scheduled_at or announcement.period != row.period:
raise ValueError("EIA 公告统计周或时间与所选日程不符,未填入其他期次的数据")
return response, raw_id
if row.source == "dol":
if row.scheduled_at.astimezone(EASTERN).date() < datetime.now(EASTERN).date() and (not row.release_url or "/press/" not in urlparse(row.release_url).path):
try:
response, raw_id = await self.archive_page(row)
except ArchiveMissing:
response, raw_id = await self.fetch("dol", dol.LATEST)
else:
try:
response, raw_id = await self.fetch("dol", row.release_url or dol.LATEST)
response.raise_for_status()
except (SourceCooling, httpx.HTTPStatusError) as error:
if isinstance(error, httpx.HTTPStatusError) and error.response.status_code != 403: raise
response, raw_id = await self.archive_page(row)
response.raise_for_status()
announcements = await asyncio.to_thread(dol.report, await asyncio.to_thread(pdf_text, response.content))
announcement = announcements[row.family]
if announcement.at < row.scheduled_at or (announcement.at > row.scheduled_at and row.first_received_at is not None):
return None
if announcement.at != row.scheduled_at:
raise ValueError("DOL 公告时间与所选日程不符,未填入其他期次的数据")
return response, raw_id
if row.source in europe.SCHEDULES:
return await self.european_result(row)
date = row.scheduled_at.astimezone(EASTERN).date()
if row.source == "umich":
response, raw_id = await self.fetch("umich", UMICH_HOME)
response.raise_for_status()
period, stage, _ = current_result(response.text)
if period == row.period and stage == row.stage:
return response, raw_id
if period < row.period or (period == row.period and stage == "preliminary" and row.stage == "final"):
return None
if row.first_received_at is not None:
return None # 最新页已换期;保留已取得的当期值,不误报历史缺口。
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,最新公告页可访问;下面必须核对正式发布日期。
url = f"https://www.bls.gov/news.release/{code}.nr0.htm"
elif row.source == "fed":
url = f"https://www.federalreserve.gov/newsevents/pressreleases/monetary{date:%Y%m%d}a.htm"
else:
# 现场核验 BEA RSS 漏了已发布的 PCE;使用官方日程的公告链接,不依赖 RSS 推送时效。
url = row.release_url
if url is None:
schedule, _ = await self.fetch("bea", "https://www.bea.gov/news/schedule/full")
schedule.raise_for_status()
url = bea_release_link(schedule.text, date, row.family)
if url is None:
return None
response, raw_id = await self.fetch(row.source, url)
if response.status_code == 404:
return None # 预约地址尚未发布;保留原始响应,不宣称采集成功。
response.raise_for_status()
published = release_date(response.text).date()
if row.source == "bls" and published < date:
return None # 到点后官网仍缓存上一期,等待当期公告,不填旧值。
if row.source == "bls" and published > date and row.first_received_at is not None:
return None # 滚动最新页不再包含本期,不能据此否认已取得的结果。
if published != date:
raise ValueError("官方公告日期与日历不符,历史公布值尚未取得")
return response, raw_id
async def european_result(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
day = row.scheduled_at.astimezone(europe.ZoneInfo(europe.ZONES[row.source])).date()
url = row.release_url
if url is None and row.source == "ecb":
index, _ = await self.fetch("ecb", f"https://www.ecb.europa.eu/press/govcdec/mopo/{day.year}/html/index_include.en.html")
index.raise_for_status()
sel = Selector(text=index.text)
urls = [urljoin(str(index.url), u) for u in sel.css('a[href]::attr(href)').getall()
if f"ecb.mp{day:%y%m%d}~" in u and u.endswith(".en.html")]
url = next(iter(dict.fromkeys(urls)), None)
elif url is None and row.source == "destatis":
# 官方主题页在 RSS 滚出后仍保留最近公告,且提供英文公告的真实链接。
paths = {"de_cpi": "Economy/Prices/Consumer-Price-Index", "de_gdp": "Economy/National-Accounts-Domestic-Product", "de_jobs": "Labour/Labour-Market/Employment"}
index, _ = await self.fetch("destatis", f"https://www.destatis.de/EN/Themes/{paths[row.family]}/_node.html")
index.raise_for_status()
suffix = {"de_cpi": "_611.html", "de_gdp": "_811.html", "de_jobs": "_132.html"}[row.family]
candidates = [urljoin("https://www.destatis.de/", u) for u in Selector(text=index.text).css('a[href]::attr(href)').getall()
if "/Press/" in u and "/PE" in u and u.endswith(suffix)]
for candidate in dict.fromkeys(candidates):
response, raw_id = await self.fetch(row.source, candidate)
response.raise_for_status()
announcement = europe.parse_announcement(row.family, response.text)
if announcement.at.date() == row.scheduled_at.date() and announcement.period == row.period and announcement.stage == row.stage:
return response, raw_id
return None
elif url is None:
entries = []
for feed in FEEDS[row.source]:
response, _ = await self.fetch(row.source, feed)
response.raise_for_status()
entries.extend(feed_entries(response.content, row.source))
for entry in entries:
target = europe.family_for(row.source, entry.title)
if target and target[0] == row.family and entry.at.astimezone(europe.ZoneInfo(europe.ZONES[row.source])).date() == day:
# 只有当期同日同系列才取原文;最终还要核对公告的统计期和阶段。
url = entry.url
break
if url is None:
return None
response, raw_id = await self.fetch(row.source, url)
if response.status_code == 404:
return None
response.raise_for_status()
if row.source == "ons" and "/releases/" in str(response.url):
patterns = {"uk_cpi": "/bulletins/consumerpriceinflation/", "uk_gdp": "/bulletins/gdp", "uk_jobs": "/bulletins/uklabourmarket/"}
matches = [urljoin(str(response.url), u) for u in Selector(text=response.text).css('a[href]::attr(href)').getall() if patterns[row.family] in u]
unique = list(dict.fromkeys(matches))
if len(unique) != 1:
raise ValueError("ONS 发布页未唯一指向目标统计公报")
response, raw_id = await self.fetch(row.source, unique[0])
response.raise_for_status()
announcement = europe.parse_announcement(row.family, response.text)
if announcement.at.astimezone(europe.ZoneInfo(europe.ZONES[row.source])).date() != day:
raise ValueError("欧洲官方公告日期与所选日程不符,未填入其他期次的数据")
return response, raw_id
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:
page = await self.result_page(row)
if page is not None:
response, raw_id = page
if row.source == "adp":
announcement = adp.report(response.text)
period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.source == "eia":
announcement = eia.report(response.text, self.eia_schedule_body)
period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.source == "dol":
announcement = (await asyncio.to_thread(dol.report, await asyncio.to_thread(pdf_text, response.content)))[row.family]
period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.source in europe.SCHEDULES:
announcement = europe.parse_announcement(row.family, response.text)
period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.family == "umich":
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}"
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 = 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))
logger.exception("公布值采集失败:%s %s", row.family, row.scheduled_at)
row.last_checked_at = now
row.next_check_at = next_poll(row.scheduled_at, datetime.now(UTC), row.first_received_at is not None, bool(row.error))
if retry_at is not None:
row.next_check_at = max(row.next_check_at, retry_at)
await session.commit()
async def results(self) -> None:
now = datetime.now(UTC)
async with SessionLocal() as session:
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,
).order_by(EconomicRelease.scheduled_at.desc()).limit(8)))
# 少量同时发布的不同报告独立获取,历史修订不阻塞当期公布。
self.poll_cache = {}
try:
await asyncio.gather(*(self.poll_one(id_) for id_ in ids))
finally:
self.poll_cache = None
async def run(self) -> None:
async def schedules_loop() -> None:
while True:
await self.schedules()
await asyncio.sleep(3600)
async def results_loop() -> None:
while True:
await self.results()
await asyncio.sleep(1)
async def heartbeat_loop() -> None:
while True:
await redis.set(STATUS_KEY, json.dumps({"at": datetime.now(UTC).isoformat(), "sources": self.sources}), ex=30)
await asyncio.sleep(5)
try:
async with asyncio.TaskGroup() as tasks:
for loop in (schedules_loop, results_loop, heartbeat_loop):
tasks.create_task(loop())
finally:
await self.http.aclose()
await self.bls.aclose()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(CalendarWorker().run())