| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- # -*- 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()
|