Add official US and European collection and calendar storage
Collect official RSS and Atom summaries, preserve historical captures, and poll macro releases with bounded cadence and Retry-After handling. Separate news collectors from the economic calendar worker. Track statistical periods, publication stages, first observations, revisions, and current official series. Include explicit migration files and isolated archive, parsing, and import tests; no migration or import is executed by this commit. Keep dependency declarations and lockfile together for PDF archives and the upcoming captcha integration. Verify the staged snapshot with 90 focused tests.
This commit is contained in:
1 parent
5fa1e37fd4
commit
e8ca4daa0e
76 files changed
+18923
-4
No files matched your search
@@ -0,0 +1,87 @@
|
||||
"""欧洲核心宏观公报历史摘要预览;可导出供 import_history 显式导入,始终不写数据库。
|
||||
|
||||
uv run python -m alphapulse.crawler.europe_history SOURCE --start 2025-01-01
|
||||
加 --output CAPTURES.jsonl.gz 保存真实响应和摘要;不覆盖已有文件。
|
||||
这里只核对核心统计/利率公报,不宣称覆盖央行全部历史讲话、采访和纪要。
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from parsel import Selector
|
||||
|
||||
from alphapulse.calendar.backfill import Document, LOCAL, family_for, fetch_documents
|
||||
from alphapulse.calendar.europe import parse_announcement
|
||||
from alphapulse.calendar.parsers import compact
|
||||
from alphapulse.core.official_sources import FEEDS, official_url
|
||||
from alphapulse.crawler.import_history import ArchivedArticle, Capture
|
||||
|
||||
|
||||
def capture(document: Document) -> Capture:
|
||||
family = family_for(document)
|
||||
if family is None:
|
||||
raise ValueError("归档响应不是已支持的核心宏观公报")
|
||||
announcement = parse_announcement(family, document.body.decode())
|
||||
s = Selector(text=document.body.decode())
|
||||
title = compact(" ".join(s.css("h1").xpath("string(.)").getall()))
|
||||
# 仅摘取官网实际公开的导语;无导语保留空摘要,不拼凑新闻正文。
|
||||
selectors = {"ecb": "main p:not(.ecb-publicationDate)", "boe": ".page-content p", "eurostat": ".component-euind-content p",
|
||||
"ons": "#main-points li", "destatis": "#content p", "insee": ".corps-publication p.paragraphe"}
|
||||
paragraphs = [compact(p.xpath("string(.)").get()) for p in s.css(selectors[document.source])]
|
||||
summary = next((p for p in paragraphs if p), "")
|
||||
url = official_url(document.source, document.url)
|
||||
article = ArchivedArticle(guid=url, url=url, title=title, summary=summary, published_at=announcement.at,
|
||||
language="fr" if document.source == "insee" else "en")
|
||||
return Capture(source=document.source, url=url, status=200, headers=document.headers,
|
||||
body=base64.b64encode(document.body).decode(), body_encoding="base64",
|
||||
fetched_at=document.captured_at, articles=[article])
|
||||
|
||||
|
||||
async def run(source: str, start: date, end: date, output: Path | None = None) -> dict:
|
||||
errors = []
|
||||
documents = await fetch_documents(start, end, set(), errors, sources={source})
|
||||
begin = datetime.combine(start, time.min, LOCAL).astimezone(UTC)
|
||||
finish = datetime.combine(end + timedelta(days=1), time.min, LOCAL).astimezone(UTC)
|
||||
captures = []
|
||||
for document in documents:
|
||||
try:
|
||||
item = capture(document)
|
||||
if begin <= item.articles[0].published_at < finish:
|
||||
captures.append(item)
|
||||
except (ValueError, KeyError, TypeError, IndexError) as exc:
|
||||
errors.append({"source": source, "url": document.url, "error": f"{type(exc).__name__}: {exc}"})
|
||||
if output is not None:
|
||||
with gzip.open(output, "xt", encoding="utf-8") as file:
|
||||
for item in captures:
|
||||
file.write(item.model_dump_json() + "\n")
|
||||
return {"mode": "export" if output else "preview", "source": source, "start": str(start), "end": str(end),
|
||||
"scope": "核心宏观统计/利率公报;不包括央行全部历史讲话、采访和纪要。首末日期不代表完整覆盖。",
|
||||
"documents": len(documents), "articles": len(captures),
|
||||
"published_start": min((c.articles[0].published_at.isoformat() for c in captures), default=None),
|
||||
"published_end": max((c.articles[0].published_at.isoformat() for c in captures), default=None),
|
||||
"output": str(output) if output else None, "errors": errors}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("source", choices=sorted(FEEDS))
|
||||
parser.add_argument("--start", type=date.fromisoformat, default=date(2025, 1, 1))
|
||||
parser.add_argument("--end", type=date.fromisoformat, default=datetime.now(LOCAL).date())
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.start < date(2025, 1, 1) or args.end < args.start or args.end > datetime.now(LOCAL).date():
|
||||
parser.error("公布日期范围必须从 2025-01-01 起,结束日期不得晚于今天")
|
||||
if args.output is not None and args.output.exists():
|
||||
parser.error("输出文件已存在,请使用新路径,避免覆盖既有采集响应")
|
||||
report = asyncio.run(run(args.source, args.start, args.end, args.output))
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if report["errors"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""导入已抓取的官方历史摘要及原始响应;独立任务不改变实时采集状态。
|
||||
|
||||
用法:python -m alphapulse.crawler.import_history SOURCE CAPTURES.jsonl.gz --start ISO时间
|
||||
每行包含一次真实 HTTP 响应(source/url/status/headers/body/fetched_at)及解析后的 articles。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, Field, HttpUrl
|
||||
from scrapy import Request, Spider, signals
|
||||
from scrapy.crawler import Crawler, CrawlerProcess
|
||||
from scrapy.http import Response
|
||||
from scrapy.utils.project import get_project_settings
|
||||
from sqlalchemy import select, update
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.official_sources import FEEDS, LANGUAGES
|
||||
from alphapulse.crawler.events import write_logs
|
||||
from alphapulse.crawler.middlewares import RawResponseMiddleware
|
||||
from alphapulse.crawler.spiders.base import RunState
|
||||
from alphapulse.models import CrawlRun, NewsItem, RawResponse
|
||||
|
||||
SOURCES = ("coindesk", "fed", "bea", "bls", *FEEDS)
|
||||
|
||||
|
||||
class ArchivedArticle(BaseModel):
|
||||
guid: str = Field(min_length=1)
|
||||
url: HttpUrl
|
||||
title: str = Field(min_length=1)
|
||||
summary: str
|
||||
published_at: AwareDatetime
|
||||
source_updated_at: AwareDatetime | None = None
|
||||
author: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
language: Literal["en", "de", "fr"] | None = None
|
||||
|
||||
|
||||
class Capture(BaseModel):
|
||||
source: Literal["coindesk", "fed", "bea", "bls", "ecb", "eurostat", "boe", "ons", "destatis", "insee"]
|
||||
url: HttpUrl
|
||||
status: Literal[200]
|
||||
headers: dict[str, str]
|
||||
body: str
|
||||
body_encoding: Literal["utf-8", "base64"] = "utf-8"
|
||||
fetched_at: AwareDatetime
|
||||
articles: list[ArchivedArticle]
|
||||
|
||||
|
||||
def article_item(source: str, article: ArchivedArticle, capture: Capture, raw_id: int) -> dict:
|
||||
content_html = f"<p>{escape(article.summary)}</p>" if article.summary else ""
|
||||
return {
|
||||
"source_code": source, "external_id": hashlib.sha256(article.guid.encode()).hexdigest(),
|
||||
"url": str(article.url), "title": article.title, "summary": article.summary,
|
||||
"content_html": content_html, "content_text": article.summary,
|
||||
"published_at": article.published_at, "source_updated_at": article.source_updated_at,
|
||||
"author": article.author, "tags": article.tags, "is_paywalled": False,
|
||||
"extra": {"language": article.language or LANGUAGES.get(source, "en"), "content_scope": "public_summary", "historical_backfill": True,
|
||||
"archive_url": str(capture.url), "archive_fetched_at": capture.fetched_at.isoformat()},
|
||||
"raw_response_id": raw_id,
|
||||
}
|
||||
|
||||
|
||||
def identity_url(url: str) -> str:
|
||||
# BEA 的 RSS GUID 同时使用 bea.gov 和 www.bea.gov;回补按原文链接识别已有文章。
|
||||
url = url.replace("https://bea.gov/", "https://www.bea.gov/").rstrip("/")
|
||||
# 官网跳转后的公报与 RSS 发布页属于同一公告;只统一导入去重键,保留原始链接。
|
||||
eurostat = re.fullmatch(r"https://ec\.europa\.eu/eurostat/(?:en/)?web/products-euro-indicators/w/([^/?#]+)", url)
|
||||
if eurostat:
|
||||
return f"https://ec.europa.eu/eurostat/product?code={eurostat[1]}"
|
||||
ons = re.fullmatch(r"https://www\.ons\.gov\.uk/.*/bulletins/(consumerpriceinflation|uklabourmarket|gdpfirstquarterlyestimateuk|quarterlynationalaccounts)/([^/?#]+)", url)
|
||||
if ons:
|
||||
series = {"consumerpriceinflation": "consumerpriceinflationuk", "quarterlynationalaccounts": "gdpquarterlynationalaccountsuk"}.get(ons[1], ons[1])
|
||||
return f"https://www.ons.gov.uk/releases/{series}{ons[2]}"
|
||||
return url
|
||||
|
||||
|
||||
class HistoryImportSpider(Spider):
|
||||
name = "history_import"
|
||||
custom_settings = {
|
||||
"SCHEDULER": "scrapy.core.scheduler.Scheduler",
|
||||
"DUPEFILTER_CLASS": "scrapy.dupefilters.RFPDupeFilter",
|
||||
}
|
||||
|
||||
def __init__(self, source: str, captures: str, start: datetime, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.name = source
|
||||
self.captures = Path(captures)
|
||||
self.start_at = start
|
||||
self.end_at = datetime.now(UTC)
|
||||
self.run = RunState(uuid.uuid4(), "backfill", self.end_at, start)
|
||||
self.completed = False
|
||||
self.known_ids: set[str] = set()
|
||||
self.known_urls: set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler: Crawler, *args, **kwargs):
|
||||
spider = super().from_crawler(crawler, *args, **kwargs)
|
||||
crawler.signals.connect(spider.on_opened, signal=signals.spider_opened)
|
||||
crawler.signals.connect(spider.on_error, signal=signals.spider_error)
|
||||
crawler.signals.connect(spider.on_error, signal=signals.item_error)
|
||||
crawler.signals.connect(spider.on_closed, signal=signals.spider_closed)
|
||||
return spider
|
||||
|
||||
async def on_opened(self):
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(
|
||||
select(NewsItem.external_id, NewsItem.url).where(NewsItem.source_code == self.name)
|
||||
)).all()
|
||||
self.known_ids = {row[0] for row in rows}
|
||||
self.known_urls = {identity_url(row[1]) for row in rows}
|
||||
session.add(CrawlRun(id=self.run.id, source_code=self.name, trigger="backfill",
|
||||
started_at=self.run.started_at, status="running"))
|
||||
await session.commit()
|
||||
|
||||
async def start(self):
|
||||
try:
|
||||
async for item in self.import_captures():
|
||||
yield item
|
||||
except Exception as exc:
|
||||
# start 的异常不走 spider_error 信号,必须让任务状态和退出码反映失败。
|
||||
self.run.errors.append(repr(exc))
|
||||
raise
|
||||
|
||||
async def import_captures(self):
|
||||
raw_writer = RawResponseMiddleware(self.crawler)
|
||||
with gzip.open(self.captures, "rt", encoding="utf-8") as file:
|
||||
for line in file:
|
||||
capture = Capture.model_validate_json(line)
|
||||
if capture.source != self.name:
|
||||
raise ValueError("历史响应来源与导入任务不一致")
|
||||
request = Request(str(capture.url), meta={"run_id": self.run.id})
|
||||
# 官方 PDF 归档需逐字节保存,不能经由文本解码破坏原始响应。
|
||||
body = base64.b64decode(capture.body, validate=True) if capture.body_encoding == "base64" else capture.body.encode()
|
||||
response = Response(str(capture.url), status=capture.status, headers=capture.headers,
|
||||
body=body, request=request)
|
||||
digest = hashlib.sha256(response.body).hexdigest()
|
||||
async with SessionLocal() as session:
|
||||
raw_id = await session.scalar(select(RawResponse.id).where(
|
||||
RawResponse.source_code == self.name, RawResponse.url == response.url,
|
||||
RawResponse.body_sha256 == digest, RawResponse.fetched_at == capture.fetched_at,
|
||||
).limit(1))
|
||||
if raw_id is None:
|
||||
await raw_writer.process_response(request, response)
|
||||
raw_id = request.meta["raw_response_id"]
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(update(RawResponse).where(RawResponse.id == raw_id)
|
||||
.values(fetched_at=capture.fetched_at))
|
||||
await session.commit()
|
||||
self.run.requests += 1
|
||||
for article in capture.articles:
|
||||
if not self.start_at <= article.published_at < self.end_at:
|
||||
continue
|
||||
item = article_item(self.name, article, capture, raw_id)
|
||||
identity = identity_url(item["url"])
|
||||
if item["external_id"] in self.known_ids or identity in self.known_urls:
|
||||
continue
|
||||
self.known_ids.add(item["external_id"])
|
||||
self.known_urls.add(identity)
|
||||
yield item
|
||||
|
||||
def on_error(self, failure: Failure):
|
||||
self.run.errors.append(str(failure.value))
|
||||
|
||||
async def on_closed(self, reason: str):
|
||||
status = "failed" if self.run.errors else ("success" if reason == "finished" else "interrupted")
|
||||
self.completed = status == "success"
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(update(CrawlRun).where(CrawlRun.id == self.run.id).values(
|
||||
finished_at=datetime.now(UTC), status=status, requests=self.run.requests,
|
||||
items_seen=self.run.items_seen, items_new=self.run.items_new, items_revised=self.run.items_revised,
|
||||
error="\n".join(self.run.errors)[:4000] if self.run.errors else None,
|
||||
))
|
||||
await session.commit()
|
||||
await write_logs([(self.name, self.run.id, "INFO" if status == "success" else "ERROR",
|
||||
f"历史摘要导入 {status}:响应 {self.run.requests},新增 {self.run.items_new},修订 {self.run.items_revised}")])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", choices=SOURCES)
|
||||
parser.add_argument("captures", type=Path)
|
||||
parser.add_argument("--start", required=True, type=datetime.fromisoformat)
|
||||
args = parser.parse_args()
|
||||
if args.start.tzinfo is None or args.start >= datetime.now(UTC):
|
||||
parser.error("起始时间须带时区且早于当前时间")
|
||||
process = CrawlerProcess(get_project_settings())
|
||||
crawler = process.create_crawler(HistoryImportSpider)
|
||||
process.crawl(crawler, source=args.source, captures=str(args.captures), start=args.start)
|
||||
process.start()
|
||||
if not crawler.spider.completed or crawler.spider.run.items_seen != crawler.spider.run.items_new:
|
||||
raise SystemExit("历史导入存在失败或意外修订,请检查采集日志")
|
||||
print(json.dumps({"source": args.source, "run_id": str(crawler.spider.run.id),
|
||||
"requests": crawler.spider.run.requests, "inserted": crawler.spider.run.items_new}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -56,7 +56,7 @@ class Manager:
|
||||
|
||||
async def run(self) -> None:
|
||||
async with SessionLocal() as session:
|
||||
codes = (await session.scalars(select(Source.code).order_by(Source.code))).all()
|
||||
codes = (await session.scalars(select(Source.code).where(Source.collector == "news").order_by(Source.code))).all()
|
||||
self.workers = {code: Worker(code) for code in codes}
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -112,7 +112,7 @@ class Manager:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(CrawlRun)
|
||||
.where(CrawlRun.source_code == w.code, CrawlRun.status == "running")
|
||||
.where(CrawlRun.source_code == w.code, CrawlRun.status == "running", CrawlRun.trigger != "backfill")
|
||||
.values(status="interrupted", finished_at=datetime.now(UTC), error="子进程退出时本轮未完成")
|
||||
)
|
||||
await session.commit()
|
||||
@@ -140,7 +140,7 @@ class Manager:
|
||||
|
||||
async def schedule_once(self, unknown: set[str]) -> None:
|
||||
async with SessionLocal() as session:
|
||||
sources = (await session.scalars(select(Source))).all()
|
||||
sources = (await session.scalars(select(Source).where(Source.collector == "news"))).all()
|
||||
now = datetime.now(UTC)
|
||||
for s in sources:
|
||||
w = self.workers.get(s.code)
|
||||
|
||||
@@ -8,7 +8,7 @@ from alphapulse.core.redis import KEY_PREFIX
|
||||
BOT_NAME = "alphapulse"
|
||||
SPIDER_MODULES = ["alphapulse.crawler.spiders"]
|
||||
|
||||
# 采集的是无需登录的公开 JSON 接口;限速由 RedisRateLimitMiddleware 统一控制
|
||||
# 采集无需登录的公开 JSON、RSS/Atom;限速由 RedisRateLimitMiddleware 统一控制
|
||||
ROBOTSTXT_OBEY = False
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
|
||||
DEFAULT_REQUEST_HEADERS = {"Accept": "application/json, text/html;q=0.9, */*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9"}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""欧洲六机构的公开宏观摘要;完整原文仍从官方链接查看。"""
|
||||
from scrapy.http import Response
|
||||
|
||||
from alphapulse.core.official_sources import FEEDS, LANGUAGES, feed_entries, relevant_news
|
||||
from alphapulse.crawler.spiders.base import NewsSpider, html_to_text
|
||||
|
||||
|
||||
class EuropeanNewsSpider(NewsSpider):
|
||||
request_headers = {"User-Agent": "AlphaPulse official news collector", "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml"}
|
||||
|
||||
def first_requests(self):
|
||||
return [self.request(url, self.parse_feed) for url in FEEDS[self.name]]
|
||||
|
||||
def parse_feed(self, response: Response):
|
||||
# 整份订阅格式破坏时让既有生命周期报告失败,不把空列表当成功。
|
||||
for entry in feed_entries(response.body, self.name):
|
||||
if relevant_news(self.name, entry.title, entry.url):
|
||||
yield self.item(response, external_id=entry.identity, url=entry.url,
|
||||
title=html_to_text(entry.title), summary=html_to_text(entry.summary),
|
||||
content_html=entry.summary, published_at=entry.at, source_updated_at=entry.updated,
|
||||
extra={"language": LANGUAGES.get(self.name, "en"), "content_scope": "feed_summary", "feed_url": response.url})
|
||||
|
||||
|
||||
class EcbSpider(EuropeanNewsSpider):
|
||||
name = "ecb"
|
||||
|
||||
|
||||
class EurostatSpider(EuropeanNewsSpider):
|
||||
name = "eurostat"
|
||||
|
||||
|
||||
class BoeSpider(EuropeanNewsSpider):
|
||||
name = "boe"
|
||||
|
||||
|
||||
class OnsSpider(EuropeanNewsSpider):
|
||||
name = "ons"
|
||||
|
||||
|
||||
class DestatisSpider(EuropeanNewsSpider):
|
||||
name = "destatis"
|
||||
|
||||
|
||||
class InseeSpider(EuropeanNewsSpider):
|
||||
name = "insee"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""国外媒体与官方公告:只保存公开 RSS 内容,不把摘要当成文章全文。"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import ssl
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
from html import escape
|
||||
from urllib.parse import urlparse
|
||||
from xml.etree import ElementTree
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.http import Request, Response
|
||||
from scrapy.settings.default_settings import RETRY_EXCEPTIONS
|
||||
|
||||
from alphapulse.crawler.spiders.base import NewsSpider, html_to_text
|
||||
|
||||
ATOM = "{http://www.w3.org/2005/Atom}"
|
||||
|
||||
|
||||
class RssNewsSpider(NewsSpider):
|
||||
feeds: tuple[str, ...]
|
||||
request_headers = {"Accept": "application/rss+xml, application/xml, text/xml", "Accept-Language": "en-US,en;q=0.9"}
|
||||
|
||||
def first_requests(self):
|
||||
return [self.request(url, self.parse_feed) for url in self.feeds]
|
||||
|
||||
def parse_feed(self, response: Response):
|
||||
root = ElementTree.fromstring(response.body)
|
||||
if root.tag == "rss" and root.find("channel") is not None:
|
||||
entries = root.findall("./channel/item")
|
||||
elif root.tag == f"{ATOM}feed":
|
||||
entries = root.findall(f"{ATOM}entry")
|
||||
else:
|
||||
raise ValueError("响应不是 RSS/Atom 新闻订阅,无法解析")
|
||||
for entry in entries:
|
||||
try:
|
||||
yield self._parse_entry(response, entry)
|
||||
except ValueError as exc:
|
||||
# 单条外部数据损坏不应阻止其他新闻入库,但本轮仍记为失败。
|
||||
message = f"RSS 条目解析失败({response.url}):{exc}"
|
||||
self.run.errors.append(message)
|
||||
self.logger.error(message)
|
||||
|
||||
def _parse_entry(self, response: Response, entry: ElementTree.Element) -> dict:
|
||||
if entry.tag == f"{ATOM}entry":
|
||||
title = (entry.findtext(f"{ATOM}title") or "").strip()
|
||||
link = entry.find(f"{ATOM}link")
|
||||
url = (link.get("href", "") if link is not None else "").strip()
|
||||
date = entry.findtext(f"{ATOM}published") or ""
|
||||
published_at = datetime.fromisoformat(date)
|
||||
updated = entry.findtext(f"{ATOM}updated")
|
||||
source_updated_at = datetime.fromisoformat(updated) if updated else None
|
||||
guid = (entry.findtext(f"{ATOM}id") or "").strip() or url
|
||||
# BLS 的 Atom content 是纯文本,先转义再交给既有 HTML 展示链路。
|
||||
content_html = escape(entry.findtext(f"{ATOM}content") or "")
|
||||
author = entry.findtext(f"{ATOM}author/{ATOM}name")
|
||||
tags = [node.text.strip() for node in entry.findall(f"{ATOM}category") if node.text and node.text.strip()]
|
||||
else:
|
||||
title = html_to_text(entry.findtext("title") or "")
|
||||
url = (entry.findtext("link") or "").strip()
|
||||
date = entry.findtext("pubDate") or ""
|
||||
published_at = parsedate_to_datetime(date)
|
||||
source_updated_at = None
|
||||
guid = (entry.findtext("guid") or "").strip() or url
|
||||
content_html = entry.findtext("description") or ""
|
||||
author = entry.findtext("{http://purl.org/dc/elements/1.1/}creator")
|
||||
tags = [node.text.strip() for node in entry.findall("category") if node.text and node.text.strip()]
|
||||
# BEA 的 2025 年四季度 GDP 条目缺少 URL 协议,仅适配这一来源的官方域名。
|
||||
if self.name == "bea" and url.startswith("www.bea.gov/"):
|
||||
url = f"https://{url}"
|
||||
parsed_url = urlparse(url)
|
||||
if not title or parsed_url.scheme not in ("http", "https") or not parsed_url.netloc:
|
||||
raise ValueError("条目缺少标题或有效原文链接")
|
||||
if published_at.tzinfo is None or (source_updated_at is not None and source_updated_at.tzinfo is None):
|
||||
raise ValueError("条目发布时间或修订时间缺少时区")
|
||||
# GUID 常为长网址,哈希后满足 external_id 的 64 字符契约;不使用标题或发布时间作身份。
|
||||
return self.item(
|
||||
response,
|
||||
external_id=hashlib.sha256(guid.encode()).hexdigest(),
|
||||
url=url,
|
||||
title=title,
|
||||
summary=html_to_text(content_html),
|
||||
content_html=content_html,
|
||||
published_at=published_at.astimezone(UTC),
|
||||
source_updated_at=source_updated_at.astimezone(UTC) if source_updated_at is not None else None,
|
||||
author=author,
|
||||
tags=tags,
|
||||
extra={"language": "en", "content_scope": "feed_summary", "feed_url": response.url},
|
||||
)
|
||||
|
||||
|
||||
class CoinDeskSpider(RssNewsSpider):
|
||||
name = "coindesk"
|
||||
feeds = ("https://www.coindesk.com/arc/outboundfeeds/rss/",)
|
||||
|
||||
|
||||
class FedSpider(RssNewsSpider):
|
||||
name = "fed"
|
||||
feeds = (
|
||||
"https://www.federalreserve.gov/feeds/press_monetary.xml",
|
||||
"https://www.federalreserve.gov/feeds/speeches_and_testimony.xml",
|
||||
)
|
||||
|
||||
|
||||
class BlsSpider(RssNewsSpider):
|
||||
name = "bls"
|
||||
# 本机 Scrapy 默认下载器访问 BLS 返回 403,已有 httpx 客户端可正常取回官方 Atom。
|
||||
# 固定使用这一下载器,不在 403 后切换客户端或叠加重试。
|
||||
custom_settings = {
|
||||
"DOWNLOAD_HANDLERS": {"https": "alphapulse.crawler.spiders.rss.BlsDownloadHandler"},
|
||||
"RETRY_EXCEPTIONS": [*RETRY_EXCEPTIONS, httpx.TransportError],
|
||||
}
|
||||
feeds = (
|
||||
"https://www.bls.gov/feed/cpi.rss",
|
||||
"https://www.bls.gov/feed/ppi.rss",
|
||||
"https://www.bls.gov/feed/empsit.rss",
|
||||
)
|
||||
|
||||
def _parse_entry(self, response: Response, entry: ElementTree.Element) -> dict:
|
||||
item = super()._parse_entry(response, entry)
|
||||
release = re.fullmatch(r"/news\.release/archives/(cpi|ppi|empsit)_(\d{8})\.htm", urlparse(item["url"]).path)
|
||||
if release is None:
|
||||
raise ValueError("BLS 条目缺少可识别的公告日期,无法确定正式发布时间")
|
||||
# 这三个订阅的官方发布安排均为 08:30 ET;Atom 时间早约 40 分钟,不能用作事件时点。
|
||||
# 日期取公告归档 URL,夏令时由 America/New_York 处理,不把采集时间当发布时间。
|
||||
# 官方安排:https://www.bls.gov/schedule/news_release/{cpi,ppi,empsit}.htm
|
||||
day = datetime.strptime(release[2], "%m%d%Y")
|
||||
item["extra"]["feed_published_at"] = item["published_at"].isoformat()
|
||||
item["extra"]["published_at_basis"] = "bls_release_schedule"
|
||||
item["published_at"] = day.replace(hour=8, minute=30, tzinfo=ZoneInfo("America/New_York")).astimezone(UTC)
|
||||
return item
|
||||
|
||||
|
||||
class BeaSpider(RssNewsSpider):
|
||||
name = "bea"
|
||||
feeds = ("https://apps.bea.gov/rss/rss.xml",)
|
||||
|
||||
|
||||
class BlsDownloadHandler:
|
||||
"""BLS 的公开 GET 订阅使用已有 httpx,状态码、解压和重试仍交给 Scrapy。"""
|
||||
|
||||
lazy = True
|
||||
|
||||
def __init__(self, timeout: float) -> None:
|
||||
self.timeout = timeout
|
||||
self.ssl_context = ssl.create_default_context()
|
||||
self.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
# BLS 对容器内 Python 的默认 TLS 配置返回 403,使用与宿主一致的系统默认套件后可取回。
|
||||
# 仅调整这个来源的密码套件,保留证书/主机名校验、TLS 1.2 下限和系统安全等级。
|
||||
self.ssl_context.set_ciphers("DEFAULT")
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler: Crawler):
|
||||
return cls(crawler.settings.getfloat("DOWNLOAD_TIMEOUT"))
|
||||
|
||||
async def close(self) -> None:
|
||||
# Scrapy 要求关闭钩子;各请求的客户端已由上下文管理器释放。
|
||||
pass
|
||||
|
||||
async def download_request(self, request: Request) -> Response:
|
||||
headers = [(name, value) for name in request.headers for value in request.headers.getlist(name)]
|
||||
async with httpx.AsyncClient(verify=self.ssl_context, timeout=request.meta.get("download_timeout", self.timeout)) as client:
|
||||
async with client.stream("GET", request.url, headers=headers) as response:
|
||||
# 保留原始压缩体,避免 httpx 与 Scrapy 的 HttpCompressionMiddleware 重复解压。
|
||||
body = b"".join([chunk async for chunk in response.aiter_raw()])
|
||||
return Response(
|
||||
str(response.url), status=response.status_code, headers=response.headers.multi_items(),
|
||||
body=body, request=request,
|
||||
)
|
||||
Reference in new issue
Block a user