Browse Source

fix(scp_core): 处理下架拍品页面404死循环问题

- 新增 LotNotFound 异常表示拍品已被撤下不存在的永久性错误
- _get_selector 和 fetch_lot_detail 函数中避免对 LotNotFound 异常重试
- 补抓任务只查询 state 为 0 或 2 的记录,跳过已成功和永久不存在的拍品
- 捕获 LotNotFound 后将对应记录状态置为 3,避免反复重试导致死循环
- 详细更新注释说明拍品状态机及异常处理逻辑
charley 1 week ago
parent
commit
f982af721d
2 changed files with 37 additions and 6 deletions
  1. 1 0
      scp_spider/README.md
  2. 36 6
      scp_spider/scp_core.py

+ 1 - 0
scp_spider/README.md

@@ -111,6 +111,7 @@ http_proxy = "http://账号:密码@proxy.123proxy.cn:36927"
 - **多图有 small/large 两套**:统一归一化到 large 再去重,避免同图重复入库。
 - **时间带时区名**(`EDT`/`EST`):解析时剥离时区名,保留东部墙钟时间入 `datetime` 字段,原文另存 `*_raw` 字段。
 - **控制台中文/en-dash 乱码**:Windows GBK 终端显示问题,实际数据 UTF-8 正确,入 `utf8mb4` 库无碍。
+- **下架 lot 的 404 死循环**(2026/07/28 修复):个别拍品会被撤下,详情页返回 HTTP 404(页面标题「Nothing Found」)。原先当普通错误重试 5×3 次、置 `state=2`,而补抓查询 `where state != 1` 会把 `state=2` 每轮重新选中 → 该 404 永久循环、阻塞其他待抓 lot。修复:新增 `LotNotFound` 异常,`_get_selector` 遇 404/410/软404 直接抛出且 `retry_if_not_exception_type` 不重试;置终态 `state=3`;补抓查询改为 `where state in (0, 2)`,跳过已成功(1)与永久不存在(3)。
 
 ---
 

+ 36 - 6
scp_spider/scp_core.py

@@ -32,7 +32,16 @@ from parsel import Selector
 from curl_cffi import requests
 from curl_cffi.requests import BrowserType
 from urllib.parse import urljoin
-from tenacity import retry, stop_after_attempt, wait_fixed
+from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_not_exception_type
+
+
+class LotNotFound(Exception):
+    """lot 详情页不存在(HTTP 404 或站点软 404「Nothing Found」页)。
+
+    该异常表示拍品已被撤下/移除,是永久性错误,不应重试;
+    捕获后应把记录标记为终态 state=3(不再进入补抓队列)。
+    """
+
 
 # —— 站点常量 ——
 BASE_URL = "https://catalogs.scpauctions.com"  # 站点根,用于拼接相对链接
@@ -104,7 +113,10 @@ def get_proxys(log):
         raise e
 
 
-@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
+# retry_if_not_exception_type(LotNotFound):404 是永久性错误,直接抛出不重试,
+# 避免对已下架的 lot 做 5×3 次徒劳请求
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log,
+       retry=retry_if_not_exception_type(LotNotFound))
 def _get_selector(log, session, impersonate, url):
     """GET 一个页面并返回 parsel Selector(带重试,走代理)。
 
@@ -118,10 +130,15 @@ def _get_selector(log, session, impersonate, url):
         Selector: 响应 HTML 的 parsel 解析对象。
 
     Raises:
-        Exception: HTTP 状态非 2xx 时透传异常以触发重试。
+        LotNotFound: 页面 HTTP 404/410 或软 404「Nothing Found」时抛出(不重试)。
+        Exception: 其他 HTTP 非 2xx(如 5xx/超时)时透传以触发重试。
     """
     resp = session.get(url, headers=headers, impersonate=impersonate,
                        proxies=get_proxys(log), timeout=30)
+    # 页面不存在:404/410 直接判定;部分站点软 404 返回 200 但页面是「Nothing Found」
+    if resp.status_code in (404, 410) or (
+            resp.status_code == 200 and "can not be found" in resp.text):
+        raise LotNotFound(f"页面不存在({resp.status_code}): {url}")
     resp.raise_for_status()
     return Selector(resp.text)
 
@@ -436,7 +453,8 @@ def parse_lot_detail(selector):
     }
 
 
-@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log,
+       retry=retry_if_not_exception_type(LotNotFound))
 def fetch_lot_detail(log, session, impersonate, detail_url):
     """GET lot 详情页并解析。
 
@@ -535,7 +553,10 @@ def get_details(log, detail_url, sql_pool, sql_id):
 
 
 def update_details_for_pending(log, sql_pool):
-    """扫库里 state != 1 的 lot,逐条补抓详情。
+    """扫库里待抓 / 瞬时失败的 lot,逐条补抓详情。
+
+    state 状态机:0 待抓,1 已抓成功,2 瞬时失败(可重试),3 页面不存在(404,永久跳过)。
+    只选 state in (0, 2)——即跳过已成功(1)与永久不存在(3),避免 404 记录每轮死循环重试。
 
     Args:
         log: logger 对象。
@@ -546,13 +567,22 @@ def update_details_for_pending(log, sql_pool):
     """
     log.debug("Updating detail pages ...")
     rows = sql_pool.select_all(
-        "select id, detail_url from scp_lot_record where state != 1"
+        "select id, detail_url from scp_lot_record where state in (0, 2)"
     )
     for row in rows:
         sql_id, detail_url = row[0], row[1]
         try:
             get_details(log, detail_url, sql_pool, sql_id)
+        except LotNotFound as e:
+            # 页面已下架/不存在:置终态 3,不再进入补抓队列
+            log.warning(f"lot 页面不存在,标记 state=3 不再重试: {detail_url} ({e})")
+            sql_pool.update_one_or_dict(
+                table="scp_lot_record",
+                data={"state": 3},
+                condition={"id": sql_id},
+            )
         except Exception as e:
+            # 瞬时错误(超时/5xx/代理抖动):置 2,下轮再试
             log.error(f"Error getting details for {detail_url}: {e}")
             sql_pool.update_one_or_dict(
                 table="scp_lot_record",