snk_jp_spider.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/07/22
  5. """SNKRDUNK 日本站 爬虫(任务1 列表页 + 任务2 详情页/交易记录)。
  6. 任务1(列表页):抓取搜索列表页中「带 SOLD 字样」的二手出品,存入表1 snkrdunk_jp_used_item。
  7. 任务2(详情页):抓取单个详情页完整字段存入表1,并抓其交易记录(买卖历史)存入表2 snkrdunk_jp_trading_history。
  8. 接口说明(均为同源 GET、无需登录 / 无加密):
  9. - 列表页: GET /search?...(Next.js SSR 页面,商品数据内嵌在 HTML 的 self.__next_f flight 里,非独立 JSON 接口)
  10. - 详情: GET /v1/apparels/{apparel_id}/used/{used_item_id}
  11. - 交易记录:GET /v3/products/{product_id}/trading-history?range=all
  12. 返回 trades[],每条 {price, soldAt, title(品相), label(枚数)}。
  13. 关于「给交易记录配图」(需求3):
  14. 交易记录接口是平台聚合/匿名化数据,每条只有「价格/时间/品相/枚数」,本身不含图片和出品ID——
  15. 这是设计使然。若日后需要配图,可复用现有管线 match_data 的思路:用 (product_id + 品相 + 价格)
  16. 关联表1,取表1 的 primary_image_url 回填到表2(届时给表2 加一个 image_url 字段即可)。
  17. """
  18. import re
  19. import sys
  20. import json
  21. import time
  22. import random
  23. import requests
  24. import schedule
  25. import user_agent
  26. from loguru import logger
  27. from mysql_pool import MySQLConnectionPool
  28. from datetime import datetime, timezone, timedelta
  29. from tenacity import retry, stop_after_attempt, wait_fixed
  30. logger.remove()
  31. logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00",
  32. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  33. level="DEBUG", retention="7 day")
  34. # 北京时区:UTC+8(与现有管线 trading_at 保持一致)
  35. BEIJING_TZ = timezone(timedelta(hours=8))
  36. # 表名
  37. TABLE_USED_ITEM = "snkrdunk_jp_used_item" # 表1:二手出品/商品信息
  38. TABLE_TRADING_HISTORY = "snkrdunk_jp_trading_history" # 表2:交易记录
  39. # 列表页 URL 模板:{page} 处替换页码,其余筛选条件与给定的搜索 URL 完全一致
  40. LIST_URL_TEMPLATE = (
  41. "https://snkrdunk.com/search?"
  42. "keywords=Pokemon+Card+Game+%E3%83%88%E3%83%AC%E3%82%AB+%28%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%82%AB%E3%83%BC%E3%83%89%29"
  43. "&searchCategoryIds=6%2F33"
  44. "&brandIds=pokemon"
  45. "&sort=launch"
  46. "&itemConditions=like_new,minor_scratches,moderate_scratches,significant_damage,"
  47. "psa_10,psa_9,psa_8_below,bgs_10_black,bgs_10_gold,bgs_9_5,bgs_9_below,"
  48. "ars_10_plus,ars_10,ars_9,ars_8_below"
  49. "&page={page}"
  50. )
  51. # 翻页安全上限,防止解析异常导致死循环
  52. MAX_PAGE = 500
  53. headers = {
  54. "accept-language": "ja",
  55. "user-agent": user_agent.generate_user_agent(),
  56. }
  57. # ============================== 公共工具 ==============================
  58. def after_log(retry_state):
  59. """tenacity retry 回调,统一打印重试日志。
  60. :param retry_state: tenacity.RetryCallState,retry 框架自动传入。
  61. """
  62. if retry_state.args and len(retry_state.args) > 0:
  63. log = retry_state.args[0]
  64. else:
  65. log = logger
  66. if retry_state.outcome.failed:
  67. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  68. else:
  69. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  70. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  71. def get_proxys(log):
  72. """获取代理配置(快代理隧道)。
  73. :param log: 日志对象。
  74. :return: 代理字典 {"http": ..., "https": ...}。
  75. """
  76. tunnel = "x371.kdltps.com:15818"
  77. kdl_username = "t13753103189895"
  78. kdl_password = "o0yefv6z"
  79. try:
  80. proxies = {
  81. "http": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password, "proxy": tunnel},
  82. "https": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password, "proxy": tunnel}
  83. }
  84. return proxies
  85. except Exception as e:
  86. log.error(f"Error getting proxy: {e}")
  87. raise e
  88. def utc_to_beijing(utc_str):
  89. """将带 Z 的 UTC 时间字符串转换成北京时间字符串。
  90. 接口返回的 soldAt / releasedAt 形如 "2026-07-21T23:16:09Z",结尾 Z 表示 UTC,
  91. 北京时间为 UTC+8。
  92. :param utc_str: UTC 时间字符串,格式 "%Y-%m-%dT%H:%M:%SZ"。
  93. :return: 北京时间字符串,格式 "%Y-%m-%d %H:%M:%S";入参为空或解析失败返回 None。
  94. """
  95. if not utc_str:
  96. return None
  97. try:
  98. dt_utc = datetime.strptime(utc_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
  99. return dt_utc.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
  100. except Exception:
  101. return None
  102. def upsert_used_items(log, sql_pool, data_list):
  103. """批量 upsert 到表1(按 apparel_id + used_item_id 唯一键去重更新)。
  104. 使用 INSERT ... ON DUPLICATE KEY UPDATE,且更新时用 COALESCE(VALUES(col), col),
  105. 保证列表页带来的空字段不会覆盖掉详情页已写入的更完整数据(反之亦然)。
  106. 同一次调用的所有 dict 必须拥有相同的 key。
  107. :param log: 日志对象。
  108. :param sql_pool: 数据库连接池 MySQLConnectionPool。
  109. :param data_list: list[dict],表1 入库字典列表。
  110. :return: int,影响行数;无数据返回 0。
  111. """
  112. if not data_list:
  113. return 0
  114. cols = list(data_list[0].keys())
  115. key_cols = {"apparel_id", "used_item_id"} # 唯一键列不参与 UPDATE
  116. col_sql = ", ".join(f"`{c}`" for c in cols)
  117. placeholder = "(" + ", ".join(["%s"] * len(cols)) + ")" # 单行占位,executemany 自动合并多行
  118. update_sql = ", ".join(
  119. f"`{c}`=COALESCE(VALUES(`{c}`), `{c}`)" for c in cols if c not in key_cols
  120. )
  121. sql = (
  122. f"INSERT INTO `{TABLE_USED_ITEM}` ({col_sql}) VALUES {placeholder} "
  123. f"ON DUPLICATE KEY UPDATE {update_sql}"
  124. )
  125. # 用封装的 insert_many(query + args_list 模式,内部 executemany,
  126. # pymysql 会识别并保留 ON DUPLICATE KEY UPDATE 尾巴、自动合并多行)
  127. args_list = [tuple(d.get(c) for c in cols) for d in data_list]
  128. total = sql_pool.insert_many(query=sql, args_list=args_list)
  129. log.info(f"upsert 表1 完成,影响 {total} 行")
  130. return total
  131. # ============================== 任务1:列表页 ==============================
  132. def _extract_flight_cards(html):
  133. """从列表页 HTML 的 flight 数据中抽取所有商品卡片对象。
  134. Next.js 把服务端渲染的数据分片写在若干 self.__next_f.push([1,"..."]) 里,
  135. 这里先把这些分片的字符串拼接还原成完整 flight 文本,再用「花括号配平」的方式
  136. 逐个提取以 {"displayCardPattern" 开头的卡片 JSON 对象。
  137. :param html: 列表页 HTML 源码。
  138. :return: list[dict],每个元素为一张卡片对象;解析失败返回空列表。
  139. """
  140. # 1) 抽取并拼接所有 flight 分片字符串
  141. parts = re.findall(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)', html)
  142. flight = ""
  143. for p in parts:
  144. try:
  145. # 分片本身是 JS 字符串字面量,用 JSON 解码还原转义
  146. flight += json.loads('"' + p + '"')
  147. except Exception:
  148. continue
  149. # 2) 花括号配平,逐个抠出卡片对象
  150. cards = []
  151. marker = '{"displayCardPattern"'
  152. start = flight.find(marker)
  153. while start != -1:
  154. depth = 0
  155. in_str = False
  156. escape = False
  157. end = -1
  158. for i in range(start, len(flight)):
  159. ch = flight[i]
  160. if escape:
  161. escape = False
  162. continue
  163. if ch == '\\':
  164. escape = True
  165. continue
  166. if ch == '"':
  167. in_str = not in_str
  168. continue
  169. if in_str:
  170. continue
  171. if ch == '{':
  172. depth += 1
  173. elif ch == '}':
  174. depth -= 1
  175. if depth == 0:
  176. end = i
  177. break
  178. if end == -1:
  179. break
  180. obj_str = flight[start:end + 1]
  181. try:
  182. cards.append(json.loads(obj_str))
  183. except Exception:
  184. pass
  185. start = flight.find(marker, end + 1)
  186. return cards
  187. def _parse_link_ids(link):
  188. """从卡片 link 中解析 apparel_id 与 used_item_id。
  189. :param link: 详情页链接,如 https://snkrdunk.com/apparels/835482/used/47705323。
  190. :return: tuple(apparel_id:int|None, used_item_id:int|None);解析失败返回 (None, None)。
  191. """
  192. m = re.search(r'/apparels/(\d+)/used/(\d+)', link or "")
  193. if not m:
  194. return None, None
  195. return int(m.group(1)), int(m.group(2))
  196. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  197. def get_single_page(log, page):
  198. """获取并解析单页列表数据,仅返回「已售」卡片。
  199. :param log: 日志对象。
  200. :param page: 页码。
  201. :return: tuple(sold_list:list[dict], total_cards:int)。
  202. sold_list 为已售卡片解析后的入库字典列表;total_cards 为该页卡片总数
  203. (用于判断是否已翻到底)。
  204. """
  205. log.info(f"获取列表第 {page} 页数据....................................................")
  206. url = LIST_URL_TEMPLATE.format(page=page)
  207. response = requests.get(url, headers={**headers, "accept": "text/html"},
  208. proxies=get_proxys(log), timeout=22)
  209. response.raise_for_status()
  210. cards = _extract_flight_cards(response.text)
  211. sold_list = []
  212. for card in cards:
  213. pattern = card.get("displayCardPattern", "")
  214. # 只保留带 SOLD 字样(displayCardPattern 含 SoldOut)的已售卡片
  215. if "SoldOut" not in pattern:
  216. continue
  217. link = card.get("link", "")
  218. apparel_id, used_item_id = _parse_link_ids(link)
  219. if not apparel_id or not used_item_id:
  220. log.warning(f"卡片 link 无法解析 id,跳过:{link}")
  221. continue
  222. # 去掉图片尺寸参数,保留原图地址,如 ...jpeg?size=m -> ...jpeg
  223. image_url = (card.get("imageUrl") or "").split("?")[0] or None
  224. sold_list.append({
  225. "apparel_id": apparel_id,
  226. "used_item_id": used_item_id,
  227. "name_ja": card.get("title"),
  228. "condition_grade": card.get("condition"),
  229. "price": card.get("salePrice"),
  230. "is_sold": 1,
  231. "primary_image_url": image_url,
  232. "detail_url": link,
  233. "data_source": "list",
  234. })
  235. log.info(f"第 {page} 页共 {len(cards)} 张卡片,其中已售 {len(sold_list)} 张")
  236. return sold_list, len(cards)
  237. def get_list_data(log, sql_pool):
  238. """翻页抓取整个列表,将所有已售卡片写入表1(自动翻到底)。
  239. :param log: 日志对象。
  240. :param sql_pool: 数据库连接池 MySQLConnectionPool。
  241. """
  242. page = 1
  243. total_sold = 0
  244. while page <= MAX_PAGE:
  245. try:
  246. sold_list, total_cards = get_single_page(log, page)
  247. except Exception as e:
  248. log.error(f"获取列表第 {page} 页出错:{e}")
  249. break
  250. # 该页无任何卡片,说明已翻到底
  251. if total_cards == 0:
  252. log.info(f"列表第 {page} 页无数据,翻页结束")
  253. break
  254. try:
  255. upsert_used_items(log, sql_pool, sold_list)
  256. total_sold += len(sold_list)
  257. except Exception as e:
  258. log.error(f"第 {page} 页数据入库出错:{e}")
  259. page += 1
  260. time.sleep(random.uniform(0.3, 1.2))
  261. log.info(f"列表抓取结束,累计入库已售卡片 {total_sold} 张")
  262. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  263. def list_main(log):
  264. """列表页爬虫主函数(任务1 入口)。
  265. :param log: 日志对象。
  266. """
  267. log.info(f'开始运行 {sys._getframe().f_code.co_name} 爬虫任务....................................................')
  268. sql_pool = MySQLConnectionPool(log=log)
  269. if not sql_pool.check_pool_health():
  270. log.error("数据库连接池异常")
  271. raise RuntimeError("数据库连接池异常")
  272. try:
  273. get_list_data(log, sql_pool)
  274. except Exception as e:
  275. log.error(f'{sys._getframe().f_code.co_name} error: {e}')
  276. finally:
  277. log.info(f'爬虫程序 {sys._getframe().f_code.co_name} 运行结束,等待下一轮的采集任务............')
  278. # ============================== 任务2:详情页 + 交易记录 ==============================
  279. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  280. def get_detail(log, apparel_id, used_item_id):
  281. """抓取单个二手出品的详情 JSON。
  282. :param log: 日志对象。
  283. :param apparel_id: 商品(款式)ID,URL 中 /apparels/{apparel_id}。
  284. :param used_item_id: 二手出品ID,URL 中 /used/{used_item_id}。
  285. :return: dict,详情接口返回的完整 JSON;请求失败抛出异常。
  286. """
  287. log.info(f"获取详情数据 -> apparel={apparel_id}, used={used_item_id} .............................")
  288. url = f"https://snkrdunk.com/v1/apparels/{apparel_id}/used/{used_item_id}"
  289. response = requests.get(url, headers={**headers, "accept": "application/json"},
  290. proxies=get_proxys(log), timeout=22)
  291. response.raise_for_status()
  292. return response.json()
  293. def parse_detail(log, detail_json, apparel_id, used_item_id):
  294. """把详情 JSON 解析成表1 入库字典。
  295. :param log: 日志对象。
  296. :param detail_json: get_detail 返回的完整 JSON。
  297. :param apparel_id: 商品(款式)ID。
  298. :param used_item_id: 二手出品ID。
  299. :return: tuple(data_dict:dict, product_id:int|None)。
  300. data_dict 为表1 入库字典;product_id 供后续抓交易记录使用。
  301. """
  302. item = detail_json.get("apparelUsedItem", {}) or {}
  303. apparel = item.get("apparel", {}) or {}
  304. # 品牌(取第一个)
  305. brands = apparel.get("brands") or []
  306. brand_id = brands[0].get("id") if brands else None
  307. brand_name = brands[0].get("localizedName") if brands else None
  308. # 类目(多个时用 / 连接,取日文名)
  309. categories = apparel.get("categories") or []
  310. category_name = " / ".join(c.get("localizedName", "") for c in categories) if categories else None
  311. # 主图去掉尺寸参数
  312. primary_image_url = ((item.get("primaryPhoto") or {}).get("imageUrl") or "").split("?")[0] or None
  313. # 全部图片 URL,多个用英文逗号拼接
  314. image_urls = item.get("imageUrls") or []
  315. image_urls_str = ",".join(image_urls) if image_urls else None
  316. # 是否已售:优先用 isDisplaySold,其次用状态码 4=取引完了
  317. is_sold = 1 if item.get("isDisplaySold") or item.get("status") == 4 else 0
  318. product_id = apparel.get("productId") or None
  319. data_dict = {
  320. "apparel_id": apparel_id,
  321. "used_item_id": used_item_id,
  322. "product_id": product_id,
  323. "product_number": apparel.get("productNumber") or None,
  324. "name_ja": apparel.get("localizedName") or None,
  325. "name_en": apparel.get("name") or None,
  326. "brand_id": brand_id,
  327. "brand_name": brand_name,
  328. "category_name": category_name,
  329. "quantity_text": (item.get("size") or {}).get("localizedName") or None,
  330. "condition_grade": item.get("displayShortConditionTitle") or None,
  331. "condition_desc": item.get("displayWearCount") or None,
  332. "price": item.get("price"),
  333. "sale_status": item.get("status"),
  334. "sale_status_text": item.get("statusText") or None,
  335. "is_sold": is_sold,
  336. "primary_image_url": primary_image_url,
  337. "image_urls": image_urls_str,
  338. "detail_url": f"https://snkrdunk.com/apparels/{apparel_id}/used/{used_item_id}",
  339. "released_at": utc_to_beijing(apparel.get("releasedAt")),
  340. "data_source": "detail",
  341. }
  342. return data_dict, product_id
  343. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  344. def get_trading_history(log, product_id):
  345. """抓取某商品的完整交易记录(买卖历史)。
  346. :param log: 日志对象。
  347. :param product_id: 商品目录ID(catalog),来自详情 apparel.productId。
  348. :return: list[dict],交易记录原始 trades 列表;请求失败抛出异常。
  349. """
  350. log.info(f"获取交易记录 -> product_id={product_id} .............................")
  351. url = f"https://snkrdunk.com/v3/products/{product_id}/trading-history"
  352. params = {"range": "all"}
  353. response = requests.get(url, headers={**headers, "accept": "application/json"},
  354. params=params, proxies=get_proxys(log), timeout=22)
  355. response.raise_for_status()
  356. return response.json().get("trades", []) or []
  357. def save_trading_history(log, sql_pool, product_id, apparel_id, product_number, trades):
  358. """把交易记录写入表2,采用「联合唯一索引 + INSERT IGNORE」累积去重。
  359. 背景:
  360. - 接口硬性截断到最近 20 条,不能删旧再插(会丢历史),必须**跨天累积**。
  361. - 表2 有联合唯一键 (product_id, sold_at, sold_price, condition_grade, quantity_text,
  362. trade_index_in_second),配合 INSERT IGNORE 保证跨天抓到的重复记录自动跳过、
  363. 新记录正常入库。
  364. trade_index_in_second 计算:
  365. 同一个 (sold_at + 价格 + 品相 + 枚数) 签名可能对应多笔真实成交(如 6 笔同秒
  366. ¥1000 的 B 品 1枚),按接口返回顺序编号 0/1/2/... 作为位序,才能把它们全部保留。
  367. 前提是接口对 trades 的排序稳定(同 soldAt 内相对顺序不变),实测方案。
  368. :param log: 日志对象。
  369. :param sql_pool: 数据库连接池 MySQLConnectionPool。
  370. :param product_id: 商品目录ID。
  371. :param apparel_id: 关联的商品(款式)ID,冗余存储便于关联表1。
  372. :param product_number: 品番,冗余存储。
  373. :param trades: get_trading_history 返回的 trades 列表(按接口原始顺序传入)。
  374. :return: int,本次实际新增行数(已存在的靠 INSERT IGNORE 跳过、不计入)。
  375. """
  376. if not trades:
  377. log.warning(f"product_id={product_id} 无交易记录")
  378. return 0
  379. sig_counter = {} # 记录每个签名出现过几次,用作 trade_index_in_second
  380. data_list = []
  381. for tr in trades:
  382. sold_at = utc_to_beijing(tr.get("soldAt"))
  383. sold_price = tr.get("price")
  384. condition_grade = tr.get("title") # 接口 title 即品相 A/B/C...
  385. quantity_text = tr.get("label") # 接口 label 即枚数 1枚/2枚...
  386. sig = (sold_at, sold_price, condition_grade, quantity_text)
  387. idx = sig_counter.get(sig, 0)
  388. sig_counter[sig] = idx + 1
  389. data_list.append({
  390. "product_id": product_id,
  391. "apparel_id": apparel_id,
  392. "product_number": product_number,
  393. "sold_price": sold_price,
  394. "condition_grade": condition_grade,
  395. "quantity_text": quantity_text,
  396. "sold_at": sold_at,
  397. "trade_index_in_second": idx,
  398. })
  399. new_rows = sql_pool.insert_many(table=TABLE_TRADING_HISTORY, data_list=data_list, ignore=True)
  400. log.info(f"交易记录入库 -> product_id={product_id},接口 {len(trades)} 条,本次新增 {new_rows} 条(重复的已跳过)")
  401. return new_rows
  402. def crawl_detail_and_history(log, sql_pool, apparel_id, used_item_id, seen_product_ids=None):
  403. """抓取单个详情页:详情入表1 + 交易记录入表2(任务2 单条流程)。
  404. 详情(表1) 每次都要抓(每个出品各自独立);但交易记录(表2) 是按 product_id 聚合的,
  405. 同一 product_id 每次 run 只需抓一次——传入 seen_product_ids 集合可实现批量模式下
  406. 的自动去重(避免同款卡的多个出品重复调交易记录接口)。
  407. :param log: 日志对象。
  408. :param sql_pool: 数据库连接池 MySQLConnectionPool。
  409. :param apparel_id: 商品(款式)ID。
  410. :param used_item_id: 二手出品ID。
  411. :param seen_product_ids: 可选,本次 run 已抓过交易记录的 product_id 集合;命中则跳过。
  412. 为 None 时不做去重(单条模式用)。
  413. """
  414. # 1) 详情 -> 表1
  415. detail_json = get_detail(log, apparel_id, used_item_id)
  416. data_dict, product_id = parse_detail(log, detail_json, apparel_id, used_item_id)
  417. upsert_used_items(log, sql_pool, [data_dict])
  418. # 2) 交易记录 -> 表2
  419. if not product_id:
  420. log.warning(f"apparel={apparel_id} 未取到 product_id,跳过交易记录")
  421. return
  422. # 同一 product_id 每次 run 只抓一次交易记录(同款卡的多个出品共用同一份买卖历史)
  423. if seen_product_ids is not None:
  424. if product_id in seen_product_ids:
  425. log.info(f"product_id={product_id} 本次已抓过交易记录,跳过")
  426. return
  427. seen_product_ids.add(product_id)
  428. try:
  429. trades = get_trading_history(log, product_id)
  430. save_trading_history(log, sql_pool, product_id, apparel_id, data_dict.get("product_number"), trades)
  431. except Exception as e:
  432. log.error(f"抓取交易记录出错 product_id={product_id}: {e}")
  433. def detail_main(log, apparel_id=None, used_item_id=None):
  434. """详情+交易记录爬虫主函数(任务2 入口)。
  435. 传入 apparel_id / used_item_id 时只抓该条;不传时从表1 里筛出列表页抓来的、
  436. 尚未补全详情的已售项(data_source='list')逐个补全详情与交易记录。
  437. :param log: 日志对象。
  438. :param apparel_id: 可选,指定要抓的商品(款式)ID。
  439. :param used_item_id: 可选,指定要抓的二手出品ID。
  440. """
  441. log.info(f'开始运行 {sys._getframe().f_code.co_name} 爬虫任务....................................................')
  442. sql_pool = MySQLConnectionPool(log=log)
  443. if not sql_pool.check_pool_health():
  444. log.error("数据库连接池异常")
  445. raise RuntimeError("数据库连接池异常")
  446. # 模式一:指定单条
  447. if apparel_id and used_item_id:
  448. try:
  449. crawl_detail_and_history(log, sql_pool, apparel_id, used_item_id)
  450. except Exception as e:
  451. log.error(f'抓取详情 apparel={apparel_id} used={used_item_id} 异常: {e}')
  452. return
  453. # 模式二:批量补全表1 中列表页抓来的记录
  454. rows = sql_pool.select_all(
  455. f"SELECT apparel_id, used_item_id FROM `{TABLE_USED_ITEM}` "
  456. f"WHERE data_source = 'list' ORDER BY id"
  457. )
  458. log.info(f"待补全详情的记录数:{len(rows)}")
  459. seen_product_ids = set() # 本次 run 已抓过交易记录的 product_id,避免重复调接口
  460. for _apparel_id, _used_item_id in rows:
  461. try:
  462. crawl_detail_and_history(log, sql_pool, _apparel_id, _used_item_id, seen_product_ids)
  463. except Exception as e:
  464. log.error(f'抓取详情 apparel={_apparel_id} used={_used_item_id} 异常: {e}')
  465. time.sleep(random.uniform(0.3, 1.0))
  466. log.info(f"批量模式结束,共抓取 {len(seen_product_ids)} 个不同 product_id 的交易记录")
  467. log.info(f'爬虫程序 {sys._getframe().f_code.co_name} 运行结束............')
  468. def schedule_task():
  469. """定时任务启动入口(每日 00:01 先跑列表,再补全详情+交易记录)。"""
  470. def daily_job():
  471. list_main(log=logger) # 任务1:列表 -> 表1
  472. detail_main(logger) # 任务2:批量补全详情 -> 表1 + 交易记录 -> 表2
  473. # daily_job()
  474. schedule.every().day.at("00:01").do(daily_job)
  475. while True:
  476. schedule.run_pending()
  477. time.sleep(1)
  478. if __name__ == '__main__':
  479. # 任务2 单条示例:主公给的详情页 https://snkrdunk.com/apparels/835482/used/47574548
  480. # detail_main(logger, apparel_id=835482, used_item_id=47574548)
  481. # 任务1:抓列表页所有已售 -> 表1
  482. # list_main(log=logger)
  483. # 任务2:批量补全表1 中列表页记录的详情与交易记录(先跑完 list_main 再放开)
  484. # detail_main(logger)
  485. # 定时任务(列表 + 详情/交易记录 每日跑一次)
  486. schedule_task()