# -*- coding: utf-8 -*- # Author : Charley # Python : 3.10.8 # Date : 2026/07/20 # # 变更(2026/07/20): # [bugfix] # 1. 分类下拉选择由「固定滑动 + 硬编码 Button[ca_idx-2] 索引」改为 # 「按分类文字(content-desc)滚动查找并点击」。原索引方案在 category_list # 增删项时会错位/越界(新增『峡谷争锋』后,index 11『迪士尼洛卡纳』点 # Button[9] 触发 list index out of range)。 # 2. 每个 (分类, 语言) 迭代整体包 try/except,单项失败只跳过、不再让异常冒泡 # 终止整个任务(修复 20260718.log 里首个分类超时后整轮任务被 kill)。 # 3. 翻页时 offspring('android.widget.Button') 查询包 try/except。 # [optimize] # 1. 设备连接/停开 App/poco 初始化从模块顶层挪进 init_env(),避免 import 即重启 App。 # 3. 删除从未被调用的死代码 scrape_detail() / is_page_changed()。 # 4. 顶部『畅销榜』Tab 改为优先按文字点击(poco('畅销榜')),失败再回退图片模板, # 免掉模板 resolution=(1080,2340) 与模拟器 900x1600 不一致的隐患。 # 5. 关键固定 time.sleep 改为 wait_for_appearance 事件等待(等到即走,更快更稳)。 # 6. 翻页中内联去重(按 title+编号);停止条件保持旧版「rank==100 或翻满 # max_pages」,另加 guard 防死循环。 # 注:曾试过「连续N页无新增即停」,但实测会把尾部截断少抓几条,已回退。 # 注:UI 里『迪士尼洛卡纳』的 content-desc 现已无空格;select_category 会自动 # 对带空格的分类名做无空格候选匹配。 import inspect import subprocess from airtest.core.api import * from poco.drivers.android.uiautomation import AndroidUiautomationPoco from loguru import logger from mysql_pool import MySQLConnectionPool from tenacity import retry, stop_after_attempt, wait_fixed logger.remove() logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00", format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="DEBUG", retention="7 day") PACKAGE_NAME = 'com.jihuanshe' DEVICE_URI = "Android:///" # 如需固定模拟器可改 "Android://127.0.0.1:5557" ADB_TARGET = "127.0.0.1:5557" # adb connect 目标 # 翻页参数(为尽量保证数据完整性:每页滑动步长调小、增加页面重叠,靠去重兜住) # SCROLLS_PER_PAGE 越小 -> 每页前进越少、重叠越多 -> 越不易跳行,但需要更多页数 SCROLLS_PER_PAGE = 2 # 每页滑动次数(旧版为 4,减小以降低跳行) MAX_PAGES = 60 # 最大翻页数(步长变小后相应增大,防止翻不到 rank100) category_list = [ {'游戏王': ['日文', '简中']}, {'宝可梦': ['简中', '日文', '英文']}, {'航海王': ['简中', '日文']}, {'数码宝贝': ['简中']}, {'符文战场': ['简中']}, {'WS黑白双翼': ['日文', '简中']}, {'携站之境UA': ['简中', '日文']}, {'VG卡片战斗先导者': ['简中', '日文']}, {'高达GCG': ['简中']}, {'影之诗': ['简中']}, {'峡谷争锋': ['简中']}, {'迪士尼洛卡纳': ['简中']} # 2026/07/20 起统一无空格,与 App 及库中数据对齐 ] # 运行时由 init_env() 初始化,避免 import 即产生副作用 # poco = None window_width = None window_height = None def init_env(): """初始化设备连接、重启目标 App、创建 poco 实例。 放到运行时执行(而非模块顶层),避免任何 import 本模块的行为都触发 App 重启。 被 @retry 的主函数每次重试都会重新调用,天然带「重连+重启」的恢复能力。 """ global poco, window_width, window_height subprocess.run(["adb", "connect", ADB_TARGET]) connect_device(DEVICE_URI) stop_app(PACKAGE_NAME) start_app(PACKAGE_NAME) poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False) window_width, window_height = poco.get_screen_size() logger.info(f"init_env 完成: 屏幕 {window_width}x{window_height}") def wait_exists(node, timeout=8): """等待 poco 节点出现,返回是否出现(超时不抛异常)。 Args: node: poco UIObjectProxy 节点。 timeout (int, optional): 最长等待秒数。Defaults to 8。 Returns: bool: True 表示在超时前出现;False 表示超时未出现。 """ try: node.wait_for_appearance(timeout=timeout) return True except Exception: return False def after_log(retry_state): """ retry 回调 :param retry_state: RetryCallState 对象 """ # 检查 args 是否存在且不为空 if retry_state.args and len(retry_state.args) > 0: local_logger = retry_state.args[0] # 获取传入的 logger else: local_logger = logger # 使用全局 logger if retry_state.outcome.failed: local_logger.warning( f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times") else: local_logger.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded") @retry(stop=stop_after_attempt(3), wait=wait_fixed(10), after=after_log) def scrape_index(): """进入首页、关闭广告、点击行情入口 imageIv。 Raises: Exception: imageIv 元素始终不可见时抛出,触发上层 retry。 """ # 等待 App 启动到首页:优先等广告或行情入口出现,最多 ~15s for _ in range(15): if poco(f'{PACKAGE_NAME}:id/ivClose').exists() or poco(f'{PACKAGE_NAME}:id/imageIv').exists(): break time.sleep(1) # 判断是否有广告 需要点击 tag_adv = poco(f'{PACKAGE_NAME}:id/ivClose') for _ in range(3): if tag_adv.exists(): logger.warning("广告元素已找到") tag_adv.click() time.sleep(1) else: logger.debug("广告元素未找到, 退出") break logger.info("开始查找imageIv元素...") if not wait_exists(poco(f'{PACKAGE_NAME}:id/imageIv'), timeout=10): logger.warning("imageIv元素不存在,准备重试...") try: snapshot_path = f"./logs/debug_screenshot_{time.strftime('%Y%m%d_%H%M%S')}.png" snapshot(filename=snapshot_path) logger.info(f"已保存调试截图到: {snapshot_path}") except Exception as snap_error: logger.warning(f"截图失败: {snap_error}") raise Exception(f"Cannot find visible node: {PACKAGE_NAME}:id/imageIv") poco(f'{PACKAGE_NAME}:id/imageIv').click() logger.success("成功找到并点击imageIv元素") def open_category_dropdown(): """点开顶部「分类/语言」下拉框。 下拉入口是标题栏里的第 2 个 Button(tag_drop_down),点开后左侧为分类列表、 右侧为语言列表。点击后等待列表首项『游戏王』出现作为就绪信号。 Returns: bool: True 表示下拉框已点开且列表就绪;False 表示未找到入口。 """ # 已经打开(首项『游戏王』可见)则不重复点击,避免把已开的下拉框又关掉 if poco('游戏王').exists(): return True tag_drop_down = \ poco("android:id/content").child("android.widget.FrameLayout").child( "android.widget.FrameLayout").child( "android.view.View").child("android.view.View").child("android.view.View").child( "android.widget.Button")[1] if not tag_drop_down.exists(): logger.warning("分类下拉框入口(tag_drop_down)未找到") return False tag_drop_down.click() if wait_exists(poco('游戏王'), timeout=3): return True # 兜底:刚才可能把已开的关掉了 / 首次没响应,再点一次 tag_drop_down.click() return wait_exists(poco('游戏王'), timeout=3) def select_category(category): """在分类下拉里按文字选中分类,可视区找不到就滚动左侧面板直到出现。 集换社行情页由 Flutter 渲染,分类项为 ImageView,名字挂在 content-desc 上, poco 的 name 匹配即 content-desc(与语言按钮同理)。改用文字定位可彻底避免旧版 「固定滑动 + 硬编码 Button 索引」在分类列表增删时错位/越界的问题。 Args: category (str): category_list 里的分类名,如 '峡谷争锋'、'迪士尼 洛卡纳'。 Returns: bool: True 表示已点中分类;False 表示滚动多次仍未找到。 """ # UI 的 content-desc 可能无空格(如 '迪士尼洛卡纳'),额外做无空格候选 candidates = [category] no_space = category.replace(' ', '') if no_space != category: candidates.append(no_space) # 先把分类面板滚回顶部,保证从已知位置开始逐屏往下找。 # 否则下拉框若残留在底部滚动位置,目标在视区上方,只往下滚会一直漏找。 # (向下滑动手指 = 内容回到顶部;目标已在当前视区则直接跳过复位) for _ in range(4): if any(poco(n).exists() for n in candidates): break swipe(v1=(0.25, 0.25), v2=(0.25, 0.55), duration=0.3) time.sleep(0.5) for attempt in range(8): for name in candidates: node = poco(name) if node.exists(): node.click() logger.info(f'点击 集换榜分类 -> {category}') time.sleep(1) return True # 未在可视区 -> 向上滑动手指(内容往上走)看后面的分类 logger.debug(f'分类 [{category}] 不在可视区,滚动下拉菜单 (第 {attempt + 1} 次)') swipe(v1=(0.25, 0.53), v2=(0.25, 0.22), duration=0.4) time.sleep(1) logger.warning(f'滚动多次仍未找到分类 [{category}],跳过') return False def click_bestseller_tab(): """点击顶部『畅销榜』Tab,优先按文字(content-desc),失败回退图片模板。 Returns: bool: True 表示已点击;False 表示文字与模板均失败。 """ tab = poco('畅销榜') if tab.exists(): tab.click() return True logger.warning("畅销榜 文字定位失败, 回退图片模板") try: touch(Template(r"tpl1761026966680.png", record_pos=(0.048, -0.781), resolution=(1080, 2340))) return True except Exception as e: logger.error(f"畅销榜 模板点击失败: {e}") return False def parse_sales_data(raw_text): """ 解析销售数据文本 Args: raw_text (str): 原始文本数据 Returns: dict: 解析后的数据字典 """ raw_list = raw_text.split("\n") # 确保第一个元素是数字,如果不是则插入空字符串 if not raw_list[0].isdigit(): raw_list.insert(0, '') # 检查列表长度是否足够 if len(raw_list) < 11: raise ValueError("输入数据不完整") tv_rank = raw_list[0] title = raw_list[1] tv_number = raw_list[2] tv_rarity = raw_list[3] tv_cur_sales_num = raw_list[5] tv_cur_sales_num = tv_cur_sales_num.replace('张', '') if '张' in tv_cur_sales_num else tv_cur_sales_num # 增加判断:如果第7行和第8行连续是"本周期销量涨跌幅"和"本周期平均集换价", # 则在它们之间插入'-',并将tv_last_sales_num设为'-' if len(raw_list) >= 8 and raw_list[6] == "本周期销量涨跌幅" and raw_list[7] == "本周期平均集换价": raw_list.insert(7, '-') tv_last_sales_num = '-' else: try: tv_last_sales_num = raw_list[7] except IndexError: tv_last_sales_num = '' tv_price = raw_list[9] tv_price = tv_price.replace('¥', '') if '¥' in tv_price else tv_price tv_sales_data_num = raw_list[10] data_dict = { 'tv_rank': tv_rank, 'title': title, 'tv_number': tv_number, 'tv_rarity': tv_rarity, 'tv_cur_sales_num': tv_cur_sales_num, 'tv_last_sales_num': tv_last_sales_num, 'tv_price': tv_price, 'tv_sales_data_num': tv_sales_data_num } return data_dict def scroll_up(): """列表向上滑动一屏(用于畅销榜翻页)。""" swipe( v1=(0.5, 0.9), # 起始点(底部) v2=(0.5, 0.7), # 结束点(顶部) duration=1.0 ) def _bottom_key(list_view): """取当前列表最后一张有效卡的 (title, 编号) 指纹。 用于判断 App 懒加载(每约 15 条加载下一批)是否已把新内容渲染出来: 滑动后底部指纹发生变化,才说明新一批卡真正加载完成、可以读取。 Args: list_view: 畅销榜列表的 poco ScrollView 节点。 Returns: tuple | None: (title, 编号);列表为空或读取失败时返回 None。 """ try: btns = list(list_view.offspring('android.widget.Button')) except Exception: return None for el in reversed(btns): child = el.child() if not child.exists(): continue txt = child.attr('name') if not txt or '加入心愿单' in txt: continue try: d = parse_sales_data(txt) return (d.get('title'), d.get('tv_number')) except Exception: continue return None def scrape_pages(category, language): """在畅销榜列表页翻页抓取当前(分类,语言)的全部卡牌数据。 翻页按 (title, 编号) 内联去重;停止条件与旧版一致:抓到 rank==100 或翻满 max_pages 才停(不做「连续无新增即停」——实测那样会把尾部截断、少抓几条)。 另加 guard 计数防止空翻页/异常反复 continue 导致死循环。 Args: category (str): 当前分类名(用于打标签入库)。 language (str): 当前语言名(用于打标签入库)。 Returns: list[dict]: 每条为卡牌销量记录(已带 category/language/crawler_date,已去重)。 """ stop_page = False page_count = 1 max_pages = MAX_PAGES data_list = [] seen_keys = set() # (title, tv_number) 内联去重 guard = 0 # 防止空翻页/异常反复 continue 导致死循环 guard_max = max_pages * 3 prev_bottom = None # 上一页底部卡指纹,用于判断懒加载是否已加载出新内容 while page_count <= max_pages and not stop_page: guard += 1 if guard > guard_max: logger.warning(f'翻页保护触发({guard}次), 停止 (已 {len(data_list)} 条)') break # 翻页操作:每页滑动 SCROLLS_PER_PAGE 次(步长小、重叠多,降低跳行) if page_count == 1: logger.debug("第一页开始翻页") scroll_up() else: logger.debug(f"{page_count}翻页.........") for _ in range(SCROLLS_PER_PAGE): scroll_up() tag_list_view = poco('android.widget.ScrollView') if not wait_exists(tag_list_view, timeout=15): logger.warning('ScrollView 未出现, 重试本次翻页') continue # 懒加载等待:App 每约 15 条才加载下一批,滑动后需等新内容真正出现再读, # 否则"还在加载就翻页"会把这批卡跳过(用底部卡片指纹是否变化来判断) if prev_bottom is not None: for _ in range(20): # 最多等 ~10s cur_bottom = _bottom_key(tag_list_view) if cur_bottom and cur_bottom != prev_bottom: break time.sleep(0.5) # 取列表 Button;取不到时重试本次翻页而不是抛异常(修复 0718 整轮被 kill) try: elements = list(tag_list_view.offspring('android.widget.Button')) except Exception as e: logger.warning(f'获取 Button 列表异常, 重试本次翻页: {e}') time.sleep(2) continue if not elements: logger.warning('未找到任何 Button,等待 2s 后重试本次翻页') time.sleep(2) continue page_last_key = prev_bottom for element in elements: element_title = element.child() if not element_title.exists(): continue tag_element_data = element_title.attr('name') try: if '加入心愿单' in tag_element_data: logger.debug('<加入心愿单> 字样在tag_element_data, 跳过') continue element_data = parse_sales_data(tag_element_data) key = (element_data.get('title'), element_data.get('tv_number')) page_last_key = key # 记录本页最后一张(不论新旧),供下页判断加载完成 if key in seen_keys: continue seen_keys.add(key) logger.info(element_data) filtered_data = {k: v for k, v in element_data.items() if k != 'tv_rank'} filtered_data.update({ 'category': category, 'language': language, 'crawler_date': time.strftime("%Y-%m-%d", time.localtime()) }) logger.debug(f'scraped data: {filtered_data}') data_list.append(filtered_data) # 排名到 100 则停止翻页 if element_data.get('tv_rank') == '100': logger.success( f'已获取第 {page_count} 页数据, 第 {element_data["tv_rank"]} 名, 停止翻页....') stop_page = True break except ValueError as e: logger.error(f"数据解析错误: {e}") if stop_page: logger.debug(f'已获取 {len(data_list)} 条数据, 停止翻页....') break prev_bottom = page_last_key # 记录本页底部, 下页据此等待懒加载完成 page_count += 1 time.sleep(1) logger.success( '---------------------------------------------------------------------------------------------') return data_list def get_data(sql_pool): """ 获取数据 :param sql_pool: MySQL连接池对象 """ scrape_index() for ca_idx, category_value in enumerate(category_list): category = list(category_value.keys())[0] lang_list = list(category_value.values())[0] logger.debug( f'当前分类索引: {ca_idx}, 分类名称: {category}, 语言列表: {lang_list} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>') for lang_idx, language in enumerate(lang_list): # 单个 (分类, 语言) 整体兜底:任一步失败只跳过该项,不让异常终止整轮任务 try: # 1. 打开下拉框 if not open_category_dropdown(): logger.warning(f'[{category}-{language}] 下拉框打开失败, 跳过') continue # 2. 按文字选分类(滚动查找,避免旧版索引错位/越界) if not select_category(category): logger.warning(f'[{category}-{language}] 分类选择失败, 跳过') continue # 3. 点击语言 logger.info(f'当前语言名称: {language} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>') tag_lang_list = poco(language) if not tag_lang_list.exists(): logger.warning(f'[{category}-{language}] 语言项 [{language}] 未找到, 跳过') continue tag_lang_list.click() # 4. 点击畅销榜(优先文字,回退模板) if not click_bestseller_tab(): logger.warning(f'[{category}-{language}] 畅销榜 Tab 点击失败, 跳过') continue wait_exists(poco('android.widget.ScrollView'), timeout=10) # 5. 翻页抓取 data_list = scrape_pages(category, language) # 6. 入库(scrape_pages 已内联去重) if data_list: sql_pool.insert_many(table='jhs_bestseller_record', data_list=data_list, ignore=True) logger.success(f'[{category}-{language}] 入库 {len(data_list)} 条') else: logger.warning(f'[{category}-{language}] 未抓到数据, 不入库') except Exception as e: logger.error(f'[{category}-{language}] 处理异常, 跳过该项: {e}') continue @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log) def jhs_app_bestseller_main(): """ 主函数 """ logger.info( f'开始运行 {inspect.currentframe().f_code.co_name} 爬虫任务....................................................') # 运行时初始化设备/App/poco(放这里而非模块顶层) init_env() # 配置 MySQL 连接池 sql_pool = MySQLConnectionPool(log=logger) if not sql_pool: logger.error("MySQL数据库连接失败") raise Exception("MySQL数据库连接失败") try: get_data(sql_pool) except Exception as e: logger.error(f'{inspect.currentframe().f_code.co_name} error: {e}') finally: logger.info(f'爬虫程序 {inspect.currentframe().f_code.co_name} 运行结束,等待下一轮的采集任务............') if __name__ == '__main__': jhs_app_bestseller_main()