瀏覽代碼

feat(spider): 改进集换社畅销榜爬虫,优化分类选择和翻页逻辑

- 设备连接、App 启动及poco初始化移至运行时,避免导入时自动执行
- 分类下拉选择由硬编码索引改为按分类文字(content-desc)滚动查找,修复越界错误
- 单项抓取加整体try/except,避免单个失败终止全任务
- 顶部畅销榜Tab优先文字定位,失败回退图片模板,增强稳健性
- 关键地方用事件等待替代固定睡眠,加快响应且更稳定
- 翻页中按(title, 编号)内联去重,保持旧版停止条件并加防死循环保护
- 删除未使用死代码(scrape_detail 与 is_page_changed)
- 调整最大翻页数与单页滑动步数,减少数据跳行风险并防止提前停止
- 修正分类名无空格问题,使分类匹配更准确
charley 2 周之前
父節點
當前提交
48c9148163
共有 3 個文件被更改,包括 1088 次插入1 次删除
  1. 535 0
      jihuanshe_spider/jhs_app_spider_0319.py
  2. 552 0
      jihuanshe_spider/jhs_app_spider_0720.py
  3. 1 1
      jihuanshe_spider/jhs_app_start_spider.py

+ 535 - 0
jihuanshe_spider/jhs_app_spider_0319.py

@@ -0,0 +1,535 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.10.8
+# Date   : 2025/3/17 17:22
+import inspect
+import subprocess
+
+# import time
+# import schedule
+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
+
+# from poco.exceptions import PocoNoSuchNodeException
+
+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")
+
+subprocess.run(["adb", "connect", "127.0.0.1:5557"])
+
+PACKAGE_NAME = 'com.jihuanshe'
+# init_device("Android")
+
+# 使用模拟器连接方式
+# connect_device("Android://127.0.0.1:5557")
+connect_device("Android:///")
+# 在Airtest中, init_device 和 connect_device 是两个用于初始化设备的函数。
+#
+#  init_device 函数用于初始化设备连接,它会自动检测并连接可用的设备。你可以通过传递设备的参数来指定要连接的设备,例如设备的序列号、设备类型等。
+#
+#  connect_device 函数用于手动连接设备。你需要提供设备的参数,例如设备的序列号、设备类型等,来指定要连接的设备。
+#
+# 总结起来, init_device 函数会自动检测并连接可用的设备,而 connect_device 函数需要手动指定要连接的设备。
+stop_app(PACKAGE_NAME)
+# time.sleep(11)
+start_app(PACKAGE_NAME)
+"""
+# 如果ADB无法找到设备,尝试重启ADB服务
+D:\投屏\QtScrcpy-win-x64-v3.3.1\adb.exe kill-server
+"""
+
+category_list = [
+    {'游戏王': ['日文', '简中']},
+    {'宝可梦': ['简中', '日文', '英文']},
+    {'航海王': ['简中', '日文']},
+    {'数码宝贝': ['简中']},       
+    {'符文战场': ['简中']},
+    {'WS黑白双翼': ['日文', '简中']},
+    {'携站之境UA': ['简中', '日文']},
+    {'VG卡片战斗先导者': ['简中', '日文']},
+    {'高达GCG': ['简中']},
+    {'影之诗': ['简中']},
+    {'峡谷争锋': ['简中']},
+    {'迪士尼 洛卡纳': ['简中']}
+]
+
+poco = AndroidUiautomationPoco(
+    use_airtest_input=True, screenshot_each_action=False)
+window_width, window_height = poco.get_screen_size()
+
+
+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():
+    # 等待进去首页
+    time.sleep(10)
+
+    # 判断是否有广告  需要点击
+    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 poco(f'{PACKAGE_NAME}:id/imageIv').exists():
+        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")
+
+    elements = poco(f'{PACKAGE_NAME}:id/imageIv')
+    elements.click()
+    logger.success("成功找到并点击imageIv元素")
+
+
+def scrape_detail(element):
+    try:
+        # element.click()
+        # panel = poco_(f'{PACKAGE_NAME}:id/content')
+        # panel.wait_for_appearance(5)
+        element_title = element.offspring(f'{PACKAGE_NAME}:id/tvName')  # 标题
+        if element_title.exists():
+            title = element_title.attr('text')
+        else:
+            logger.warning("title元素未找到")
+            return None
+
+        # element_cardImage = element.offspring(f'{PACKAGE_NAME}:id/cardImage')
+        # if element_cardImage.exists():
+        #     cardImage = element_cardImage.attr('text')
+        # else:
+        #     logger.warning("cardImage元素未找到")
+        #     return None
+
+        element_tvSalesDataNum = element.offspring(f'{PACKAGE_NAME}:id/tvSalesDataNum')  # 销量
+        if element_tvSalesDataNum.exists():
+            tvSalesDataNum = element_tvSalesDataNum.attr('text')
+        else:
+            logger.warning("tvSalesDataNum元素未找到")
+            ele_tvSalesData = element.offspring(f'{PACKAGE_NAME}:id/tvSalesData')
+            if ele_tvSalesData.exists():
+                tvSalesDataNum = ele_tvSalesData.attr('text')
+                logger.debug(f'tvSalesDataNum元素未找到,使用tvSalesData元素')
+            else:
+                tvSalesDataNum = None
+            # return None
+
+        element_tvNumber = element.offspring(f'{PACKAGE_NAME}:id/tvNumber')  # 编号
+        if element_tvNumber.exists():
+            tvNumber = element_tvNumber.attr('text')
+        else:
+            logger.warning("tvNumber元素未找到")
+            return None
+
+        element_tvRarity = element.offspring(f'{PACKAGE_NAME}:id/tvRarity')  # 稀有度
+        if element_tvRarity.exists():
+            tvRarity = element_tvRarity.attr('text')
+        else:
+            logger.warning("tvRarity元素未找到")
+            return None
+
+        element_tvCurSalesNum = element.offspring(f'{PACKAGE_NAME}:id/tvCurSalesNum')  # 上周期销量
+        if element_tvCurSalesNum.exists():
+            tvCurSalesNum = element_tvCurSalesNum.attr('text')
+            if '张' in tvCurSalesNum:
+                tvCurSalesNum = tvCurSalesNum.replace('张', '')
+        else:
+            logger.warning("tvCurSalesNum元素未找到")
+            return None
+
+        element_tvLastSalesNum = element.offspring(f'{PACKAGE_NAME}:id/tvLastSalesNum')  # 本周期销量涨跌幅
+        if element_tvLastSalesNum.exists():
+            tvLastSalesNum = element_tvLastSalesNum.attr('text')
+        else:
+            logger.warning("tvLastSalesNum元素未找到")
+            return None
+
+        element_tvPrice = element.offspring(f'{PACKAGE_NAME}:id/tvPrice')  # 本周期平均集换价
+        if element_tvPrice.exists():
+            tvPrice = element_tvPrice.attr('text')
+        else:
+            logger.warning("tvPrice元素未找到")
+            return None
+
+        element_tvRank = element.offspring(f'{PACKAGE_NAME}:id/tvRank')
+        if element_tvRank.exists():
+            tvRank = element_tvRank.attr('text')
+        else:
+            logger.warning("tvRank元素未找到, 查找rankIv元素(前三名)")
+            element_rankIv = element.offspring(f'{PACKAGE_NAME}:id/rankIv')
+            if element_rankIv.exists():
+                tvRank = element_rankIv.attr('text')
+                logger.debug(f'tvRank元素未找到,使用rankIv元素(前三名)')
+                logger.debug(tvRank)
+            else:
+                tvRank = None
+
+        # keyevent('BACK')
+        data_dict = {
+            'title': title,
+            # 'card_image': cardImage,
+            'tv_sales_data_num': tvSalesDataNum,
+            'tv_number': tvNumber,
+            'tv_rarity': tvRarity,
+            'tv_cur_sales_num': tvCurSalesNum,
+            'tv_last_sales_num': tvLastSalesNum,
+            'tv_price': tvPrice,
+            'tv_rank': tvRank
+        }
+        # logger.debug(f'data_dict:{data_dict}')
+        return data_dict
+    except Exception as e:
+        logger.error(f"抓取详情时发生错误: {e}")
+        return None
+
+
+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
+
+    # tv_last_sales_num = raw_list[7]
+    # 增加判断:如果第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((window_width * 0.5, window_height * 0.8),
+    #       vector=[0, -0.5], duration=1)
+    swipe(
+        v1=(0.5, 0.9),  # 起始点(底部)
+        v2=(0.5, 0.7),  # 结束点(顶部)
+        duration=1.0  # 滑动持续 1 秒(单位:秒)
+    )
+
+
+def is_page_changed(old_elements, new_elements):
+    """检查页面是否成功翻页"""
+    if not old_elements or not new_elements:
+        return True
+
+    # 比较多个元素来判断页面是否变化
+    # min_len = min(len(old_elements), len(new_elements), 5)  # 比较前5个元素
+
+    # for i in range(min_len):
+    old_title_element = old_elements[-1].offspring(f'{PACKAGE_NAME}:id/tvName')
+    new_title_element = new_elements[-1].offspring(f'{PACKAGE_NAME}:id/tvName')
+
+    if old_title_element.exists() and new_title_element.exists():
+        old_title = old_title_element.attr('text')
+        new_title = new_title_element.attr('text')
+        logger.debug(f'old_title: {old_title}, new_title: {new_title}')
+        if old_title != new_title:
+            return True  # 找到不同元素,页面已变化
+        else:
+            logger.debug(f'old_title: {old_title}, new_title: {new_title}')
+            return False
+    else:
+        logger.warning("title元素未找到")
+        return True
+
+
+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]
+
+        # 调试:跳过指定分类之前的所有分类
+        # if category != '迪士尼 洛卡纳':
+        #     continue
+
+        lang_list = list(category_value.values())[0]
+        logger.debug(
+            f'当前分类索引: {ca_idx}, 分类名称: {category}, 语言列表: {lang_list} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        for lang_idx, language in enumerate(lang_list):
+
+            # 点击下拉框列表
+            # tag_drop_down = poco_(f"{PACKAGE_NAME}:id/tvName")
+            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]
+            tag_drop_down.click()
+
+            # 获取集换榜
+            tag_bang_list = 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.ScrollView")
+
+            # tag_bang_list.wait_for_appearance(timeout=60)
+            if not tag_bang_list.exists():
+                logger.warning("tag_bang_list元素未找到")
+            else:
+                # 在 tag_bang_list 循环内部,当 ca_idx > 8 时,需要滑动下拉菜单
+                if ca_idx > 8:
+                    logger.debug('滑动下拉菜单')
+                    # tag_drop_down.click()
+                    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.ScrollView").child("android.widget.Button")[
+                        7].swipe(
+                        [0.0035, -0.3152])
+                    time.sleep(2)
+
+                    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.ScrollView").child("android.widget.Button")[
+                        ca_idx - 2].click()
+                    logger.info(f'点击 集换榜分类 -> {category}')
+                else:
+                    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.ScrollView").child("android.widget.Button")[
+                        ca_idx].click()
+                    logger.info(f'点击 集换榜分类 -> {category}')
+
+                    # tag_bang.wait_for_appearance(timeout=60)
+
+                # time.sleep(2)
+
+                # 点击语言分类
+                logger.info(f'当前语言名称: {language} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+                # tag_lang_list = 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.view.View")[1].child("android.widget.Button")[lang_idx]
+                tag_lang_list = poco(language)
+                tag_lang_list.click()
+                # time.sleep(2)
+
+                # 点击畅销榜
+                touch(Template(r"tpl1761026966680.png", record_pos=(0.048, -0.781), resolution=(1080, 2340)))
+                # try:
+                #     # 定位滚动容器
+                #     scroll_view = 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.view.View")[0]
+                #     # print(scroll_view.attr("text"))
+                #
+                #     # 方法1:优先用文本匹配(最直观)
+                #     target = scroll_view.offspring(descContains="畅销榜")
+                #     if target.exists():
+                #         target.click()
+                #     else:
+                #         print("未找到“畅销榜”标签")
+                #
+                # except PocoNoSuchNodeException as e:
+                #     print("找不到指定的 UI 路径:", e)
+                time.sleep(2)
+
+                # 数据列表 -> 翻页
+                stop_page = False
+                # previous_elements = None
+                page_count = 1
+                max_pages = 30
+                data_list = []
+
+                while page_count <= max_pages and not stop_page:
+                    if page_count > max_pages:
+                        logger.warning("已达到最大翻页次数,停止翻页")
+                        break
+
+                    # 翻页操作
+                    if page_count == 1:
+                        logger.debug("第一页开始翻页")
+                        scroll_up()
+                        # scroll_up()
+                    else:
+                        logger.debug(f"{page_count}翻页.........")
+                        scroll_up()
+                        scroll_up()
+                        scroll_up()
+                        scroll_up()
+
+                    tag_list_view = poco('android.widget.ScrollView')
+                    appeared = False
+                    for _ in range(30):  # 最多等 15s (30 * 0.5s),出现后立即跳出
+                        if tag_list_view.exists():
+                            appeared = True
+                            break
+                        time.sleep(0.5)
+
+                    if not appeared:
+                        logger.warning('ScrollView 未出现, 重试本次翻页')
+                        continue
+
+                    elements = list(tag_list_view.offspring('android.widget.Button'))
+                    if not elements:
+                        logger.warning('未找到任何 Button,等待 2s 后重试本次翻页')
+                        time.sleep(2)
+                        continue
+
+                    for element in elements:
+                        element_title = element.child()
+                        if not element_title.exists():
+                            continue
+
+                        # element_data = scrape_detail(element)
+                        tag_element_data = element_title.attr('name')
+                        # print(f'element_data:{tag_element_data}')
+
+                        try:
+                            if '加入心愿单' not in tag_element_data:
+                                element_data = parse_sales_data(tag_element_data)
+                                logger.info(element_data)
+
+                                if 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
+                                else:
+                                    logger.warning("未获取到数据")
+                            else:
+                                logger.debug('<加入心愿单> 字样在tag_element_data, 跳过')
+
+                        except ValueError as e:
+                            logger.error(f"数据解析错误: {e}")
+
+                    if stop_page:
+                        logger.debug(f'已获取 {len(data_list)} 条数据, 停止翻页....')
+                        break
+
+                    page_count += 1
+                    time.sleep(1)
+
+                    logger.success(
+                        '---------------------------------------------------------------------------------------------')
+                # 保存数据
+                # 替换掉原来的 list(set(...)) 实现去重
+                seen = set()
+                unique_data_list = []
+                for item in data_list:
+                    frozen_item = frozenset(item.items())
+                    if frozen_item not in seen:
+                        seen.add(frozen_item)
+                        unique_data_list.append(item)
+
+                data_list = unique_data_list
+                # print(data_list)
+                sql_pool.insert_many(table='jhs_bestseller_record', data_list=data_list, ignore=True)
+
+
+@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} 爬虫任务....................................................')
+
+    # 配置 MySQL 连接池
+    sql_pool = MySQLConnectionPool(log=logger)
+    if not sql_pool:
+        logger.error("MySQL数据库连接失败")
+        raise Exception("MySQL数据库连接失败")
+
+    try:
+        get_data(sql_pool)
+
+        # stop_app(PACKAGE_NAME)
+    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__':
+    # schedule_task()
+    jhs_app_bestseller_main()

+ 552 - 0
jihuanshe_spider/jhs_app_spider_0720.py

@@ -0,0 +1,552 @@
+# -*- 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()

+ 1 - 1
jihuanshe_spider/jhs_app_start_spider.py

@@ -10,7 +10,7 @@ import subprocess
 def run_spider():
     """执行爬虫脚本"""
     try:
-        result = subprocess.run(['python', 'jhs_app_spider.py'], check=True)
+        result = subprocess.run(['python', 'jhs_app_spider_0720.py'], check=True)
         print(f"爬虫执行成功,返回码: {result.returncode}")
     except subprocess.CalledProcessError as e:
         print(f"爬虫执行失败: {e}")