Collect news text and image references without file attachments
Skip unsupported document formats and retire attachment work from active gap reporting. Preserve HTTP failures, normalize dynamic page content, and keep legacy bill queues resumable.
This commit is contained in:
1 parent
8766e829f9
commit
736a18def7
17 files changed
+547
-258
No files matched your search
@@ -18,6 +18,7 @@ from alphapulse.core.db import get_session
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.core.official_sources import REGIONS
|
||||
from alphapulse.calendar.catalog import FAMILIES
|
||||
from alphapulse.crawler.content_policy import ignored_retry
|
||||
from alphapulse.crawler.events import CONTROL_CHANNEL, HISTORY_SOURCES, LOG_CHANNEL, STATUS_KEY
|
||||
from alphapulse.crawler.spiders.us_official import configuration_error
|
||||
from alphapulse.models import CrawlLog, CrawlRun, EconomicRelease, NewsItem, Source
|
||||
@@ -196,13 +197,16 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
|
||||
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
|
||||
attachment_stats = {}
|
||||
for source in sources:
|
||||
retries = (source.history_cursor or {}).get('attachment_retries', {})
|
||||
groups = {key: {url: value for url, value in (source.history_cursor or {}).get(key, {}).items() if not ignored_retry(key, value)} for key in ('attachment_retries', 'document_retries', 'bill_retries')}
|
||||
ignored = sum(ignored_retry(key, value) for key in groups for value in (source.history_cursor or {}).get(key, {}).values())
|
||||
retries = groups['attachment_retries']
|
||||
pending = sum(value['attempts'] < 3 for value in retries.values())
|
||||
documents = (source.history_cursor or {}).get('document_retries', {})
|
||||
documents = groups['document_retries']
|
||||
document_pending = sum(value['attempts'] < 3 for value in documents.values())
|
||||
bills = (source.history_cursor or {}).get('bill_retries', {})
|
||||
bills = groups['bill_retries']
|
||||
bill_pending = sum(value['attempts'] < 3 for value in bills.values())
|
||||
attachment_stats[source.code] = {
|
||||
'policy_ignored': ignored,
|
||||
'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),
|
||||
|
||||
@@ -19,6 +19,9 @@ async def public_address(url):
|
||||
async def fetch_public_response(url):
|
||||
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
|
||||
for _ in range(4):
|
||||
from alphapulse.crawler.content_policy import file_url
|
||||
if file_url(url):
|
||||
raise ValueError('订阅地址不支持 PDF 或附件')
|
||||
parsed, address = await public_address(url)
|
||||
# Pin the checked address while retaining TLS hostname verification.
|
||||
target = httpx.URL(url).copy_with(host=address)
|
||||
@@ -27,11 +30,18 @@ async def fetch_public_response(url):
|
||||
location = response.headers.get('location')
|
||||
if not location: raise ValueError('重定向缺少地址')
|
||||
url = str(httpx.URL(url).join(location)); continue
|
||||
chunks, size = [], 0
|
||||
from scrapy.http import Headers
|
||||
from alphapulse.crawler.content_policy import binary_headers
|
||||
if binary_headers(Headers(response.headers.multi_items())):
|
||||
raise ValueError('订阅返回不支持的文件类型')
|
||||
chunks, size, prefix = [], 0, b''
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if size > 2*1024*1024: raise ValueError('响应超过2MiB限制')
|
||||
chunks.append(chunk)
|
||||
prefix = (prefix + chunk)[:8]
|
||||
if prefix.lstrip().startswith(b'%PDF'):
|
||||
raise ValueError('订阅返回 PDF 文件')
|
||||
body = b''.join(chunks)
|
||||
# Bytes are already decompressed; Scrapy must not decode them again.
|
||||
headers = [(k, v) for k, v in response.headers.multi_items()
|
||||
|
||||
@@ -32,7 +32,7 @@ SOURCES = {
|
||||
'doj': OfficialSource('DOJ·加密机构执法', ('https://www.justice.gov/api/v1/press_releases.json?pagesize=50&sort=date&direction=DESC&fields=uuid,date',), r'/(?:opa|usao[^/]*|archives/opa)/(?:pr|speech)/[^/]+', '.field--name-body, .field--name-field-pr-body', ('www.justice.gov',)),
|
||||
'fed': OfficialSource('美联储·政策与讲话', tuple(f'https://www.federalreserve.gov/newsevents/{kind}/{year}-{suffix}.htm' for year in range(datetime.now(UTC).year, 2024, -1) for kind, suffix in (('pressreleases', 'press'), ('speech', 'speeches'), ('testimony', 'testimony'))) + ('https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm', 'https://www.federalreserve.gov/releases/h41/releaseDates.json'), r'/(?:newsevents/(?:pressreleases|speech|testimony)/[^/]+\.htm|monetarypolicy/(?:fomcminutes|fomcprojtabl)\d+\.htm|releases/h41/\d+/)', '#article, .col-xs-12.col-sm-8.col-md-8, #content', ('www.federalreserve.gov',), 60),
|
||||
}
|
||||
RULE_VERSION = 'us-policy-v2'
|
||||
RULE_VERSION = 'us-policy-v3-text-images'
|
||||
CRYPTO = re.compile(r'\b(?:crypto(?:currency|currencies|[ -]assets?)?|digital[ -]assets?|virtual[ -]currenc(?:y|ies)|stablecoins?|bitcoin|ethereum|blockchain|tokeniz(?:ed|ation))\b', re.I)
|
||||
POLICY = re.compile(r'\b(?:regulat\w*|rules?|guidance|legislation|acts?|sanctions?|licenses?|executive order|monetary policy|federal funds|interest rates?|balance sheet|liquidity|quantitative|refunding|borrowing|debt limit|tariffs?|fiscal|budget|appropriation|tax reform)\b', re.I)
|
||||
INSTITUTION = re.compile(r'\b(?:exchanges?|banks?|financial institutions?|payment processors?|custodians?|stablecoin issuers?|binance|coinbase|tether|circle|kraken|bybit|bitfinex)\b', re.I)
|
||||
@@ -88,7 +88,7 @@ def published(value: str) -> tuple[datetime, str]:
|
||||
raise ValueError('官方材料缺少可识别的发布时间')
|
||||
|
||||
|
||||
def validate_body(content: str) -> None:
|
||||
def validate_body(content: str, *, allow_images: bool = False) -> None:
|
||||
"""Reject error documents without requiring full-page HTML around fragments."""
|
||||
if content.lstrip().startswith('<?xml'):
|
||||
from lxml import etree
|
||||
@@ -103,13 +103,21 @@ def validate_body(content: str) -> None:
|
||||
raise ValueError('官方正文返回验证或登录页面')
|
||||
if re.search(r'enable javascript and cookies to continue', text, re.I):
|
||||
raise ValueError('官方正文返回验证页面')
|
||||
if len(text) < 40:
|
||||
if len(text) < 40 and not (allow_images and sel.css('img[src], img[data-src]')):
|
||||
raise ValueError('官方正文为空或过短')
|
||||
|
||||
|
||||
class UnsupportedDocument(ValueError):
|
||||
"""官方页面只提供文件,没有支持的网页正文。"""
|
||||
|
||||
|
||||
def article(source: str, html: str, url: str, *, release_date: str | None = None) -> dict:
|
||||
sel = Selector(html)
|
||||
validate_body(html)
|
||||
from alphapulse.crawler.content_policy import SKIP_REASON
|
||||
file_entry = (source == 'sec' and sel.css('article.node--type-regulation a[href*=".pdf"]')) or (source == 'fincen' and sel.css('main .field--name-field-advisory-file, main a[href*="/system/files/"]'))
|
||||
if file_entry and not sel.css(SOURCES[source].body):
|
||||
raise UnsupportedDocument(SKIP_REASON)
|
||||
validate_body(html, allow_images=True)
|
||||
selectors = {
|
||||
'fed': ('#article h3.title', '#article h3', '#article h2', '#content .heading h3', '#content .col-xs-12.col-sm-8.col-md-8 h3'),
|
||||
'treasury': ('h2.uswds-page-title',),
|
||||
@@ -158,9 +166,10 @@ def article(source: str, html: str, url: str, *, release_date: str | None = None
|
||||
if parent is not None:
|
||||
parent.remove(node.root)
|
||||
body = sorted(body, key=lambda node: len(node.xpath('string(.)').get()), reverse=True)
|
||||
content = body[0].get()
|
||||
from alphapulse.crawler.content_policy import clean_html
|
||||
content = clean_html(body[0].get(), url)
|
||||
text = ' '.join(body[0].xpath('.//text()').getall()).strip()
|
||||
if len(text) < 40:
|
||||
if len(text) < 40 and not body[0].css('img[src], img[data-src]'):
|
||||
raise ValueError('官方正文为空或过短')
|
||||
attachments = []
|
||||
for link in body[0].css('a[href]'):
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""新闻只采集网页文字与图片引用;经济日历不使用此策略。"""
|
||||
import re
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from parsel import Selector
|
||||
|
||||
POLICY = 'web_text_images_v1'
|
||||
SKIP_REASON = '按采集规则不下载 PDF 和附件'
|
||||
FILE_PATH = re.compile(r'\.(?:pdf|docx?|xlsx?|pptx?|zip|gz|rar|7z|rtf|odt|ods|odp|exe|bin)(?:$|/)', re.I)
|
||||
|
||||
|
||||
def file_url(url):
|
||||
parts = urlsplit(url)
|
||||
return bool(FILE_PATH.search(parts.path) or re.search(r'\.pdf(?:[?&#]|$)', parts.query, re.I))
|
||||
|
||||
|
||||
def binary_headers(headers):
|
||||
kind = headers.get(b'Content-Type', b'').split(b';')[0].strip().lower()
|
||||
if not kind:
|
||||
return False # 无类型时由首段 PDF 签名和后续正文解析继续校验。
|
||||
return not (kind.startswith(b'text/') or kind in (
|
||||
b'application/json', b'application/xml', b'application/rss+xml',
|
||||
b'application/atom+xml', b'application/xhtml+xml',
|
||||
) or kind.endswith((b'+json', b'+xml')))
|
||||
|
||||
|
||||
|
||||
def clean_html(html, base=''):
|
||||
"""剔除执行/嵌入节点和已确认的动态分享编码,保留正文及图片。"""
|
||||
sel = Selector(html.replace('\x00', ''))
|
||||
for node in sel.css('script, style, iframe, object, embed, video, audio, .shareDL'):
|
||||
parent = node.root.getparent()
|
||||
if parent is not None:
|
||||
previous, tail = node.root.getprevious(), node.root.tail
|
||||
parent.remove(node.root)
|
||||
if tail:
|
||||
if previous is None:
|
||||
parent.text = (parent.text or '') + tail
|
||||
else:
|
||||
previous.tail = (previous.tail or '') + tail
|
||||
for node in sel.css('[href*="/cdn-cgi/l/email-protection"]'):
|
||||
node.root.attrib.pop('href', None)
|
||||
for node in sel.css('[data-cfemail]'):
|
||||
node.root.attrib.pop('data-cfemail', None)
|
||||
for node in sel.css('img, picture source'):
|
||||
if node.root.tag == 'img':
|
||||
src = node.attrib.get('data-src') or node.attrib.get('src')
|
||||
if src:
|
||||
node.root.set('src', urljoin(base, src))
|
||||
for attr in ('srcset', 'data-srcset'):
|
||||
if value := node.attrib.get(attr):
|
||||
candidates = []
|
||||
for part in value.split(','):
|
||||
fields = part.strip().split()
|
||||
if fields:
|
||||
candidates.append(' '.join([urljoin(base, fields[0]), *fields[1:]]))
|
||||
node.root.set('srcset', ', '.join(candidates))
|
||||
# Selector adds an html/body wrapper; keep the original fragment contract.
|
||||
body = sel.css('body')
|
||||
return ''.join(body[0].xpath('node()').getall()) if body else sel.get()
|
||||
|
||||
|
||||
def ignored_retry(group, value):
|
||||
return group == 'attachment_retries' or bool(value.get('skipped_policy')) or file_url(value.get('document', {}).get('url', ''))
|
||||
@@ -46,6 +46,12 @@ class RawResponseMiddleware:
|
||||
return cls(crawler)
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
if response.body.lstrip().startswith(b'%PDF'):
|
||||
if not 200 <= response.status < 300:
|
||||
from scrapy.spidermiddlewares.httperror import HttpError
|
||||
raise HttpError(response)
|
||||
request.meta['policy_skipped'] = True
|
||||
raise IgnoreRequest('按采集规则不保存 PDF 响应')
|
||||
sha256 = hashlib.sha256(response.body).hexdigest()
|
||||
headers = {k.decode(): b", ".join(v).decode("latin-1") for k, v in response.headers.items()}
|
||||
async with SessionLocal() as session:
|
||||
@@ -66,3 +72,38 @@ class RawResponseMiddleware:
|
||||
await session.commit()
|
||||
request.meta["raw_response_id"] = raw_id
|
||||
return response
|
||||
|
||||
|
||||
class NewsContentPolicyMiddleware:
|
||||
"""在下载前及响应头阶段阻止文件;此下载器仅由新闻采集使用。"""
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
from scrapy import signals
|
||||
obj = cls()
|
||||
crawler.signals.connect(obj.headers_received, signal=signals.headers_received)
|
||||
crawler.signals.connect(obj.bytes_received, signal=signals.bytes_received)
|
||||
return obj
|
||||
|
||||
def process_request(self, request):
|
||||
from alphapulse.crawler.content_policy import file_url, SKIP_REASON
|
||||
if file_url(request.url) or request.meta.get('parsed'):
|
||||
request.meta['policy_skipped'] = True
|
||||
raise IgnoreRequest(SKIP_REASON)
|
||||
|
||||
def headers_received(self, headers, request, **kwargs):
|
||||
from scrapy.exceptions import StopDownload
|
||||
from alphapulse.crawler.content_policy import binary_headers
|
||||
if binary_headers(headers):
|
||||
request.meta['policy_skipped'] = True
|
||||
raise StopDownload()
|
||||
|
||||
def bytes_received(self, data, request, **kwargs):
|
||||
from scrapy.exceptions import StopDownload
|
||||
prefix = request.meta.get('_content_prefix', b'')
|
||||
if len(prefix) < 8:
|
||||
prefix = (prefix + data)[:8]
|
||||
request.meta['_content_prefix'] = prefix
|
||||
if prefix.lstrip().startswith(b'%PDF'):
|
||||
request.meta['policy_skipped'] = True
|
||||
raise StopDownload()
|
||||
@@ -6,7 +6,9 @@ from scrapy.crawler import Crawler
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from alphapulse.crawler.content_policy import clean_html
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.crawler.spiders.base import html_to_text
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.crawler.events import NEWS_CHANNEL
|
||||
from alphapulse.models import NewsItem, NewsRevision
|
||||
@@ -31,6 +33,11 @@ class NewsPipeline:
|
||||
|
||||
async def process_item(self, item: dict[str, Any]) -> dict[str, Any]:
|
||||
spider = self.crawler.spider
|
||||
for field in ('title', 'summary', 'author'):
|
||||
if isinstance(item.get(field), str):
|
||||
item[field] = item[field].replace('\x00', '')
|
||||
item['content_html'] = clean_html(item['content_html'], item['url'])
|
||||
item['content_text'] = html_to_text(item['content_html'])
|
||||
digest = content_hash(item)
|
||||
raw_response_id = item.pop("raw_response_id")
|
||||
locked = (
|
||||
@@ -56,11 +63,14 @@ class NewsPipeline:
|
||||
if inserted_id is not None:
|
||||
kind = "new"
|
||||
else:
|
||||
same_content = (news.title.replace('\x00', '') == item['title']
|
||||
and news.summary.replace('\x00', '') == item['summary']
|
||||
and clean_html(news.content_html, news.url) == item['content_html'])
|
||||
# 主记录保存最新元数据;正文修订仍只由内容哈希变化决定。
|
||||
for field in UPDATABLE_FIELDS:
|
||||
setattr(news, field, item[field])
|
||||
news.last_seen_at = func.now()
|
||||
if news.content_hash != digest:
|
||||
if not same_content and news.content_hash != digest:
|
||||
kind = "revised"
|
||||
news.content_hash = digest
|
||||
news.revision_no += 1
|
||||
|
||||
@@ -25,6 +25,7 @@ RETRY_TIMES = 2
|
||||
RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408]
|
||||
|
||||
DOWNLOADER_MIDDLEWARES = {
|
||||
"alphapulse.crawler.middlewares.NewsContentPolicyMiddleware": 90,
|
||||
"alphapulse.crawler.middlewares.RedisRateLimitMiddleware": 100,
|
||||
# process_response 按优先级从大到小调用:排在 HttpCompression(590) 之后拿到解压后的响应体,
|
||||
# 排在 Retry(550) 之前,被重试的每次响应也都会记录
|
||||
|
||||
@@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.http import Request, Response
|
||||
from scrapy.http import Request, Response, Headers
|
||||
from scrapy.settings.default_settings import RETRY_EXCEPTIONS
|
||||
|
||||
from alphapulse.crawler.spiders.base import NewsSpider, html_to_text
|
||||
@@ -164,7 +164,16 @@ class BlsDownloadHandler:
|
||||
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()])
|
||||
from alphapulse.crawler.content_policy import binary_headers
|
||||
if binary_headers(Headers(response.headers.multi_items())):
|
||||
raise ValueError('订阅返回不支持的文件类型')
|
||||
chunks, prefix = [], b''
|
||||
async for chunk in response.aiter_raw():
|
||||
chunks.append(chunk)
|
||||
prefix = (prefix + chunk)[:8]
|
||||
if prefix.lstrip().startswith(b'%PDF'):
|
||||
raise ValueError('订阅返回 PDF 文件')
|
||||
body = b''.join(chunks)
|
||||
return Response(
|
||||
str(response.url), status=response.status_code, headers=response.headers.multi_items(),
|
||||
body=body, request=request,
|
||||
|
||||
@@ -4,24 +4,24 @@ import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from html import escape, unescape
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from lxml import etree
|
||||
from pypdf import apply_configuration
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from scrapy import Request
|
||||
from scrapy.exceptions import StopDownload
|
||||
from scrapy.http import Response
|
||||
from scrapy.spidermiddlewares.httperror import HttpError
|
||||
from sqlalchemy import select
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from alphapulse.calendar.umich import pdf_text
|
||||
from alphapulse.crawler.content_policy import POLICY, SKIP_REASON, file_url
|
||||
from alphapulse.core.config import get_settings
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.us_news import RULE_VERSION, SOURCES, article, identity, official_url, published, selection, validate_body
|
||||
from alphapulse.core.us_news import RULE_VERSION, SOURCES, UnsupportedDocument, article, identity, official_url, published, selection, validate_body
|
||||
from alphapulse.crawler.spiders.base import HISTORY_RUN_SECONDS, NewsSpider, html_to_text
|
||||
from alphapulse.models import NewsItem, Source
|
||||
|
||||
@@ -143,17 +143,11 @@ class USOfficialSpider(NewsSpider):
|
||||
self.cursor[lane]['pending'] = [d for d in self.cursor[lane]['pending'] if d['url'] not in indexes]
|
||||
state = self.cursor.setdefault('realtime', {'entry': 0})
|
||||
retries = self.cursor.get('document_retries', {})
|
||||
due_documents = [{**value['document'], 'document_retry': True} for value in retries.values() if value['attempts'] < 3 and value['next_at'] <= self.run.started_at.timestamp()]
|
||||
rechecks = [{**v['document'], 'document_retry': True, 'policy_recheck': True} for v in retries.values() if v['attempts'] >= 3 and not v.get('policy_checked') and not v.get('skipped_policy') and not file_url(v['document']['url'])][:3]
|
||||
state.setdefault('pending', []).extend(d for d in rechecks if d['url'] not in {p['url'] for p in state['pending']})
|
||||
due_documents = [{**value['document'], 'document_retry': True} for value in retries.values() if not value.get('skipped_policy') and value['attempts'] < 3 and value['next_at'] <= self.run.started_at.timestamp()]
|
||||
if due_documents:
|
||||
state.setdefault('pending', []).extend(copy.deepcopy(d) for d in due_documents if d['url'] not in {p['url'] for p in state['pending']})
|
||||
due = [value['document'] for value in self.cursor.get('attachment_retries', {}).values() if value['attempts'] < 3 and value['next_at'] <= self.run.started_at.timestamp()]
|
||||
for doc in due:
|
||||
pending = state.setdefault('pending', [])
|
||||
existing = next((value for value in pending if value['url'] == doc['url']), None)
|
||||
if existing is None:
|
||||
pending.append({**copy.deepcopy(doc), 'attachment_retry': True})
|
||||
else:
|
||||
existing['attachment_retry'] = True
|
||||
bill_due = [{**v['document'], 'bill_retry': True} for v in self.cursor.get('bill_retries', {}).values() if v['attempts'] < 3 and v['next_at'] <= self.run.started_at.timestamp()]
|
||||
if bill_due:
|
||||
state.setdefault('pending', []).extend(d for d in bill_due if d['url'] not in {p['url'] for p in state['pending']})
|
||||
@@ -182,10 +176,15 @@ class USOfficialSpider(NewsSpider):
|
||||
return []
|
||||
if state.get('pending'):
|
||||
doc = state['pending'][0]
|
||||
if file_url(doc['url']) or doc.get('format') == 'pdf':
|
||||
return self.skip_document(lane, doc)
|
||||
gap = self.cursor.get('document_retries', {}).get(doc['url'])
|
||||
if doc['url'] in self.run.document_retries_started or (gap and (gap['attempts'] >= 3 or gap['next_at'] > self.run.started_at.timestamp())):
|
||||
recheck = doc.get('policy_recheck') and gap and not gap.get('policy_checked')
|
||||
if doc['url'] in self.run.document_retries_started or (not recheck and gap and (gap.get('skipped_policy') or gap['attempts'] >= 3 or gap['next_at'] > self.run.started_at.timestamp())):
|
||||
state['pending'].pop(0)
|
||||
return self.advance(lane)
|
||||
if recheck:
|
||||
gap['policy_checked'] = POLICY
|
||||
if gap:
|
||||
doc['document_retry'] = True
|
||||
self.run.document_retries_started.add(doc['url'])
|
||||
@@ -344,6 +343,9 @@ class USOfficialSpider(NewsSpider):
|
||||
continue
|
||||
fincen_row = a.xpath('ancestor::tr[ancestor::main][1]') if self.name == 'fincen' and response.url.split('?')[0] in SOURCES['fincen'].entries[1:] else []
|
||||
fincen_pdf = bool(fincen_row and urlsplit(url).path.lower().endswith('.pdf'))
|
||||
if file_url(url) and re.search(pattern, urlsplit(url).path) and not fincen_pdf:
|
||||
docs.append({'url': url, 'kind': 'article', 'format': 'pdf'})
|
||||
continue
|
||||
if fincen_pdf or (re.search(pattern, urlsplit(url).path) and url not in SOURCES[self.name].entries and not re.search(r'/\d{4}-(?:press|speeches|testimony)\.htm$', urlsplit(url).path) and not url.lower().endswith('.pdf')):
|
||||
doc = {'url': url, 'kind': 'article'}
|
||||
if fincen_row:
|
||||
@@ -352,11 +354,13 @@ class USOfficialSpider(NewsSpider):
|
||||
begin = self.run.history_target if lane == 'history' else self.run.cutoff
|
||||
if re.fullmatch(r'(?:0?[1-9]|1[0-2])/\d{4}', date) and int(date[-4:]) < begin.year:
|
||||
continue
|
||||
doc['listing_date'] = published(date)[0].date().isoformat()
|
||||
try:
|
||||
doc['listing_date'] = published(date)[0].date().isoformat()
|
||||
except ValueError:
|
||||
if not fincen_pdf:
|
||||
raise
|
||||
if fincen_pdf:
|
||||
title = fincen_row.xpath('normalize-space(td[3])').get() if response.url.split('?')[0] == SOURCES['fincen'].entries[2] else a.xpath('normalize-space(.)').get()
|
||||
if not title:
|
||||
raise ValueError('FinCEN PDF 列表缺少标题')
|
||||
doc.update(format='pdf', title=title, listing_url=response.url)
|
||||
elif self.name in ('sec', 'treasury', 'cftc', 'fincen', 'whitehouse'):
|
||||
container = {
|
||||
@@ -410,7 +414,7 @@ class USOfficialSpider(NewsSpider):
|
||||
self.cursor['checked'] = {k: v for k, v in checked.items() if v >= threshold}
|
||||
docs = [d for d in docs if self.cursor['checked'].get(d['url'], 0) < threshold]
|
||||
retries = self.cursor.get('document_retries', {})
|
||||
docs = [d for d in docs if d['url'] not in retries or (retries[d['url']]['attempts'] < 3 and retries[d['url']]['next_at'] <= self.run.started_at.timestamp())]
|
||||
docs = [d for d in docs if d['url'] not in retries or (not retries[d['url']].get('skipped_policy') and retries[d['url']]['attempts'] < 3 and retries[d['url']]['next_at'] <= self.run.started_at.timestamp())]
|
||||
docs = [{**d, 'document_retry': True} if d['url'] in retries else d for d in docs]
|
||||
if next_url and official_url(self.name, next_url, response.url) == response.url:
|
||||
raise ValueError('官方列表翻页重复当前页面,未推进断点')
|
||||
@@ -440,7 +444,7 @@ class USOfficialSpider(NewsSpider):
|
||||
now = datetime.now(UTC)
|
||||
if not previous or previous['next_at'] <= now.timestamp():
|
||||
retries[doc['url']] = {'document': copy.deepcopy(doc), 'attempts': previous.get('attempts', 0) + 1,
|
||||
'next_at': (now + timedelta(hours=1)).timestamp(), 'error': error}
|
||||
'next_at': (now + timedelta(hours=1)).timestamp(), 'error': error, **({'policy_checked': POLICY} if previous.get('policy_checked') else {})}
|
||||
self.run.document_retries_started.add(doc['url'])
|
||||
self.run.errors.append(error)
|
||||
self.logger.error('文档缺口:%s;%s', doc['url'], error)
|
||||
@@ -449,7 +453,15 @@ class USOfficialSpider(NewsSpider):
|
||||
return self.advance(request.meta['lane'])
|
||||
|
||||
def on_request_error(self, failure):
|
||||
# 文件响应可在响应头阶段被截断,但 HTTP 故障仍须保留退避和重试语义。
|
||||
if failure.check(StopDownload) and not 200 <= failure.value.response.status < 300:
|
||||
response, request = failure.value.response, failure.request
|
||||
request.meta.pop('policy_skipped', None)
|
||||
failure = Failure(HttpError(response))
|
||||
failure.request = request
|
||||
status = failure.value.response.status if failure.check(HttpError) else None
|
||||
if failure.request.meta.get('parsed'):
|
||||
return list(self.attach_or_finish(failure.request.meta['parsed'], failure.request.meta['lane']))
|
||||
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))
|
||||
@@ -461,10 +473,10 @@ class USOfficialSpider(NewsSpider):
|
||||
self.block_until(until)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
self.logger.warning('忽略无效 Retry-After 响应头')
|
||||
if self.name in ('sec', 'doj', 'congress') and (parsed := failure.request.meta.get('parsed')):
|
||||
attachment = next(a for a in parsed['extra']['attachments'] if a['status'] == 'pending')
|
||||
attachment.update(status='unavailable', error=f'请求失败:{status or repr(failure.value)}')
|
||||
return list(self.attach_or_finish(parsed, failure.request.meta['lane']))
|
||||
if failure.request.meta.get('policy_skipped'):
|
||||
if doc := failure.request.meta.get('document'):
|
||||
return self.skip_document(failure.request.meta['lane'], doc)
|
||||
return super().on_request_error(failure)
|
||||
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:
|
||||
@@ -475,6 +487,9 @@ class USOfficialSpider(NewsSpider):
|
||||
try:
|
||||
async for result in self._parse_document(response):
|
||||
yield result
|
||||
except UnsupportedDocument:
|
||||
for request in self.skip_document(response.meta['lane'], response.meta['document']):
|
||||
yield request
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
if '验证' in str(exc) or '登录页面' in str(exc):
|
||||
self.block_until(datetime.now(UTC) + timedelta(minutes=15))
|
||||
@@ -487,6 +502,10 @@ class USOfficialSpider(NewsSpider):
|
||||
async def _parse_document(self, response):
|
||||
lane, doc = response.meta['lane'], response.meta['document']
|
||||
begin = self.run.history_target if lane == 'history' else self.run.cutoff
|
||||
if file_url(doc['url']) or doc.get('format') == 'pdf':
|
||||
for request in self.skip_document(lane, doc):
|
||||
yield request
|
||||
return
|
||||
if not (doc.get('document_retry') or doc.get('attachment_retry')) and doc.get('listing_date') and published(doc['listing_date'])[0].date() < begin.date():
|
||||
self.run.stats['outside_range'] = self.run.stats.get('outside_range', 0) + 1
|
||||
for result in self.complete(lane):
|
||||
@@ -572,12 +591,16 @@ class USOfficialSpider(NewsSpider):
|
||||
yield item
|
||||
else:
|
||||
for version in data['textVersions']:
|
||||
if version.get('formats') and all(f['type'] == 'PDF' for f in version['formats']):
|
||||
self.cursor[lane]['bill']['policy_skipped'] = True
|
||||
self.run.stats['policy_skipped'] = self.run.stats.get('policy_skipped', 0) + 1
|
||||
continue
|
||||
if not version.get('date'):
|
||||
self.cursor[lane]['bill']['missing_material'] = True
|
||||
self.cursor[lane]['bill'].setdefault('material_errors', []).append(f"{version.get('type', '正文版本')} 缺少官方日期")
|
||||
continue
|
||||
formats = version.get('formats', [])
|
||||
chosen = next((f for f in formats if f['type'] == 'Formatted Text'), None) or next((f for f in formats if f['type'] == 'Formatted XML'), None) or next((f for f in formats if f['type'] == 'PDF'), None)
|
||||
chosen = next((f for f in formats if f['type'] == 'Formatted Text'), None) or next((f for f in formats if f['type'] == 'Formatted XML'), None) or None
|
||||
if not chosen:
|
||||
self.cursor[lane]['bill']['missing_material'] = True
|
||||
self.cursor[lane]['bill'].setdefault('material_errors', []).append(f"{version.get('type', '正文版本')} 缺少支持的正文格式")
|
||||
@@ -628,7 +651,7 @@ class USOfficialSpider(NewsSpider):
|
||||
attachment_html = ''
|
||||
elif not isinstance(attachment_html, str):
|
||||
raise ValueError('DOJ API 附件结构未识别')
|
||||
html = f'<h1>{escape(title)}</h1><time datetime="{date}"></time><article><div class="field--name-body">{record["body"]}{attachment_html}</div></article>'
|
||||
html = f'<h1>{escape(title)}</h1><time datetime="{date}"></time><article><div class="field--name-body">{record["body"]}{record.get("image") or ""}{attachment_html}</div></article>'
|
||||
validate_body(record['body'])
|
||||
parsed = article(self.name, html, canonical)
|
||||
known = {a['url'] for a in parsed['extra']['attachments']}
|
||||
@@ -640,20 +663,13 @@ class USOfficialSpider(NewsSpider):
|
||||
parsed['source_updated_at'] = published(times[0])[0]
|
||||
parsed['extra']['official_uuid'] = record['uuid']
|
||||
doc = {**doc, 'canonical': canonical}
|
||||
elif doc['kind'] == 'text' or doc.get('format') == 'pdf':
|
||||
at, precision = published(doc['listing_date'] if doc.get('format') == 'pdf' else doc['at'])
|
||||
if response.body.startswith(b'%PDF'):
|
||||
content = '<pre>' + escape(self.pdf_text(response.body)) + '</pre>'
|
||||
else:
|
||||
content = response.text
|
||||
validate_body(content)
|
||||
if doc.get('format') == 'pdf':
|
||||
raise ValueError('官方 PDF 返回非 PDF 正文')
|
||||
if '<' not in content:
|
||||
content = '<pre>' + escape(content) + '</pre>'
|
||||
elif doc['kind'] == 'text':
|
||||
at, precision = published(doc['at'])
|
||||
content = response.text
|
||||
validate_body(content)
|
||||
if '<' not in content:
|
||||
content = '<pre>' + escape(content) + '</pre>'
|
||||
parsed = {'title': doc['title'], 'content_html': content, 'published_at': at, 'extra': {'content_scope': 'article', 'language': 'en', 'published_at_precision': precision, 'published_date': at.date().isoformat(), 'rule_version': RULE_VERSION, 'official_document': doc, 'attachments': []}}
|
||||
if doc.get('format') == 'pdf':
|
||||
parsed['extra'].update(published_at_basis='official_listing', published_at_source=doc['listing_url'])
|
||||
if doc.get('pdf'):
|
||||
parsed['extra']['attachments'].append({'url': official_url(self.name, doc['pdf']), 'title': 'Official PDF', 'status': 'pending'})
|
||||
else:
|
||||
@@ -698,7 +714,7 @@ class USOfficialSpider(NewsSpider):
|
||||
def finish_bill(self, lane):
|
||||
context = self.cursor[lane].pop('bill')
|
||||
retries = self.cursor.setdefault('bill_retries', {})
|
||||
if not context['bodies'] or context.get('missing_material'):
|
||||
if (not context['bodies'] and not context.get('policy_skipped')) or context.get('missing_material'):
|
||||
previous = retries.get(context['url'], {})
|
||||
errors = list(context.get('material_errors', []))
|
||||
if not context['bodies']:
|
||||
@@ -714,66 +730,38 @@ class USOfficialSpider(NewsSpider):
|
||||
if lane == 'realtime':
|
||||
self.cursor.setdefault('checked', {})[context['document_url']] = self.run.started_at.timestamp()
|
||||
|
||||
def skip_document(self, lane, doc):
|
||||
if doc.get('bill'):
|
||||
self.cursor[lane]['bill']['policy_skipped'] = True
|
||||
previous = self.cursor.get('document_retries', {}).get(doc['url'], {})
|
||||
self.cursor.setdefault('document_retries', {})[doc['url']] = {
|
||||
**previous, 'document': doc, 'attempts': previous.get('attempts', 0),
|
||||
'next_at': 0, 'skipped_policy': POLICY, 'error': SKIP_REASON,
|
||||
'previous_error': previous.get('previous_error') or previous.get('error'),
|
||||
}
|
||||
self.run.stats['policy_skipped'] = self.run.stats.get('policy_skipped', 0) + 1
|
||||
self.cursor[lane]['pending'].pop(0)
|
||||
return self.advance(lane)
|
||||
|
||||
def attach_or_finish(self, parsed, lane):
|
||||
retired = self.cursor.get('attachment_retries', {}).get(parsed['url'], {})
|
||||
if retired.get('retired_at'):
|
||||
for attachment in parsed['extra']['attachments']:
|
||||
if attachment['status'] == 'pending':
|
||||
attachment.update(status='unavailable', error=retired['error'])
|
||||
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()
|
||||
for attachment in parsed['extra'].get('attachments', []):
|
||||
attachment.update(status='skipped_policy', error=SKIP_REASON)
|
||||
parsed['extra']['collection_policy'] = POLICY
|
||||
parsed['extra'].setdefault('content_scope', 'article')
|
||||
parsed.pop('retry_document', None)
|
||||
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'])
|
||||
retry_doc = parsed.pop('retry_document', None)
|
||||
retries = self.cursor.setdefault('attachment_retries', {})
|
||||
if incomplete and retry_doc and not retired.get('retired_at'):
|
||||
previous = retries.get(parsed['url'], {})
|
||||
error = ';'.join(f"{a['url']}:{a.get('error') or '附件尚未完成文字提取'}" for a in parsed['extra']['attachments'] if a['status'] != 'complete')
|
||||
retries[parsed['url']] = {'document': retry_doc, 'attempts': previous.get('attempts', 0) + 1, 'next_at': (datetime.now(UTC) + timedelta(hours=1)).timestamp(), 'error': error, 'failed_urls': [a['url'] for a in parsed['extra']['attachments'] if a['status'] != 'complete']}
|
||||
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'
|
||||
self.run.stats['partial'] = self.run.stats.get('partial', 0) + int(incomplete)
|
||||
raw_id = parsed.pop('raw_response_id')
|
||||
item = self.item(Response(parsed['url'], request=Request(parsed['url'], meta={'raw_response_id': raw_id})), summary='', **parsed)
|
||||
yield item
|
||||
yield self.item(Response(parsed['url'], request=Request(parsed['url'], meta={'raw_response_id': raw_id})), summary='', **parsed)
|
||||
else:
|
||||
self.run.stats['excluded'] = self.run.stats.get('excluded', 0) + 1
|
||||
if incomplete:
|
||||
self.run.stats['unclassified_attachment_gaps'] = self.run.stats.get('unclassified_attachment_gaps', 0) + 1
|
||||
yield from self.complete(lane)
|
||||
|
||||
def pdf_text(self, body):
|
||||
# Bound the known complex FinCEN report without changing other collectors' limits.
|
||||
context = apply_configuration(stream_parsing_work_maximum_count=4_000_000) if self.name == 'fincen' else nullcontext()
|
||||
with context:
|
||||
return pdf_text(body)
|
||||
|
||||
def parse_attachment(self, response):
|
||||
parsed = response.meta['parsed']
|
||||
attachment = next(a for a in parsed['extra']['attachments'] if a['url'] == response.url or a['status'] == 'pending')
|
||||
attachment['raw_response_id'] = response.meta['raw_response_id']
|
||||
try:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'HTTP {response.status}')
|
||||
if response.headers.get('Content-Type', b'').lower().startswith(b'image/') and response.body.startswith((b'\x89PNG\r\n\x1a\n', b'\xff\xd8\xff', b'GIF87a', b'GIF89a')):
|
||||
raise ValueError('官方图片附件尚未提取文字,请核对原文')
|
||||
text = self.pdf_text(response.body)
|
||||
attachment['status'] = 'complete'
|
||||
parsed['content_html'] += f"<section><h2>{escape(attachment['title'])}</h2><pre>{escape(text)}</pre></section>"
|
||||
except (ValueError, OSError) as exc:
|
||||
attachment.update(status='unavailable', error=str(exc))
|
||||
self.logger.warning('附件缺口:%s', response.url)
|
||||
yield from self.attach_or_finish(parsed, response.meta['lane'])
|
||||
"""只供 Redis 遗留回调反序列化;新流程不创建此请求。"""
|
||||
yield from self.attach_or_finish(response.meta['parsed'], response.meta['lane'])
|
||||
|
||||
def retire_exhausted(self):
|
||||
if self.name not in ('sec', 'doj', 'congress'):
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""新闻格式限制、内容稳定性及历史哈希兼容。"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from scrapy import Request
|
||||
from scrapy.exceptions import IgnoreRequest, StopDownload
|
||||
from scrapy.http import Headers
|
||||
|
||||
from alphapulse.crawler.content_policy import clean_html, ignored_retry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guard(trading):
|
||||
from alphapulse.crawler.middlewares import NewsContentPolicyMiddleware
|
||||
return NewsContentPolicyMiddleware()
|
||||
|
||||
|
||||
def test_body_images_tables_and_real_changes_survive_normalization():
|
||||
html = '<p>政策正文\x00</p><script>noise</script><iframe src="a.pdf"></iframe><table><tr><td>3%</td></tr></table><img data-src="/chart.png" srcset="/a.png 1x, /b.png 2x"><figcaption>图表说明</figcaption>'
|
||||
value = clean_html(html, 'https://official.example/news/1')
|
||||
assert '政策正文' in value and '3%' in value and '图表说明' in value
|
||||
assert 'https://official.example/chart.png' in value
|
||||
assert 'https://official.example/a.png 1x' in value
|
||||
assert 'iframe' not in value and 'script' not in value and '\x00' not in value
|
||||
assert clean_html(value, 'https://official.example/news/1') == value
|
||||
assert clean_html(html.replace('3%', '4%')) != clean_html(html)
|
||||
|
||||
|
||||
def test_email_share_encoding_is_not_news_content():
|
||||
html = '<p>Monetary policy.</p><a href="/cdn-cgi/l/email-protection#abc" data-cfemail="abc">Email</a>'
|
||||
assert clean_html(html) == clean_html(html.replace('abc', 'def'))
|
||||
|
||||
|
||||
def test_picture_sources_and_lazy_candidates_resolve_to_publisher():
|
||||
html = ('<picture><source type="image/webp" srcset="/wide.webp 1x, ../large.webp 2x">'
|
||||
'<source media="(min-width: 800px)" data-srcset="//cdn.example/chart.webp 800w">'
|
||||
'<img src="/fallback.png" alt="政策图表"></picture>')
|
||||
base = 'https://official.example/news/article'
|
||||
value = clean_html(html, base)
|
||||
from parsel import Selector
|
||||
sel = Selector(value)
|
||||
assert sel.css('picture source::attr(srcset)').getall() == [
|
||||
'https://official.example/wide.webp 1x, https://official.example/large.webp 2x',
|
||||
'https://cdn.example/chart.webp 800w',
|
||||
]
|
||||
assert sel.css('img::attr(src)').get() == 'https://official.example/fallback.png'
|
||||
assert sel.css('img::attr(alt)').get() == '政策图表'
|
||||
assert clean_html(value, base) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://official.example/a.PDF?download=1', 'https://official.example/a.docx', 'https://official.example/exit?https://other.example/a.pdf'])
|
||||
def test_file_urls_are_rejected_before_download(url, guard):
|
||||
request = Request(url)
|
||||
with pytest.raises(IgnoreRequest):
|
||||
guard.process_request(request)
|
||||
assert request.meta['policy_skipped']
|
||||
|
||||
|
||||
def test_unknown_file_and_split_magic_stop_without_a_complete_body(guard):
|
||||
req = Request('https://official.example/download')
|
||||
with pytest.raises(StopDownload):
|
||||
guard.headers_received(Headers({'Content-Type': 'application/pdf'}), req)
|
||||
req = Request('https://official.example/mislabeled')
|
||||
guard.bytes_received(b'%P', req)
|
||||
with pytest.raises(StopDownload):
|
||||
guard.bytes_received(b'DF-1.7', req)
|
||||
assert req.meta['policy_skipped']
|
||||
|
||||
|
||||
def test_legacy_attachment_request_is_rejected_even_without_file_extension(guard):
|
||||
request = Request('https://official.example/media/1/download', meta={'parsed': {'title': 'old'}})
|
||||
with pytest.raises(IgnoreRequest):
|
||||
guard.process_request(request)
|
||||
assert ignored_retry('attachment_retries', {'attempts': 3})
|
||||
assert not ignored_retry('document_retries', {'document': {'url': 'https://official.example/news/1'}})
|
||||
|
||||
|
||||
def test_normalization_preserves_existing_hash_and_sends_only_real_updates(trading, monkeypatch):
|
||||
from alphapulse.crawler import pipelines
|
||||
html = '<p>Monetary policy.</p><a href="/cdn-cgi/l/email-protection#abc">Email</a>'
|
||||
fields = dict(url='https://official.example/news/1', title='Policy', summary='', content_html=html,
|
||||
content_text='Monetary policy.Email', author=None, tags=[], is_paywalled=False,
|
||||
published_at=None, source_updated_at=None, extra={})
|
||||
news = SimpleNamespace(id=1, revision_no=5, content_hash='legacy-hash', **fields)
|
||||
session = AsyncMock()
|
||||
session.scalar.return_value = news
|
||||
session.add = lambda row: None
|
||||
context = AsyncMock()
|
||||
context.__aenter__.return_value = session
|
||||
monkeypatch.setattr(pipelines, 'SessionLocal', lambda: context)
|
||||
publish = AsyncMock()
|
||||
monkeypatch.setattr(pipelines, 'redis', SimpleNamespace(publish=publish))
|
||||
run = SimpleNamespace(items_seen=0, items_revised=0)
|
||||
pipeline = pipelines.NewsPipeline(SimpleNamespace(spider=SimpleNamespace(run=run)))
|
||||
def item(content):
|
||||
return {**fields, 'source_code': 'fed', 'external_id': 'id', 'content_html': content, 'raw_response_id': None}
|
||||
asyncio.run(pipeline.process_item(item(html.replace('abc', 'def'))))
|
||||
assert news.revision_no == 5 and news.content_hash == 'legacy-hash'
|
||||
publish.assert_not_awaited()
|
||||
asyncio.run(pipeline.process_item(item(html.replace('Monetary policy.', 'New interest rate.'))))
|
||||
assert news.revision_no == 6 and run.items_revised == 1
|
||||
publish.assert_awaited_once()
|
||||
|
||||
|
||||
def test_html_cleanup_keeps_text_after_removed_nodes_and_image_only_articles():
|
||||
from alphapulse.core.us_news import article
|
||||
assert '保留文字' in clean_html('<script>noise</script>保留文字')
|
||||
html = '<time datetime="2026-11-06"></time><article><h1>Policy chart</h1><div class="field--name-body"><img src="/chart.png"><figcaption>利率图</figcaption></div></article>'
|
||||
parsed = article('cftc', html, 'https://www.cftc.gov/PressRoom/PressReleases/1-26')
|
||||
assert 'https://www.cftc.gov/chart.png' in parsed['content_html']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('source,body', [
|
||||
('sec', '<article class="node--type-regulation"><a href="/files/rules/report.pdf">Final Rule</a></article>'),
|
||||
('fincen', '<main><a href="/system/files/advisory.pdf">Official advisory</a></main>'),
|
||||
])
|
||||
def test_file_entry_pages_are_not_mistaken_for_news(source, body):
|
||||
from alphapulse.core.us_news import UnsupportedDocument, article
|
||||
html = '<h1>Official policy document</h1><time datetime="2026-11-06"></time>' + body
|
||||
with pytest.raises(UnsupportedDocument):
|
||||
article(source, html, 'https://official.example/document')
|
||||
@@ -12,6 +12,7 @@ from scrapy.http import HtmlResponse, Response
|
||||
from scrapy.spidermiddlewares.httperror import HttpError
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from alphapulse.crawler.content_policy import file_url
|
||||
from alphapulse.core.us_news import SOURCES, article, official_url, published, selection
|
||||
|
||||
|
||||
@@ -40,6 +41,72 @@ def collection_sample(source, suffix=''):
|
||||
return next(s for s in samples if s['source'] == source and s['url'].endswith(suffix))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('source', ['ofac', 'sec'])
|
||||
@pytest.mark.parametrize('status', [200, 403, 429, 503])
|
||||
def test_stopped_binary_response_preserves_http_errors(trading, source, status):
|
||||
from scrapy.exceptions import StopDownload
|
||||
from scrapy.http import Headers
|
||||
from alphapulse.crawler.middlewares import NewsContentPolicyMiddleware
|
||||
obj = spider(trading, source)
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
doc = {'url': SOURCES[source].entries[0] + '/document', 'kind': 'article'}
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
request = Request(doc['url'], meta={'document': doc, 'lane': 'history'})
|
||||
headers = Headers({'Content-Type': 'application/octet-stream', 'Retry-After': '120'})
|
||||
with pytest.raises(StopDownload) as stopped:
|
||||
NewsContentPolicyMiddleware().headers_received(headers, request)
|
||||
stopped.value.response = Response(request.url, status=status, headers=headers, request=request)
|
||||
failure = Failure(stopped.value)
|
||||
failure.request = request
|
||||
obj.on_request_error(failure)
|
||||
gap = obj.cursor.get('document_retries', {}).get(doc['url'], {})
|
||||
if status == 200:
|
||||
assert gap['skipped_policy']
|
||||
assert obj.run.stats['policy_skipped'] == 1
|
||||
assert not obj.run.errors
|
||||
else:
|
||||
assert not request.meta.get('policy_skipped')
|
||||
assert not gap.get('skipped_policy')
|
||||
assert not obj.run.stats.get('policy_skipped')
|
||||
assert str(status) in obj.run.errors[0]
|
||||
if gap:
|
||||
assert gap['attempts'] == 1 and gap['next_at'] > obj.run.started_at.timestamp()
|
||||
if source == 'sec' or status in (403, 429):
|
||||
assert obj.run.blocked and obj.run.cooldown_until is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('missing_material', [False, True])
|
||||
def test_legacy_bill_pdf_skip_does_not_hide_real_text_gaps(trading, monkeypatch, missing_material):
|
||||
monkeypatch.setattr('alphapulse.crawler.spiders.us_official.configuration_error', lambda _: None)
|
||||
obj = spider(trading, 'congress')
|
||||
base = 'https://api.congress.gov/v3/bill/119/s/1'
|
||||
pdf = {'url': 'https://www.congress.gov/old.pdf', 'kind': 'text', 'bill': base}
|
||||
obj.cursor['history'].update(entry=len(SOURCES['congress'].entries), pending=[pdf, {'url': base, 'kind': 'bill_finish'}],
|
||||
bill={'url': base, 'document_url': base, 'title': 'Crypto policy', 'version': 'v1', 'bodies': 0,
|
||||
'reasons': ['crypto_policy'], 'missing_material': missing_material,
|
||||
'material_errors': ['网页正文缺少官方日期'] if missing_material else []})
|
||||
assert obj.advance('history') == []
|
||||
assert obj.cursor['document_retries'][pdf['url']]['skipped_policy']
|
||||
assert not obj.cursor['history']['pending']
|
||||
if missing_material:
|
||||
gap = obj.cursor['bill_retries'][base]
|
||||
assert '网页正文缺少官方日期' in gap['error'] and gap['attempts'] == 1
|
||||
else:
|
||||
assert not obj.cursor['bill_retries']
|
||||
assert obj.run.stats['unresolved_bills'] == 0
|
||||
|
||||
|
||||
def test_decoded_pdf_error_keeps_http_failure_before_raw_storage(trading):
|
||||
from alphapulse.crawler.middlewares import RawResponseMiddleware
|
||||
obj = spider(trading)
|
||||
request = Request('https://ofac.treasury.gov/recent-actions/20261104')
|
||||
response = Response(request.url, status=503, body=b'%PDF error body', request=request)
|
||||
with pytest.raises(HttpError) as error:
|
||||
asyncio.run(RawResponseMiddleware(SimpleNamespace(spider=obj)).process_response(request, response))
|
||||
assert error.value.response.status == 503
|
||||
assert not request.meta.get('policy_skipped')
|
||||
|
||||
|
||||
def test_real_empty_register_day_continues_to_previous_day(trading):
|
||||
obj = spider(trading, 'federal_register')
|
||||
obj.cursor['realtime']['day'] = '2026-11-05'
|
||||
@@ -61,24 +128,18 @@ def test_register_completed_body_checkpoint_matches_discovery_after_restart(trad
|
||||
assert any(r.url == metadata for r in requests) is not recent
|
||||
|
||||
|
||||
def test_register_completes_metadata_only_after_body_and_pdf(trading, monkeypatch):
|
||||
from alphapulse.crawler.spiders import us_official
|
||||
def test_register_completes_after_text_without_pdf(trading, monkeypatch):
|
||||
obj = spider(trading, 'federal_register')
|
||||
metadata = 'https://www.federalregister.gov/api/v1/documents/2026-22248.json'
|
||||
body = 'https://www.federalregister.gov/documents/full_text/html/2026/11/03/2026-22248.html'
|
||||
pdf = 'https://www.govinfo.gov/content/pkg/FR-2026-11-03/pdf/2026-22248.pdf'
|
||||
doc = {'url': metadata, 'kind': 'register'}
|
||||
obj.cursor['realtime'].update(pending=[doc], entry=len(SOURCES[obj.name].entries))
|
||||
data = {'body_html_url': body, 'title': 'Digital asset exchange rule', 'publication_date': '2026-11-03', 'html_url': 'https://www.federalregister.gov/documents/2026/11/03/2026-22248/rule', 'document_number': '2026-22248', 'pdf_url': pdf}
|
||||
async def parse(res):
|
||||
return [item async for item in obj.parse_document(res)]
|
||||
body_request, = asyncio.run(parse(response(metadata, json.dumps(data), lane='realtime', document=doc)))
|
||||
assert metadata not in obj.cursor.get('checked', {})
|
||||
pdf_request, = asyncio.run(parse(response(body, '<p>This official rule regulates digital asset exchanges and financial institutions.</p>', **body_request.meta)))
|
||||
assert metadata not in obj.cursor.get('checked', {})
|
||||
monkeypatch.setattr(us_official, 'pdf_text', lambda value: 'The official digital asset exchange rule and supporting material.')
|
||||
list(obj.parse_attachment(Response(pdf, body=b'%PDF-test', request=pdf_request.replace(meta={**pdf_request.meta, 'raw_response_id': 13}))))
|
||||
assert metadata in obj.cursor['checked'] and body in obj.cursor['checked']
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
doc = {'url': 'https://www.federalregister.gov/body', 'kind': 'text', 'title': 'Digital asset rule', 'at': '2026-11-03', 'pdf': 'https://www.govinfo.gov/a.pdf', 'metadata_url': 'https://www.federalregister.gov/metadata'}
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
async def collect():
|
||||
return [v async for v in obj.parse_document(response(doc['url'], '<p>Rules for digital asset exchanges and financial institutions.</p>', lane='history', document=doc))]
|
||||
item, = asyncio.run(collect())
|
||||
assert item['extra']['attachments'][0]['status'] == 'skipped_policy'
|
||||
assert not obj.run.errors
|
||||
assert not obj.cursor.get('attachment_retries')
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize('data', [{'count': 2}, {'count': '0', 'description': 'x'}, {'count': 0}, {'count': 0, 'description': 'x', 'results': None}])
|
||||
@@ -125,59 +186,55 @@ def test_fincen_real_pdf_tables_finish_outside_realtime_window(trading, suffix):
|
||||
|
||||
|
||||
@pytest.mark.parametrize('suffix,title', [('/news/enforcement-actions', 'Bank of America'), ('/resources/advisoriesbulletinsfact-sheets', 'Foreign-Agent-Directed')])
|
||||
def test_fincen_real_pdf_tables_preserve_title_and_date_for_history(trading, suffix, title):
|
||||
def test_fincen_pdf_tables_skip_files_and_continue_history(trading, suffix, title):
|
||||
obj = spider(trading, 'fincen')
|
||||
sample = collection_sample('fincen', suffix)
|
||||
request, = obj.parse_listing(response(sample['url'], sample['body'], lane='history'))
|
||||
doc = request.meta['document']
|
||||
assert doc['kind'] == 'article' and doc['format'] == 'pdf'
|
||||
assert title in doc['title'] and len(doc['listing_date']) == 10
|
||||
assert doc['listing_url'] == sample['url']
|
||||
assert request.url.endswith('.pdf')
|
||||
output = list(obj.parse_listing(response(sample['url'], sample['body'], lane='history')))
|
||||
assert all(not file_url(r.url) for r in output if isinstance(r, Request))
|
||||
assert obj.run.stats.get('policy_skipped', 0) > 0
|
||||
|
||||
|
||||
def test_fincen_pdf_keeps_listing_date_raw_evidence_and_selection(trading, monkeypatch):
|
||||
from alphapulse.crawler.spiders import us_official
|
||||
|
||||
def test_fincen_direct_pdf_is_skipped(trading, monkeypatch):
|
||||
obj = spider(trading, 'fincen')
|
||||
doc = {'url': 'https://www.fincen.gov/system/files/crypto.pdf', 'kind': 'article', 'format': 'pdf', 'title': 'Crypto exchange consent order', 'listing_date': '2026-11-03', 'listing_url': SOURCES['fincen'].entries[1]}
|
||||
obj.cursor['realtime'].update(pending=[doc], entry=len(SOURCES['fincen'].entries))
|
||||
monkeypatch.setattr(us_official, 'pdf_text', lambda body: 'This enforcement action concerns a virtual currency exchange financial institution.')
|
||||
request = Request(doc['url'], meta={'lane': 'realtime', 'document': doc, 'raw_response_id': 12})
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
doc = {'url': 'https://www.fincen.gov/system/files/report.pdf', 'kind': 'article', 'format': 'pdf'}
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
async def collect():
|
||||
return [item async for item in obj.parse_document(Response(doc['url'], body=b'%PDF-test', request=request))]
|
||||
item, = asyncio.run(collect())
|
||||
assert item['published_at'] == datetime(2026, 11, 3, tzinfo=UTC)
|
||||
assert item['raw_response_id'] == 12
|
||||
assert item['extra']['published_at_precision'] == 'date'
|
||||
assert item['extra']['published_at_basis'] == 'official_listing'
|
||||
assert item['extra']['selection_reasons']
|
||||
assert doc['url'] in obj.cursor['checked']
|
||||
return [v async for v in obj.parse_document(response(doc['url'], '<h1>PDF or error document</h1>', lane='history', document=doc))]
|
||||
assert asyncio.run(collect()) == []
|
||||
assert obj.cursor['document_retries'][doc['url']]['skipped_policy']
|
||||
assert not obj.run.errors and not obj.cursor['history']['pending']
|
||||
|
||||
|
||||
def test_fincen_invalid_pdf_remains_a_document_gap(trading):
|
||||
|
||||
def test_fincen_pdf_error_response_is_still_policy_skipped(trading):
|
||||
obj = spider(trading, 'fincen')
|
||||
doc = {'url': 'https://www.fincen.gov/system/files/crypto.pdf', 'kind': 'article', 'format': 'pdf', 'title': 'Crypto exchange consent order', 'listing_date': '2026-11-03', 'listing_url': SOURCES['fincen'].entries[1]}
|
||||
obj.cursor['realtime'].update(pending=[doc], entry=len(SOURCES['fincen'].entries))
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
doc = {'url': 'https://www.fincen.gov/system/files/report.pdf', 'kind': 'article', 'format': 'pdf'}
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
async def collect():
|
||||
return [item async for item in obj.parse_document(response(doc['url'], '<h1>Document</h1><p>The server returned an HTML document instead of the expected official PDF.</p>', lane='realtime', document=doc))]
|
||||
assert not asyncio.run(collect())
|
||||
assert doc['url'] not in obj.cursor.get('checked', {})
|
||||
assert obj.cursor['document_retries'][doc['url']]['attempts'] == 1
|
||||
assert '非 PDF' in obj.run.errors[0]
|
||||
return [v async for v in obj.parse_document(response(doc['url'], '<h1>PDF or error document</h1>', lane='history', document=doc))]
|
||||
assert asyncio.run(collect()) == []
|
||||
assert obj.cursor['document_retries'][doc['url']]['skipped_policy']
|
||||
assert not obj.run.errors and not obj.cursor['history']['pending']
|
||||
|
||||
|
||||
|
||||
def test_fincen_pdf_table_missing_date_fails_explicitly(trading):
|
||||
obj = spider(trading, 'fincen')
|
||||
body = '<main><h1>Actions</h1><table><tr><td><a href="/system/files/order.pdf">Consent order</a></td><td></td></tr></table></main>'
|
||||
with pytest.raises(ValueError, match='发布时间'):
|
||||
list(obj.parse_listing(response(SOURCES['fincen'].entries[1], body, lane='realtime')))
|
||||
output = list(obj.parse_listing(response(SOURCES['fincen'].entries[1], body, lane='realtime')))
|
||||
assert all(not file_url(r.url) for r in output)
|
||||
assert not obj.run.errors
|
||||
|
||||
|
||||
def test_fincen_pdf_table_does_not_invent_day_for_current_month(trading):
|
||||
obj = spider(trading, 'fincen')
|
||||
body = '<main><h1>Actions</h1><table><tr><td><a href="/system/files/order.pdf">Consent order</a></td><td>11/2026</td></tr></table></main>'
|
||||
with pytest.raises(ValueError, match='发布时间'):
|
||||
list(obj.parse_listing(response(SOURCES['fincen'].entries[1], body, lane='realtime')))
|
||||
output = list(obj.parse_listing(response(SOURCES['fincen'].entries[1], body, lane='realtime')))
|
||||
assert all(not file_url(r.url) for r in output)
|
||||
assert not obj.run.errors
|
||||
|
||||
|
||||
def test_cftc_commissioner_index_is_not_a_speech(trading):
|
||||
@@ -262,41 +319,29 @@ def test_due_retry_in_old_pending_cursor_is_not_discarded_by_moving_window(tradi
|
||||
|
||||
|
||||
@pytest.mark.parametrize('restored_pending', [False, True])
|
||||
def test_attachment_retry_outside_realtime_window_fetches_and_completes_pdf(trading, monkeypatch, restored_pending):
|
||||
from alphapulse.crawler.spiders import us_official
|
||||
def test_legacy_attachment_retry_is_not_scheduled(trading, monkeypatch, restored_pending):
|
||||
obj = spider(trading)
|
||||
doc = {'url': 'https://ofac.treasury.gov/recent-actions/20250101', 'kind': 'article', 'listing_date': '2025-01-01'}
|
||||
obj.cursor['attachment_retries'] = {doc['url']: {'document': doc, 'attempts': 1, 'next_at': 0}}
|
||||
if restored_pending:
|
||||
obj.cursor['realtime']['pending'] = [copy.deepcopy(doc)]
|
||||
request, = obj.first_requests()
|
||||
body = '<h1>Crypto sanctions</h1><main><article class="node--type-ofac-recent-action"><div class="field--name-field-body"><p>New sanctions against a virtual currency exchange institution.</p><a href="/media/123/download">Order</a></div></article></main>'
|
||||
async def collect():
|
||||
return [item async for item in obj.parse_document(response(request.url, body, **request.meta))]
|
||||
attachment, = asyncio.run(collect())
|
||||
assert isinstance(attachment, Request) and attachment.url.endswith('/media/123/download')
|
||||
requests = obj.first_requests()
|
||||
assert all(not r.meta.get('parsed') and not r.meta.get('document', {}).get('attachment_retry') for r in requests)
|
||||
assert obj.cursor['attachment_retries'][doc['url']]['attempts'] == 1
|
||||
assert doc['url'] not in obj.cursor.get('checked', {})
|
||||
monkeypatch.setattr(us_official, 'pdf_text', lambda body: 'The official sanctions order applies to a virtual currency exchange.')
|
||||
items = list(obj.parse_attachment(Response(attachment.url, body=b'%PDF-test', request=attachment.replace(meta={**attachment.meta, 'raw_response_id': 13}))))
|
||||
item = next(item for item in items if isinstance(item, dict))
|
||||
assert item['extra']['content_scope'] == 'article'
|
||||
assert item['extra']['attachments'][0]['status'] == 'complete'
|
||||
assert not obj.cursor['attachment_retries']
|
||||
assert doc['url'] in obj.cursor['checked']
|
||||
|
||||
|
||||
def test_due_attachment_is_queued_alongside_document_retry_without_duplicates(trading):
|
||||
|
||||
def test_only_document_retry_is_scheduled(trading):
|
||||
obj = spider(trading)
|
||||
documents = [{'url': f'https://ofac.treasury.gov/recent-actions/2026110{day}', 'kind': 'article'} for day in (3, 4)]
|
||||
obj.cursor['document_retries'] = {documents[0]['url']: {'document': documents[0], 'attempts': 1, 'next_at': 0, 'error': 'HTTP503'}}
|
||||
obj.cursor['attachment_retries'] = {doc['url']: {'document': doc, 'attempts': 1, 'next_at': 0} for doc in documents}
|
||||
request, = obj.first_requests()
|
||||
pending = obj.cursor['realtime']['pending']
|
||||
assert [doc['url'] for doc in pending] == [doc['url'] for doc in documents]
|
||||
assert request.meta['document']['document_retry'] and request.meta['document']['attachment_retry']
|
||||
assert [doc['url'] for doc in pending] == [documents[0]['url']]
|
||||
assert request.meta['document']['document_retry'] and not request.meta['document'].get('attachment_retry')
|
||||
requests = obj.complete('realtime')
|
||||
assert requests[0].url == documents[1]['url'] and requests[0].meta['document']['attachment_retry']
|
||||
assert all(r.url != documents[1]['url'] for r in requests)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('first_lane', ['realtime', 'history'])
|
||||
@@ -529,14 +574,13 @@ def test_unrelated_failed_attachment_is_not_news(trading):
|
||||
parsed = {'url': doc['url'], 'title': 'Federal prosecutor receives national award', 'external_id': 'award', 'content_html': '<p>The department recognized superior performance in Indian Country.</p>', 'published_at': datetime(2026, 11, 4, tzinfo=UTC), 'raw_response_id': 12, 'retry_document': doc,
|
||||
'extra': {'attachments': [{'url': 'https://www.justice.gov/media/1/dl', 'status': 'unavailable'}]}}
|
||||
assert list(obj.attach_or_finish(parsed, 'history')) == []
|
||||
assert obj.run.stats['unclassified_attachment_gaps'] == 1
|
||||
assert obj.cursor['attachment_retries'][doc['url']]['attempts'] == 1
|
||||
assert not obj.run.stats.get('unclassified_attachment_gaps')
|
||||
assert not obj.cursor.get('attachment_retries')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content_type,body', [('image/png', b'\x89PNG\r\n\x1a\n'), ('image/jpeg', b'\xff\xd8\xff'), ('image/gif', b'GIF89a')])
|
||||
@pytest.mark.parametrize('relevant', [True, False])
|
||||
def test_image_attachment_preserves_unknown_text_gap(trading, monkeypatch, content_type, body, relevant):
|
||||
from alphapulse.crawler.spiders import us_official
|
||||
def test_legacy_image_attachment_does_not_create_a_gap(trading, monkeypatch, content_type, body, relevant):
|
||||
obj = spider(trading, 'doj')
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
url = 'https://www.justice.gov/media/1/dl'
|
||||
@@ -544,25 +588,21 @@ def test_image_attachment_preserves_unknown_text_gap(trading, monkeypatch, conte
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
parsed = {'url': doc['url'], 'title': 'Cryptocurrency exchange charged' if relevant else 'Department announces an enforcement action', 'external_id': 'case', 'content_html': '<p>The cryptocurrency exchange was charged with money laundering.</p>' if relevant else '<p>See the attached consent order for the findings and penalty.</p>', 'published_at': datetime(2026, 11, 4, tzinfo=UTC), 'raw_response_id': 12, 'retry_document': doc,
|
||||
'extra': {'attachments': [{'url': url, 'title': 'Consent Order', 'status': 'pending'}]}}
|
||||
monkeypatch.setattr(us_official, 'pdf_text', lambda body: pytest.fail('Images must not reach the PDF parser'))
|
||||
res = Response(url, body=body, headers={'Content-Type': content_type}, request=Request(url, meta={'lane': 'history', 'parsed': parsed, 'raw_response_id': 13}))
|
||||
items = list(obj.parse_attachment(res))
|
||||
if relevant:
|
||||
item, = items
|
||||
assert item['extra']['content_scope'] == 'partial'
|
||||
assert item['extra']['content_scope'] == 'article'
|
||||
else:
|
||||
assert items == []
|
||||
assert obj.run.stats['unclassified_attachment_gaps'] == 1
|
||||
assert not obj.run.stats.get('unclassified_attachment_gaps')
|
||||
attachment = parsed['extra']['attachments'][0]
|
||||
assert attachment['status'] == 'unavailable'
|
||||
assert '图片' in attachment['error']
|
||||
assert attachment['error'] in obj.cursor['attachment_retries'][doc['url']]['error']
|
||||
assert url in obj.cursor['attachment_retries'][doc['url']]['error']
|
||||
assert attachment['raw_response_id'] == 13
|
||||
assert obj.cursor['attachment_retries'][doc['url']]['attempts'] == 1
|
||||
assert attachment['status'] == 'skipped_policy'
|
||||
assert '采集规则' in attachment['error']
|
||||
assert not obj.cursor.get('attachment_retries')
|
||||
|
||||
|
||||
def test_source_api_keeps_attachment_gaps_visible_across_rounds(trading, monkeypatch):
|
||||
def test_source_api_separates_ignored_attachments_from_real_gaps(trading, monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.api import crawler
|
||||
from alphapulse.models import Source
|
||||
@@ -586,9 +626,10 @@ def test_source_api_keeps_attachment_gaps_visible_across_rounds(trading, monkeyp
|
||||
for stats in ({'discovered': 2, 'attachment_gaps': 999, 'unresolved_bills': 999}, {}):
|
||||
latest.stats = stats
|
||||
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
|
||||
assert result.collection_stats['attachment_gaps'] == 3
|
||||
assert result.collection_stats['attachment_retry_pending'] == 2
|
||||
assert result.collection_stats['attachment_retry_exhausted'] == 1
|
||||
assert result.collection_stats['attachment_gaps'] == 0
|
||||
assert result.collection_stats['policy_ignored'] == 3
|
||||
assert result.collection_stats['attachment_retry_pending'] == 0
|
||||
assert result.collection_stats['attachment_retry_exhausted'] == 0
|
||||
assert result.collection_stats['document_gaps'] == 2
|
||||
assert result.collection_stats['document_retry_pending'] == 1
|
||||
assert result.collection_stats['document_retry_exhausted'] == 1
|
||||
@@ -603,8 +644,9 @@ def test_source_api_keeps_attachment_gaps_visible_across_rounds(trading, monkeyp
|
||||
assert result.collection_stats['round_running'] == 0
|
||||
source.history_cursor['attachment_retries'].pop('a')
|
||||
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
|
||||
assert result.collection_stats['attachment_gaps'] == 2
|
||||
assert result.collection_stats['attachment_retry_pending'] == 1
|
||||
assert result.collection_stats['attachment_gaps'] == 0
|
||||
assert result.collection_stats['policy_ignored'] == 2
|
||||
assert result.collection_stats['attachment_retry_pending'] == 0
|
||||
source.history_cursor = {}
|
||||
latest.stats = {'attachment_gaps': 999}
|
||||
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
|
||||
@@ -816,7 +858,7 @@ def test_h41_uses_publisher_release_dates_not_assumed_thursdays(trading):
|
||||
assert obj.cursor['history']['pending'][1]['url'].endswith('20250103/')
|
||||
|
||||
|
||||
def test_attachment_failure_is_visible_and_keeps_a_bounded_retry(trading):
|
||||
def test_attachment_reference_is_skipped_without_retry(trading):
|
||||
obj = spider(trading)
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
url = 'https://ofac.treasury.gov/recent-actions/20250101'
|
||||
@@ -824,13 +866,9 @@ def test_attachment_failure_is_visible_and_keeps_a_bounded_retry(trading):
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
parsed = {'url': url, 'title': 'Crypto sanctions', 'external_id': 'x', 'content_html': '<p>Virtual currency exchange sanctions.</p>', 'published_at': datetime(2025, 1, 1, tzinfo=UTC), 'raw_response_id': 12, 'retry_document': doc,
|
||||
'extra': {'attachments': [{'url': 'https://ofac.treasury.gov/a.pdf', 'title': 'License', 'status': 'unavailable'}]}}
|
||||
before = datetime.now(UTC)
|
||||
item, = obj.attach_or_finish(parsed, 'history')
|
||||
after = datetime.now(UTC)
|
||||
assert item['extra']['content_scope'] == 'partial'
|
||||
assert obj.cursor['attachment_retries'][url]['attempts'] == 1
|
||||
assert obj.cursor['attachment_retries'][url]['document'] == doc
|
||||
assert (before + timedelta(hours=1)).timestamp() <= obj.cursor['attachment_retries'][url]['next_at'] <= (after + timedelta(hours=1)).timestamp()
|
||||
assert item['extra']['attachments'][0]['status'] == 'skipped_policy'
|
||||
assert not obj.cursor.get('attachment_retries')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('canceled,failed', [(False, False), (True, False), (False, True)])
|
||||
@@ -1269,7 +1307,7 @@ def test_bill_undated_text_is_visible_retry_gap(trading):
|
||||
assert '缺少官方日期' in obj.cursor['bill_retries'][base]['error']
|
||||
|
||||
|
||||
def test_public_api_and_pdf_fixtures_replay_without_io(trading):
|
||||
def test_public_api_text_fixtures_replay_without_io(trading):
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
script = runpy.run_path(str(Path(__file__).resolve().parents[2] / 'scripts/smoke_us_news.py'))
|
||||
@@ -1286,25 +1324,20 @@ def test_congress_official_xml_crosscheck(trading):
|
||||
assert len(asyncio.run(script['congress_reference_items']())) == 3
|
||||
|
||||
|
||||
def test_congress_meeting_includes_deduplicated_witness_pdfs(trading, monkeypatch):
|
||||
from alphapulse.crawler.spiders import us_official
|
||||
monkeypatch.setattr(us_official, 'get_settings', lambda: SimpleNamespace(congress_api_key='test', crawler_contact_email='[email protected]'))
|
||||
def test_congress_meeting_keeps_deduplicated_pdf_references(trading, monkeypatch):
|
||||
obj = spider(trading, 'congress')
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
doc = {'url': 'https://api.congress.gov/v3/committee-meeting/119/house/1', 'kind': 'meeting'}
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
pdf = {'url': 'https://www.congress.gov/witness.pdf', 'format': 'PDF', 'documentType': 'Witness Statement'}
|
||||
data = {'committeeMeeting': {'title': 'Digital asset regulation', 'updateDate': '2025-01-01T12:00:00Z', 'meetingDocuments': [pdf], 'witnessDocuments': [pdf]}}
|
||||
async def collect():
|
||||
return [v async for v in obj.parse_document(response(doc['url'], json.dumps(data), document=doc, lane='history'))]
|
||||
req, = asyncio.run(collect())
|
||||
assert req.url == pdf['url']
|
||||
assert len(req.meta['parsed']['extra']['attachments']) == 1
|
||||
assert req.meta['parsed']['extra']['attachments'][0]['title'] == 'Witness Statement'
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
req.meta['raw_response_id'] = 13
|
||||
item, = obj.parse_attachment(Response(req.url, status=404, request=req))
|
||||
assert item['extra']['content_scope'] == 'partial'
|
||||
assert obj.cursor['attachment_retries'][doc['url']]['document'] == doc
|
||||
item, = asyncio.run(collect())
|
||||
assert len(item['extra']['attachments']) == 1
|
||||
assert item['extra']['attachments'][0]['status'] == 'skipped_policy'
|
||||
assert not obj.cursor.get('attachment_retries')
|
||||
|
||||
|
||||
|
||||
def test_dated_but_unavailable_bill_text_remains_a_gap(trading):
|
||||
@@ -1336,12 +1369,12 @@ def test_real_congress_api_fixture_replays_with_visible_source_gap(trading, tmp_
|
||||
assert len(items) == 100
|
||||
meetings = [item for item in items if item['extra'].get('official_meeting')]
|
||||
assert len(meetings) == 1
|
||||
assert meetings[0]['extra']['content_scope'] == 'partial'
|
||||
assert meetings[0]['extra']['content_scope'] == 'article'
|
||||
attachments = meetings[0]['extra']['attachments']
|
||||
assert len(attachments) == 15
|
||||
assert len({a['url'] for a in attachments}) == 15
|
||||
assert sum(a['status'] == 'complete' for a in attachments) == 14
|
||||
assert sum(a['status'] == 'unavailable' for a in attachments) == 1
|
||||
assert all(a['status'] == 'skipped_policy' for a in attachments)
|
||||
assert not any(a['status'] == 'unavailable' for a in attachments)
|
||||
assert any(item['extra']['content_scope'] == 'official_action' for item in items)
|
||||
|
||||
|
||||
@@ -1395,7 +1428,7 @@ def test_sec_uses_official_dateline_not_last_reviewed_date():
|
||||
assert item['extra']['published_at_precision'] == 'date'
|
||||
|
||||
|
||||
def test_attachment_http_failure_spends_durable_retry_budget(trading):
|
||||
def test_legacy_attachment_failure_does_not_spend_retry_budget(trading):
|
||||
obj = spider(trading, 'doj')
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
url = 'https://www.justice.gov/opa/pr/example'
|
||||
@@ -1408,9 +1441,8 @@ def test_attachment_http_failure_spends_durable_retry_budget(trading):
|
||||
failure = Failure(HttpError(Response(request.url, status=503, request=request)))
|
||||
failure.request = request
|
||||
item, = obj.on_request_error(failure)
|
||||
assert item['extra']['content_scope'] == 'partial'
|
||||
assert obj.cursor['attachment_retries'][url]['attempts'] == 1
|
||||
assert '503' in obj.cursor['attachment_retries'][url]['error']
|
||||
assert item['extra']['content_scope'] == 'article'
|
||||
assert not obj.cursor.get('attachment_retries')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('dated', [True, False])
|
||||
@@ -1447,7 +1479,7 @@ def treasury_manifest():
|
||||
|
||||
def test_treasury_json_replaces_only_discovery_cursors(trading):
|
||||
obj = spider(trading, 'treasury')
|
||||
obj.cursor.update(checked={'completed': 1}, document_retries={'gap': {'document': {}, 'attempts': 3}},
|
||||
obj.cursor.update(checked={'completed': 1}, document_retries={'gap': {'document': {'url': 'https://home.treasury.gov/news/press-releases/sb0001'}, 'attempts': 3, 'next_at': 0, 'policy_checked': 'web_text_images_v1'}},
|
||||
attachment_retries={'attachment': {'attempts': 3}}, transport='old')
|
||||
obj.cursor['history']['page'] = SOURCES['treasury'].entries[0] + '?page=2'
|
||||
requests = obj.first_requests()
|
||||
@@ -1496,34 +1528,29 @@ def test_treasury_json_incomplete_manifest_cannot_complete_history(trading, fail
|
||||
|
||||
|
||||
@pytest.mark.parametrize('source, expected', [('fincen', 4_000_000), ('sec', 2_000_000)])
|
||||
def test_pdf_budget_is_bounded_scoped_and_restored(trading, monkeypatch, source, expected):
|
||||
from pypdf import get_configuration
|
||||
from alphapulse.crawler.spiders import us_official
|
||||
def extract(body):
|
||||
assert get_configuration().stream_parsing_work_maximum_count == expected
|
||||
raise ValueError('known PDF error')
|
||||
monkeypatch.setattr(us_official, 'pdf_text', extract)
|
||||
with pytest.raises(ValueError, match='known PDF error'):
|
||||
spider(trading, source).pdf_text(b'%PDF-test')
|
||||
assert get_configuration().stream_parsing_work_maximum_count == 2_000_000
|
||||
def test_news_spider_has_no_pdf_parser(trading, monkeypatch, source, expected):
|
||||
from alphapulse.crawler.middlewares import NewsContentPolicyMiddleware
|
||||
from scrapy.exceptions import IgnoreRequest
|
||||
obj = spider(trading, source)
|
||||
assert not hasattr(obj, 'pdf_text')
|
||||
request = Request(SOURCES[source].entries[0] + '/report.pdf')
|
||||
with pytest.raises(IgnoreRequest):
|
||||
NewsContentPolicyMiddleware().process_request(request)
|
||||
assert request.meta['policy_skipped']
|
||||
|
||||
|
||||
def test_pdf_resource_failure_retains_reason_as_a_gap(trading, monkeypatch):
|
||||
from pypdf.errors import LimitReachedError
|
||||
from alphapulse.calendar import umich
|
||||
def reader(_):
|
||||
raise LimitReachedError('stream work budget exhausted')
|
||||
monkeypatch.setattr(umich, 'PdfReader', reader)
|
||||
|
||||
def test_legacy_pdf_resource_is_a_policy_skip(trading, monkeypatch):
|
||||
obj = spider(trading, 'fincen')
|
||||
doc = {'url': 'https://www.fincen.gov/system/files/report.pdf', 'kind': 'article', 'format': 'pdf',
|
||||
'listing_date': '2026-11-03', 'listing_url': SOURCES['fincen'].entries[1], 'title': 'Official report'}
|
||||
obj.cursor['realtime'].update(pending=[doc], entry=len(SOURCES['fincen'].entries))
|
||||
obj.run.history_deadline = time.monotonic() - 1
|
||||
doc = {'url': 'https://www.fincen.gov/system/files/report.pdf', 'kind': 'article', 'format': 'pdf'}
|
||||
obj.cursor['history']['pending'] = [doc]
|
||||
async def collect():
|
||||
return [item async for item in obj.parse_document(Response(doc['url'], body=b'%PDF-test',
|
||||
request=Request(doc['url'], meta={'lane': 'realtime', 'document': doc, 'raw_response_id': 12})))]
|
||||
return [v async for v in obj.parse_document(response(doc['url'], '<h1>PDF or error document</h1>', lane='history', document=doc))]
|
||||
assert asyncio.run(collect()) == []
|
||||
assert 'stream work budget exhausted' in obj.cursor['document_retries'][doc['url']]['error']
|
||||
assert doc['url'] not in obj.cursor.get('checked', {})
|
||||
assert obj.cursor['document_retries'][doc['url']]['skipped_policy']
|
||||
assert not obj.run.errors and not obj.cursor['history']['pending']
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', ['count', 'date', 'host', 'path'])
|
||||
|
||||
Reference in new issue
Block a user