rea_core.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/07/10
  5. """
  6. REA (collectrea.com) 公用模块:HTTP 配置、场次列表解析、场次内 lot 分页抓取、详情页解析。
  7. 被 rea_history.py / rea_spider.py 复用。
  8. 目标网站: https://collectrea.com/search
  9. 逻辑要点:
  10. 1. 站点为 Laravel + Livewire + Alpine.js,页面服务端渲染(SSR),无独立数据 API。
  11. curl_cffi 拿到的是「Alpine 未执行」的原始 HTML,解析选择器均按原始 HTML 校准。
  12. 2. /search 页三个 tab 面板按文档顺序排列(role="tabpanel"):
  13. 面板1 = RECENT AUCTIONS(最近 16 场,Swiper 封面卡)
  14. 面板2 = AUCTION ARCHIVE(历史归档 68 场,按年份分组文字链)
  15. 面板3 = PAST CATALOG(往期图录,<select>,不属于本爬虫范围)
  16. 两个 archives 面板的链接形如 /archives/{year}/{month}/,即一个「场次」。
  17. 3. 场次页 /archives/{year}/{month}/ 是 Livewire 搜索组件,预置过滤该场次。
  18. 翻页直接用查询参数:?page=N&pageSize=M(Livewire WithPagination 同步到 URL),
  19. 超出末页返回空列表 → 作为停止条件。列表卡片只有 lot_id/lot_number/slug/封面图。
  20. 4. lot 详情页 /archives/{year}/{Season}/{lotNumber}/{slug}:
  21. 标题在 <h1>(排除 "... - Item detail" 面包屑那条);
  22. Sold For / Year / Auction / Lot # / Category 在 <dl> 的 dt/dd;
  23. 多图为 rea-image-archive CDN 上的 -N.jpg。
  24. """
  25. import re
  26. import random
  27. from loguru import logger
  28. from parsel import Selector
  29. from curl_cffi import requests
  30. from curl_cffi.requests import BrowserType
  31. from urllib.parse import urljoin, quote
  32. from tenacity import retry, stop_after_attempt, wait_fixed
  33. # —— 站点常量 ——
  34. BASE_URL = "https://collectrea.com" # 站点根,用于拼接相对链接
  35. SEARCH_URL = "https://collectrea.com/search" # 场次列表页(含三个 tab)
  36. PAGE_SIZE = 200 # 场次内 lot 每页条数(Livewire pageSize 参数,实测最高支持 1000);逐分类抓取时用较大值减少请求数
  37. IMG_CDN_PREFIX = "https://rea-image-archive.nyc3.cdn.digitaloceanspaces.com" # 拍品图 CDN 前缀
  38. # 直接用库内置的所有浏览器指纹(应对 Cloudflare 的 TLS 指纹校验)
  39. client_identifier_list = [b.value for b in BrowserType]
  40. # 不写死 user-agent:curl_cffi 会按 impersonate 指纹自动带「与 JA3 匹配的 UA」,
  41. # 若在此写死 UA 会覆盖它,造成 TLS 指纹与 UA 头矛盾,反而更易被 Cloudflare 识别。
  42. # 这里只保留通用、不与指纹冲突的头。
  43. headers = {
  44. "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",
  45. "accept-language": "en-US,en;q=0.9",
  46. }
  47. def after_log(retry_state):
  48. """tenacity retry 回调,统一打印重试日志。
  49. Args:
  50. retry_state: tenacity.RetryCallState,retry 框架自动传入。
  51. """
  52. if retry_state.args and len(retry_state.args) > 0:
  53. log = retry_state.args[0]
  54. else:
  55. log = logger
  56. if retry_state.outcome.failed:
  57. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  58. else:
  59. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  60. @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
  61. def get_proxys(log):
  62. """获取代理字典,全部请求方法的 proxies 参数都走这里。
  63. 默认直连(返回 None)。站点走 Cloudflare,实测 curl_cffi + 浏览器指纹直连可用;
  64. 若后续被限流/封禁,在此按 {"http": ..., "https": ...} 填入代理即可,函数已带重试。
  65. Args:
  66. log: logger 对象。
  67. Returns:
  68. dict | None: requests 风格代理字典;直连时返回 None。
  69. Raises:
  70. Exception: 透传内部异常以便 tenacity 触发重试。
  71. """
  72. # 住宅ip池 北美
  73. http_proxy = "http://u1952150085001297:sJMHl4qc4bM0@proxy.123proxy.cn:36927"
  74. https_proxy = "http://u1952150085001297:sJMHl4qc4bM0@proxy.123proxy.cn:36927"
  75. # url = "https://ifconfig.me"
  76. try:
  77. proxySettings = {
  78. "http": http_proxy,
  79. "https": https_proxy,
  80. }
  81. return proxySettings
  82. except Exception as e:
  83. log.error(f"Error getting proxy: {e}")
  84. raise e
  85. @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
  86. def _get_selector(log, session, impersonate, url):
  87. """GET 一个页面并返回 parsel Selector(带重试)。
  88. Args:
  89. log: logger 对象。
  90. session (requests.Session): curl_cffi 会话对象。
  91. impersonate (str): 浏览器指纹标识。
  92. url (str): 目标绝对 URL。
  93. Returns:
  94. Selector: 响应 HTML 的 parsel 解析对象。
  95. Raises:
  96. Exception: HTTP 状态非 2xx 时透传异常以触发重试。
  97. """
  98. resp = session.get(url, headers=headers, impersonate=impersonate,
  99. # timeout=20)
  100. proxies=get_proxys(log), timeout=20)
  101. resp.raise_for_status()
  102. return Selector(resp.text)
  103. def _auction_from_href(href):
  104. """把场次相对链接解析成 {auction_key, auction_name, url}。
  105. Args:
  106. href (str): 形如 /archives/2025/spring/ 的相对链接。
  107. Returns:
  108. dict | None: {"auction_key": "2025/spring", "auction_name": "Spring 2025",
  109. "url": "https://collectrea.com/archives/2025/spring/"};非法链接返回 None。
  110. """
  111. parts = [p for p in href.strip("/").split("/") if p] # ['archives','2025','spring']
  112. if len(parts) < 3 or parts[0] != "archives":
  113. return None
  114. year, month = parts[1], parts[2]
  115. return {
  116. "auction_key": f"{year}/{month}", # 唯一标识(等价 wheatland 的 auction_id)
  117. "auction_name": f"{month.title()} {year}", # 展示名,如 "Spring 2025"
  118. "url": urljoin(BASE_URL, href if href.endswith("/") else href + "/"),
  119. }
  120. def parse_auction_list(selector, only_recent=False):
  121. """解析 /search 页的场次列表。
  122. 页面三个 role="tabpanel" 面板按文档顺序排列,含 archives 链接的两个面板依次为
  123. 「RECENT(最近)」「AUCTION ARCHIVE(历史)」。only_recent=True 时只取最近面板。
  124. Args:
  125. selector (Selector): /search 页 GET 响应的 parsel 解析对象。
  126. only_recent (bool, optional): 是否只取「最近」面板。Defaults to False(取全部)。
  127. Returns:
  128. list[dict]: 每个元素为 _auction_from_href 返回的场次 dict,已按 auction_key 去重。
  129. Raises:
  130. ValueError: 页面找不到任何含 archives 链接的 tab 面板时抛出(说明响应异常)。
  131. """
  132. # 只取「叶子」tabpanel:原始 HTML 最外层有个包裹用的 <section role="tabpanel">,
  133. # 它嵌套了全部内层面板;用 not(.//*[@role="tabpanel"]) 排除外壳,得到真实的 3 个面板。
  134. panels = selector.xpath('//*[@role="tabpanel" and not(.//*[@role="tabpanel"])]')
  135. # 收集「含 archives 链接」的面板,保持文档顺序:[0]=最近,[1]=历史
  136. link_panels = []
  137. for p in panels:
  138. hrefs = [h for h in p.css('a::attr(href)').getall() if "/archives/" in h]
  139. if hrefs:
  140. link_panels.append(hrefs)
  141. if not link_panels:
  142. raise ValueError("找不到任何含 archives 链接的 tab 面板,页面结构可能已变更")
  143. target_panels = link_panels[:1] if only_recent else link_panels
  144. result, seen = [], set()
  145. for hrefs in target_panels:
  146. for href in hrefs:
  147. auc = _auction_from_href(href)
  148. if auc and auc["auction_key"] not in seen:
  149. seen.add(auc["auction_key"])
  150. result.append(auc)
  151. return result
  152. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
  153. def get_auction_list(log, session, impersonate, only_recent=False):
  154. """GET /search 首页,解析出场次列表。
  155. Args:
  156. log: logger 对象。
  157. session (requests.Session): curl_cffi 会话对象。
  158. impersonate (str): 浏览器指纹标识。
  159. only_recent (bool, optional): True 仅取「最近」面板(增量用);False 取全部(全量用)。
  160. Defaults to False。
  161. Returns:
  162. list[dict]: [{"auction_key": ..., "auction_name": ..., "url": ...}, ...]。
  163. """
  164. scope = "最近" if only_recent else "全部"
  165. log.info(f"获取{scope}场次列表")
  166. sel = _get_selector(log, session, impersonate, SEARCH_URL)
  167. auctions = parse_auction_list(sel, only_recent=only_recent)
  168. log.info(f"共解析到 {len(auctions)} 个场次:{[a['auction_key'] for a in auctions[:5]]}...")
  169. return auctions
  170. def parse_lot_cards(selector, auction):
  171. """解析场次页单页的 lot 结果卡片。
  172. 列表卡片信息极简:只有内部 lot_id(数字 wire:key)、详情链接(含 lot_number 与 slug)、
  173. 封面图。标题/价格/明细均在详情页,由第二阶段补抓。
  174. Args:
  175. selector (Selector): 场次页某一页 GET 响应的 parsel 解析对象。
  176. auction (dict): 当前场次 dict(含 auction_key / auction_name),回填到每条 lot。
  177. Returns:
  178. list[dict]: 每条 lot 一个 dict,字段见 crawl_one_auction 说明;无卡片返回空列表。
  179. """
  180. # 只有 lot 卡片是「纯数字」wire:key;筛选器控件是 wire:key="item-..." / "Category" 等非数字,天然区分
  181. lot_ids = selector.re(r'wire:key="(\d+)"')
  182. # 详情链接:/archives/{year}/{Season}/{lotNumber}/{slug}
  183. cards = []
  184. for a in selector.css('a[href*="/archives/"]'):
  185. href = a.attrib.get("href", "")
  186. parts = [p for p in href.strip("/").split("/") if p]
  187. # ['archives', year, Season, lotNumber, slug...]
  188. if len(parts) < 5 or parts[0] != "archives" or not parts[3].isdigit():
  189. continue
  190. cards.append({
  191. "lot_number": parts[3], # lot 编号
  192. "slug": "/".join(parts[4:]), # slug(可能含斜杠,保险起见 join)
  193. "detail_url": urljoin(BASE_URL, href), # 详情绝对 URL
  194. })
  195. # 数字 wire:key 与详情卡片按文档顺序 1:1 对应,逐条配对回填 lot_id
  196. rows = []
  197. for i, card in enumerate(cards):
  198. rows.append({
  199. "auction_key": auction["auction_key"], # 场次唯一标识
  200. "auction_name": auction["auction_name"], # 场次展示名
  201. "lot_id": lot_ids[i] if i < len(lot_ids) else "", # 站内 lot 唯一 id(wire:key)
  202. **card,
  203. })
  204. return rows
  205. @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
  206. def _fetch_lot_page(log, session, impersonate, auction_url, page, extra_query=""):
  207. """抓取场次页的某一页 lot 卡片。
  208. Args:
  209. log: logger 对象。
  210. session (requests.Session): curl_cffi 会话对象。
  211. impersonate (str): 浏览器指纹标识。
  212. auction_url (str): 场次页绝对 URL,如 https://collectrea.com/archives/2025/spring/。
  213. page (int): 页码,从 1 开始。
  214. extra_query (str, optional): 追加的查询串(不带前导 &),
  215. 如 "Category[]=Football...&sortBy=Price:asc"。Defaults to ""。
  216. Returns:
  217. Selector: 该页响应的 parsel 解析对象。
  218. """
  219. url = f"{auction_url}?page={page}&pageSize={PAGE_SIZE}"
  220. if extra_query:
  221. url += f"&{extra_query}"
  222. return _get_selector(log, session, impersonate, url)
  223. def parse_category_facet(selector):
  224. """从场次页左侧筛选栏解析 Category 分类列表及各分类的数量。
  225. 用于绕过搜索结果 1000 条硬上限:逐个分类过滤抓取。
  226. Args:
  227. selector (Selector): 场次页任意一页的 parsel 解析对象(筛选栏恒渲染)。
  228. Returns:
  229. list[dict]: 每个元素为 {"value": 分类原值, "count": 该分类数量(int或None)};
  230. 无分类栏时返回空列表。
  231. """
  232. cats = []
  233. for inp in selector.css('input[name="Category[]"]'):
  234. value = (inp.attrib.get("value") or "").strip() # parsel 已把 &amp; 还原为 &
  235. if not value:
  236. continue
  237. # 紧邻的 <label> 文本形如 "Football Cards and Memorabilia (152)"
  238. label = inp.xpath('./following-sibling::label[1]')
  239. text = label.xpath('normalize-space(.)').get() if label else ""
  240. m = re.search(r'\((\d+)\)\s*$', text or "")
  241. cats.append({"value": value, "count": int(m.group(1)) if m else None})
  242. return cats
  243. def _collect_pages(log, session, impersonate, auction, extra_query, seen, all_lots):
  244. """按给定过滤条件从 page=1 翻到空页,去重后追加到 all_lots。
  245. Args:
  246. log: logger 对象。
  247. session (requests.Session): curl_cffi 会话对象。
  248. impersonate (str): 浏览器指纹标识。
  249. auction (dict): 场次 dict。
  250. extra_query (str): 追加查询串(不带前导 &),空串表示整场不过滤。
  251. seen (set): 跨分类共享的 detail_url 去重集合(原地更新)。
  252. all_lots (list): 累积结果列表(原地追加)。
  253. Returns:
  254. int: 本次新增(去重后)的 lot 条数。
  255. """
  256. page = 1
  257. added = 0
  258. while True:
  259. sel = _fetch_lot_page(log, session, impersonate, auction["url"], page, extra_query)
  260. lots = parse_lot_cards(sel, auction)
  261. if not lots:
  262. break # 空页 = 已翻过末页(含 1000 上限触顶)
  263. new = [x for x in lots if x["detail_url"] not in seen]
  264. for x in new:
  265. seen.add(x["detail_url"])
  266. all_lots.extend(new)
  267. added += len(new)
  268. # 本页全是重复(分页异常/越界回卷兜底),停止
  269. if not new:
  270. break
  271. page += 1
  272. return added
  273. def fetch_auction_lots(log, session, impersonate, auction):
  274. """抓取一个场次下的全部 lot 列表(绕过 1000 条搜索上限)。
  275. 站点搜索结果有 1000 条硬上限(任何排序下深翻页最多取到前 1000)。为取全,
  276. 改为逐个 Category 分类过滤抓取(各分类天然只属该场次);某分类 >1000 时再分别
  277. 用价格升序 + 降序各取 ≤1000 并去重,可覆盖到 2000。全程以 detail_url 去重。
  278. 若某分类 >2000,升+降序仍无法全覆盖,会打 warning 提示漏采条数(当前站点单分类
  279. 最大约 1100,暂无此情况)。若页面无分类栏,则退回整场翻页(受 1000 上限)。
  280. Args:
  281. log: logger 对象。
  282. session (requests.Session): curl_cffi 会话对象。
  283. impersonate (str): 浏览器指纹标识。
  284. auction (dict): 场次 dict(含 auction_key / auction_name / url)。
  285. Returns:
  286. list[dict]: 该场次全部 lot 列表(阶段一字段,不含详情)。
  287. """
  288. log.info(f"开始抓取场次 {auction['auction_key']} 的 lot 列表")
  289. all_lots, seen = [], set()
  290. # 先取首页拿分类 facet(同时这一页也含 lot,但按分类重抓更全,故此处只用来取 facet)
  291. first_sel = _fetch_lot_page(log, session, impersonate, auction["url"], 1)
  292. cats = parse_category_facet(first_sel)
  293. if not cats:
  294. # 兜底:无分类栏,整场直接翻页(最多 1000)
  295. log.warning(f"{auction['auction_key']} 未解析到 Category 分类栏,退回整场翻页(受 1000 上限)")
  296. _collect_pages(log, session, impersonate, auction, "", seen, all_lots)
  297. log.info(f"场次 {auction['auction_key']} 共抓 {len(all_lots)} 条 lot")
  298. return all_lots
  299. log.info(f"{auction['auction_key']} 共 {len(cats)} 个分类,逐类抓取以绕过 1000 上限")
  300. for c in cats:
  301. value, count = c["value"], c["count"]
  302. if count == 0:
  303. continue
  304. cat_q = f"Category[]={quote(value, safe='')}"
  305. if count is not None and count > 1000:
  306. if count > 2000:
  307. log.warning(f" 分类[{value}] {count} 条 >2000,升+降序仍会漏约 {count - 2000} 条")
  308. for sort in ("Price:asc", "Price:desc"):
  309. n = _collect_pages(log, session, impersonate, auction,
  310. f"{cat_q}&sortBy={quote(sort, safe='')}", seen, all_lots)
  311. log.info(f" 分类[{value}] {sort} 新增 {n}(累计 {len(all_lots)})")
  312. else:
  313. n = _collect_pages(log, session, impersonate, auction, cat_q, seen, all_lots)
  314. log.info(f" 分类[{value}](facet={count}) 新增 {n}(累计 {len(all_lots)})")
  315. log.info(f"场次 {auction['auction_key']} 共抓 {len(all_lots)} 条 lot")
  316. return all_lots
  317. def _clean_price(text):
  318. """把 "Sold For" 文本清洗成纯数字字符串。
  319. Args:
  320. text (str): 原始价格文本,如 "$336,000"。
  321. Returns:
  322. str: 去掉 $ 和千分位逗号后的数字串,如 "336000";空值返回空串。
  323. """
  324. if not text:
  325. return ""
  326. return text.replace("$", "").replace(",", "").strip()
  327. def parse_lot_detail(selector):
  328. """解析 lot 详情页,抽取标题、5 个明细字段、多图。
  329. Args:
  330. selector (Selector): 详情页 GET 响应的 parsel 解析对象。
  331. Returns:
  332. dict: {
  333. "title": 标题, "sold_for": 成交价(纯数字串), "year": 年份,
  334. "auction": 场次名(如 "2025 Spring"), "lot_no": Lot 编号,
  335. "category": 分类, "imgs": 多图逗号拼接串
  336. }。
  337. """
  338. # 标题:页面有两个 h1,其一为 "xxx - Item detail" 面包屑,取另一条真实标题
  339. h1s = [t.strip() for t in selector.css('h1::text').getall() if t.strip()]
  340. real = [h for h in h1s if "Item detail" not in h]
  341. title = real[-1] if real else (h1s[-1] if h1s else "")
  342. # dt/dd 明细字段
  343. fields = {}
  344. for dt in selector.css('dt'):
  345. label = (dt.xpath('normalize-space(.)').get() or "").rstrip(":").strip()
  346. dd = dt.xpath('following-sibling::dd[1]')
  347. value = dd.xpath('normalize-space(.)').get() if dd else ""
  348. if label:
  349. fields[label] = value or ""
  350. # 多图:CDN 上的拍品图,去重并保持出现顺序
  351. imgs, seen = [], set()
  352. for u in selector.re(rf'{IMG_CDN_PREFIX}/[^"\'\s]+'):
  353. if u not in seen:
  354. seen.add(u)
  355. imgs.append(u)
  356. return {
  357. "title": title,
  358. "sold_for": _clean_price(fields.get("Sold For", "")),
  359. "year": fields.get("Year", ""),
  360. "auction": fields.get("Auction", ""),
  361. "lot_no": fields.get("Lot #", ""),
  362. "category": fields.get("Category", ""),
  363. "imgs": ",".join(imgs),
  364. }
  365. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
  366. def fetch_lot_detail(log, session, impersonate, detail_url):
  367. """GET lot 详情页并解析。
  368. Args:
  369. log: logger 对象。
  370. session (requests.Session): curl_cffi 会话对象。
  371. impersonate (str): 浏览器指纹标识。
  372. detail_url (str): 详情页绝对 URL。
  373. Returns:
  374. dict: parse_lot_detail 的返回结果。
  375. """
  376. log.debug(f"获取详情 {detail_url}")
  377. sel = _get_selector(log, session, impersonate, detail_url)
  378. return parse_lot_detail(sel)
  379. def crawl_one_auction(log, sql_pool, session, impersonate, auction):
  380. """抓取单个场次的全部 lot 列表(阶段一:只抓列表,不进详情页)。
  381. 与 wheatland 一致的两阶段设计:本函数只负责列表入库(state 默认 0),
  382. 详情字段由后续 update_details_for_pending 扫库 state != 1 的记录单独补抓。
  383. 入库字段(表 rea_record):
  384. auction_key, auction_name, lot_id, lot_number, slug, detail_url,
  385. (以下由阶段二补写)title, sold_for, year, auction, lot_no, category, imgs, state。
  386. Args:
  387. log: logger 对象。
  388. sql_pool: MySQL 连接池;传 None 时只返回数据,不入库。
  389. session (requests.Session): curl_cffi 会话对象。
  390. impersonate (str): 浏览器指纹标识。
  391. auction (dict): 场次 dict(含 auction_key / auction_name / url)。
  392. Returns:
  393. list[dict]: 该场次全部 lot 列表数据(阶段一字段,不含详情)。
  394. """
  395. lots = fetch_auction_lots(log, session, impersonate, auction)
  396. # 入库(state 默认 0 待补详情;detail_url 建唯一索引,ignore 去重)
  397. if sql_pool is not None and lots:
  398. sql_pool.insert_many(table="rea_record", data_list=lots, ignore=True)
  399. log.info(f"场次 {auction['auction_key']}({auction['auction_name']}) 共抓 {len(lots)} 条 lot")
  400. return lots
  401. def get_details(log, detail_url, sql_pool, sql_id):
  402. """对单条已入库记录补抓详情(阶段二),写回 rea_record。
  403. Args:
  404. log: logger 对象。
  405. detail_url (str): 详情页 URL。
  406. sql_pool: MySQL 连接池。
  407. sql_id: 数据库记录 id。
  408. """
  409. log.info(f">>> 补抓详情 {detail_url}")
  410. impersonate = random.choice(client_identifier_list)
  411. with requests.Session() as session:
  412. detail = fetch_lot_detail(log, session, impersonate, detail_url)
  413. data = {**detail, "state": 1} # 详情 7 字段 + 标记已抓
  414. sql_pool.update_one_or_dict(
  415. table="rea_record",
  416. data=data,
  417. condition={"id": sql_id},
  418. )
  419. def update_details_for_pending(log, sql_pool):
  420. """扫库里 state != 1 的记录,逐条补抓详情。
  421. Args:
  422. log: logger 对象。
  423. sql_pool: MySQL 连接池。
  424. """
  425. log.debug("Updating detail pages ...")
  426. rows = sql_pool.select_all(
  427. "select id, detail_url from rea_record where state != 1"
  428. )
  429. for row in rows:
  430. sql_id, detail_url = row[0], row[1]
  431. try:
  432. get_details(log, detail_url, sql_pool, sql_id)
  433. except Exception as e:
  434. log.error(f"Error getting details for {detail_url}: {e}")
  435. sql_pool.update_one_or_dict(
  436. table="rea_record",
  437. data={"state": 2},
  438. condition={"id": sql_id},
  439. )