Fix official policy selection and persistent attachment gaps
Read the OFAC action body and use focused macro policy phrases, including national budgets, appropriations and reserve policies. Keep unknown image text as an explicit gap instead of treating it as complete or irrelevant.\n\nPreserve meeting attachment retries and derive pending/exhausted document counts from saved source cursors. Cover real selection negatives, positive macro boundaries and attachment failures with isolated regressions.
This commit is contained in:
1 parent
328f1e91a1
commit
80665905e5
7 files changed
+145
-11
No files matched your search
@@ -188,6 +188,15 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
|
|||||||
run_stats = {run.source_code: run.stats for run in latest_runs}
|
run_stats = {run.source_code: run.stats for run in latest_runs}
|
||||||
statuses = await redis.hgetall(STATUS_KEY)
|
statuses = await redis.hgetall(STATUS_KEY)
|
||||||
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
|
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', {})
|
||||||
|
pending = sum(value['attempts'] < 3 for value in retries.values())
|
||||||
|
attachment_stats[source.code] = {
|
||||||
|
'attachment_gaps': len(retries),
|
||||||
|
'attachment_retry_pending': pending,
|
||||||
|
'attachment_retry_exhausted': len(retries) - pending,
|
||||||
|
}
|
||||||
return success(SourcesOut(
|
return success(SourcesOut(
|
||||||
manager_alive=bool(statuses),
|
manager_alive=bool(statuses),
|
||||||
db_size_bytes=db_size,
|
db_size_bytes=db_size,
|
||||||
@@ -198,7 +207,7 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
|
|||||||
"region_code": REGIONS.get(s.code),
|
"region_code": REGIONS.get(s.code),
|
||||||
"supports_history": s.code in HISTORY_SOURCES,
|
"supports_history": s.code in HISTORY_SOURCES,
|
||||||
"configuration_error": configuration_error(s.code),
|
"configuration_error": configuration_error(s.code),
|
||||||
"collection_stats": run_stats.get(s.code, {}),
|
"collection_stats": {**run_stats.get(s.code, {}), **attachment_stats[s.code]},
|
||||||
"process": json.loads(statuses[s.code]) if s.code in statuses else None,
|
"process": json.loads(statuses[s.code]) if s.code in statuses else None,
|
||||||
"today_new": today_counts.get(s.code, (0, 0))[0],
|
"today_new": today_counts.get(s.code, (0, 0))[0],
|
||||||
"today_revised": today_counts.get(s.code, (0, 0))[1],
|
"today_revised": today_counts.get(s.code, (0, 0))[1],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ SOURCES = {
|
|||||||
'sec': OfficialSource('SEC·证券监管', ('https://www.sec.gov/newsroom/press-releases', 'https://www.sec.gov/newsroom/speeches-statements', 'https://www.sec.gov/rules-regulations/rulemaking-activity', 'https://www.sec.gov/rules-regulations/self-regulatory-organization-rulemaking'), r'/(?:newsroom/(?:press-releases|speeches-statements)/[^/]+|rules-regulations/.*\d.*)', '.node-details-layout__main-region__content .field--name-body', ('www.sec.gov',)),
|
'sec': OfficialSource('SEC·证券监管', ('https://www.sec.gov/newsroom/press-releases', 'https://www.sec.gov/newsroom/speeches-statements', 'https://www.sec.gov/rules-regulations/rulemaking-activity', 'https://www.sec.gov/rules-regulations/self-regulatory-organization-rulemaking'), r'/(?:newsroom/(?:press-releases|speeches-statements)/[^/]+|rules-regulations/.*\d.*)', '.node-details-layout__main-region__content .field--name-body', ('www.sec.gov',)),
|
||||||
'cftc': OfficialSource('CFTC·衍生品监管', ('https://www.cftc.gov/PressRoom/PressReleases', 'https://www.cftc.gov/PressRoom/SpeechesTestimony', 'https://www.cftc.gov/LawRegulation/CFTCStaffLetters/letters.htm'), r'/(?:PressRoom/(?:PressReleases/\d+-\d+|SpeechesTestimony/(?!index)[^/]+)|csl/)', 'article .field--name-body', ('www.cftc.gov',)),
|
'cftc': OfficialSource('CFTC·衍生品监管', ('https://www.cftc.gov/PressRoom/PressReleases', 'https://www.cftc.gov/PressRoom/SpeechesTestimony', 'https://www.cftc.gov/LawRegulation/CFTCStaffLetters/letters.htm'), r'/(?:PressRoom/(?:PressReleases/\d+-\d+|SpeechesTestimony/(?!index)[^/]+)|csl/)', 'article .field--name-body', ('www.cftc.gov',)),
|
||||||
'treasury': OfficialSource('美国财政部', ('https://home.treasury.gov/news/press-releases',), r'/news/press-releases/[a-z]+\d+/?$', '.field--name-field-news-body', ('home.treasury.gov', 'www.treasury.gov', 'ofac.treasury.gov')),
|
'treasury': OfficialSource('美国财政部', ('https://home.treasury.gov/news/press-releases',), r'/news/press-releases/[a-z]+\d+/?$', '.field--name-field-news-body', ('home.treasury.gov', 'www.treasury.gov', 'ofac.treasury.gov')),
|
||||||
'ofac': OfficialSource('OFAC·金融制裁', ('https://ofac.treasury.gov/recent-actions',), r'/recent-actions/\d{8}[^/]*$', '.field--name-body, .body-content', ('ofac.treasury.gov', 'home.treasury.gov')),
|
'ofac': OfficialSource('OFAC·金融制裁', ('https://ofac.treasury.gov/recent-actions',), r'/recent-actions/\d{8}[^/]*$', 'article.node--type-ofac-recent-action .field--name-field-body', ('ofac.treasury.gov', 'home.treasury.gov')),
|
||||||
'fincen': OfficialSource('FinCEN·反洗钱', ('https://www.fincen.gov/news/press-releases', 'https://www.fincen.gov/news/enforcement-actions', 'https://www.fincen.gov/resources/advisoriesbulletinsfact-sheets'), r'/(?:news/news-releases/[^/]+|resources/advisories/[^/]+|news/enforcement-actions/[^/]+)', 'article .field--name-body', ('www.fincen.gov',)),
|
'fincen': OfficialSource('FinCEN·反洗钱', ('https://www.fincen.gov/news/press-releases', 'https://www.fincen.gov/news/enforcement-actions', 'https://www.fincen.gov/resources/advisoriesbulletinsfact-sheets'), r'/(?:news/news-releases/[^/]+|resources/advisories/[^/]+|news/enforcement-actions/[^/]+)', 'article .field--name-body', ('www.fincen.gov',)),
|
||||||
'whitehouse': OfficialSource('白宫·政策', ('https://www.whitehouse.gov/presidential-actions/', 'https://www.whitehouse.gov/fact-sheets/', 'https://www.whitehouse.gov/briefings-statements/', 'https://bidenwhitehouse.archives.gov/briefing-room/'), r'/(?:presidential-actions|fact-sheets|briefings-statements|briefing-room/[^/]+)/202[5-9]/\d{2}/\d{2}/|/(?:presidential-actions|fact-sheets|briefings-statements)/202[5-9]/\d{2}/[^/]+', '.wp-block-post-content, .body-content, .entry-content', ('www.whitehouse.gov', 'bidenwhitehouse.archives.gov')),
|
'whitehouse': OfficialSource('白宫·政策', ('https://www.whitehouse.gov/presidential-actions/', 'https://www.whitehouse.gov/fact-sheets/', 'https://www.whitehouse.gov/briefings-statements/', 'https://bidenwhitehouse.archives.gov/briefing-room/'), r'/(?:presidential-actions|fact-sheets|briefings-statements|briefing-room/[^/]+)/202[5-9]/\d{2}/\d{2}/|/(?:presidential-actions|fact-sheets|briefings-statements)/202[5-9]/\d{2}/[^/]+', '.wp-block-post-content, .body-content, .entry-content', ('www.whitehouse.gov', 'bidenwhitehouse.archives.gov')),
|
||||||
'congress': OfficialSource('美国国会·立法', ('https://api.congress.gov/v3/bill?format=json&limit=250', 'https://api.congress.gov/v3/committee-meeting?format=json&limit=250'), r'/bill/', '', ('api.congress.gov', 'www.congress.gov', 'www.govinfo.gov'), 900),
|
'congress': OfficialSource('美国国会·立法', ('https://api.congress.gov/v3/bill?format=json&limit=250', 'https://api.congress.gov/v3/committee-meeting?format=json&limit=250'), r'/bill/', '', ('api.congress.gov', 'www.congress.gov', 'www.govinfo.gov'), 900),
|
||||||
@@ -32,11 +32,11 @@ 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',)),
|
'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),
|
'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-v1'
|
RULE_VERSION = 'us-policy-v2'
|
||||||
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)
|
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)
|
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)
|
INSTITUTION = re.compile(r'\b(?:exchanges?|banks?|financial institutions?|payment processors?|custodians?|stablecoin issuers?|binance|coinbase|tether|circle|kraken|bybit|bitfinex)\b', re.I)
|
||||||
MACRO = re.compile(r'\b(?:monetary policy|federal funds|interest rates?|fomc|balance sheet|liquidity|quantitative|refunding|borrowing estimates|debt (?:limit|ceiling)|tariffs?|fiscal|budget|appropriation|tax reform|financial sanctions)\b', re.I)
|
MACRO = re.compile(r"\b(?:monetary policy|federal funds (?:rate|target)|interest rates?|discount rates?|(?:primary|secondary) credit rates?|reserve requirements?|interest on reserve balances|fomc|balance sheet|liquidity|quantitative (?:easing|tightening)|refunding|borrowing estimates|debt (?:limit|ceiling)|tariffs?|fiscal (?:policy|stimulus|outlook)|federal (?:budget|deficit)|president['’]s budget|budget of the united states|(?:continuing|consolidated|full[ -]year) appropriations?|government (?:shutdown|funding)|tax reform|financial sanctions)\b", re.I)
|
||||||
|
|
||||||
|
|
||||||
def selection(source: str, title: str, text: str) -> list[str]:
|
def selection(source: str, title: str, text: str) -> list[str]:
|
||||||
@@ -44,7 +44,8 @@ def selection(source: str, title: str, text: str) -> list[str]:
|
|||||||
crypto = bool(CRYPTO.search(content))
|
crypto = bool(CRYPTO.search(content))
|
||||||
if crypto and (source not in ('doj', 'ofac', 'fincen') or POLICY.search(title) or INSTITUTION.search(content)):
|
if crypto and (source not in ('doj', 'ofac', 'fincen') or POLICY.search(title) or INSTITUTION.search(content)):
|
||||||
return ['crypto_policy' if POLICY.search(content) else 'crypto_institution']
|
return ['crypto_policy' if POLICY.search(content) else 'crypto_institution']
|
||||||
if source not in ('doj', 'cftc', 'sec') and MACRO.search(content):
|
macro_scope = title if source == 'federal_register' else content
|
||||||
|
if source not in ('doj', 'cftc', 'sec') and MACRO.search(macro_scope):
|
||||||
return ['us_macro_policy']
|
return ['us_macro_policy']
|
||||||
if source == 'ofac' and INSTITUTION.search(content) and POLICY.search(content):
|
if source == 'ofac' and INSTITUTION.search(content) and POLICY.search(content):
|
||||||
return ['financial_sanctions']
|
return ['financial_sanctions']
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ class USOfficialSpider(NewsSpider):
|
|||||||
for result in self.complete(lane):
|
for result in self.complete(lane):
|
||||||
yield result
|
yield result
|
||||||
return
|
return
|
||||||
parsed = {'title': data['title'], 'content_html': '<pre>' + escape(json.dumps(data, ensure_ascii=False, indent=2)) + '</pre>', 'published_at': at, 'url': response.url, 'external_id': identity(response.url), 'raw_response_id': response.meta['raw_response_id'],
|
parsed = {'title': data['title'], 'content_html': '<pre>' + escape(json.dumps(data, ensure_ascii=False, indent=2)) + '</pre>', 'published_at': at, 'url': response.url, 'external_id': identity(response.url), 'raw_response_id': response.meta['raw_response_id'], 'retry_document': doc,
|
||||||
'extra': {'content_scope': 'article', 'published_at_precision': precision, 'published_at_basis': 'official_update', 'published_date': at.date().isoformat(), 'official_meeting': data, 'rule_version': RULE_VERSION, 'attachments': [{'url': official_url(self.name, d['url']), 'title': d.get('name') or d.get('documentType', ''), 'status': 'pending'} for d in {d['url']: d for d in data.get('meetingDocuments', []) + data.get('witnessDocuments', []) if d.get('format') == 'PDF'}.values()]}}
|
'extra': {'content_scope': 'article', 'published_at_precision': precision, 'published_at_basis': 'official_update', 'published_date': at.date().isoformat(), 'official_meeting': data, 'rule_version': RULE_VERSION, 'attachments': [{'url': official_url(self.name, d['url']), 'title': d.get('name') or d.get('documentType', ''), 'status': 'pending'} for d in {d['url']: d for d in data.get('meetingDocuments', []) + data.get('witnessDocuments', []) if d.get('format') == 'PDF'}.values()]}}
|
||||||
for result in self.attach_or_finish(parsed, lane):
|
for result in self.attach_or_finish(parsed, lane):
|
||||||
yield result
|
yield result
|
||||||
@@ -514,7 +514,7 @@ class USOfficialSpider(NewsSpider):
|
|||||||
retries[parsed['url']] = {'document': retry_doc, 'attempts': previous.get('attempts', 0) + 1, 'next_at': (self.run.started_at + timedelta(hours=1)).timestamp()}
|
retries[parsed['url']] = {'document': retry_doc, 'attempts': previous.get('attempts', 0) + 1, 'next_at': (self.run.started_at + timedelta(hours=1)).timestamp()}
|
||||||
elif not incomplete:
|
elif not incomplete:
|
||||||
retries.pop(parsed['url'], None)
|
retries.pop(parsed['url'], None)
|
||||||
if reasons or incomplete:
|
if reasons:
|
||||||
parsed['extra']['selection_reasons'] = reasons
|
parsed['extra']['selection_reasons'] = reasons
|
||||||
parsed['extra']['content_scope'] = 'partial' if incomplete else 'article'
|
parsed['extra']['content_scope'] = 'partial' if incomplete else 'article'
|
||||||
self.run.stats['partial'] = self.run.stats.get('partial', 0) + int(incomplete)
|
self.run.stats['partial'] = self.run.stats.get('partial', 0) + int(incomplete)
|
||||||
@@ -523,6 +523,8 @@ class USOfficialSpider(NewsSpider):
|
|||||||
yield item
|
yield item
|
||||||
else:
|
else:
|
||||||
self.run.stats['excluded'] = self.run.stats.get('excluded', 0) + 1
|
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)
|
yield from self.complete(lane)
|
||||||
|
|
||||||
def parse_attachment(self, response):
|
def parse_attachment(self, response):
|
||||||
@@ -532,6 +534,8 @@ class USOfficialSpider(NewsSpider):
|
|||||||
try:
|
try:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise ValueError(f'HTTP {response.status}')
|
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 = pdf_text(response.body)
|
text = pdf_text(response.body)
|
||||||
attachment['status'] = 'complete'
|
attachment['status'] = 'complete'
|
||||||
parsed['content_html'] += f"<section><h2>{escape(attachment['title'])}</h2><pre>{escape(text)}</pre></section>"
|
parsed['content_html'] += f"<section><h2>{escape(attachment['title'])}</h2><pre>{escape(text)}</pre></section>"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from scrapy import Request
|
from scrapy import Request
|
||||||
from scrapy.http import HtmlResponse
|
from scrapy.http import HtmlResponse, Response
|
||||||
|
|
||||||
from alphapulse.core.us_news import SOURCES, article, official_url, published, selection
|
from alphapulse.core.us_news import SOURCES, article, official_url, published, selection
|
||||||
|
|
||||||
@@ -39,6 +39,14 @@ def response(url, body, **meta):
|
|||||||
('treasury', 'Meeting with delegation', 'Greetings and cooperation', False),
|
('treasury', 'Meeting with delegation', 'Greetings and cooperation', False),
|
||||||
('ofac', 'New sanctions', 'Sanctions against a financial institution', True),
|
('ofac', 'New sanctions', 'Sanctions against a financial institution', True),
|
||||||
('sec', 'Ethernet equipment', 'Method and hardware', False),
|
('sec', 'Ethernet equipment', 'Method and hardware', False),
|
||||||
|
('whitehouse', "The President's Budget for Fiscal Year 2027", 'The President submitted his budget request to Congress, proposing nationwide spending and revenue changes.', True),
|
||||||
|
('whitehouse', 'The President’s Budget', 'Annual spending and revenue proposals.', True),
|
||||||
|
('congress', 'Continuing Appropriations Act, 2027', 'This bill provides continuing appropriation for the operations of the United States.', True),
|
||||||
|
('federal_register', 'Adjustments to the Reserve Bank Discount Rates', 'The Federal Reserve increases interest rates charged on loans to depository institutions.', True),
|
||||||
|
('federal_register', 'Reserve Requirements of Depository Institutions', 'The Federal Reserve adjusts reserve requirements.', True),
|
||||||
|
('federal_register', 'Interest on Reserve Balances', 'The Federal Reserve adjusts interest paid on reserve balances.', True),
|
||||||
|
('federal_register', 'Hospital Budget Estimates for Fiscal Year 2027', 'The budget provides funding for hospital operations.', False),
|
||||||
|
('whitehouse', 'Health research funding', 'The department budget supports quantitative research during the fiscal year.', False),
|
||||||
])
|
])
|
||||||
def test_selection_boundaries(source, title, text, expected):
|
def test_selection_boundaries(source, title, text, expected):
|
||||||
assert bool(selection(source, title, text)) is expected
|
assert bool(selection(source, title, text)) is expected
|
||||||
@@ -58,14 +66,102 @@ def test_dates_do_not_fabricate_publisher_time():
|
|||||||
|
|
||||||
|
|
||||||
def test_ofac_original_body_and_attachment_boundaries():
|
def test_ofac_original_body_and_attachment_boundaries():
|
||||||
data = article('ofac', '''<h1>Crypto sanctions</h1><main><div class="field--name-body"><p>New sanctions against a virtual currency exchange institution.</p>
|
data = article('ofac', '''<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">License</a><a href="https://evil.example/a.pdf">external</a></div></main>''', 'https://ofac.treasury.gov/recent-actions/20250101')
|
<a href="/media/123/download">License</a><a href="https://evil.example/a.pdf">external</a></div></article></main>''', 'https://ofac.treasury.gov/recent-actions/20250101')
|
||||||
assert data['published_at'] == datetime(2025, 1, 1, tzinfo=UTC)
|
assert data['published_at'] == datetime(2025, 1, 1, tzinfo=UTC)
|
||||||
assert data['extra']['published_at_precision'] == 'date'
|
assert data['extra']['published_at_precision'] == 'date'
|
||||||
assert len(data['extra']['attachments']) == 2
|
assert len(data['extra']['attachments']) == 2
|
||||||
assert data['extra']['attachments'][1]['status'] == 'unsupported_host'
|
assert data['extra']['attachments'][1]['status'] == 'unsupported_host'
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_ofac_body_and_policy_selection():
|
||||||
|
import gzip
|
||||||
|
from pathlib import Path
|
||||||
|
sample = json.loads(gzip.decompress((Path(__file__).parent / 'fixtures/us_news/live_selection.json.gz').read_bytes()))
|
||||||
|
parsed = article('ofac', sample['ofac']['body'], sample['ofac']['url'])
|
||||||
|
assert 'Iran General License FF-1' in parsed['content_html']
|
||||||
|
assert "A Part of Treasury's Office" not in parsed['content_html']
|
||||||
|
for case in sample['cases']:
|
||||||
|
assert bool(selection(case['source_code'], case['title'], case['content_text'])) is case['expected'], case['title']
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_failed_attachment_is_not_news(trading):
|
||||||
|
obj = spider(trading, 'doj')
|
||||||
|
obj.run.history_deadline = time.monotonic() - 1
|
||||||
|
doc = {'url': 'https://www.justice.gov/api/v1/press_releases/x.json', 'kind': 'doj'}
|
||||||
|
obj.cursor['history']['pending'] = [doc]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
obj = spider(trading, 'doj')
|
||||||
|
obj.run.history_deadline = time.monotonic() - 1
|
||||||
|
url = 'https://www.justice.gov/media/1/dl'
|
||||||
|
doc = {'url': 'https://www.justice.gov/api/v1/press_releases/x.json', 'kind': 'doj'}
|
||||||
|
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'
|
||||||
|
else:
|
||||||
|
assert items == []
|
||||||
|
assert obj.run.stats['unclassified_attachment_gaps'] == 1
|
||||||
|
attachment = parsed['extra']['attachments'][0]
|
||||||
|
assert attachment['status'] == 'unavailable'
|
||||||
|
assert '图片' in attachment['error']
|
||||||
|
assert attachment['raw_response_id'] == 13
|
||||||
|
assert obj.cursor['attachment_retries'][doc['url']]['attempts'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_api_keeps_attachment_gaps_visible_across_rounds(trading, monkeypatch):
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from alphapulse.api import crawler
|
||||||
|
from alphapulse.models import Source
|
||||||
|
|
||||||
|
source = Source(code='doj', name='DOJ', list_url=SOURCES['doj'].entries[0], enabled=False, interval_seconds=300, backfill_hours=72,
|
||||||
|
consecutive_failures=0, last_success_at=None, last_error=None, paused_until=None, history_cursor={'attachment_retries': {'a': {'attempts': 1}, 'b': {'attempts': 2}, 'c': {'attempts': 3}}},
|
||||||
|
history_target=None, history_position=None, history_done=False)
|
||||||
|
latest = SimpleNamespace(source_code='doj', stats={'discovered': 2, 'attachment_gaps': 999})
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
async def scalars(self, statement):
|
||||||
|
rows = [source] if statement.column_descriptions[0]['entity'] is Source else [latest]
|
||||||
|
return SimpleNamespace(all=lambda: rows)
|
||||||
|
async def execute(self, statement):
|
||||||
|
return SimpleNamespace(all=lambda: []) if 'news_items' in str(statement) else []
|
||||||
|
async def scalar(self, statement):
|
||||||
|
return 1234
|
||||||
|
|
||||||
|
monkeypatch.setattr(crawler.redis, 'hgetall', AsyncMock(return_value={}))
|
||||||
|
monkeypatch.setattr(crawler, 'calendar_sources', AsyncMock(return_value=[]))
|
||||||
|
for stats in ({'discovered': 2, 'attachment_gaps': 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
|
||||||
|
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
|
||||||
|
source.history_cursor = {}
|
||||||
|
latest.stats = {'attachment_gaps': 999}
|
||||||
|
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
|
||||||
|
assert result.collection_stats['attachment_gaps'] == 0
|
||||||
|
assert result.collection_stats['attachment_retry_exhausted'] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_challenge_is_failure_not_empty_success(trading):
|
def test_challenge_is_failure_not_empty_success(trading):
|
||||||
obj = spider(trading)
|
obj = spider(trading)
|
||||||
with pytest.raises(ValueError, match='未识别'):
|
with pytest.raises(ValueError, match='未识别'):
|
||||||
@@ -730,6 +826,11 @@ def test_congress_meeting_includes_deduplicated_witness_pdfs(trading, monkeypatc
|
|||||||
assert req.url == pdf['url']
|
assert req.url == pdf['url']
|
||||||
assert len(req.meta['parsed']['extra']['attachments']) == 1
|
assert len(req.meta['parsed']['extra']['attachments']) == 1
|
||||||
assert req.meta['parsed']['extra']['attachments'][0]['title'] == 'Witness Statement'
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_dated_but_unavailable_bill_text_remains_a_gap(trading):
|
def test_dated_but_unavailable_bill_text_remains_a_gap(trading):
|
||||||
|
|||||||
@@ -276,6 +276,22 @@ attachment text are searchable and versioned. Date-only releases are displayed a
|
|||||||
dates, not precise release instants. Backfilled documents describe the version
|
dates, not precise release instants. Backfilled documents describe the version
|
||||||
available at capture time; they are not historical point-in-time evidence.
|
available at capture time; they are not historical point-in-time evidence.
|
||||||
|
|
||||||
|
Selection rule `us-policy-v2` uses explicit national budget, continuing/consolidated
|
||||||
|
appropriations, discount-rate and reserve-policy phrases instead of standalone
|
||||||
|
`budget`, `fiscal` or `quantitative`. Federal Register macro screening uses the title
|
||||||
|
to avoid incidental references in unrelated regulations; crypto screening still
|
||||||
|
reads the complete body and extracted attachments.
|
||||||
|
|
||||||
|
Image attachments remain an explicit text-extraction gap, including when their
|
||||||
|
format is PNG, JPEG or GIF. They do not establish completeness or irrelevance.
|
||||||
|
Relevant documents remain `partial`; documents whose relevance is still unknown
|
||||||
|
are kept in bounded attachment retries rather than inserted as unrelated news.
|
||||||
|
Congress meeting attachment failures retain the same retry evidence as articles.
|
||||||
|
The source API derives `attachment_gaps`, `attachment_retry_pending` and
|
||||||
|
`attachment_retry_exhausted` from the persisted cursor, independently of the latest
|
||||||
|
round's statistics. The console keeps these document counts visible across rounds;
|
||||||
|
success clears the gap, while three failed attempts remain visible for investigation.
|
||||||
|
|
||||||
Validation without production credentials:
|
Validation without production credentials:
|
||||||
|
|
||||||
- `cd backend && uv run pytest && uvx ruff check --select F,E9,B src tests`
|
- `cd backend && uv run pytest && uvx ruff check --select F,E9,B src tests`
|
||||||
|
|||||||
@@ -160,8 +160,11 @@ onMounted(loadLatest)
|
|||||||
<div v-if="source.collection_stats?.unresolved_bills" class="text-xs text-amber-600">
|
<div v-if="source.collection_stats?.unresolved_bills" class="text-xs text-amber-600">
|
||||||
待核验法案 {{ source.collection_stats.unresolved_bills }};官方资料尚不充分
|
待核验法案 {{ source.collection_stats.unresolved_bills }};官方资料尚不充分
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="source.collection_stats?.attachment_gaps" class="text-xs text-amber-600">
|
||||||
|
附件待核验文档 {{ source.collection_stats.attachment_gaps }};待重试 {{ source.collection_stats.attachment_retry_pending }};重试已耗尽 {{ source.collection_stats.attachment_retry_exhausted }},请核对原文
|
||||||
|
</div>
|
||||||
<div v-if="source.collection_stats?.discovered" class="text-xs text-slate-500">
|
<div v-if="source.collection_stats?.discovered" class="text-xs text-slate-500">
|
||||||
本轮发现 {{ source.collection_stats.discovered }};筛除 {{ source.collection_stats.excluded ?? 0 }};附件缺口 {{ source.collection_stats.partial ?? 0 }}
|
本轮发现 {{ source.collection_stats.discovered }};筛除 {{ source.collection_stats.excluded ?? 0 }};不完整包 {{ source.collection_stats.partial ?? 0 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-xs text-slate-500">
|
<div class="flex justify-between text-xs text-slate-500">
|
||||||
<span>今日新增 <b class="num text-[var(--el-color-primary)]">+{{ source.today_new }}</b></span>
|
<span>今日新增 <b class="num text-[var(--el-color-primary)]">+{{ source.today_new }}</b></span>
|
||||||
|
|||||||
Reference in new issue
Block a user