product_scraper.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. # coding=utf-8
  2. """
  3. 小红书商品详情页采集
  4. 基于 uiautomator2 真机自动化方案
  5. 使用方法:
  6. 1. 填好下方变量区
  7. 2. 手机打开小红书, 搜索目标关键词, 进入商品列表页
  8. 3. python product_scraper.py
  9. """
  10. import uiautomator2 as u2
  11. import time
  12. import re
  13. import random
  14. import subprocess
  15. import datetime
  16. # ============================================================
  17. # ====== 变量区 ======
  18. # ============================================================
  19. DEVICE_ID = "XOYPOZDADU79VGVG"
  20. XHS_PACKAGE = "com.xingin.xhs"
  21. PRODUCT_NAME = "小儿感冒颗粒"
  22. BRAND = "999"
  23. SPEC_LIST = '24'
  24. # ============================================================
  25. # ====== 变量区结束 ======
  26. # ============================================================
  27. class XHS:
  28. """小红书商品详情页采集器"""
  29. # ---- 类常量 ----
  30. SEP = "=" * 60
  31. SLEEP_CLICK = 1.5 # 点击后等待页面响应
  32. SLEEP_APP_START = 3 # App 启动等待
  33. SLEEP_CLIPBOARD = 0.8 # 复制链接等待
  34. # 弹窗检测:命中 >= POPUP_THRESHOLD 个 xpath 即认定为弹窗
  35. POPUP_XPATHS = [
  36. '//*[@text="立即领取"]',
  37. '//*[@text="送你 66周年庆礼券 啦 "]',
  38. '//*[contains(@text, "可用")]', # 匹配 "满150可用" 等变体
  39. ]
  40. POPUP_THRESHOLD = 2
  41. # 列表页判定锚点(命中 >= LIST_PAGE_HIT_THRESHOLD 个即认为在列表页)
  42. LIST_PAGE_ANCHORS = [
  43. '//*[@text="搜索"]',
  44. '//*[@text="全部"]',
  45. '//*[@text="用户"]',
  46. '//*[@text="商品"]',
  47. '//*[@content-desc="全部删除"]',
  48. ]
  49. LIST_PAGE_HIT_THRESHOLD = 2
  50. def __init__(self, device_id, product_name="", brand="", spec_list=None):
  51. self.package_name = XHS_PACKAGE
  52. self.device_id = device_id
  53. self.d = None
  54. self.product_name = str(product_name or "").strip()
  55. self.brand = str(brand or "").strip()
  56. self.spec_list = self._normalize_rule_list(spec_list)
  57. # ============================================================
  58. # 工具方法
  59. # ============================================================
  60. @staticmethod
  61. def _normalize_rule_list(value):
  62. if value is None:
  63. return []
  64. if isinstance(value, (list, tuple, set)):
  65. raw_values = value
  66. else:
  67. raw_values = [value]
  68. return [str(v).strip() for v in raw_values if str(v).strip()]
  69. @staticmethod
  70. def _normalize_match_text(value):
  71. return re.sub(r'\s+', '', str(value or '')).lower()
  72. @staticmethod
  73. def get_sleep_time():
  74. return random.uniform(0.5, 1.0)
  75. @staticmethod
  76. def get_current_date():
  77. return datetime.datetime.now().strftime('%Y/%m/%d')
  78. @staticmethod
  79. def _strip_content_desc_prefix(text, prefix):
  80. """去掉 content-desc 中的前缀标签(兼容中英文逗号)"""
  81. for sep in (",", ","):
  82. text = text.replace(f"{prefix}{sep}", "")
  83. return text.strip()
  84. def _safe_get_attr(self, xpath, attr="text"):
  85. """安全获取元素属性(text 或 content_desc),避免元素缺失导致崩溃"""
  86. try:
  87. el = self.d.xpath(xpath)
  88. if not el.exists:
  89. return ""
  90. if attr == "content_desc":
  91. return (el.info.get('contentDescription') or "").strip()
  92. return (el.text or "").strip()
  93. except Exception:
  94. return ""
  95. def _scroll_until_found(self, xpath, max_swipes=5, direction="up", scale=0.3):
  96. """翻页直到找到目标元素,返回元素对象;找不到返回 None"""
  97. el = self.d.xpath(xpath)
  98. if el.exists:
  99. return el
  100. for _ in range(max_swipes):
  101. self.d.swipe_ext(direction, scale=scale)
  102. time.sleep(self.get_sleep_time())
  103. el = self.d.xpath(xpath)
  104. if el.exists:
  105. return el
  106. return None
  107. def _count_popup_elements(self):
  108. """统计当前页面命中弹窗特征 xpath 的数量"""
  109. count = 0
  110. for xpath in self.POPUP_XPATHS:
  111. if self.d.xpath(xpath).exists:
  112. count += 1
  113. return count
  114. def _dismiss_popup_if_exists(self):
  115. """检测弹窗,命中 >= 阈值则按 back 关闭,返回是否关闭"""
  116. count = self._count_popup_elements()
  117. if count >= self.POPUP_THRESHOLD:
  118. print(f" [弹窗检测] 命中 {count} 个特征,关闭弹窗")
  119. self.d.press("back")
  120. time.sleep(self.get_sleep_time())
  121. return True
  122. return False
  123. def _find_with_popup_retry(self, find_func, step_name="", max_retries=2):
  124. """查找元素,失败则关闭弹窗后重试;返回找到的元素或 None"""
  125. for attempt in range(max_retries):
  126. result = find_func()
  127. if result:
  128. return result
  129. if attempt < max_retries - 1:
  130. print(f" [{step_name}] 未找到,关闭弹窗后重试 ({attempt + 1}/{max_retries - 1})")
  131. self._dismiss_popup_if_exists()
  132. time.sleep(random.uniform(1.5, 2))
  133. return None
  134. @staticmethod
  135. def _match_any_keyword(text, keywords):
  136. """判断 text 是否命中 keywords 中的任一关键词;keywords 为空则放行"""
  137. keyword_list = XHS._normalize_rule_list(keywords)
  138. if not keyword_list:
  139. return True
  140. normalized = XHS._normalize_match_text(text)
  141. return any(XHS._normalize_match_text(k) in normalized for k in keyword_list)
  142. def is_on_list_page(self):
  143. """判断当前是否在商品列表页"""
  144. hits = sum(1 for xp in self.LIST_PAGE_ANCHORS if self.d.xpath(xp).exists)
  145. return hits >= self.LIST_PAGE_HIT_THRESHOLD
  146. def is_title_useful(self, title):
  147. """标题需同时包含产品名、品牌、规格(各自命中一个即可),打印不匹配原因"""
  148. if self.product_name and not self._match_any_keyword(title, self.product_name):
  149. print(f" 不匹配: 产品名「{self.product_name}」")
  150. return False
  151. if self.brand and not self._match_any_keyword(title, self.brand):
  152. print(f" 不匹配: 品牌「{self.brand}」")
  153. return False
  154. if self.spec_list and not self._match_any_keyword(title, self.spec_list):
  155. print(f" 不匹配: 规格「{self.spec_list}」")
  156. return False
  157. return True
  158. def get_product_cards(self):
  159. """获取当前屏幕可见的商品卡片(按子元素高度定位正确的 RecyclerView)"""
  160. cards = []
  161. for idx in (1, 2, 3):
  162. candidates = self.d.xpath(
  163. f'(//androidx.recyclerview.widget.RecyclerView)[{idx}]/android.widget.FrameLayout'
  164. ).all()
  165. for c in candidates:
  166. try:
  167. bounds = c.info.get('bounds', {})
  168. h = bounds.get('bottom', 0) - bounds.get('top', 0)
  169. if h > 400: # 商品卡片高 600+,tab 栏只有 ~140
  170. cards = candidates
  171. break
  172. except Exception:
  173. continue
  174. if cards:
  175. break
  176. if not cards:
  177. return []
  178. visible = []
  179. for card in cards:
  180. try:
  181. bounds = card.info.get('bounds', {})
  182. top = bounds.get('top', 0)
  183. bottom = bounds.get('bottom', 0)
  184. h = bottom - top
  185. if h > 400 and top >= 554 and bottom <= 2600:
  186. visible.append(card)
  187. except Exception:
  188. continue
  189. return visible
  190. def back_to_list_page(self, max_attempts=5):
  191. """按 back 直到回到列表页"""
  192. for _ in range(max_attempts):
  193. if self.is_on_list_page():
  194. return True
  195. self.d.press("back")
  196. time.sleep(self.get_sleep_time())
  197. return self.is_on_list_page()
  198. def _find_shop_avatar(self):
  199. """在店铺页按位置范围找店铺头像 ImageView"""
  200. imgs = self.d.xpath('//android.widget.ImageView').all()
  201. for img in imgs:
  202. try:
  203. bounds = img.info.get('bounds', {})
  204. left = bounds.get('left', 0)
  205. top = bounds.get('top', 0)
  206. right = bounds.get('right', 0)
  207. bottom = bounds.get('bottom', 0)
  208. w = right - left
  209. h = bottom - top
  210. if 30 <= left <= 120 and 300 <= top <= 370 and abs(w - h) <= 30:
  211. return img
  212. except Exception:
  213. continue
  214. return None
  215. # ============================================================
  216. # 设备连接 & App 控制
  217. # ============================================================
  218. def connect_device(self):
  219. try:
  220. self.d = u2.connect_usb(self.device_id)
  221. self._restart_uiautomator_services()
  222. print(f'[连接成功] 设备: {self.device_id}')
  223. return True
  224. except Exception as e:
  225. print(f'[连接失败] {self.device_id}: {e}')
  226. return False
  227. def _restart_uiautomator_services(self):
  228. stop_cmd = f'adb -s {self.device_id} shell /data/local/tmp/atx-agent server -d --stop'
  229. start_cmd = f'adb -s {self.device_id} shell /data/local/tmp/atx-agent server -d'
  230. subprocess.run(stop_cmd, capture_output=True, text=True, shell=True)
  231. time.sleep(self.get_sleep_time())
  232. subprocess.run(start_cmd, capture_output=True, text=True, shell=True)
  233. time.sleep(self.get_sleep_time())
  234. def start_app(self):
  235. self.d.app_start(self.package_name)
  236. time.sleep(self.SLEEP_APP_START)
  237. # ============================================================
  238. # 1. 提取标题
  239. # ============================================================
  240. def get_title(self):
  241. print("\n[1/7] 提取标题...")
  242. # 优先从 content-desc 取(直接进入的详情页)
  243. title = self._safe_get_attr(
  244. '//*[contains(@content-desc, "商品名称")]', "content_desc"
  245. )
  246. if title:
  247. title = self._strip_content_desc_prefix(title, "商品名称")
  248. print(f" 标题={title}")
  249. return title
  250. # fallback: 取页面上最长的 TextView(搜索结果点进来的详情页)
  251. try:
  252. text_els = self.d.xpath('//android.widget.TextView').all()
  253. longest = ""
  254. for el in text_els:
  255. t = (el.text or "").strip()
  256. if len(t) > len(longest):
  257. longest = t
  258. if longest and len(longest) > 5:
  259. print(f" 标题(fallback)={longest}")
  260. return longest
  261. except Exception:
  262. pass
  263. print(" [失败] 未找到标题")
  264. return ""
  265. # ============================================================
  266. # 2. 提取价格
  267. # ============================================================
  268. def get_price(self):
  269. print("\n[2/7] 提取价格...")
  270. buy_btn = self.d.xpath('//*[@text="立即购买"]')
  271. if not buy_btn.exists:
  272. buy_btn = self.d.xpath('//*[@text="领券购买"]')
  273. if not buy_btn.exists:
  274. print(" [失败] 未找到「购买选项」")
  275. return None
  276. buy_btn.click()
  277. print(" 点击「购买」")
  278. time.sleep(self.SLEEP_CLICK)
  279. # 优先到手价,兜底 ¥
  280. price_str = ""
  281. price_xpath = '//*[contains(@content-desc, "到手价")]'
  282. if self.d.xpath(price_xpath).exists:
  283. price_str = self._safe_get_attr(price_xpath, "content_desc")
  284. print(f" [到手价] {price_str}")
  285. else:
  286. fallback_xpath = '//*[contains(@content-desc, "¥")]'
  287. if self.d.xpath(fallback_xpath).exists:
  288. price_str = self._safe_get_attr(fallback_xpath, "content_desc")
  289. print(f" [兜底¥] {price_str}")
  290. self.d.press("back")
  291. time.sleep(self.get_sleep_time())
  292. if not price_str:
  293. return None
  294. match = re.search(r'¥([\d\.]+)', price_str)
  295. if match:
  296. price = float(match.group(1))
  297. print(f" 价格={price} 元")
  298. return price
  299. print(f" [失败] 无法解析价格: {price_str}")
  300. return None
  301. # ============================================================
  302. # 3. 提取规格
  303. # ============================================================
  304. def get_spec(self):
  305. print("\n[3/7] 提取规格...")
  306. spec = self._safe_get_attr(
  307. '//*[contains(@content-desc, "已选规格")]', "content_desc"
  308. )
  309. if spec:
  310. spec = self._strip_content_desc_prefix(spec, "已选规格")
  311. print(f" 规格={spec}")
  312. else:
  313. print(" [失败] 未找到规格")
  314. return spec
  315. # ============================================================
  316. # 4. 提取店铺名
  317. # ============================================================
  318. def get_shop_name(self):
  319. print("\n[4/7] 提取店铺名...")
  320. self._scroll_until_found('//*[@text="进店"]', max_swipes=5)
  321. shop_name = self._safe_get_attr(
  322. '//*[contains(@content-desc, "旗舰店")]', "content_desc"
  323. )
  324. if not shop_name:
  325. shop_name = self._safe_get_attr('//*[contains(@text, "旗舰店")]')
  326. if shop_name:
  327. print(f" 店铺名={shop_name}")
  328. else:
  329. print(" [失败] 未找到店铺名")
  330. return shop_name
  331. # ============================================================
  332. # 5. 提取公司名
  333. # ============================================================
  334. def get_company_name(self):
  335. print("\n[5/7] 提取公司名...")
  336. enter_btn = self._scroll_until_found('//*[@text="进店"]', max_swipes=5)
  337. if not enter_btn:
  338. print(" [失败] 未找到「进店」")
  339. return ""
  340. enter_btn.click()
  341. time.sleep(random.uniform(1.5, 2))
  342. print(" 点击「进店」")
  343. self._dismiss_popup_if_exists()
  344. # 优先点头像进资质页,没有头像再找"X篇笔记"
  345. avatar = self._find_shop_avatar()
  346. if avatar:
  347. avatar.click()
  348. time.sleep(random.uniform(1.5, 2))
  349. print(" 点击「店铺头像」")
  350. else:
  351. notes_btn = self.d.xpath('//*[contains(@text, "篇笔记")]')
  352. if not notes_btn.exists:
  353. print(" [失败] 未找到店铺头像和「X篇笔记」")
  354. self.d.press("back")
  355. time.sleep(self.get_sleep_time())
  356. return ""
  357. notes_btn.click()
  358. time.sleep(random.uniform(1.5, 2))
  359. print(" 点击「X篇笔记」资质入口")
  360. self._dismiss_popup_if_exists()
  361. self.get_sleep_time()
  362. company = self._safe_get_attr('//*[contains(@text, "公司")]')
  363. if company:
  364. print(f" 公司名={company}")
  365. self.d.press("back")
  366. time.sleep(self.get_sleep_time())
  367. self._dismiss_popup_if_exists()
  368. self.d.press("back")
  369. time.sleep(self.get_sleep_time())
  370. self._dismiss_popup_if_exists()
  371. return company
  372. # ============================================================
  373. # 6. 提取商品链接
  374. # ============================================================
  375. def get_product_link(self):
  376. print("\n[6/7] 提取商品链接...")
  377. try:
  378. self.d.set_clipboard("")
  379. except Exception:
  380. pass
  381. share_btn = self.d.xpath('//*[@content-desc="分享商品"]')
  382. if not share_btn.exists:
  383. print(" [失败] 未找到「分享商品」")
  384. return ""
  385. share_btn.click()
  386. time.sleep(1)
  387. print(" 点击「分享商品」")
  388. copy_btn = self.d.xpath('//*[@text="复制链接"]')
  389. if copy_btn.exists:
  390. copy_btn.click()
  391. time.sleep(self.SLEEP_CLIPBOARD)
  392. link = (self.d.clipboard or "").strip()
  393. if link:
  394. match = re.search(r'https?://xhslink\.com/\S+', link)
  395. if match:
  396. link = match.group(0)
  397. print(f" 商品链接={link}")
  398. else:
  399. print(" [失败] 剪贴板为空")
  400. return link
  401. # ============================================================
  402. # 7. 提取批准文号
  403. # ============================================================
  404. def get_approval_number(self):
  405. print("\n[7/7] 提取批准文号...")
  406. # 步骤1: 小幅度滑动到"批准文号"标签(起点压低避开"退货包运费")
  407. w, h = self.d.window_size()
  408. for _ in range(6):
  409. self.d.swipe(w // 2, int(h * 0.88), w // 2, int(h * 0.50), duration=0.3)
  410. time.sleep(self.get_sleep_time())
  411. if self.d.xpath('//*[@text="批准文号"]').exists:
  412. break
  413. # 步骤2: 国药准字有就拿,没有就空
  414. approval = self._safe_get_attr('//*[starts-with(@text, "国药准字")]')
  415. if approval:
  416. print(f" 批准文号={approval}")
  417. else:
  418. print(" [无] 未找到国药准字")
  419. return approval
  420. # ============================================================
  421. # 数据聚合
  422. # ============================================================
  423. def integrate_data(self):
  424. print(self.SEP)
  425. print(f" 小红书商品详情页采集")
  426. print(f" 时间: {self.get_current_date()}")
  427. print(f" 设备: {self.device_id}")
  428. print(f" 目标: {self.product_name} | 品牌: {self.brand or '-'} | 规格: {self.spec_list or '-'}")
  429. print(self.SEP)
  430. title = self.get_title()
  431. price = self.get_price()
  432. spec = self.get_spec()
  433. shop_name = self.get_shop_name()
  434. company = self.get_company_name()
  435. link = self.get_product_link()
  436. approval = self.get_approval_number()
  437. print(f"\n{self.SEP}")
  438. print(" 采集结果汇总")
  439. print(self.SEP)
  440. print(f" 标题: {title}")
  441. print(f" 价格: {price} 元" if price else " 价格: (未取到)")
  442. print(f" 规格: {spec}")
  443. print(f" 店铺名: {shop_name}")
  444. print(f" 公司名: {company}")
  445. print(f" 批准文号: {approval}")
  446. print(f" 商品链接: {link}")
  447. print(self.SEP)
  448. data = {
  449. "product_name": title,
  450. "min_price": price,
  451. "spec": spec,
  452. "shop_name": shop_name,
  453. "company_name": company,
  454. "approval_number": approval,
  455. "product_link": link,
  456. "scrape_date": self.get_current_date(),
  457. }
  458. print(f"\n 完整数据:\n {data}")
  459. return data
  460. # ============================================================
  461. # 进入搜索页
  462. # ============================================================
  463. def enter_search_page(self):
  464. """首页 → 市集 → 搜索框 → 粘贴关键字 → 搜索 → 商品列表页"""
  465. # 1. 点市集
  466. el = self.d.xpath('//*[@text="市集"]')
  467. if not el.exists:
  468. print("[错误] 未找到「市集」")
  469. return False
  470. el.click()
  471. time.sleep(random.uniform(1.5, 2))
  472. self._dismiss_popup_if_exists()
  473. # 2. 点搜索入口(弹窗遮挡时自动重试)
  474. def _find_search_entry():
  475. tvs = self.d.xpath('//android.widget.TextView').all()
  476. for tv in tvs:
  477. try:
  478. b = tv.info.get('bounds', {})
  479. if 150 <= b.get('top', 0) <= 180 and 200 <= b.get('bottom', 0) <= 250:
  480. tv.click()
  481. return True
  482. except Exception:
  483. continue
  484. return False
  485. if not self._find_with_popup_retry(_find_search_entry, step_name="搜索入口"):
  486. print("[错误] 未找到搜索入口")
  487. return False
  488. time.sleep(random.uniform(1, 1.5))
  489. # 3. 输入关键字
  490. spec_str = " ".join(self.spec_list) if self.spec_list else ""
  491. search_key = f"{self.brand} {self.product_name} {spec_str}".strip()
  492. print(f" 搜索词: {search_key}")
  493. def _find_edit_text():
  494. edit = self.d.xpath('//android.widget.EditText')
  495. if edit.exists:
  496. edit.click()
  497. return True
  498. return False
  499. if not self._find_with_popup_retry(_find_edit_text, step_name="搜索输入框"):
  500. print("[错误] 未找到搜索输入框")
  501. return False
  502. time.sleep(0.5)
  503. self.d.send_keys(search_key, clear=True)
  504. time.sleep(random.uniform(0.5, 1))
  505. # 4. 点搜索按钮
  506. def _find_search_btn():
  507. search_btn = self.d.xpath('//*[@text="搜索"]')
  508. if search_btn.exists:
  509. search_btn.click()
  510. return True
  511. return False
  512. if not self._find_with_popup_retry(_find_search_btn, step_name="搜索按钮"):
  513. print("[错误] 未找到搜索按钮")
  514. return False
  515. time.sleep(random.uniform(1.5, 2))
  516. self._dismiss_popup_if_exists()
  517. print(" 已进入商品列表页")
  518. return True
  519. # ============================================================
  520. # 列表遍历主循环
  521. # ============================================================
  522. def run(self, max_pages=50):
  523. """主采集循环:遍历列表页 → 逐个商品 → 详情采集 → 退回列表"""
  524. if not self.is_on_list_page():
  525. print("[错误] 当前不在商品列表页,请手动进入列表页后重试")
  526. return
  527. collected = 0
  528. unrelated = 0
  529. for page in range(max_pages):
  530. print(f"\n{'=' * 60}")
  531. print(f" 第 {page + 1} 页")
  532. print(f"{'=' * 60}")
  533. cards = self.get_product_cards()
  534. print(f" 当前页 {len(cards)} 个可见商品")
  535. for i, card in enumerate(cards):
  536. # 连续 10 个不达标则暂停
  537. if unrelated >= 10:
  538. print(f"\n [暂停] 连续 {unrelated} 个商品不达标,停止采集")
  539. return
  540. print(f"\n [{i + 1}]")
  541. # 1. 点击卡片进入详情页
  542. try:
  543. card.click()
  544. except Exception as e:
  545. print(f" [失败] 点击失败: {e}")
  546. continue
  547. time.sleep(self.SLEEP_CLICK)
  548. self._dismiss_popup_if_exists()
  549. # 2. 确认已离开列表页
  550. if self.is_on_list_page():
  551. print(f" [跳过] 点击后仍在列表页")
  552. continue
  553. # 3. 读详情页标题,判断是否匹配
  554. title = self.get_title()
  555. if not title:
  556. print(f" [跳过] 未读到标题")
  557. unrelated += 1
  558. self.back_to_list_page()
  559. continue
  560. if not self.is_title_useful(title):
  561. print(f" [跳过] 标题不匹配: {title[:40]}...")
  562. unrelated += 1
  563. self.back_to_list_page()
  564. continue
  565. # 4. 采集详情(价格、规格、店铺等)
  566. unrelated = 0
  567. try:
  568. self.integrate_data()
  569. collected += 1
  570. # 每采集 5 个重启一次 atx 服务,防止越来越卡
  571. if collected % 5 == 0:
  572. print(" [维护] 重启自动化服务...")
  573. self._restart_uiautomator_services()
  574. except Exception as e:
  575. print(f" [异常] 采集详情失败: {e}")
  576. # 5. 退回列表
  577. if not self.back_to_list_page():
  578. print(" [警告] 未能回到列表页,尝试继续...")
  579. time.sleep(self.get_sleep_time())
  580. time.sleep(self.get_sleep_time())
  581. # 翻到下一页
  582. print(f"\n 翻到第 {page + 2} 页...")
  583. self.d.swipe_ext("up", scale=0.5)
  584. time.sleep(self.get_sleep_time())
  585. print(f"\n 遍历完成,共 {max_pages} 页")
  586. # ============================================================
  587. # 测试入口
  588. # ============================================================
  589. def main():
  590. print("=" * 60)
  591. print(" 小红书商品详情页采集器")
  592. print("=" * 60)
  593. xhs = XHS(
  594. device_id=DEVICE_ID,
  595. product_name=PRODUCT_NAME,
  596. brand=BRAND,
  597. spec_list=SPEC_LIST,
  598. )
  599. if not xhs.connect_device():
  600. print("设备连接失败,退出")
  601. return
  602. # 确保小红书在前台
  603. try:
  604. current_pkg = xhs.d.app_current().get("package", "")
  605. if current_pkg != XHS_PACKAGE:
  606. print(f"当前前台非小红书(package={current_pkg}),启动...")
  607. xhs.start_app()
  608. else:
  609. print("小红书已在前台")
  610. except Exception as e:
  611. print(f"获取前台失败: {e},启动小红书...")
  612. xhs.start_app()
  613. # 自动导航:市集 → 搜索 → 商品列表
  614. if not xhs.enter_search_page():
  615. print("进入搜索页失败,退出")
  616. return
  617. xhs.run()
  618. print("\n采集完成!")
  619. if __name__ == "__main__":
  620. main()