jhs_app_spider_0319.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.10.8
  4. # Date : 2025/3/17 17:22
  5. import inspect
  6. import subprocess
  7. # import time
  8. # import schedule
  9. from airtest.core.api import *
  10. from poco.drivers.android.uiautomation import AndroidUiautomationPoco
  11. from loguru import logger
  12. from mysql_pool import MySQLConnectionPool
  13. from tenacity import retry, stop_after_attempt, wait_fixed
  14. # from poco.exceptions import PocoNoSuchNodeException
  15. logger.remove()
  16. logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00",
  17. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  18. level="DEBUG", retention="7 day")
  19. subprocess.run(["adb", "connect", "127.0.0.1:5557"])
  20. PACKAGE_NAME = 'com.jihuanshe'
  21. # init_device("Android")
  22. # 使用模拟器连接方式
  23. # connect_device("Android://127.0.0.1:5557")
  24. connect_device("Android:///")
  25. # 在Airtest中, init_device 和 connect_device 是两个用于初始化设备的函数。
  26. #
  27. # init_device 函数用于初始化设备连接,它会自动检测并连接可用的设备。你可以通过传递设备的参数来指定要连接的设备,例如设备的序列号、设备类型等。
  28. #
  29. # connect_device 函数用于手动连接设备。你需要提供设备的参数,例如设备的序列号、设备类型等,来指定要连接的设备。
  30. #
  31. # 总结起来, init_device 函数会自动检测并连接可用的设备,而 connect_device 函数需要手动指定要连接的设备。
  32. stop_app(PACKAGE_NAME)
  33. # time.sleep(11)
  34. start_app(PACKAGE_NAME)
  35. """
  36. # 如果ADB无法找到设备,尝试重启ADB服务
  37. D:\投屏\QtScrcpy-win-x64-v3.3.1\adb.exe kill-server
  38. """
  39. category_list = [
  40. {'游戏王': ['日文', '简中']},
  41. {'宝可梦': ['简中', '日文', '英文']},
  42. {'航海王': ['简中', '日文']},
  43. {'数码宝贝': ['简中']},
  44. {'符文战场': ['简中']},
  45. {'WS黑白双翼': ['日文', '简中']},
  46. {'携站之境UA': ['简中', '日文']},
  47. {'VG卡片战斗先导者': ['简中', '日文']},
  48. {'高达GCG': ['简中']},
  49. {'影之诗': ['简中']},
  50. {'峡谷争锋': ['简中']},
  51. {'迪士尼 洛卡纳': ['简中']}
  52. ]
  53. poco = AndroidUiautomationPoco(
  54. use_airtest_input=True, screenshot_each_action=False)
  55. window_width, window_height = poco.get_screen_size()
  56. def after_log(retry_state):
  57. """
  58. retry 回调
  59. :param retry_state: RetryCallState 对象
  60. """
  61. # 检查 args 是否存在且不为空
  62. if retry_state.args and len(retry_state.args) > 0:
  63. local_logger = retry_state.args[0] # 获取传入的 logger
  64. else:
  65. local_logger = logger # 使用全局 logger
  66. if retry_state.outcome.failed:
  67. local_logger.warning(
  68. f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  69. else:
  70. local_logger.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  71. @retry(stop=stop_after_attempt(3), wait=wait_fixed(10), after=after_log)
  72. def scrape_index():
  73. # 等待进去首页
  74. time.sleep(10)
  75. # 判断是否有广告 需要点击
  76. tag_adv = poco(f'{PACKAGE_NAME}:id/ivClose')
  77. for _ in range(3):
  78. if tag_adv.exists():
  79. logger.warning("广告元素已找到")
  80. tag_adv.click()
  81. time.sleep(1)
  82. else:
  83. logger.debug("广告元素未找到, 退出")
  84. break
  85. logger.info("开始查找imageIv元素...")
  86. if not poco(f'{PACKAGE_NAME}:id/imageIv').exists():
  87. logger.warning("imageIv元素不存在,准备重试...")
  88. try:
  89. snapshot_path = f"./logs/debug_screenshot_{time.strftime('%Y%m%d_%H%M%S')}.png"
  90. snapshot(filename=snapshot_path)
  91. logger.info(f"已保存调试截图到: {snapshot_path}")
  92. except Exception as snap_error:
  93. logger.warning(f"截图失败: {snap_error}")
  94. raise Exception(f"Cannot find visible node: {PACKAGE_NAME}:id/imageIv")
  95. elements = poco(f'{PACKAGE_NAME}:id/imageIv')
  96. elements.click()
  97. logger.success("成功找到并点击imageIv元素")
  98. def scrape_detail(element):
  99. try:
  100. # element.click()
  101. # panel = poco_(f'{PACKAGE_NAME}:id/content')
  102. # panel.wait_for_appearance(5)
  103. element_title = element.offspring(f'{PACKAGE_NAME}:id/tvName') # 标题
  104. if element_title.exists():
  105. title = element_title.attr('text')
  106. else:
  107. logger.warning("title元素未找到")
  108. return None
  109. # element_cardImage = element.offspring(f'{PACKAGE_NAME}:id/cardImage')
  110. # if element_cardImage.exists():
  111. # cardImage = element_cardImage.attr('text')
  112. # else:
  113. # logger.warning("cardImage元素未找到")
  114. # return None
  115. element_tvSalesDataNum = element.offspring(f'{PACKAGE_NAME}:id/tvSalesDataNum') # 销量
  116. if element_tvSalesDataNum.exists():
  117. tvSalesDataNum = element_tvSalesDataNum.attr('text')
  118. else:
  119. logger.warning("tvSalesDataNum元素未找到")
  120. ele_tvSalesData = element.offspring(f'{PACKAGE_NAME}:id/tvSalesData')
  121. if ele_tvSalesData.exists():
  122. tvSalesDataNum = ele_tvSalesData.attr('text')
  123. logger.debug(f'tvSalesDataNum元素未找到,使用tvSalesData元素')
  124. else:
  125. tvSalesDataNum = None
  126. # return None
  127. element_tvNumber = element.offspring(f'{PACKAGE_NAME}:id/tvNumber') # 编号
  128. if element_tvNumber.exists():
  129. tvNumber = element_tvNumber.attr('text')
  130. else:
  131. logger.warning("tvNumber元素未找到")
  132. return None
  133. element_tvRarity = element.offspring(f'{PACKAGE_NAME}:id/tvRarity') # 稀有度
  134. if element_tvRarity.exists():
  135. tvRarity = element_tvRarity.attr('text')
  136. else:
  137. logger.warning("tvRarity元素未找到")
  138. return None
  139. element_tvCurSalesNum = element.offspring(f'{PACKAGE_NAME}:id/tvCurSalesNum') # 上周期销量
  140. if element_tvCurSalesNum.exists():
  141. tvCurSalesNum = element_tvCurSalesNum.attr('text')
  142. if '张' in tvCurSalesNum:
  143. tvCurSalesNum = tvCurSalesNum.replace('张', '')
  144. else:
  145. logger.warning("tvCurSalesNum元素未找到")
  146. return None
  147. element_tvLastSalesNum = element.offspring(f'{PACKAGE_NAME}:id/tvLastSalesNum') # 本周期销量涨跌幅
  148. if element_tvLastSalesNum.exists():
  149. tvLastSalesNum = element_tvLastSalesNum.attr('text')
  150. else:
  151. logger.warning("tvLastSalesNum元素未找到")
  152. return None
  153. element_tvPrice = element.offspring(f'{PACKAGE_NAME}:id/tvPrice') # 本周期平均集换价
  154. if element_tvPrice.exists():
  155. tvPrice = element_tvPrice.attr('text')
  156. else:
  157. logger.warning("tvPrice元素未找到")
  158. return None
  159. element_tvRank = element.offspring(f'{PACKAGE_NAME}:id/tvRank')
  160. if element_tvRank.exists():
  161. tvRank = element_tvRank.attr('text')
  162. else:
  163. logger.warning("tvRank元素未找到, 查找rankIv元素(前三名)")
  164. element_rankIv = element.offspring(f'{PACKAGE_NAME}:id/rankIv')
  165. if element_rankIv.exists():
  166. tvRank = element_rankIv.attr('text')
  167. logger.debug(f'tvRank元素未找到,使用rankIv元素(前三名)')
  168. logger.debug(tvRank)
  169. else:
  170. tvRank = None
  171. # keyevent('BACK')
  172. data_dict = {
  173. 'title': title,
  174. # 'card_image': cardImage,
  175. 'tv_sales_data_num': tvSalesDataNum,
  176. 'tv_number': tvNumber,
  177. 'tv_rarity': tvRarity,
  178. 'tv_cur_sales_num': tvCurSalesNum,
  179. 'tv_last_sales_num': tvLastSalesNum,
  180. 'tv_price': tvPrice,
  181. 'tv_rank': tvRank
  182. }
  183. # logger.debug(f'data_dict:{data_dict}')
  184. return data_dict
  185. except Exception as e:
  186. logger.error(f"抓取详情时发生错误: {e}")
  187. return None
  188. def parse_sales_data(raw_text):
  189. """
  190. 解析销售数据文本
  191. Args:
  192. raw_text (str): 原始文本数据
  193. Returns:
  194. dict: 解析后的数据字典
  195. """
  196. raw_list = raw_text.split("\n")
  197. # 确保第一个元素是数字,如果不是则插入空字符串
  198. if not raw_list[0].isdigit():
  199. raw_list.insert(0, '')
  200. # 检查列表长度是否足够
  201. if len(raw_list) < 11:
  202. raise ValueError("输入数据不完整")
  203. tv_rank = raw_list[0]
  204. title = raw_list[1]
  205. tv_number = raw_list[2]
  206. tv_rarity = raw_list[3]
  207. tv_cur_sales_num = raw_list[5]
  208. tv_cur_sales_num = tv_cur_sales_num.replace('张', '') if '张' in tv_cur_sales_num else tv_cur_sales_num
  209. # tv_last_sales_num = raw_list[7]
  210. # 增加判断:如果第7行和第8行连续是"本周期销量涨跌幅"和"本周期平均集换价",
  211. # 则在它们之间插入'-',并将tv_last_sales_num设为'-'
  212. if len(raw_list) >= 8 and raw_list[6] == "本周期销量涨跌幅" and raw_list[7] == "本周期平均集换价":
  213. # 在"本周期销量涨跌幅"后插入'-'
  214. raw_list.insert(7, '-')
  215. tv_last_sales_num = '-'
  216. else:
  217. try:
  218. tv_last_sales_num = raw_list[7]
  219. except IndexError:
  220. tv_last_sales_num = ''
  221. tv_price = raw_list[9]
  222. tv_price = tv_price.replace('¥', '') if '¥' in tv_price else tv_price
  223. tv_sales_data_num = raw_list[10]
  224. data_dict = {
  225. 'tv_rank': tv_rank,
  226. 'title': title,
  227. 'tv_number': tv_number,
  228. 'tv_rarity': tv_rarity,
  229. 'tv_cur_sales_num': tv_cur_sales_num,
  230. 'tv_last_sales_num': tv_last_sales_num,
  231. 'tv_price': tv_price,
  232. 'tv_sales_data_num': tv_sales_data_num
  233. }
  234. return data_dict
  235. def scroll_up():
  236. # swipe((window_width * 0.5, window_height * 0.8),
  237. # vector=[0, -0.5], duration=1)
  238. swipe(
  239. v1=(0.5, 0.9), # 起始点(底部)
  240. v2=(0.5, 0.7), # 结束点(顶部)
  241. duration=1.0 # 滑动持续 1 秒(单位:秒)
  242. )
  243. def is_page_changed(old_elements, new_elements):
  244. """检查页面是否成功翻页"""
  245. if not old_elements or not new_elements:
  246. return True
  247. # 比较多个元素来判断页面是否变化
  248. # min_len = min(len(old_elements), len(new_elements), 5) # 比较前5个元素
  249. # for i in range(min_len):
  250. old_title_element = old_elements[-1].offspring(f'{PACKAGE_NAME}:id/tvName')
  251. new_title_element = new_elements[-1].offspring(f'{PACKAGE_NAME}:id/tvName')
  252. if old_title_element.exists() and new_title_element.exists():
  253. old_title = old_title_element.attr('text')
  254. new_title = new_title_element.attr('text')
  255. logger.debug(f'old_title: {old_title}, new_title: {new_title}')
  256. if old_title != new_title:
  257. return True # 找到不同元素,页面已变化
  258. else:
  259. logger.debug(f'old_title: {old_title}, new_title: {new_title}')
  260. return False
  261. else:
  262. logger.warning("title元素未找到")
  263. return True
  264. def get_data(sql_pool):
  265. """
  266. 获取数据
  267. :param sql_pool: MySQL连接池对象
  268. """
  269. scrape_index()
  270. for ca_idx, category_value in enumerate(category_list):
  271. category = list(category_value.keys())[0]
  272. # 调试:跳过指定分类之前的所有分类
  273. # if category != '迪士尼 洛卡纳':
  274. # continue
  275. lang_list = list(category_value.values())[0]
  276. logger.debug(
  277. f'当前分类索引: {ca_idx}, 分类名称: {category}, 语言列表: {lang_list} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
  278. for lang_idx, language in enumerate(lang_list):
  279. # 点击下拉框列表
  280. # tag_drop_down = poco_(f"{PACKAGE_NAME}:id/tvName")
  281. tag_drop_down = \
  282. poco("android:id/content").child("android.widget.FrameLayout").child(
  283. "android.widget.FrameLayout").child(
  284. "android.view.View").child("android.view.View").child("android.view.View").child(
  285. "android.widget.Button")[1]
  286. tag_drop_down.click()
  287. # 获取集换榜
  288. tag_bang_list = poco("android:id/content").child("android.widget.FrameLayout").child(
  289. "android.widget.FrameLayout").child("android.view.View").child("android.view.View").child(
  290. "android.view.View").child("android.widget.ScrollView")
  291. # tag_bang_list.wait_for_appearance(timeout=60)
  292. if not tag_bang_list.exists():
  293. logger.warning("tag_bang_list元素未找到")
  294. else:
  295. # 在 tag_bang_list 循环内部,当 ca_idx > 8 时,需要滑动下拉菜单
  296. if ca_idx > 8:
  297. logger.debug('滑动下拉菜单')
  298. # tag_drop_down.click()
  299. poco("android:id/content").child("android.widget.FrameLayout").child(
  300. "android.widget.FrameLayout").child("android.view.View").child("android.view.View").child(
  301. "android.view.View").child("android.widget.ScrollView").child("android.widget.Button")[
  302. 7].swipe(
  303. [0.0035, -0.3152])
  304. time.sleep(2)
  305. poco("android:id/content").child("android.widget.FrameLayout").child(
  306. "android.widget.FrameLayout").child("android.view.View").child("android.view.View").child(
  307. "android.view.View").child("android.widget.ScrollView").child("android.widget.Button")[
  308. ca_idx - 2].click()
  309. logger.info(f'点击 集换榜分类 -> {category}')
  310. else:
  311. poco("android:id/content").child("android.widget.FrameLayout").child(
  312. "android.widget.FrameLayout").child("android.view.View").child("android.view.View").child(
  313. "android.view.View").child("android.widget.ScrollView").child("android.widget.Button")[
  314. ca_idx].click()
  315. logger.info(f'点击 集换榜分类 -> {category}')
  316. # tag_bang.wait_for_appearance(timeout=60)
  317. # time.sleep(2)
  318. # 点击语言分类
  319. logger.info(f'当前语言名称: {language} >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
  320. # tag_lang_list = poco_("android:id/content").child("android.widget.FrameLayout").child(
  321. # "android.widget.FrameLayout").child("android.view.View").child("android.view.View").child(
  322. # "android.view.View").child("android.view.View")[1].child("android.widget.Button")[lang_idx]
  323. tag_lang_list = poco(language)
  324. tag_lang_list.click()
  325. # time.sleep(2)
  326. # 点击畅销榜
  327. touch(Template(r"tpl1761026966680.png", record_pos=(0.048, -0.781), resolution=(1080, 2340)))
  328. # try:
  329. # # 定位滚动容器
  330. # 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]
  331. # # print(scroll_view.attr("text"))
  332. #
  333. # # 方法1:优先用文本匹配(最直观)
  334. # target = scroll_view.offspring(descContains="畅销榜")
  335. # if target.exists():
  336. # target.click()
  337. # else:
  338. # print("未找到“畅销榜”标签")
  339. #
  340. # except PocoNoSuchNodeException as e:
  341. # print("找不到指定的 UI 路径:", e)
  342. time.sleep(2)
  343. # 数据列表 -> 翻页
  344. stop_page = False
  345. # previous_elements = None
  346. page_count = 1
  347. max_pages = 30
  348. data_list = []
  349. while page_count <= max_pages and not stop_page:
  350. if page_count > max_pages:
  351. logger.warning("已达到最大翻页次数,停止翻页")
  352. break
  353. # 翻页操作
  354. if page_count == 1:
  355. logger.debug("第一页开始翻页")
  356. scroll_up()
  357. # scroll_up()
  358. else:
  359. logger.debug(f"{page_count}翻页.........")
  360. scroll_up()
  361. scroll_up()
  362. scroll_up()
  363. scroll_up()
  364. tag_list_view = poco('android.widget.ScrollView')
  365. appeared = False
  366. for _ in range(30): # 最多等 15s (30 * 0.5s),出现后立即跳出
  367. if tag_list_view.exists():
  368. appeared = True
  369. break
  370. time.sleep(0.5)
  371. if not appeared:
  372. logger.warning('ScrollView 未出现, 重试本次翻页')
  373. continue
  374. elements = list(tag_list_view.offspring('android.widget.Button'))
  375. if not elements:
  376. logger.warning('未找到任何 Button,等待 2s 后重试本次翻页')
  377. time.sleep(2)
  378. continue
  379. for element in elements:
  380. element_title = element.child()
  381. if not element_title.exists():
  382. continue
  383. # element_data = scrape_detail(element)
  384. tag_element_data = element_title.attr('name')
  385. # print(f'element_data:{tag_element_data}')
  386. try:
  387. if '加入心愿单' not in tag_element_data:
  388. element_data = parse_sales_data(tag_element_data)
  389. logger.info(element_data)
  390. if element_data:
  391. filtered_data = {k: v for k, v in element_data.items() if k != 'tv_rank'}
  392. filtered_data.update({
  393. 'category': category,
  394. 'language': language,
  395. 'crawler_date': time.strftime("%Y-%m-%d", time.localtime())
  396. })
  397. logger.debug(f'scraped data: {filtered_data}')
  398. data_list.append(filtered_data)
  399. # 检查排名是否为100,如果是则停止翻页
  400. if element_data.get('tv_rank') == '100':
  401. logger.success(
  402. f'已获取第 {page_count} 页数据, 第 {element_data["tv_rank"]} 名, 停止翻页....')
  403. stop_page = True
  404. break
  405. else:
  406. logger.warning("未获取到数据")
  407. else:
  408. logger.debug('<加入心愿单> 字样在tag_element_data, 跳过')
  409. except ValueError as e:
  410. logger.error(f"数据解析错误: {e}")
  411. if stop_page:
  412. logger.debug(f'已获取 {len(data_list)} 条数据, 停止翻页....')
  413. break
  414. page_count += 1
  415. time.sleep(1)
  416. logger.success(
  417. '---------------------------------------------------------------------------------------------')
  418. # 保存数据
  419. # 替换掉原来的 list(set(...)) 实现去重
  420. seen = set()
  421. unique_data_list = []
  422. for item in data_list:
  423. frozen_item = frozenset(item.items())
  424. if frozen_item not in seen:
  425. seen.add(frozen_item)
  426. unique_data_list.append(item)
  427. data_list = unique_data_list
  428. # print(data_list)
  429. sql_pool.insert_many(table='jhs_bestseller_record', data_list=data_list, ignore=True)
  430. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  431. def jhs_app_bestseller_main():
  432. """
  433. 主函数
  434. """
  435. logger.info(
  436. f'开始运行 {inspect.currentframe().f_code.co_name} 爬虫任务....................................................')
  437. # 配置 MySQL 连接池
  438. sql_pool = MySQLConnectionPool(log=logger)
  439. if not sql_pool:
  440. logger.error("MySQL数据库连接失败")
  441. raise Exception("MySQL数据库连接失败")
  442. try:
  443. get_data(sql_pool)
  444. # stop_app(PACKAGE_NAME)
  445. except Exception as e:
  446. logger.error(f'{inspect.currentframe().f_code.co_name} error: {e}')
  447. finally:
  448. logger.info(f'爬虫程序 {inspect.currentframe().f_code.co_name} 运行结束,等待下一轮的采集任务............')
  449. if __name__ == '__main__':
  450. # schedule_task()
  451. jhs_app_bestseller_main()