201 lines
11 KiB
Python
201 lines
11 KiB
Python
"""可恢复历史摘要导出;输出真实响应供既有 import_history 导入,不写业务表。"""
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import gzip
|
|
import json
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
import httpx
|
|
from parsel import Selector
|
|
from sqlalchemy import select
|
|
|
|
from alphapulse.calendar.backfill import fetch_documents, family_for
|
|
from alphapulse.calendar.archives import parse_announcements, publication_time
|
|
from alphapulse.crawler.europe_history import capture as european_capture
|
|
from alphapulse.crawler.import_history import ArchivedArticle, Capture
|
|
from alphapulse.core.db import SessionLocal, engine
|
|
from alphapulse.core.official_sources import FEEDS, relevant_news
|
|
from alphapulse.models import RawBody, RawResponse
|
|
|
|
SOURCES = ('coindesk', 'bls', 'bea', *FEEDS)
|
|
|
|
|
|
def timeline_articles(data):
|
|
if not isinstance(data.get('articles'), list) or not isinstance(data.get('hasMore'), bool):
|
|
raise ValueError('CoinDesk 时间线结构未识别')
|
|
return [ArchivedArticle(guid=a['_id'], url=urljoin('https://www.coindesk.com', a['pathname']),
|
|
title=a['title'], summary=a.get('description') or '',
|
|
published_at=a['articleDates']['displayDate']) for a in data['articles']]
|
|
|
|
|
|
def ecb_articles(data):
|
|
if not isinstance(data, list) or len(data) % 13:
|
|
raise ValueError('ECB 官方索引记录宽度已变化')
|
|
articles = []
|
|
for i in range(0, len(data), 13):
|
|
row = data[i:i + 13]
|
|
at = datetime.fromtimestamp(row[1], UTC)
|
|
for path in row[9]:
|
|
url = urljoin('https://www.ecb.europa.eu', path)
|
|
title = row[10]['Title']
|
|
if relevant_news('ecb', title, url) and not url.endswith('.pdf'):
|
|
articles.append(ArchivedArticle(guid=url, url=url, title=title, summary='', published_at=at))
|
|
return articles
|
|
|
|
|
|
def package(source, url, body, at, headers, articles):
|
|
return Capture(source=source, url=url, status=200, headers=headers,
|
|
body=base64.b64encode(body).decode(), body_encoding='base64',
|
|
fetched_at=at, articles=articles)
|
|
|
|
|
|
async def export(source, begin, end, output):
|
|
errors, captures = [], []
|
|
existing = []
|
|
async with SessionLocal() as session:
|
|
rows = (await session.execute(select(RawResponse, RawBody.body).join(
|
|
RawBody, RawBody.sha256 == RawResponse.body_sha256).where(
|
|
RawResponse.source_code == source, RawResponse.status == 200,
|
|
RawResponse.url.contains('/chunk_') if source == 'ecb' else
|
|
RawResponse.url.contains('/api/v1/articles/timeline'))
|
|
.distinct(RawResponse.url, RawResponse.body_sha256)
|
|
.order_by(RawResponse.url, RawResponse.body_sha256, RawResponse.id))).all() if source in ('ecb', 'coindesk') else []
|
|
for raw, body in rows:
|
|
body = gzip.decompress(body)
|
|
try:
|
|
articles = ecb_articles(json.loads(body)) if source == 'ecb' else timeline_articles(json.loads(body))
|
|
captures.append(package(source, raw.url, body, raw.fetched_at, raw.headers, articles))
|
|
existing.append(raw.url)
|
|
except (ValueError, KeyError, TypeError, IndexError) as exc:
|
|
errors.append({'url': raw.url, 'error': str(exc)})
|
|
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
|
|
async def get(url, host):
|
|
await asyncio.sleep(1)
|
|
for _ in range(6):
|
|
parsed = urlparse(url)
|
|
if parsed.scheme != 'https' or parsed.hostname != host:
|
|
raise ValueError('归档链接或重定向不属于预期官方域名')
|
|
r = await client.get(url, headers={'User-Agent': 'AlphaPulse historical news collector'})
|
|
if not r.is_redirect:
|
|
break
|
|
url = urljoin(url, r.headers['location'])
|
|
else:
|
|
raise ValueError('归档重定向超过五次')
|
|
r.raise_for_status()
|
|
return r
|
|
try:
|
|
if source == 'coindesk':
|
|
url, visited = 'https://www.coindesk.com/api/v1/articles/timeline?size=16&lang=en', set()
|
|
while url:
|
|
if url in visited:
|
|
raise ValueError('CoinDesk 历史分页形成循环')
|
|
visited.add(url)
|
|
r = await get(url, 'www.coindesk.com'); data = r.json()
|
|
articles = timeline_articles(data)
|
|
captures.append(package(source, str(r.url), r.content, datetime.now(UTC), dict(r.headers), articles))
|
|
if not articles:
|
|
if data['hasMore']:
|
|
raise ValueError('CoinDesk 空页仍声明有后续数据')
|
|
break
|
|
dates = [a.published_at for a in articles]
|
|
if dates != sorted(dates, reverse=True):
|
|
raise ValueError('CoinDesk 时间线日期非倒序')
|
|
if min(dates) < begin or not data['hasMore']:
|
|
break
|
|
a = data['articles'][-1]
|
|
url = str(httpx.URL('https://www.coindesk.com/api/v1/articles/timeline', params={
|
|
'size': 16, 'lang': 'en', 'lastDisplayDate': a['articleDates']['displayDate'], 'lastId': a['_id']}))
|
|
elif source == 'bea':
|
|
url, visited = 'https://www.bea.gov/news/archive?field_related_product_target_id=All&created_1=All&title=', set()
|
|
while url:
|
|
if url in visited:
|
|
raise ValueError('BEA 归档分页形成循环')
|
|
visited.add(url)
|
|
r = await get(url, 'www.bea.gov'); sel = Selector(text=r.text)
|
|
links = list(dict.fromkeys(urljoin(str(r.url), u) for u in sel.css('a[href]::attr(href)').getall()
|
|
if any('/news/' + str(y) + '/' in u for y in range(begin.year, end.year + 1))))
|
|
for link in links:
|
|
page = await get(link, 'www.bea.gov'); article = Selector(text=page.text)
|
|
stamp = article.css('.field--name-field-release-date').xpath('string(.)').get()
|
|
if not stamp:
|
|
raise ValueError('BEA 公告缺少正式发布日期')
|
|
a = ArchivedArticle(guid=link, url=link, title=article.css('h1').xpath('string(.)').get(),
|
|
summary=article.css('meta[name="description"]::attr(content)').get() or '', published_at=publication_time(stamp))
|
|
if begin <= a.published_at < end:
|
|
captures.append(package(source, link, page.content, datetime.now(UTC), dict(page.headers), [a]))
|
|
following = sel.css('.pager__item--next a::attr(href)').get()
|
|
years = re.findall(r'/news/(20\d\d)/', r.text)
|
|
url = urljoin(str(r.url), following) if following and (not years or max(map(int, years)) >= begin.year) else None
|
|
elif source == 'ecb':
|
|
if not existing:
|
|
raise ValueError('ECB 缺少已验证的官方索引入口')
|
|
for url in dict.fromkeys(existing):
|
|
r = await get(url, 'www.ecb.europa.eu')
|
|
captures.append(package(source, str(r.url), r.content, datetime.now(UTC), dict(r.headers), ecb_articles(r.json())))
|
|
errors.append({'error': 'ECB 已存索引已重验;动态索引版本至任务终点的完整性仍未证实'})
|
|
elif source == 'boe':
|
|
r = await get('https://www.bankofengland.co.uk/sitemap/speeches', 'www.bankofengland.co.uk')
|
|
links = list(dict.fromkeys(urljoin(str(r.url), p) for p in Selector(text=r.text).css('a[href]::attr(href)').getall() if '/speech/' in p))
|
|
if not links:
|
|
raise ValueError('BoE 讲话索引没有可识别的链接')
|
|
for url in links:
|
|
page = await get(url, 'www.bankofengland.co.uk');sel = Selector(text=page.text)
|
|
dates = sel.css('time::attr(datetime), meta[property="article:published_time"]::attr(content)').getall()
|
|
if not dates:
|
|
raise ValueError('BoE 讲话缺少可识别的正式日期')
|
|
a = ArchivedArticle(guid=url, url=url, title=sel.css('h1').xpath('string(.)').get(),
|
|
summary=sel.css('.page-content p').xpath('string(.)').get() or '', published_at=dates[0])
|
|
if begin <= a.published_at < end:
|
|
captures.append(package(source, url, page.content, datetime.now(UTC), dict(page.headers), [a]))
|
|
except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError) as exc:
|
|
item = {'error': f'{type(exc).__name__}: {exc}'}
|
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
item['retry_after'] = exc.response.headers.get('Retry-After')
|
|
errors.append(item)
|
|
if source != 'coindesk' and not (source in ('boe', 'ecb') and any('HTTPStatusError' in e.get('error', '') for e in errors)):
|
|
documents = await fetch_documents(begin.date(), end.date(), set(), errors, sources={source})
|
|
for doc in documents:
|
|
try:
|
|
if source in FEEDS:
|
|
captures.append(european_capture(doc))
|
|
else:
|
|
family = family_for(doc)
|
|
if family:
|
|
announcements = parse_announcements(family, doc.body, pdf=doc.body.startswith(b'%PDF'))
|
|
title = Selector(text=doc.body.decode(errors='replace')).css('h1').xpath('string(.)').get() or family
|
|
articles = [ArchivedArticle(guid=doc.url, url=doc.url, title=title, summary='', published_at=a.at) for a in announcements]
|
|
captures.append(package(source, doc.url, doc.body, doc.captured_at, doc.headers, articles))
|
|
except (ValueError, KeyError, TypeError, IndexError) as exc:
|
|
errors.append({'url': doc.url, 'error': str(exc)})
|
|
with gzip.open(output, 'xt', encoding='utf-8') as stream:
|
|
for capture in captures:
|
|
capture.articles = [a for a in capture.articles if begin <= a.published_at < end]
|
|
stream.write(capture.model_dump_json() + '\n')
|
|
return {'source': source, 'responses': len(captures), 'articles': sum(len(c.articles) for c in captures),
|
|
'errors': errors, 'output': str(output)}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('source', choices=SOURCES)
|
|
parser.add_argument('--start', required=True, type=datetime.fromisoformat)
|
|
parser.add_argument('--end', required=True, type=datetime.fromisoformat)
|
|
parser.add_argument('--output', required=True, type=Path)
|
|
args = parser.parse_args()
|
|
if args.start.tzinfo is None or args.end.tzinfo is None or args.start >= args.end:
|
|
parser.error('范围须带时区且开始早于结束')
|
|
async def run():
|
|
try:
|
|
return await export(args.source, args.start, args.end, args.output)
|
|
finally:
|
|
await engine.dispose()
|
|
print(json.dumps(asyncio.run(run()), ensure_ascii=False))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|