"""Official US news catalog and deterministic collection policy.""" import hashlib import json import re from dataclasses import dataclass from datetime import UTC, datetime from urllib.parse import urljoin, urlsplit, urlunsplit from parsel import Selector @dataclass(frozen=True) class OfficialSource: name: str entries: tuple[str, ...] article_path: str body: str hosts: tuple[str, ...] interval: int = 300 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',)), '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')), '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',)), '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), 'federal_register': OfficialSource('Federal Register·规则', ('https://www.federalregister.gov/api/v1/documents.json',), r'/documents/', '', ('www.federalregister.gov', 'public-inspection.federalregister.gov', 'www.govinfo.gov'), 900), 'occ': OfficialSource('OCC·银行与稳定币', tuple(f'https://www.occ.gov/news-events/newsroom/news-issuances-by-year/{kind}/{year}-{kind}.html' for year in range(datetime.now(UTC).year, 2024, -1) for kind in ('news-releases', 'bulletins')), r'/news-issuances/(?:news-releases|bulletins|interpretations-and-precedents)/202[5-9]/[^/]+', '.occgov-issuance-content', ('www.occ.gov', 'occ.gov', 'www.occ.treas.gov', 'occ.treas.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), } 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) 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) 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]: content = f'{title}\n{text}' crypto = bool(CRYPTO.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'] 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'] if source == 'ofac' and INSTITUTION.search(content) and POLICY.search(content): return ['financial_sanctions'] return [] def official_url(source: str, url: str, base: str = '') -> str: value = urlsplit(urljoin(base, url)) if value.scheme != 'https' or value.hostname not in SOURCES[source].hosts or value.username or value.password: raise ValueError('链接不是该来源的官方 HTTPS 地址') if re.search(r'(?:api_key|token|key)=', value.query, re.I): raise ValueError('官方链接含凭据参数') return urlunsplit((value.scheme, value.netloc, value.path, value.query, '')) def identity(url: str) -> str: return hashlib.sha256(url.encode()).hexdigest() def published(value: str) -> tuple[datetime, str]: if not isinstance(value, str): raise ValueError('官方材料缺少可识别的发布时间') value = value.strip().replace('\xa0', ' ') if re.fullmatch(r'\d{4}-\d{2}-\d{2}', value): return datetime.fromisoformat(value).replace(tzinfo=UTC), 'date' try: dt = datetime.fromisoformat(value.replace('Z', '+00:00')) if dt.tzinfo: return dt.astimezone(UTC), 'datetime' except ValueError: pass match = re.search(r'([A-Z][a-z]+\.? \d{1,2}, \d{4}|\d{1,2}/\d{1,2}/\d{4})', value) if match: for fmt in ('%B %d, %Y', '%b %d, %Y', '%m/%d/%Y'): try: return datetime.strptime(match[0].replace('.', ''), fmt).replace(tzinfo=UTC), 'date' except ValueError: pass raise ValueError('官方材料缺少可识别的发布时间') def validate_body(content: str) -> None: """Reject error documents without requiring full-page HTML around fragments.""" if content.lstrip().startswith(' dict: sel = Selector(html) validate_body(html) 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',), 'cftc': ('article h1:not(.press-release-title)',), }.get(source, ('.main-content__main h1', '#main_content h1', 'main h1', 'h1')) title = next((value for css in selectors if (value := sel.css(css).xpath('normalize-space(.)').get())), None) if source == 'fed' and '/releases/h41/' in url: title = 'Federal Reserve Balance Sheet — H.4.1' if not title: raise ValueError('官方正文缺少标题') values = sel.css('meta[property="article:published_time"]::attr(content), meta[name="DC.date.issued"]::attr(content), meta[name="dcterms.date"]::attr(content), time::attr(datetime)').getall() for script in sel.css('script[type="application/ld+json"]::text').getall(): try: data = json.loads(script) nodes = data if isinstance(data, list) else data.get('@graph', [data]) values += [n['datePublished'] for n in nodes if isinstance(n, dict) and n.get('datePublished')] except (ValueError, TypeError, AttributeError): continue values += sel.css('.node-details-layout__main-region__content, .date, .date-display-single, .release-date, .article__time, .published-date, .field--name-field-date, .field--name-field-release-date, .press-release-date').xpath('normalize-space(.)').getall() values += re.findall(r'(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}', ' '.join(sel.css('main, article, #content').xpath('string(.)').getall()))[:3] # OFAC action identifiers encode the publisher's day, not the fetch time. if source == 'fed' and (m := re.search(r'/releases/h41/(\d{4})(\d{2})(\d{2})', url)): values.insert(0, '-'.join(m.groups())) if source == 'ofac' and (m := re.search(r'/recent-actions/(\d{4})(\d{2})(\d{2})', url)): values.insert(0, '-'.join(m.groups())) if source == 'fed' and '/monetarypolicy/fomcminutes' in url: if release_date is None: raise ValueError('FOMC 纪要缺少官方日历发布日期') values = [release_date] at = precision = None for value in values: try: at, precision = published(value) break except ValueError: continue if at is None: raise ValueError('官方材料缺少可识别的发布时间') body = sel.css(SOURCES[source].body) if not body: raise ValueError('官方正文结构未识别') for node in body.css('script, style, nav, header, footer, form'): node.root.getparent().remove(node.root) body = sorted(body, key=lambda node: len(node.xpath('string(.)').get()), reverse=True) content = body[0].get() text = ' '.join(body[0].xpath('.//text()').getall()).strip() if len(text) < 40: raise ValueError('官方正文为空或过短') attachments = [] for link in body[0].css('a[href]'): href = link.attrib['href'] label = link.xpath('normalize-space(.)').get() if re.search(r'\.pdf(?:\?|$)|/media/\d+/(?:download|dl)|/csl/[^/]+/download', href, re.I): try: link_url = official_url(source, href, url) except ValueError: candidate = urljoin(url, href) if urlsplit(candidate).scheme == 'https': attachments.append({'url': candidate, 'title': label, 'status': 'unsupported_host', 'error': '附件不在该来源的官方域名范围内,未下载'}) continue if link_url not in [a['url'] for a in attachments]: attachments.append({'url': link_url, 'title': label, 'status': 'pending'}) return dict(title=title, content_html=content, published_at=at, extra={'language': 'en', 'content_scope': 'article', 'published_at_precision': precision, 'published_date': at.date().isoformat(), 'rule_version': RULE_VERSION, 'attachments': attachments})