scp_core.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/07/15
  5. """
  6. SCP Auctions (catalogs.scpauctions.com) 公用模块:HTTP 配置、拍卖会列表解析、
  7. 场次内 lot 列表分页抓取、详情页解析。被 scp_history.py / scp_spider.py 复用。
  8. 目标网站: https://catalogs.scpauctions.com/auctions/past
  9. 逻辑要点:
  10. 1. 站点为 Bidsquare 托管平台,页面服务端渲染(SSR),无独立数据 API,
  11. 全部数据直接在 HTML 里,用 parsel 解析即可(本地开了 VPN,请求统一走代理)。
  12. 2. 拍卖会列表页 /auctions/past 分页:?page=N(每页 24 场),翻到空页为止。
  13. 每张拍卖会卡片是 <div class="gtm-visible_event" data-event_id=...>:
  14. event_id(去重键)、标题、类型、起止时间、主页/图录链接、封面图。
  15. 3. 图录(lot 列表)页 catalog_url?page=N&limit=120(limit 上限 120):
  16. lot 卡片是 <div id="stl-{item_id}" data-item_id=...>,含 Lot 号 / 标题 /
  17. 详情链接 / 缩略图 / 出价数 / 成交状态与成交价。翻到空页为止。
  18. 4. lot 详情页 /online-auctions/{house}/{slug}-{item_id}:
  19. 标题取 og:title;成交价在 .bidding-price(流拍则为空);
  20. 属性(Collection/Sport/Type/Athlete/Team 等)在 .item-attributes li,
  21. label/span 成对,字段数量不固定(2~5 个,可能缺 Athlete/Team),
  22. 故按 label 建字典再按需取值,缺失置空;
  23. 多图为 s1.img.bidsquare.com/item/{l|s}/... 归一化到 large 尺寸去重。
  24. """
  25. import re
  26. import random
  27. from datetime import datetime
  28. from loguru import logger
  29. from parsel import Selector
  30. from curl_cffi import requests
  31. from curl_cffi.requests import BrowserType
  32. from urllib.parse import urljoin
  33. from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_not_exception_type
  34. class LotNotFound(Exception):
  35. """lot 详情页不存在(HTTP 404 或站点软 404「Nothing Found」页)。
  36. 该异常表示拍品已被撤下/移除,是永久性错误,不应重试;
  37. 捕获后应把记录标记为终态 state=3(不再进入补抓队列)。
  38. """
  39. # —— 站点常量 ——
  40. BASE_URL = "https://catalogs.scpauctions.com" # 站点根,用于拼接相对链接
  41. PAST_URL = "https://catalogs.scpauctions.com/auctions/past" # 拍卖会(历史)列表页
  42. CATALOG_PAGE_SIZE = 120 # 图录页每页 lot 数(limit 参数,站点上限 120)
  43. IMG_ITEM_RE = r'https://s1\.img\.bidsquare\.com/item/[ls]/\d+/\d+\.jpe?g(?:\?t=[^"\'\s\\]+)?' # 拍品图 URL
  44. # 直接用库内置的所有浏览器指纹(伪装真实浏览器 TLS/UA,规避潜在的指纹风控)
  45. client_identifier_list = [b.value for b in BrowserType]
  46. # 不写死 user-agent:curl_cffi 会按 impersonate 指纹自动带匹配的 UA,
  47. # 写死会造成 TLS 指纹与 UA 头矛盾,反而更易被识别。只保留通用、不冲突的头。
  48. headers = {
  49. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
  50. "accept-language": "en-US,en;q=0.9",
  51. }
  52. def after_log(retry_state):
  53. """tenacity retry 回调,统一打印重试日志。
  54. Args:
  55. retry_state: tenacity.RetryCallState,retry 框架自动传入。
  56. Returns:
  57. None: 仅打印日志,无返回值。
  58. """
  59. if retry_state.args and len(retry_state.args) > 0:
  60. log = retry_state.args[0]
  61. else:
  62. log = logger
  63. if retry_state.outcome.failed:
  64. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  65. else:
  66. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  67. @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
  68. def get_proxys(log):
  69. """获取代理字典,全部请求方法的 proxies 参数都走这里。
  70. 本地开了 VPN,从大陆直连站点不通,请求必须走代理。默认用住宅代理(北美出口,
  71. 与 SCP 面向的美区一致)。若要改用本地 VPN 客户端的 HTTP 代理端口(如 Clash 的
  72. 127.0.0.1:7890),把 http_proxy / https_proxy 换成对应地址即可。
  73. Args:
  74. log: logger 对象。
  75. Returns:
  76. dict: requests 风格代理字典 {"http": ..., "https": ...}。
  77. Raises:
  78. Exception: 透传内部异常以便 tenacity 触发重试。
  79. """
  80. # 住宅 ip 池 北美(与参考项目 rea_spider 一致);如需切本地 VPN 端口在此改
  81. http_proxy = "http://u1952150085001297:sJMHl4qc4bM0@proxy.123proxy.cn:36927"
  82. https_proxy = "http://u1952150085001297:sJMHl4qc4bM0@proxy.123proxy.cn:36927"
  83. # 本地 VPN(Clash)示例,二选一:
  84. # http_proxy = https_proxy = "http://127.0.0.1:7890"
  85. try:
  86. return {
  87. "http": http_proxy,
  88. "https": https_proxy,
  89. }
  90. except Exception as e:
  91. log.error(f"Error getting proxy: {e}")
  92. raise e
  93. # retry_if_not_exception_type(LotNotFound):404 是永久性错误,直接抛出不重试,
  94. # 避免对已下架的 lot 做 5×3 次徒劳请求
  95. @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log,
  96. retry=retry_if_not_exception_type(LotNotFound))
  97. def _get_selector(log, session, impersonate, url):
  98. """GET 一个页面并返回 parsel Selector(带重试,走代理)。
  99. Args:
  100. log: logger 对象。
  101. session (requests.Session): curl_cffi 会话对象。
  102. impersonate (str): 浏览器指纹标识。
  103. url (str): 目标绝对 URL。
  104. Returns:
  105. Selector: 响应 HTML 的 parsel 解析对象。
  106. Raises:
  107. LotNotFound: 页面 HTTP 404/410 或软 404「Nothing Found」时抛出(不重试)。
  108. Exception: 其他 HTTP 非 2xx(如 5xx/超时)时透传以触发重试。
  109. """
  110. resp = session.get(url, headers=headers, impersonate=impersonate,
  111. proxies=get_proxys(log), timeout=30)
  112. # 页面不存在:404/410 直接判定;部分站点软 404 返回 200 但页面是「Nothing Found」
  113. if resp.status_code in (404, 410) or (
  114. resp.status_code == 200 and "can not be found" in resp.text):
  115. raise LotNotFound(f"页面不存在({resp.status_code}): {url}")
  116. resp.raise_for_status()
  117. return Selector(resp.text)
  118. def _parse_datetime(raw):
  119. """把站点时间原文解析成 datetime(丢弃时区名,保留东部墙钟时间)。
  120. Args:
  121. raw (str): 时间原文,如 "May 20, 2026 01:00PM EDT" 或带 "Start:" 前缀。
  122. Returns:
  123. datetime | None: 解析成功返回 datetime;失败或空返回 None。
  124. """
  125. if not raw:
  126. return None
  127. # 去掉 Start:/End: 前缀与末尾时区名(EDT/EST/…),只留 "May 20, 2026 01:00PM"
  128. text = re.sub(r'^\s*(Start|End)\s*:\s*', '', raw.strip(), flags=re.I)
  129. m = re.search(r'([A-Za-z]{3,9}\s+\d{1,2},\s*\d{4}\s+\d{1,2}:\d{2}\s*[AP]M)', text, re.I)
  130. if not m:
  131. return None
  132. try:
  133. return datetime.strptime(m.group(1).replace(" ", " ").upper(), "%b %d, %Y %I:%M%p")
  134. except ValueError:
  135. return None
  136. def parse_auction_list(selector):
  137. """解析 /auctions/past 单页的拍卖会卡片列表。
  138. Args:
  139. selector (Selector): 拍卖会列表页某一页 GET 响应的 parsel 解析对象。
  140. Returns:
  141. list[dict]: 每场一个 dict,字段:event_id, auction_name, auction_type,
  142. event_status, start_time_raw, end_time_raw, start_time, end_time,
  143. auction_url, catalog_url, cover_img;无卡片返回空列表。
  144. """
  145. auctions = []
  146. for card in selector.css('div.gtm-visible_event[data-event_id]'):
  147. event_id = card.attrib.get("data-event_id", "").strip()
  148. if not event_id:
  149. continue
  150. # 标题 + 拍卖会主页链接
  151. title_a = card.css('.list-top h1 a')
  152. auction_name = (title_a.xpath('normalize-space(.)').get() or
  153. card.attrib.get("data-event_name", "")).strip()
  154. auction_url = title_a.attrib.get("href", "").strip()
  155. # 类型(Timed Auction 等)
  156. auction_type = (card.css('.list-top label::text').get() or "").strip()
  157. # 起止时间:.list-top 下多个 <span>,按前缀区分
  158. start_raw = end_raw = ""
  159. for sp in card.css('.list-top span'):
  160. t = (sp.xpath('normalize-space(.)').get() or "").strip()
  161. if t.lower().startswith("start"):
  162. start_raw = t
  163. elif t.lower().startswith("end"):
  164. end_raw = t
  165. # 图录链接(去掉锚点 #catalog)
  166. catalog_url = (card.css('a.view-catalog-btn::attr(href)').get() or "").strip()
  167. catalog_url = catalog_url.split("#")[0]
  168. cover_img = (card.css('.slider-for img::attr(src)').get() or "").strip()
  169. start_dt = _parse_datetime(start_raw)
  170. end_dt = _parse_datetime(end_raw)
  171. auctions.append({
  172. "event_id": event_id,
  173. "auction_name": auction_name,
  174. "auction_type": auction_type,
  175. "event_status": card.attrib.get("data-event_status", "").strip(),
  176. "start_time_raw": re.sub(r'^\s*Start\s*:\s*', '', start_raw, flags=re.I).strip(),
  177. "end_time_raw": re.sub(r'^\s*End\s*:\s*', '', end_raw, flags=re.I).strip(),
  178. "start_time": start_dt.strftime("%Y-%m-%d %H:%M:%S") if start_dt else None,
  179. "end_time": end_dt.strftime("%Y-%m-%d %H:%M:%S") if end_dt else None,
  180. "auction_url": urljoin(BASE_URL, auction_url) if auction_url else None,
  181. "catalog_url": urljoin(BASE_URL, catalog_url) if catalog_url else None,
  182. "cover_img": cover_img or None,
  183. })
  184. return auctions
  185. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
  186. def get_auction_list(log, session, impersonate, only_first_page=False):
  187. """翻页抓取 /auctions/past 全部(或仅首页)拍卖会。
  188. Args:
  189. log: logger 对象。
  190. session (requests.Session): curl_cffi 会话对象。
  191. impersonate (str): 浏览器指纹标识。
  192. only_first_page (bool, optional): True 仅取首页(增量用,最近场次在首页顶部);
  193. False 翻完全部页(全量用)。Defaults to False。
  194. Returns:
  195. list[dict]: 拍卖会 dict 列表,已按 event_id 去重。
  196. """
  197. scope = "首页" if only_first_page else "全部"
  198. log.info(f"获取{scope}拍卖会列表")
  199. result, seen = [], set()
  200. page = 1
  201. while True:
  202. url = f"{PAST_URL}?page={page}"
  203. sel = _get_selector(log, session, impersonate, url)
  204. page_auctions = parse_auction_list(sel)
  205. if not page_auctions:
  206. break # 空页 = 已翻过末页
  207. new = [a for a in page_auctions if a["event_id"] not in seen]
  208. for a in new:
  209. seen.add(a["event_id"])
  210. result.append(a)
  211. if not new:
  212. break # 本页全重复(越界回卷兜底),停止
  213. log.info(f" 第 {page} 页解析到 {len(page_auctions)} 场(新增 {len(new)},累计 {len(result)})")
  214. if only_first_page:
  215. break
  216. page += 1
  217. log.info(f"共解析到 {len(result)} 场拍卖会")
  218. return result
  219. def _clean_price(text):
  220. """把成交价文本清洗成纯数字字符串。
  221. Args:
  222. text (str): 原始价格文本,如 "$90,000"。
  223. Returns:
  224. str: 去掉 $ 和千分位逗号后的数字串,如 "90000";无价格返回空串。
  225. """
  226. if not text:
  227. return ""
  228. m = re.search(r'\$?\s*([\d,]+)', text)
  229. return m.group(1).replace(",", "") if m else ""
  230. def parse_lot_cards(selector, auction):
  231. """解析图录页单页的 lot 卡片。
  232. Args:
  233. selector (Selector): 图录页某一页 GET 响应的 parsel 解析对象。
  234. auction (dict): 当前场次 dict(含 event_id / auction_name),回填到每条 lot。
  235. Returns:
  236. list[dict]: 每条 lot 一个 dict,字段:item_id, event_id, auction_name,
  237. lot_number, title, detail_url, list_img, sold_status, sold_for, bids;
  238. 无卡片返回空列表。
  239. """
  240. rows = []
  241. for card in selector.css('div[id^="stl-"][data-item_id]'):
  242. item_id = card.attrib.get("data-item_id", "").strip()
  243. if not item_id:
  244. continue
  245. title_a = card.css('.lot_title a')
  246. title = (title_a.xpath('normalize-space(.)').get() or "").strip()
  247. detail_url = (title_a.attrib.get("href") or "").strip()
  248. # Lot 号:"Lot 1" → "1"
  249. lot_raw = (card.css('.lot_Num::text').get() or "").strip()
  250. lot_number = (re.search(r'(\d+)', lot_raw) or [None, ""])[1] if lot_raw else ""
  251. list_img = (card.css('.catalog_img img::attr(src)').get() or "").strip()
  252. # 底部区:"9 BidsSold for$90,000" / "24 BidsUnsold"
  253. bottom = card.css('.item-list-bottom')
  254. bottom_text = (bottom.xpath('normalize-space(.)').get() or "") if bottom else ""
  255. bids_m = re.search(r'(\d+)\s*Bids?', bottom_text, re.I)
  256. bids = bids_m.group(1) if bids_m else ""
  257. if re.search(r'Unsold', bottom_text, re.I):
  258. sold_status, sold_for = "unsold", ""
  259. elif re.search(r'Sold\s*for', bottom_text, re.I):
  260. sold_status = "sold"
  261. sold_for = _clean_price(re.sub(r'.*Sold\s*for', '', bottom_text, flags=re.I))
  262. else:
  263. sold_status, sold_for = "", ""
  264. rows.append({
  265. "item_id": item_id,
  266. "event_id": auction["event_id"],
  267. "auction_name": auction.get("auction_name"),
  268. "lot_number": lot_number,
  269. "title": title,
  270. "detail_url": urljoin(BASE_URL, detail_url) if detail_url else None,
  271. "list_img": list_img or None,
  272. "sold_status": sold_status or None,
  273. "sold_for": sold_for or None,
  274. "bids": bids or None,
  275. })
  276. return rows
  277. @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
  278. def _fetch_lot_page(log, session, impersonate, catalog_url, page):
  279. """抓取图录页的某一页 lot 卡片。
  280. Args:
  281. log: logger 对象。
  282. session (requests.Session): curl_cffi 会话对象。
  283. impersonate (str): 浏览器指纹标识。
  284. catalog_url (str): 图录页绝对 URL(不含 query)。
  285. page (int): 页码,从 1 开始。
  286. Returns:
  287. Selector: 该页响应的 parsel 解析对象。
  288. """
  289. url = f"{catalog_url}?page={page}&limit={CATALOG_PAGE_SIZE}"
  290. return _get_selector(log, session, impersonate, url)
  291. def fetch_auction_lots(log, session, impersonate, auction):
  292. """抓取一个场次下的全部 lot 列表(翻页,去重 item_id)。
  293. Args:
  294. log: logger 对象。
  295. session (requests.Session): curl_cffi 会话对象。
  296. impersonate (str): 浏览器指纹标识。
  297. auction (dict): 场次 dict(含 event_id / auction_name / catalog_url)。
  298. Returns:
  299. list[dict]: 该场次全部 lot 列表(阶段一字段,不含详情)。
  300. """
  301. catalog_url = auction.get("catalog_url")
  302. if not catalog_url:
  303. log.warning(f"场次 {auction['event_id']} 无 catalog_url,跳过")
  304. return []
  305. log.info(f"开始抓取场次 {auction['event_id']}({auction.get('auction_name')}) 的 lot 列表")
  306. all_lots, seen = [], set()
  307. page = 1
  308. while True:
  309. sel = _fetch_lot_page(log, session, impersonate, catalog_url, page)
  310. lots = parse_lot_cards(sel, auction)
  311. if not lots:
  312. break # 空页 = 已翻过末页
  313. new = [x for x in lots if x["item_id"] not in seen]
  314. for x in new:
  315. seen.add(x["item_id"])
  316. all_lots.extend(new)
  317. log.info(f" 第 {page} 页 {len(lots)} 条(新增 {len(new)},累计 {len(all_lots)})")
  318. if not new:
  319. break # 本页全重复,停止
  320. page += 1
  321. log.info(f"场次 {auction['event_id']} 共抓 {len(all_lots)} 条 lot")
  322. return all_lots
  323. def parse_lot_detail(selector):
  324. """解析 lot 详情页,抽取标题、成交价、属性、多图。
  325. 详情页属性区(.item-attributes)字段数量不固定:不同拍品可能是 2~5 个,
  326. Athlete/Team 等常缺失。故先把所有 li 的 label→span 建成字典,再按需取字段,
  327. 缺失字段返回 None,保证解析健壮。
  328. Args:
  329. selector (Selector): 详情页 GET 响应的 parsel 解析对象。
  330. Returns:
  331. dict: {
  332. "title": 标题, "sold_for": 成交价(纯数字串,流拍为空),
  333. "sold_status": "sold"/"unsold", "bids": 出价数,
  334. "collection", "sport", "item_type", "athlete", "team": 属性(缺失为 None),
  335. "imgs": 多图 URL 逗号拼接
  336. }。
  337. """
  338. # 标题:og:title 最干净(页面首个 h1 是拍卖会名,非拍品标题)
  339. title = (selector.css('meta[property="og:title"]::attr(content)').get() or "").strip()
  340. if not title:
  341. # 兜底:取非拍卖会名的那个 h1
  342. h1s = [t.strip() for t in selector.css('h1 ::text, h1::text').getall() if t.strip()]
  343. title = h1s[1] if len(h1s) > 1 else (h1s[0] if h1s else "")
  344. # 成交价:.bidding-price 内首个 $金额;流拍则为空
  345. price_text = selector.css('.bidding-price').xpath('normalize-space(.)').get() or ""
  346. sold_for = _clean_price(price_text)
  347. sold_status = "sold" if sold_for else "unsold"
  348. # 出价数:页面 "[9 Bids]"
  349. body_text = selector.xpath('normalize-space(//body)').get() or ""
  350. bids_m = re.search(r'\[?\s*(\d+)\s*Bids?\s*\]?', body_text, re.I)
  351. bids = bids_m.group(1) if bids_m else ""
  352. # 属性:.item-attributes li -> {label: value},字段数量不固定,按需取
  353. attrs = {}
  354. for li in selector.css('.item-attributes li'):
  355. label = (li.css('label::text').get() or "").strip().rstrip(":")
  356. value = (li.css('span').xpath('normalize-space(.)').get() or "").strip()
  357. if label:
  358. attrs[label.lower()] = value
  359. # 多图:/item/{l|s}/... 统一归一化到 large 尺寸并去重(保持出现顺序)
  360. imgs, seen = [], set()
  361. for u in selector.re(IMG_ITEM_RE):
  362. large = u.replace("/item/s/", "/item/l/")
  363. if large not in seen:
  364. seen.add(large)
  365. imgs.append(large)
  366. return {
  367. "title": title,
  368. "sold_for": sold_for or None,
  369. "sold_status": sold_status,
  370. "bids": bids or None,
  371. "collection": attrs.get("collection") or None,
  372. "sport": attrs.get("sport") or None,
  373. "item_type": attrs.get("type") or None,
  374. "athlete": attrs.get("athlete") or None,
  375. "team": attrs.get("team") or None,
  376. "imgs": ",".join(imgs) if imgs else None,
  377. }
  378. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log,
  379. retry=retry_if_not_exception_type(LotNotFound))
  380. def fetch_lot_detail(log, session, impersonate, detail_url):
  381. """GET lot 详情页并解析。
  382. Args:
  383. log: logger 对象。
  384. session (requests.Session): curl_cffi 会话对象。
  385. impersonate (str): 浏览器指纹标识。
  386. detail_url (str): 详情页绝对 URL。
  387. Returns:
  388. dict: parse_lot_detail 的返回结果。
  389. """
  390. log.debug(f"获取详情 {detail_url}")
  391. sel = _get_selector(log, session, impersonate, detail_url)
  392. return parse_lot_detail(sel)
  393. def save_auction(log, sql_pool, auction):
  394. """把一场拍卖会写入 scp_auction_record(幂等,靠 event_id 唯一索引去重)。
  395. Args:
  396. log: logger 对象。
  397. sql_pool: MySQL 连接池;传 None 时不入库。
  398. auction (dict): parse_auction_list 产出的场次 dict。
  399. Returns:
  400. None: 仅入库,无返回值。
  401. """
  402. if sql_pool is None:
  403. return
  404. row = {k: auction.get(k) for k in (
  405. "event_id", "auction_name", "auction_type", "event_status",
  406. "start_time_raw", "end_time_raw", "start_time", "end_time",
  407. "auction_url", "catalog_url", "cover_img",
  408. )}
  409. sql_pool.insert_many(table="scp_auction_record", data_list=[row], ignore=True)
  410. def crawl_one_auction(log, sql_pool, session, impersonate, auction):
  411. """抓取单个场次的全部 lot 列表并入库(阶段一:只抓列表,不进详情页)。
  412. 两阶段设计:本函数只负责 lot 列表入库(state 默认 0),详情字段由后续
  413. update_details_for_pending 扫库 state != 1 的记录单独补抓。抓完把
  414. scp_auction_record.lots_state 置 1,标记该场列表已抓。
  415. Args:
  416. log: logger 对象。
  417. sql_pool: MySQL 连接池;传 None 时只返回数据,不入库。
  418. session (requests.Session): curl_cffi 会话对象。
  419. impersonate (str): 浏览器指纹标识。
  420. auction (dict): 场次 dict(含 event_id / auction_name / catalog_url)。
  421. Returns:
  422. list[dict]: 该场次全部 lot 列表数据(阶段一字段,不含详情)。
  423. """
  424. lots = fetch_auction_lots(log, session, impersonate, auction)
  425. if sql_pool is not None:
  426. if lots:
  427. sql_pool.insert_many(table="scp_lot_record", data_list=lots, ignore=True)
  428. # 标记该场 lot 列表已抓
  429. sql_pool.update_one_or_dict(
  430. table="scp_auction_record",
  431. data={"lots_state": 1},
  432. condition={"event_id": auction["event_id"]},
  433. )
  434. log.info(f"场次 {auction['event_id']}({auction.get('auction_name')}) 共抓 {len(lots)} 条 lot")
  435. return lots
  436. def get_details(log, detail_url, sql_pool, sql_id):
  437. """对单条已入库 lot 补抓详情(阶段二),写回 scp_lot_record。
  438. Args:
  439. log: logger 对象。
  440. detail_url (str): 详情页 URL。
  441. sql_pool: MySQL 连接池。
  442. sql_id: 数据库记录 id。
  443. Returns:
  444. None: 仅入库,无返回值。
  445. """
  446. log.info(f">>> 补抓详情 {detail_url}")
  447. impersonate = random.choice(client_identifier_list)
  448. with requests.Session() as session:
  449. detail = fetch_lot_detail(log, session, impersonate, detail_url)
  450. # 详情字段 + 标记已抓;成交价/状态/出价以详情页为准(覆盖列表阶段的值)
  451. data = {**detail, "state": 1}
  452. sql_pool.update_one_or_dict(
  453. table="scp_lot_record",
  454. data=data,
  455. condition={"id": sql_id},
  456. )
  457. def update_details_for_pending(log, sql_pool):
  458. """扫库里待抓 / 瞬时失败的 lot,逐条补抓详情。
  459. state 状态机:0 待抓,1 已抓成功,2 瞬时失败(可重试),3 页面不存在(404,永久跳过)。
  460. 只选 state in (0, 2)——即跳过已成功(1)与永久不存在(3),避免 404 记录每轮死循环重试。
  461. Args:
  462. log: logger 对象。
  463. sql_pool: MySQL 连接池。
  464. Returns:
  465. None: 仅入库,无返回值。
  466. """
  467. log.debug("Updating detail pages ...")
  468. rows = sql_pool.select_all(
  469. "select id, detail_url from scp_lot_record where state in (0, 2)"
  470. )
  471. for row in rows:
  472. sql_id, detail_url = row[0], row[1]
  473. try:
  474. get_details(log, detail_url, sql_pool, sql_id)
  475. except LotNotFound as e:
  476. # 页面已下架/不存在:置终态 3,不再进入补抓队列
  477. log.warning(f"lot 页面不存在,标记 state=3 不再重试: {detail_url} ({e})")
  478. sql_pool.update_one_or_dict(
  479. table="scp_lot_record",
  480. data={"state": 3},
  481. condition={"id": sql_id},
  482. )
  483. except Exception as e:
  484. # 瞬时错误(超时/5xx/代理抖动):置 2,下轮再试
  485. log.error(f"Error getting details for {detail_url}: {e}")
  486. sql_pool.update_one_or_dict(
  487. table="scp_lot_record",
  488. data={"state": 2},
  489. condition={"id": sql_id},
  490. )