|
|
@@ -2,6 +2,7 @@
|
|
|
# Author : Charley
|
|
|
# Python : 3.10.8
|
|
|
# Date : 2025/12/2 14:08
|
|
|
+import shutil
|
|
|
import threading
|
|
|
import time
|
|
|
import inspect
|
|
|
@@ -35,6 +36,69 @@ headers = {
|
|
|
# 全局变量标识首次运行是否完成
|
|
|
detail_first_run_completed = False
|
|
|
|
|
|
+# 详情页浏览器需拦截的资源模式。目标: 该页只需 DOM 结构做 xpath 解析(Activity history),
|
|
|
+# 故把「不影响 DOM 数据」的重资源全部拦掉——省请求、提速、降内存, 且不会 OOM。
|
|
|
+# 注意: 拦 CSS/字体不影响 xpath(xpath 只认 DOM 不认样式), 图片已由 no_imgs 关闭。
|
|
|
+
|
|
|
+# 视频/大媒体: 最占带宽与内存
|
|
|
+VIDEO_PATTERNS = [
|
|
|
+ '*.mp4',
|
|
|
+ '*.webm',
|
|
|
+ '*.avi',
|
|
|
+ '*.mov',
|
|
|
+ '*.flv',
|
|
|
+ '*.m3u8',
|
|
|
+ '*.ts',
|
|
|
+ '*googlevideo.com*',
|
|
|
+ '*videoplayback*',
|
|
|
+ '*video.twimg.com*',
|
|
|
+ '*videocdn*',
|
|
|
+ '*.mpd', # DASH视频流
|
|
|
+]
|
|
|
+
|
|
|
+# 字体: 纯展示资源, 拦掉可省请求且绝不影响 DOM/JS。
|
|
|
+# 注意: 不要拦 *.css! courtyard.io 是 Next.js 应用, CSS 与 JS 同属路由 chunk 依赖图,
|
|
|
+# 拦掉 CSS 会导致 chunk-loading Promise 被 reject → 触发 React 错误边界(client-side exception)白屏。
|
|
|
+STYLE_FONT_PATTERNS = [
|
|
|
+ '*.woff',
|
|
|
+ '*.woff2',
|
|
|
+ '*.ttf',
|
|
|
+ '*.otf',
|
|
|
+ '*.eot',
|
|
|
+]
|
|
|
+
|
|
|
+# 第三方埋点/统计/广告/监控: 与业务数据无关, 纯拖慢加载
|
|
|
+TRACKER_PATTERNS = [
|
|
|
+ '*google-analytics.com*',
|
|
|
+ '*googletagmanager.com*',
|
|
|
+ '*doubleclick.net*',
|
|
|
+ '*facebook.net*',
|
|
|
+ '*connect.facebook*',
|
|
|
+ '*segment.com*',
|
|
|
+ '*segment.io*',
|
|
|
+ '*sentry.io*',
|
|
|
+ '*mixpanel.com*',
|
|
|
+ '*hotjar.com*',
|
|
|
+ '*intercom.io*',
|
|
|
+ '*fullstory.com*',
|
|
|
+ '*amplitude.com*',
|
|
|
+]
|
|
|
+
|
|
|
+# 汇总: 实际下发给浏览器的拦截清单
|
|
|
+BLOCKED_URL_PATTERNS = VIDEO_PATTERNS + STYLE_FONT_PATTERNS + TRACKER_PATTERNS
|
|
|
+
|
|
|
+# 连续失败达到该阈值即判定浏览器卡死, 触发重启
|
|
|
+MAX_CONSECUTIVE_FAILURES = 3
|
|
|
+
|
|
|
+# 每连续处理该条数就主动重启一次浏览器, 释放 Chromium 累积内存(V8 堆/渲染进程/缓存随页面数线性增长)
|
|
|
+BROWSER_RESTART_INTERVAL = 100
|
|
|
+# 每处理该条数就清理一次缓存/cookies, 减缓单实例内存增长
|
|
|
+BROWSER_CLEAR_CACHE_INTERVAL = 10
|
|
|
+
|
|
|
+# auto_port 临时用户目录的基路径(放 D 盘专属子目录)。auto_port 每次用随机端口, 会在此
|
|
|
+# 目录下不断新建 userData/{port} 且退出不自动删除, 故每次新建浏览器前会清空整个此目录
|
|
|
+BROWSER_TMP_PATH = r'D:\Drissionpage_temp\courtyard'
|
|
|
+
|
|
|
def after_log(retry_state):
|
|
|
"""
|
|
|
retry 回调
|
|
|
@@ -87,7 +151,8 @@ def get_goods_list(log, sql_pool):
|
|
|
# print(response.text)
|
|
|
response.raise_for_status()
|
|
|
|
|
|
- vendingMachines = response.json().get("vendingMachines", [])
|
|
|
+ # 同样用 `or []` 兜底: 防止 vendingMachines 为 null 时下方 for 迭代抛 TypeError
|
|
|
+ vendingMachines = response.json().get("vendingMachines") or []
|
|
|
|
|
|
for item in vendingMachines:
|
|
|
bag_id = item.get("id")
|
|
|
@@ -136,7 +201,15 @@ def get_goods_detail(log, query_dict: dict, sql_pool=None):
|
|
|
# print(response.text)
|
|
|
response.raise_for_status()
|
|
|
|
|
|
- pulls = response.json().get("assets", [])
|
|
|
+ resp_json = response.json()
|
|
|
+ # 接口偶发返回 {"error": ...}(无 assets 键,多为限流/暂无数据),显式跳过并告警,避免静默吞掉
|
|
|
+ if isinstance(resp_json, dict) and "assets" not in resp_json:
|
|
|
+ log.warning(f"recent-pulls 返回无 assets, bag_id: {query_dict['bag_id']}, resp: {str(resp_json)[:150]}")
|
|
|
+ return
|
|
|
+
|
|
|
+ # 注意: dict.get(key, default) 的 default 仅在 key 缺失时生效;
|
|
|
+ # 若 key 存在但值为 null(None) 仍返回 None, 故统一用 `or []` 兜底, 防止后续 len()/迭代抛 TypeError
|
|
|
+ pulls = resp_json.get("assets") or []
|
|
|
info_list = []
|
|
|
for item in pulls:
|
|
|
detail_title = item.get("title")
|
|
|
@@ -146,7 +219,8 @@ def get_goods_detail(log, query_dict: dict, sql_pool=None):
|
|
|
log.error(f"信息异常, detail_id: {detail_id}")
|
|
|
continue
|
|
|
|
|
|
- asset_pictures = item.get("asset_pictures", [])
|
|
|
+ # asset_pictures 实测存在为 null 的情况, 原写法 len(None) 是本次 TypeError 崩溃主因, 用 `or []` 兜底
|
|
|
+ asset_pictures = item.get("asset_pictures") or []
|
|
|
img_front = asset_pictures[0] if len(asset_pictures) > 0 else None
|
|
|
img_back = asset_pictures[1] if len(asset_pictures) > 1 else None
|
|
|
|
|
|
@@ -204,8 +278,13 @@ def get_sale_detail_single_page(log, page, sql_id, detail_id, sql_pool=None):
|
|
|
# page_url = "https://courtyard.io/asset/a4f0bbebd858370567f1779fddf0f55630810116d80965e33940fc8ff5ac94b4"
|
|
|
page_url = f"https://courtyard.io/asset/{detail_id}"
|
|
|
page.get(page_url)
|
|
|
- page.wait.load_start()
|
|
|
- log.debug(f'{inspect.currentframe().f_code.co_name} -> 页面加载成功, url: {page_url}')
|
|
|
+ # 加载策略为 none, get() 立即返回; 这里只等目标节点 "Activity history" 渲染出现即可开始解析,
|
|
|
+ # 不等整页 load 完成——目标节点在, 就代表要抓的数据已就绪, 大幅缩短单条耗时
|
|
|
+ target = page.ele('xpath://h6[text()="Activity history"]', timeout=25)
|
|
|
+ if not target:
|
|
|
+ log.error(f'{inspect.currentframe().f_code.co_name} -> 目标节点未出现(可能加载失败/被限流), 重试..........')
|
|
|
+ raise Exception('目标节点未出现, 重新加载........') # 抛出异常以便重试
|
|
|
+ log.debug(f'{inspect.currentframe().f_code.co_name} -> 目标节点已就绪, url: {page_url}')
|
|
|
|
|
|
html = page.html
|
|
|
if not html:
|
|
|
@@ -244,8 +323,6 @@ def get_sale_detail_single_page(log, page, sql_id, detail_id, sql_pool=None):
|
|
|
"burn_to": None, "burn_time": None, "sale_price": None, "sale_from": None, "sale_to": None,
|
|
|
"sale_time": None, "mint_price": None, "mint_from": None, "mint_to": None, "mint_time": None}
|
|
|
|
|
|
- # 提取各个标签的数据
|
|
|
- # tag_div_list = selector.xpath('//div[@class="MuiBox-root css-aylq9e"]/div')# class="MuiBox-root css-1b09fes"
|
|
|
# 获取 "Activity history" 后面的 div
|
|
|
activity_div = selector.xpath('//h6[text()="Activity history"]/following-sibling::div[1]/div')
|
|
|
for tag_div in activity_div:
|
|
|
@@ -286,6 +363,91 @@ def get_sale_detail_single_page(log, page, sql_id, detail_id, sql_pool=None):
|
|
|
|
|
|
# 保存数据
|
|
|
sql_pool.insert_one_or_dict(table="courtyard_detail_record", data=data_dict, ignore=True)
|
|
|
+ sql_pool.update_one("UPDATE courtyard_list_record SET state = 1 WHERE id = %s", (sql_id,))
|
|
|
+
|
|
|
+
|
|
|
+def _create_detail_browser(log):
|
|
|
+ """创建并配置用于详情采集的浏览器实例。
|
|
|
+
|
|
|
+ 集中管理浏览器启动参数、视频拦截规则与超时, 供首次启动和卡死重启复用。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: loguru logger 对象, 从调用方透传。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ ChromiumPage: 已配置拦截规则与超时的浏览器页面对象。
|
|
|
+ """
|
|
|
+ # auto_port 的临时用户目录退出不会自动清理, 每次随机端口都会在 BROWSER_TMP_PATH 下留一份;
|
|
|
+ # 故新建实例前先清空整个目录, 保证不残留缓存、不累积占磁盘(重启时旧实例已 quit, 无占用)
|
|
|
+ shutil.rmtree(BROWSER_TMP_PATH, ignore_errors=True)
|
|
|
+
|
|
|
+ options = ChromiumOptions()
|
|
|
+ # 无需登录态, 不做任何持久化: auto_port 自动分配空闲端口(每次全新进程, 确保内存被彻底释放),
|
|
|
+ # 相比固定端口: 分批重启不会因端口被残留进程占用而失败/误连到旧浏览器
|
|
|
+ options.auto_port(True)
|
|
|
+ # 临时用户目录基路径放到 D 盘专属子目录(默认在 C 盘 %TEMP%/DrissionPage)
|
|
|
+ options.set_tmp_path(BROWSER_TMP_PATH)
|
|
|
+ # options.set_proxy("http://" + tunnel)
|
|
|
+
|
|
|
+ options.no_imgs(True)
|
|
|
+
|
|
|
+ # 加载策略设为 none: page.get() 不等整页 load 完成立即返回, 之后由业务代码只等
|
|
|
+ # 目标元素(Activity history)出现即开始解析——这是提速最关键的一招(SPA 整页 load
|
|
|
+ # 往往还挂着一堆无关请求/长连接, 傻等会白白拖慢每一条)
|
|
|
+ options.set_load_mode('none')
|
|
|
+
|
|
|
+ # 禁止媒体相关设置
|
|
|
+ options.set_argument('--autoplay-policy=user-gesture-required')
|
|
|
+ options.set_argument('--disable-features=PreloadMediaEngagementData')
|
|
|
+
|
|
|
+ # ---- 省资源(真正有效且无副作用的项): 减少下载与缓存占用, 不掐内存上限 ----
|
|
|
+ # 说明: 不再使用 --max-old-space-size / --renderer-process-limit=1 / --process-per-site。
|
|
|
+ # 前者会在重 SPA 需要更多 V8 堆时直接触发渲染进程 OOM 崩溃(即"喔唷崩溃啦 Out of Memory");
|
|
|
+ # 后两者强制单渲染进程复用, 反而关闭了浏览器靠"进程销毁"回收内存的机制, 让内存只增不减。
|
|
|
+ # 真正的内存回收依赖每 BROWSER_RESTART_INTERVAL 条整浏览器重启(见 get_sale_detail_list)。
|
|
|
+ options.set_argument('--disk-cache-size=1') # 几乎禁用磁盘缓存
|
|
|
+ options.set_argument('--media-cache-size=1') # 几乎禁用媒体缓存
|
|
|
+ options.set_argument('--disable-application-cache') # 禁用应用缓存
|
|
|
+ options.set_argument('--disable-dev-shm-usage') # 不占用共享内存, 避免共享内存耗尽
|
|
|
+ options.set_argument('--disable-extensions') # 禁用扩展
|
|
|
+ options.set_argument('--disable-background-networking') # 关闭后台网络活动
|
|
|
+
|
|
|
+ # 最大化
|
|
|
+ options.set_argument("--start-maximized")
|
|
|
+ options.set_argument("--disable-gpu")
|
|
|
+ options.set_argument("-accept-lang=en-US")
|
|
|
+ page = ChromiumPage(options)
|
|
|
+
|
|
|
+ # 使用 set.blocked_urls() 拦截视频/样式/字体/埋点等无关资源(不影响 DOM 取数)
|
|
|
+ page.set.blocked_urls(BLOCKED_URL_PATTERNS)
|
|
|
+ # 设置超时: 页面加载最多等 30s, 基础操作 20s, 避免浏览器卡死时无限等待
|
|
|
+ page.set.timeouts(base=20, page_load=30)
|
|
|
+ return page
|
|
|
+
|
|
|
+
|
|
|
+def _restart_detail_browser(log, page, reason='卡死'):
|
|
|
+ """关闭旧浏览器并重建一个新实例。
|
|
|
+
|
|
|
+ 先尽力 quit 旧实例(失败忽略), 等待端口释放后重新创建。既用于连续采集失败时的
|
|
|
+ 自愈重启, 也用于每处理 BROWSER_RESTART_INTERVAL 条后的主动内存释放重启。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: loguru logger 对象, 从调用方透传。
|
|
|
+ page (ChromiumPage): 待关闭的旧浏览器实例。
|
|
|
+ reason (str, optional): 重启原因, 仅用于日志区分。Defaults to '卡死'。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ ChromiumPage: 全新的浏览器页面对象。
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ page.quit()
|
|
|
+ except Exception as e:
|
|
|
+ # 卡死的浏览器 quit 本身也可能超时/报错, 忽略即可
|
|
|
+ log.warning(f'关闭旧浏览器失败(忽略): {e}')
|
|
|
+ # 等待端口(9138)与用户数据目录释放, 再重建, 避免端口占用导致启动失败
|
|
|
+ time.sleep(3)
|
|
|
+ log.warning(f'浏览器{reason}, 正在重启新实例..........')
|
|
|
+ return _create_detail_browser(log)
|
|
|
|
|
|
|
|
|
@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
|
|
|
@@ -298,35 +460,55 @@ def get_sale_detail_list(log, sql_pool=None):
|
|
|
:return:
|
|
|
"""
|
|
|
log.info(f"========================== 获取商品 <sale> 详情 LIST ==========================")
|
|
|
- options = ChromiumOptions()
|
|
|
- options.set_paths(local_port=9138, user_data_path=r'D:\Drissionpage_temp\courtyard_port_9138')
|
|
|
- # options.set_proxy("http://" + tunnel)
|
|
|
- # options.auto_port(True)
|
|
|
+ page = _create_detail_browser(log)
|
|
|
+ # 连续失败计数: 用于区分"单条数据问题"与"浏览器整体卡死", 后者需重启浏览器
|
|
|
+ consecutive_failures = 0
|
|
|
|
|
|
- options.no_imgs(True)
|
|
|
- # 最大化
|
|
|
- options.set_argument("--start-maximized")
|
|
|
- options.set_argument("--disable-gpu")
|
|
|
- options.set_argument("-accept-lang=en-US")
|
|
|
- page = ChromiumPage(options)
|
|
|
try:
|
|
|
sql_detail_id_list = sql_pool.select_all("SELECT id, detail_id FROM courtyard_list_record WHERE state = 0")
|
|
|
|
|
|
- for sql_detail_id in sql_detail_id_list:
|
|
|
+ for idx, sql_detail_id in enumerate(sql_detail_id_list):
|
|
|
sql_id = sql_detail_id[0]
|
|
|
detail_id = sql_detail_id[1]
|
|
|
+
|
|
|
+ # 每处理 BROWSER_RESTART_INTERVAL 条主动重启浏览器, 彻底释放 Chromium 累积内存;
|
|
|
+ # 单实例连续跑几百页时 V8 堆/渲染进程/缓存会线性增长到数 GB, 这是内存/CPU 高的主因
|
|
|
+ if idx > 0 and idx % BROWSER_RESTART_INTERVAL == 0:
|
|
|
+ log.info(f'已连续处理 {idx} 条, 达到批次阈值, 主动重启浏览器释放内存..........')
|
|
|
+ page = _restart_detail_browser(log, page, reason='达到批次重启阈值')
|
|
|
+ consecutive_failures = 0
|
|
|
+
|
|
|
try:
|
|
|
get_sale_detail_single_page(log, page, sql_id, detail_id, sql_pool)
|
|
|
- sql_pool.update_one("UPDATE courtyard_list_record SET state = 1 WHERE id = %s", (sql_id,))
|
|
|
+ consecutive_failures = 0 # 成功即清零, 只累计"连续"失败
|
|
|
+ # 定期清理缓存/cookies, 减缓单实例在两次批次重启之间的内存增长
|
|
|
+ if idx > 0 and idx % BROWSER_CLEAR_CACHE_INTERVAL == 0:
|
|
|
+ try:
|
|
|
+ page.clear_cache()
|
|
|
+ except Exception as ce:
|
|
|
+ log.warning(f'清理浏览器缓存失败(忽略): {ce}')
|
|
|
except Exception as e:
|
|
|
- log.error(f'get_sale_detail_single_page error: {e}')
|
|
|
- sql_pool.update_one("UPDATE courtyard_list_record SET state = 2 WHERE id = %s", (sql_id,))
|
|
|
+ consecutive_failures += 1
|
|
|
+ log.error(f'get_sale_detail_single_page error '
|
|
|
+ f'({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}), sql_id: {sql_id}: {e}')
|
|
|
+
|
|
|
+ if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
|
|
+ # 连续多条失败, 判定浏览器已卡死: 不标记 state=2(避免误伤),
|
|
|
+ # 保留 state=0 待下轮重试, 并重启浏览器后继续处理后续条目
|
|
|
+ page = _restart_detail_browser(log, page)
|
|
|
+ consecutive_failures = 0
|
|
|
+ else:
|
|
|
+ # 未达阈值, 视为该条数据自身问题, 标记 state=2 跳过
|
|
|
+ sql_pool.update_one("UPDATE courtyard_list_record SET state = 2 WHERE id = %s", (sql_id,))
|
|
|
|
|
|
except Exception as e:
|
|
|
log.error(f'get_response error: {e}')
|
|
|
- raise 'get_response error'
|
|
|
+ raise # 直接透传原始异常(原写法 raise 字符串会抛 TypeError 掩盖真因)
|
|
|
finally:
|
|
|
- page.quit()
|
|
|
+ try:
|
|
|
+ page.quit()
|
|
|
+ except Exception as e:
|
|
|
+ log.warning(f'最终关闭浏览器失败(忽略): {e}')
|
|
|
|
|
|
|
|
|
@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
|