393 lines
17 KiB
Python
393 lines
17 KiB
Python
import asyncio
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
import uuid
|
||
from collections.abc import Callable, Coroutine, Iterable
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, datetime, timedelta
|
||
from email.utils import parsedate_to_datetime
|
||
from typing import Any
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from scrapy import Request, signals
|
||
from scrapy.crawler import Crawler
|
||
from scrapy.http import Response
|
||
from scrapy.spidermiddlewares.httperror import HttpError
|
||
from scrapy_redis.spiders import RedisSpider
|
||
from sqlalchemy import select
|
||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||
from twisted.python.failure import Failure
|
||
from w3lib.html import remove_tags, replace_entities
|
||
|
||
from alphapulse.core.db import SessionLocal
|
||
from alphapulse.crawler.events import write_logs
|
||
from alphapulse.models import CrawlRun, NewsItem, Source
|
||
|
||
# 日志落库失败时只输出到 stdout,避免被自己的处理器再次捕获
|
||
sink_logger = logging.getLogger("alphapulse.logsink")
|
||
# 日志里的时间按北京时间显示
|
||
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
|
||
|
||
MAX_PAGES = 200
|
||
BACKOFF_BASE_SECONDS = 30
|
||
BACKOFF_MAX_SECONDS = 15 * 60
|
||
# 每轮用于历史回补的时长上限:回补和实时采集共用一个子进程,不能拖住实时轮次
|
||
HISTORY_RUN_SECONDS = 20
|
||
|
||
|
||
def ts(epoch: int) -> datetime:
|
||
return datetime.fromtimestamp(epoch, UTC)
|
||
|
||
|
||
def html_to_text(html: str) -> str:
|
||
text = re.sub(r"(?i)<br\s*/?>|</p>|</div>|</h\d>|</li>", "\n", html)
|
||
text = replace_entities(remove_tags(text))
|
||
return re.sub(r"\n\s*\n+", "\n", text).strip()
|
||
|
||
|
||
@dataclass
|
||
class RunState:
|
||
id: uuid.UUID
|
||
trigger: str
|
||
started_at: datetime
|
||
# 早于该时间的内容不再翻页回溯
|
||
cutoff: datetime
|
||
requests: int = 0
|
||
items_seen: int = 0
|
||
items_new: int = 0
|
||
items_revised: int = 0
|
||
errors: list[str] = field(default_factory=list)
|
||
# 遇到 403/429 后本轮不再发新请求
|
||
blocked: bool = False
|
||
# 历史回补:本轮推进到的最早时间;仅在本轮成功结束时写回 sources
|
||
history_target: datetime | None = None
|
||
history_position: datetime | None = None
|
||
history_done: bool = False
|
||
history_deadline: float = 0
|
||
official_cursor: dict | None = None
|
||
stats: dict = field(default_factory=dict)
|
||
completed_documents_safe: bool = False
|
||
cooldown_until: datetime | None = None
|
||
document_retries_started: set[str] = field(default_factory=set)
|
||
|
||
@property
|
||
def full_scan(self) -> bool:
|
||
# 进程启动后的第一轮一直翻到回溯时间,补上次被中断的回溯;常规轮次遇到整页已知即停
|
||
return self.trigger in ("startup", "restart")
|
||
|
||
|
||
class DbLogHandler(logging.Handler):
|
||
"""把本来源的日志和全局 WARNING 以上日志收集起来,由刷新任务批量落库并发布"""
|
||
|
||
def __init__(self, spider: "NewsSpider") -> None:
|
||
super().__init__(logging.INFO)
|
||
self.spider = spider
|
||
|
||
def emit(self, record: logging.LogRecord) -> None:
|
||
if record.name == sink_logger.name:
|
||
return
|
||
if record.name != self.spider.name and record.levelno < logging.WARNING:
|
||
return
|
||
message = record.getMessage()
|
||
# scrapy-redis 每次取到起始任务都会打这一行,没有信息量
|
||
if message.startswith("start req url"):
|
||
return
|
||
if record.exc_info:
|
||
message += "\n" + logging.Formatter().formatException(record.exc_info)
|
||
run_id = self.spider.run.id if self.spider.run else None
|
||
self.spider.log_buffer.append((self.spider.name, run_id, record.levelname, message))
|
||
|
||
|
||
class NewsSpider(RedisSpider):
|
||
"""一个来源一个子进程。管理进程往 Redis 推一条触发消息即开始一轮采集,引擎空闲即本轮结束。"""
|
||
|
||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||
super().__init__(*args, **kwargs)
|
||
self.run: RunState | None = None
|
||
self.log_buffer: list[tuple[str, uuid.UUID | None, str, str]] = []
|
||
self._tasks: set[asyncio.Task] = set()
|
||
# 上一轮成功后的回补进度 (目标, 断点, 是否结束)。写库是异步的,管理进程推来的下一条触发可能还带着旧断点
|
||
self.history_checkpoint: tuple[datetime, datetime, bool] | None = None
|
||
self.finish_task: asyncio.Task | None = None
|
||
|
||
@classmethod
|
||
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> "NewsSpider":
|
||
spider = super().from_crawler(crawler, *args, **kwargs)
|
||
crawler.signals.connect(spider._on_opened, signal=signals.spider_opened)
|
||
crawler.signals.connect(spider._on_idle, signal=signals.spider_idle)
|
||
crawler.signals.connect(spider._on_error, signal=signals.spider_error)
|
||
crawler.signals.connect(spider._on_item_error, signal=signals.item_error)
|
||
crawler.signals.connect(spider._on_closed, signal=signals.spider_closed)
|
||
return spider
|
||
|
||
# ---------- 子类实现 ----------
|
||
|
||
def first_requests(self) -> Iterable[Request]:
|
||
raise NotImplementedError
|
||
|
||
def history_request(self, before: datetime) -> Request:
|
||
"""早于 before 的第一页列表请求,meta 需带 history=True;仅 HISTORY_SOURCES 中的来源实现"""
|
||
raise NotImplementedError
|
||
|
||
# ---------- 运行生命周期 ----------
|
||
|
||
async def start(self):
|
||
for request in self.next_requests():
|
||
yield request
|
||
|
||
def make_request_from_data(self, data: bytes) -> list[Request]:
|
||
tick = json.loads(data)
|
||
if self.run is not None:
|
||
# scrapy-redis 的 idle 处理先于 _on_idle 执行:引擎已空闲说明上一轮其实已结束
|
||
if not self.crawler.engine.spider_is_idle():
|
||
self.logger.warning("上一轮尚未结束,跳过本次触发(%s)", tick["trigger"])
|
||
return []
|
||
self._close_run()
|
||
now = datetime.now(UTC)
|
||
self.run = RunState(uuid.uuid4(), tick["trigger"], now, now - timedelta(hours=tick["backfill_hours"]))
|
||
self._spawn(self._insert_run(self.run))
|
||
requests = list(self.first_requests())
|
||
history = tick.get("history")
|
||
if history:
|
||
target = datetime.fromisoformat(history["target"])
|
||
# 断点为空表示首次回补:从现在开始往前翻,顺带补上中间可能的缺口
|
||
position = datetime.fromisoformat(history["position"]) if history["position"] else now
|
||
checkpoint = self.history_checkpoint
|
||
if checkpoint and checkpoint[0] == target and checkpoint[1] <= position:
|
||
position = checkpoint[1]
|
||
if checkpoint[2]:
|
||
history = None
|
||
if history:
|
||
self.run.history_target = target
|
||
self.run.history_position = position
|
||
self.run.history_deadline = time.monotonic() + HISTORY_RUN_SECONDS
|
||
requests.append(self.history_request(position))
|
||
self.logger.info("开始采集(触发方式:%s,历史回补自 %s 往前)", tick["trigger"], position.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M"))
|
||
else:
|
||
self.logger.info("开始采集(触发方式:%s)", tick["trigger"])
|
||
return requests
|
||
|
||
def _on_opened(self) -> None:
|
||
logging.getLogger().addHandler(DbLogHandler(self))
|
||
self._spawn(self._flush_logs_forever())
|
||
self.logger.info("采集进程已启动")
|
||
|
||
def _on_idle(self) -> None:
|
||
# scrapy-redis 的 idle 处理先执行,可能刚取到新触发并已开始新一轮;引擎仍有待处理请求时不能结束
|
||
if self.run is None or not self.crawler.engine.spider_is_idle():
|
||
return
|
||
self._close_run()
|
||
|
||
def _close_run(self) -> None:
|
||
run, self.run = self.run, None
|
||
if run.history_target is not None and not run.errors:
|
||
self.history_checkpoint = (run.history_target, run.history_position, run.history_done)
|
||
self.finish_task = self._spawn(self._finish_run(run))
|
||
|
||
def _on_error(self, failure: Failure, response: Response) -> None:
|
||
if self.run:
|
||
self.run.errors.append(f"解析异常 {response.url}:{failure.value!r}")
|
||
|
||
def _on_item_error(self, failure: Failure) -> None:
|
||
if self.run:
|
||
self.run.completed_documents_safe = False
|
||
self.run.errors.append(f"新闻处理或落库失败:{failure.value!r}")
|
||
|
||
async def _on_closed(self) -> None:
|
||
self.logger.info("采集进程退出")
|
||
await self._flush_logs()
|
||
|
||
def _spawn(self, coro: Coroutine) -> asyncio.Task:
|
||
task = asyncio.get_running_loop().create_task(coro)
|
||
self._tasks.add(task)
|
||
task.add_done_callback(self._tasks.discard)
|
||
return task
|
||
|
||
async def _insert_run(self, run: RunState) -> None:
|
||
async with SessionLocal() as session:
|
||
await session.execute(
|
||
pg_insert(CrawlRun).values(
|
||
id=run.id, source_code=self.name, trigger=run.trigger, started_at=run.started_at, status="running"
|
||
).on_conflict_do_nothing()
|
||
)
|
||
await session.commit()
|
||
|
||
async def _finish_run(self, run: RunState) -> None:
|
||
now = datetime.now(UTC)
|
||
failed = bool(run.errors)
|
||
values = {
|
||
"finished_at": now,
|
||
"status": "failed" if failed else "success",
|
||
"stats": run.stats,
|
||
"requests": run.requests,
|
||
"items_seen": run.items_seen,
|
||
"items_new": run.items_new,
|
||
"items_revised": run.items_revised,
|
||
"error": "\n".join(run.errors)[:4000] if failed else None,
|
||
}
|
||
async with SessionLocal() as session:
|
||
# 与 _insert_run 并发时谁先执行都正确
|
||
await session.execute(
|
||
pg_insert(CrawlRun)
|
||
.values(id=run.id, source_code=self.name, trigger=run.trigger, started_at=run.started_at, **values)
|
||
.on_conflict_do_update(index_elements=[CrawlRun.id], set_=values)
|
||
)
|
||
source = await session.get(Source, self.name, with_for_update=True)
|
||
# 本轮失败不推进断点,下一轮从原位置重做(写入幂等);目标期间被修改或取消时丢弃本轮进度
|
||
history_saved = not failed and run.history_target is not None and source.history_target == run.history_target
|
||
if not failed and run.official_cursor is not None:
|
||
cursor = dict(source.history_cursor or {})
|
||
cursor.update({k: v for k, v in run.official_cursor.items() if k != "history"})
|
||
if history_saved:
|
||
cursor["history"] = run.official_cursor.get("history", {})
|
||
source.history_cursor = cursor
|
||
elif failed and run.official_cursor is not None and run.completed_documents_safe:
|
||
# Retain completed reads and explicit gaps, never failed lane positions.
|
||
cursor = dict(source.history_cursor or {})
|
||
for key in ('transport', 'checked', 'document_retries', 'attachment_retries', 'bill_retries'):
|
||
if key in run.official_cursor:
|
||
cursor[key] = run.official_cursor[key]
|
||
source.history_cursor = cursor
|
||
if history_saved:
|
||
source.history_position = run.history_position
|
||
source.history_done = run.history_done
|
||
if failed:
|
||
source.consecutive_failures += 1
|
||
delay = min(BACKOFF_BASE_SECONDS * 2 ** (source.consecutive_failures - 1), BACKOFF_MAX_SECONDS)
|
||
if run.cooldown_until:
|
||
delay = max(delay, int((run.cooldown_until - now).total_seconds()) + 1)
|
||
source.paused_until = now + timedelta(seconds=delay)
|
||
source.last_error = run.errors[0][:1000]
|
||
else:
|
||
source.consecutive_failures = 0
|
||
source.paused_until = None
|
||
source.last_error = None
|
||
source.last_success_at = now
|
||
await session.commit()
|
||
|
||
elapsed = (now - run.started_at).total_seconds()
|
||
summary = f"请求 {run.requests},条目 {run.items_seen},新增 {run.items_new},修订 {run.items_revised},用时 {elapsed:.1f}s"
|
||
if failed:
|
||
self.logger.error("本轮失败:%s;错误 %d 个,暂停 %ds", summary, len(run.errors), delay)
|
||
else:
|
||
self.logger.info("本轮完成:%s", summary)
|
||
if history_saved:
|
||
position = run.history_position.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M")
|
||
if not run.history_done:
|
||
self.logger.info("历史回补已到 %s", position)
|
||
elif run.history_position <= run.history_target:
|
||
self.logger.info("历史回补完成,已到 %s", position)
|
||
else:
|
||
self.logger.warning("历史回补结束:来源最早只能翻到 %s", position)
|
||
|
||
async def _flush_logs(self) -> None:
|
||
rows, self.log_buffer = self.log_buffer, []
|
||
try:
|
||
await write_logs(rows)
|
||
except Exception:
|
||
sink_logger.exception("日志落库失败,丢弃 %d 条", len(rows))
|
||
|
||
async def _flush_logs_forever(self) -> None:
|
||
while True:
|
||
await asyncio.sleep(1)
|
||
await self._flush_logs()
|
||
|
||
# ---------- 子类使用的工具 ----------
|
||
|
||
def request(self, url: str, callback: Callable, **meta: Any) -> Request:
|
||
self.run.requests += 1
|
||
return Request(
|
||
url,
|
||
callback=callback,
|
||
errback=self.on_request_error,
|
||
dont_filter=True,
|
||
meta={"run_id": self.run.id, **meta},
|
||
headers=getattr(self, "request_headers", None),
|
||
)
|
||
|
||
def block_until(self, until: datetime) -> None:
|
||
self.run.blocked = True
|
||
self.run.cooldown_until = max(self.run.cooldown_until or until, until)
|
||
|
||
def on_request_error(self, failure: Failure) -> None:
|
||
status = failure.value.response.status if failure.check(HttpError) else None
|
||
if status in (403, 429):
|
||
now = datetime.now(UTC)
|
||
self.block_until(now + timedelta(seconds=BACKOFF_MAX_SECONDS))
|
||
retry_after = failure.value.response.headers.get('Retry-After', b'').decode('ascii', errors='ignore')
|
||
if retry_after:
|
||
try:
|
||
until = now + timedelta(seconds=int(retry_after)) if retry_after.isdigit() else parsedate_to_datetime(retry_after)
|
||
self.block_until(until)
|
||
except (ValueError, TypeError, OverflowError):
|
||
self.logger.warning('忽略无效 Retry-After 响应头')
|
||
message = f"请求失败 {failure.request.url}:{status or repr(failure.value)}"
|
||
self.run.errors.append(message)
|
||
self.logger.error(message)
|
||
|
||
async def known_items(self, external_ids: list[str]) -> dict[str, tuple[str, str]]:
|
||
"""已入库条目的 (title, summary),用于判断是否新增、是否需要重抓详情、是否继续翻页"""
|
||
async with SessionLocal() as session:
|
||
rows = await session.execute(
|
||
select(NewsItem.external_id, NewsItem.title, NewsItem.summary)
|
||
.where(NewsItem.source_code == self.name, NewsItem.external_id.in_(external_ids))
|
||
)
|
||
return {external_id: (title, summary) for external_id, title, summary in rows}
|
||
|
||
def should_continue(self, external_ids: list[str], known: dict, oldest: datetime, page: int) -> bool:
|
||
"""未越过回溯时间、未被限流,且本页有新条目(或本轮为全量扫描)时才翻下一页"""
|
||
return (
|
||
not self.run.blocked
|
||
and page < MAX_PAGES
|
||
and oldest > self.run.cutoff
|
||
and (self.run.full_scan or any(external_id not in known for external_id in external_ids))
|
||
)
|
||
|
||
def history_step(self, oldest: datetime | None) -> bool:
|
||
"""处理完一页回补列表后调用:推进本轮断点,返回是否继续翻下一页。oldest 为本页最早时间,空页传 None"""
|
||
run = self.run
|
||
if oldest is None or oldest >= run.history_position:
|
||
# 空页或没有更早的数据(部分接口越过下限会返回最新数据):来源已经翻到底
|
||
run.history_done = True
|
||
return False
|
||
run.history_position = oldest
|
||
if oldest <= run.history_target:
|
||
run.history_done = True
|
||
return False
|
||
return not run.blocked and time.monotonic() < run.history_deadline
|
||
|
||
def item(
|
||
self,
|
||
response: Response,
|
||
*,
|
||
external_id: str,
|
||
url: str,
|
||
title: str,
|
||
summary: str,
|
||
content_html: str,
|
||
published_at: datetime,
|
||
author: str | None = None,
|
||
tags: list[str] | None = None,
|
||
is_paywalled: bool = False,
|
||
source_updated_at: datetime | None = None,
|
||
extra: dict | None = None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"source_code": self.name,
|
||
"external_id": external_id,
|
||
"url": url,
|
||
"title": title,
|
||
"summary": summary,
|
||
"content_html": content_html,
|
||
"content_text": html_to_text(content_html),
|
||
"author": author or None,
|
||
"tags": tags or [],
|
||
"is_paywalled": is_paywalled,
|
||
"published_at": published_at,
|
||
"source_updated_at": source_updated_at,
|
||
"extra": extra or {},
|
||
"raw_response_id": response.meta["raw_response_id"],
|
||
}
|