jhs_app_spider_0720.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.10.8
  4. # Date : 2026/07/20
  5. #
  6. # 变更(2026/07/20):
  7. # [bugfix]
  8. # 1. 分类下拉选择由「固定滑动 + 硬编码 Button[ca_idx-2] 索引」改为
  9. # 「按分类文字(content-desc)滚动查找并点击」。原索引方案在 category_list
  10. # 增删项时会错位/越界(新增『峡谷争锋』后,index 11『迪士尼洛卡纳』点
  11. # Button[9] 触发 list index out of range)。
  12. # 2. 每个 (分类, 语言) 迭代整体包 try/except,单项失败只跳过、不再让异常冒泡
  13. # 终止整个任务(修复 20260718.log 里首个分类超时后整轮任务被 kill)。
  14. # 3. 翻页时 offspring('android.widget.Button') 查询包 try/except。
  15. # [optimize]
  16. # 1. 设备连接/停开 App/poco 初始化从模块顶层挪进 init_env(),避免 import 即重启 App。
  17. # 3. 删除从未被调用的死代码 scrape_detail() / is_page_changed()。
  18. # 4. 顶部『畅销榜』Tab 改为优先按文字点击(poco('畅销榜')),失败再回退图片模板,
  19. # 免掉模板 resolution=(1080,2340) 与模拟器 900x1600 不一致的隐患。
  20. # 5. 关键固定 time.sleep 改为 wait_for_appearance 事件等待(等到即走,更快更稳)。
  21. # 6. 翻页中内联去重(按 title+编号);停止条件保持旧版「rank==100 或翻满
  22. # max_pages」,另加 guard 防死循环。
  23. # 注:曾试过「连续N页无新增即停」,但实测会把尾部截断少抓几条,已回退。
  24. # 注:UI 里『迪士尼洛卡纳』的 content-desc 现已无空格;select_category 会自动
  25. # 对带空格的分类名做无空格候选匹配。
  26. import inspect
  27. import subprocess
  28. from airtest.core.api import *
  29. from poco.drivers.android.uiautomation import AndroidUiautomationPoco
  30. from loguru import logger
  31. from mysql_pool import MySQLConnectionPool
  32. from tenacity import retry, stop_after_attempt, wait_fixed
  33. logger.remove()
  34. logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00",
  35. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  36. level="DEBUG", retention="7 day")
  37. PACKAGE_NAME = 'com.jihuanshe'
  38. DEVICE_URI = "Android:///" # 如需固定模拟器可改 "Android://127.0.0.1:5557"
  39. ADB_TARGET = "127.0.0.1:5557" # adb connect 目标
  40. # 翻页参数(为尽量保证数据完整性:每页滑动步长调小、增加页面重叠,靠去重兜住)
  41. # SCROLLS_PER_PAGE 越小 -> 每页前进越少、重叠越多 -> 越不易跳行,但需要更多页数
  42. SCROLLS_PER_PAGE = 2 # 每页滑动次数(旧版为 4,减小以降低跳行)
  43. MAX_PAGES = 60 # 最大翻页数(步长变小后相应增大,防止翻不到 rank100)
  44. category_list = [
  45. {'游戏王': ['日文', '简中']},
  46. {'宝可梦': ['简中', '日文', '英文']},
  47. {'航海王': ['简中', '日文']},
  48. {'数码宝贝': ['简中']},
  49. {'符文战场': ['简中']},
  50. {'WS黑白双翼': ['日文', '简中']},
  51. {'携站之境UA': ['简中', '日文']},
  52. {'VG卡片战斗先导者': ['简中', '日文']},
  53. {'高达GCG': ['简中']},
  54. {'影之诗': ['简中']},
  55. {'峡谷争锋': ['简中']},
  56. {'迪士尼洛卡纳': ['简中']} # 2026/07/20 起统一无空格,与 App 及库中数据对齐
  57. ]
  58. # 运行时由 init_env() 初始化,避免 import 即产生副作用
  59. # poco = None
  60. window_width = None
  61. window_height = None
  62. def init_env():
  63. """初始化设备连接、重启目标 App、创建 poco 实例。
  64. 放到运行时执行(而非模块顶层),避免任何 import 本模块的行为都触发 App 重启。
  65. 被 @retry 的主函数每次重试都会重新调用,天然带「重连+重启」的恢复能力。
  66. """
  67. global poco, window_width, window_height
  68. subprocess.run(["adb", "connect", ADB_TARGET])
  69. connect_device(DEVICE_URI)
  70. stop_app(PACKAGE_NAME)
  71. start_app(PACKAGE_NAME)
  72. poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)
  73. window_width, window_height = poco.get_screen_size()
  74. logger.info(f"init_env 完成: 屏幕 {window_width}x{window_height}")
  75. def wait_exists(node, timeout=8):
  76. """等待 poco 节点出现,返回是否出现(超时不抛异常)。
  77. Args:
  78. node: poco UIObjectProxy 节点。
  79. timeout (int, optional): 最长等待秒数。Defaults to 8。
  80. Returns:
  81. bool: True 表示在超时前出现;False 表示超时未出现。
  82. """
  83. try:
  84. node.wait_for_appearance(timeout=timeout)
  85. return True
  86. except Exception:
  87. return False
  88. def after_log(retry_state):
  89. """
  90. retry 回调
  91. :param retry_state: RetryCallState 对象
  92. """
  93. # 检查 args 是否存在且不为空
  94. if retry_state.args and len(retry_state.args) > 0:
  95. local_logger = retry_state.args[0] # 获取传入的 logger
  96. else:
  97. local_logger = logger # 使用全局 logger
  98. if retry_state.outcome.failed:
  99. local_logger.warning(
  100. f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  101. else:
  102. local_logger.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  103. @retry(stop=stop_after_attempt(3), wait=wait_fixed(10), after=after_log)
  104. def scrape_index():
  105. """进入首页、关闭广告、点击行情入口 imageIv。
  106. Raises:
  107. Exception: imageIv 元素始终不可见时抛出,触发上层 retry。
  108. """
  109. # 等待 App 启动到首页:优先等广告或行情入口出现,最多 ~15s
  110. for _ in range(15):
  111. if poco(f'{PACKAGE_NAME}:id/ivClose').exists() or poco(f'{PACKAGE_NAME}:id/imageIv').exists():
  112. break
  113. time.sleep(1)
  114. # 判断是否有广告 需要点击
  115. tag_adv = poco(f'{PACKAGE_NAME}:id/ivClose')
  116. for _ in range(3):
  117. if tag_adv.exists():
  118. logger.warning("广告元素已找到")
  119. tag_adv.click()
  120. time.sleep(1)
  121. else:
  122. logger.debug("广告元素未找到, 退出")
  123. break
  124. logger.info("开始查找imageIv元素...")
  125. if not wait_exists(poco(f'{PACKAGE_NAME}:id/imageIv'), timeout=10):
  126. logger.warning("imageIv元素不存在,准备重试...")
  127. try:
  128. snapshot_path = f"./logs/debug_screenshot_{time.strftime('%Y%m%d_%H%M%S')}.png"
  129. snapshot(filename=snapshot_path)
  130. logger.info(f"已保存调试截图到: {snapshot_path}")
  131. except Exception as snap_error:
  132. logger.warning(f"截图失败: {snap_error}")
  133. raise Exception(f"Cannot find visible node: {PACKAGE_NAME}:id/imageIv")
  134. poco(f'{PACKAGE_NAME}:id/imageIv').click()
  135. logger.success("成功找到并点击imageIv元素")
  136. def open_category_dropdown():
  137. """点开顶部「分类/语言」下拉框。
  138. 下拉入口是标题栏里的第 2 个 Button(tag_drop_down),点开后左侧为分类列表、
  139. 右侧为语言列表。点击后等待列表首项『游戏王』出现作为就绪信号。
  140. Returns:
  141. bool: True 表示下拉框已点开且列表就绪;False 表示未找到入口。
  142. """
  143. # 已经打开(首项『游戏王』可见)则不重复点击,避免把已开的下拉框又关掉
  144. if poco('游戏王').exists():
  145. return True
  146. tag_drop_down = \
  147. poco("android:id/content").child("android.widget.FrameLayout").child(
  148. "android.widget.FrameLayout").child(
  149. "android.view.View").child("android.view.View").child("android.view.View").child(
  150. "android.widget.Button")[1]
  151. if not tag_drop_down.exists():
  152. logger.warning("分类下拉框入口(tag_drop_down)未找到")
  153. return False
  154. tag_drop_down.click()
  155. if wait_exists(poco('游戏王'), timeout=3):
  156. return True
  157. # 兜底:刚才可能把已开的关掉了 / 首次没响应,再点一次
  158. tag_drop_down.click()
  159. return wait_exists(poco('游戏王'), timeout=3)
  160. def select_category(category):
  161. """在分类下拉里按文字选中分类,可视区找不到就滚动左侧面板直到出现。
  162. 集换社行情页由 Flutter 渲染,分类项为 ImageView,名字挂在 content-desc 上,
  163. poco 的 name 匹配即 content-desc(与语言按钮同理)。改用文字定位可彻底避免旧版
  164. 「固定滑动 + 硬编码 Button 索引」在分类列表增删时错位/越界的问题。
  165. Args:
  166. category (str): category_list 里的分类名,如 '峡谷争锋'、'迪士尼 洛卡纳'。
  167. Returns:
  168. bool: True 表示已点中分类;False 表示滚动多次仍未找到。
  169. """
  170. # UI 的 content-desc 可能无空格(如 '迪士尼洛卡纳'),额外做无空格候选
  171. candidates = [category]
  172. no_space = category.replace(' ', '')
  173. if no_space != category:
  174. candidates.append(no_space)
  175. # 先把分类面板滚回顶部,保证从已知位置开始逐屏往下找。
  176. # 否则下拉框若残留在底部滚动位置,目标在视区上方,只往下滚会一直漏找。
  177. # (向下滑动手指 = 内容回到顶部;目标已在当前视区则直接跳过复位)
  178. for _ in range(4):
  179. if any(poco(n).exists() for n in candidates):
  180. break
  181. swipe(v1=(0.25, 0.25), v2=(0.25, 0.55), duration=0.3)
  182. time.sleep(0.5)
  183. for attempt in range(8):
  184. for name in candidates:
  185. node = poco(name)
  186. if node.exists():
  187. node.click()
  188. logger.info(f'点击 集换榜分类 -> {category}')
  189. time.sleep(1)
  190. return True
  191. # 未在可视区 -> 向上滑动手指(内容往上走)看后面的分类
  192. logger.debug(f'分类 [{category}] 不在可视区,滚动下拉菜单 (第 {attempt + 1} 次)')
  193. swipe(v1=(0.25, 0.53), v2=(0.25, 0.22), duration=0.4)
  194. time.sleep(1)
  195. logger.warning(f'滚动多次仍未找到分类 [{category}],跳过')
  196. return False
  197. def click_bestseller_tab():
  198. """点击顶部『畅销榜』Tab,优先按文字(content-desc),失败回退图片模板。
  199. Returns:
  200. bool: True 表示已点击;False 表示文字与模板均失败。
  201. """
  202. tab = poco('畅销榜')
  203. if tab.exists():
  204. tab.click()
  205. return True
  206. logger.warning("畅销榜 文字定位失败, 回退图片模板")
  207. try:
  208. touch(Template(r"tpl1761026966680.png", record_pos=(0.048, -0.781), resolution=(1080, 2340)))
  209. return True
  210. except Exception as e:
  211. logger.error(f"畅销榜 模板点击失败: {e}")
  212. return False
  213. def parse_sales_data(raw_text):
  214. """
  215. 解析销售数据文本
  216. Args:
  217. raw_text (str): 原始文本数据
  218. Returns:
  219. dict: 解析后的数据字典
  220. """
  221. raw_list = raw_text.split("\n")
  222. # 确保第一个元素是数字,如果不是则插入空字符串
  223. if not raw_list[0].isdigit():
  224. raw_list.insert(0, '')
  225. # 检查列表长度是否足够
  226. if len(raw_list) < 11:
  227. raise ValueError("输入数据不完整")
  228. tv_rank = raw_list[0]
  229. title = raw_list[1]
  230. tv_number = raw_list[2]
  231. tv_rarity = raw_list[3]
  232. tv_cur_sales_num = raw_list[5]
  233. tv_cur_sales_num = tv_cur_sales_num.replace('张', '') if '张' in tv_cur_sales_num else tv_cur_sales_num
  234. # 增加判断:如果第7行和第8行连续是"本周期销量涨跌幅"和"本周期平均集换价",
  235. # 则在它们之间插入'-',并将tv_last_sales_num设为'-'
  236. if len(raw_list) >= 8 and raw_list[6] == "本周期销量涨跌幅" and raw_list[7] == "本周期平均集换价":
  237. raw_list.insert(7, '-')
  238. tv_last_sales_num = '-'
  239. else:
  240. try:
  241. tv_last_sales_num = raw_list[7]
  242. except IndexError:
  243. tv_last_sales_num = ''
  244. tv_price = raw_list[9]
  245. tv_price = tv_price.replace('¥', '') if '¥' in tv_price else tv_price
  246. tv_sales_data_num = raw_list[10]
  247. data_dict = {
  248. 'tv_rank': tv_rank,
  249. 'title': title,
  250. 'tv_number': tv_number,
  251. 'tv_rarity': tv_rarity,
  252. 'tv_cur_sales_num': tv_cur_sales_num,
  253. 'tv_last_sales_num': tv_last_sales_num,
  254. 'tv_price': tv_price,
  255. 'tv_sales_data_num': tv_sales_data_num
  256. }
  257. return data_dict
  258. def scroll_up():
  259. """列表向上滑动一屏(用于畅销榜翻页)。"""
  260. swipe(
  261. v1=(0.5, 0.9), # 起始点(底部)
  262. v2=(0.5, 0.7), # 结束点(顶部)
  263. duration=1.0
  264. )
  265. def _bottom_key(list_view):
  266. """取当前列表最后一张有效卡的 (title, 编号) 指纹。
  267. 用于判断 App 懒加载(每约 15 条加载下一批)是否已把新内容渲染出来:
  268. 滑动后底部指纹发生变化,才说明新一批卡真正加载完成、可以读取。
  269. Args:
  270. list_view: 畅销榜列表的 poco ScrollView 节点。
  271. Returns:
  272. tuple | None: (title, 编号);列表为空或读取失败时返回 None。
  273. """
  274. try:
  275. btns = list(list_view.offspring('android.widget.Button'))
  276. except Exception:
  277. return None
  278. for el in reversed(btns):
  279. child = el.child()
  280. if not child.exists():
  281. continue
  282. txt = child.attr('name')
  283. if not txt or '加入心愿单' in txt:
  284. continue
  285. try:
  286. d = parse_sales_data(txt)
  287. return (d.get('title'), d.get('tv_number'))
  288. except Exception:
  289. continue
  290. return None
  291. def scrape_pages(category, language):
  292. """在畅销榜列表页翻页抓取当前(分类,语言)的全部卡牌数据。
  293. 翻页按 (title, 编号) 内联去重;停止条件与旧版一致:抓到 rank==100 或翻满
  294. max_pages 才停(不做「连续无新增即停」——实测那样会把尾部截断、少抓几条)。
  295. 另加 guard 计数防止空翻页/异常反复 continue 导致死循环。
  296. Args:
  297. category (str): 当前分类名(用于打标签入库)。
  298. language (str): 当前语言名(用于打标签入库)。
  299. Returns:
  300. list[dict]: 每条为卡牌销量记录(已带 category/language/crawler_date,已去重)。
  301. """
  302. stop_page = False
  303. page_count = 1
  304. max_pages = MAX_PAGES
  305. data_list = []
  306. seen_keys = set() # (title, tv_number) 内联去重
  307. guard = 0 # 防止空翻页/异常反复 continue 导致死循环
  308. guard_max = max_pages * 3
  309. prev_bottom = None # 上一页底部卡指纹,用于判断懒加载是否已加载出新内容
  310. while page_count <= max_pages and not stop_page:
  311. guard += 1
  312. if guard > guard_max:
  313. logger.warning(f'翻页保护触发({guard}次), 停止 (已 {len(data_list)} 条)')
  314. break
  315. # 翻页操作:每页滑动 SCROLLS_PER_PAGE 次(步长小、重叠多,降低跳行)
  316. if page_count == 1:
  317. logger.debug("第一页开始翻页")
  318. scroll_up()
  319. else:
  320. logger.debug(f"{page_count}翻页.........")
  321. for _ in range(SCROLLS_PER_PAGE):
  322. scroll_up()
  323. tag_list_view = poco('android.widget.ScrollView')
  324. if not wait_exists(tag_list_view, timeout=15):
  325. logger.warning('ScrollView 未出现, 重试本次翻页')
  326. continue
  327. # 懒加载等待:App 每约 15 条才加载下一批,滑动后需等新内容真正出现再读,
  328. # 否则"还在加载就翻页"会把这批卡跳过(用底部卡片指纹是否变化来判断)
  329. if prev_bottom is not None:
  330. for _ in range(20): # 最多等 ~10s
  331. cur_bottom = _bottom_key(tag_list_view)
  332. if cur_bottom and cur_bottom != prev_bottom:
  333. break
  334. time.sleep(0.5)
  335. # 取列表 Button;取不到时重试本次翻页而不是抛异常(修复 0718 整轮被 kill)
  336. try:
  337. elements = list(tag_list_view.offspring('android.widget.Button'))
  338. except Exception as e:
  339. logger.warning(f'获取 Button 列表异常, 重试本次翻页: {e}')
  340. time.sleep(2)
  341. continue
  342. if not elements:
  343. logger.warning('未找到任何 Button,等待 2s 后重试本次翻页')
  344. time.sleep(2)
  345. continue
  346. page_last_key = prev_bottom
  347. for element in elements:
  348. element_title = element.child()
  349. if not element_title.exists():
  350. continue
  351. tag_element_data = element_title.attr('name')
  352. try:
  353. if '加入心愿单' in tag_element_data:
  354. logger.debug('<加入心愿单> 字样在tag_element_data, 跳过')
  355. continue
  356. element_data = parse_sales_data(tag_element_data)
  357. key = (element_data.get('title'), element_data.get('tv_number'))
  358. page_last_key = key # 记录本页最后一张(不论新旧),供下页判断加载完成
  359. if key in seen_keys:
  360. continue
  361. seen_keys.add(key)
  362. logger.info(element_data)
  363. filtered_data = {k: v for k, v in element_data.items() if k != 'tv_rank'}
  364. filtered_data.update({
  365. 'category': category,
  366. 'language': language,
  367. 'crawler_date': time.strftime("%Y-%m-%d", time.localtime())
  368. })
  369. logger.debug(f'scraped data: {filtered_data}')
  370. data_list.append(filtered_data)
  371. # 排名到 100 则停止翻页
  372. if element_data.get('tv_rank') == '100':
  373. logger.success(
  374. f'已获取第 {page_count} 页数据, 第 {element_data["tv_rank"]} 名, 停止翻页....')
  375. stop_page = True
  376. break
  377. except ValueError as e:
  378. logger.error(f"数据解析错误: {e}")
  379. if stop_page:
  380. logger.debug(f'已获取 {len(data_list)} 条数据, 停止翻页....')
  381. break
  382. prev_bottom = page_last_key # 记录本页底部, 下页据此等待懒加载完成
  383. page_count += 1
  384. time.sleep(1)
  385. logger.success(
  386. '---------------------------------------------------------------------------------------------')
  387. return data_list
  388. def get_data(sql_pool):
  389. """
  390. 获取数据
  391. :param sql_pool: MySQL连接池对象
  392. """
  393. scrape_index()
  394. for ca_idx, category_value in enumerate(category_list):
  395. category = list(category_value.keys())[0]
  396. lang_list = list(category_value.values())[0]
  397. logger.debug(
  398. f'当前分类索引: {ca_idx}, 分类名称: {category}, 语言列表: {lang_list} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
  399. for lang_idx, language in enumerate(lang_list):
  400. # 单个 (分类, 语言) 整体兜底:任一步失败只跳过该项,不让异常终止整轮任务
  401. try:
  402. # 1. 打开下拉框
  403. if not open_category_dropdown():
  404. logger.warning(f'[{category}-{language}] 下拉框打开失败, 跳过')
  405. continue
  406. # 2. 按文字选分类(滚动查找,避免旧版索引错位/越界)
  407. if not select_category(category):
  408. logger.warning(f'[{category}-{language}] 分类选择失败, 跳过')
  409. continue
  410. # 3. 点击语言
  411. logger.info(f'当前语言名称: {language} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
  412. tag_lang_list = poco(language)
  413. if not tag_lang_list.exists():
  414. logger.warning(f'[{category}-{language}] 语言项 [{language}] 未找到, 跳过')
  415. continue
  416. tag_lang_list.click()
  417. # 4. 点击畅销榜(优先文字,回退模板)
  418. if not click_bestseller_tab():
  419. logger.warning(f'[{category}-{language}] 畅销榜 Tab 点击失败, 跳过')
  420. continue
  421. wait_exists(poco('android.widget.ScrollView'), timeout=10)
  422. # 5. 翻页抓取
  423. data_list = scrape_pages(category, language)
  424. # 6. 入库(scrape_pages 已内联去重)
  425. if data_list:
  426. sql_pool.insert_many(table='jhs_bestseller_record', data_list=data_list, ignore=True)
  427. logger.success(f'[{category}-{language}] 入库 {len(data_list)} 条')
  428. else:
  429. logger.warning(f'[{category}-{language}] 未抓到数据, 不入库')
  430. except Exception as e:
  431. logger.error(f'[{category}-{language}] 处理异常, 跳过该项: {e}')
  432. continue
  433. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  434. def jhs_app_bestseller_main():
  435. """
  436. 主函数
  437. """
  438. logger.info(
  439. f'开始运行 {inspect.currentframe().f_code.co_name} 爬虫任务....................................................')
  440. # 运行时初始化设备/App/poco(放这里而非模块顶层)
  441. init_env()
  442. # 配置 MySQL 连接池
  443. sql_pool = MySQLConnectionPool(log=logger)
  444. if not sql_pool:
  445. logger.error("MySQL数据库连接失败")
  446. raise Exception("MySQL数据库连接失败")
  447. try:
  448. get_data(sql_pool)
  449. except Exception as e:
  450. logger.error(f'{inspect.currentframe().f_code.co_name} error: {e}')
  451. finally:
  452. logger.info(f'爬虫程序 {inspect.currentframe().f_code.co_name} 运行结束,等待下一轮的采集任务............')
  453. if __name__ == '__main__':
  454. jhs_app_bestseller_main()