snapshot.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. """
  2. 网页快照模块 — 模仿美团 MTScreenshot:
  3. 详情页滚动截图拼接长图 → 压缩 → 上传阿里云OSS → 删除本地临时文件 → 返回OSS URL
  4. 任何失败返回空字符串,不阻塞主流程
  5. """
  6. import io
  7. import json
  8. import os
  9. import random
  10. import re
  11. import subprocess
  12. import time
  13. from pathlib import Path
  14. import oss2
  15. from PIL import Image
  16. from human_touch import wrist_arc_pts, motionevent_drag, touchpipe_drag
  17. from commons import err_log
  18. CONFIG_PATH = Path(__file__).parent / "config.json"
  19. SCREENSHOT_DIR = Path(__file__).parent / "scrape_data"
  20. RESIZE_RATIO = 0.8 # 长图缩放比例(美团同款)
  21. JPEG_QUALITY = 70 # 压缩质量
  22. DEFAULT_SCROLLS = 1 # 首屏后上滑1次(共2屏),慢速滑动
  23. _bucket = None
  24. def _load_oss_config() -> dict:
  25. cfg = {}
  26. try:
  27. with open(CONFIG_PATH, encoding="utf-8") as f:
  28. cfg = json.load(f)
  29. except Exception:
  30. pass
  31. return cfg.get("oss", {}) or {}
  32. def _get_bucket():
  33. global _bucket
  34. if _bucket is not None:
  35. return _bucket
  36. c = _load_oss_config()
  37. if not all([c.get("access_key_id"), c.get("access_key_secret"), c.get("endpoint"), c.get("bucket_name")]):
  38. print("[snapshot] OSS配置不完整,跳过快照")
  39. _bucket = None
  40. return None
  41. try:
  42. auth = oss2.Auth(c["access_key_id"], c["access_key_secret"])
  43. _bucket = oss2.Bucket(auth, c["endpoint"], c["bucket_name"])
  44. print(f"[snapshot] OSS连接成功: {c['bucket_name']}")
  45. except Exception as e:
  46. print(f"[snapshot] OSS连接失败: {e}")
  47. _bucket = None
  48. return _bucket
  49. def _upload_to_oss(local_path: str):
  50. """上传OSS,成功返回完整URL,失败返回None(美团同款)"""
  51. bucket = _get_bucket()
  52. if not bucket or not os.path.exists(local_path):
  53. return None
  54. c = _load_oss_config()
  55. file_name = os.path.basename(local_path)
  56. safe_name = re.sub(r'[^\w\.\-]', '_', file_name)
  57. oss_key = f"{c.get('oss_prefix', 'scrape_data/')}{safe_name}"
  58. try:
  59. oss2.resumable_upload(bucket, oss_key, local_path)
  60. return f"https://{c['bucket_name']}.{c['endpoint']}/{oss_key}"
  61. except Exception as e:
  62. print(f"[snapshot] OSS上传失败: {e}")
  63. return None
  64. def _merge_screenshots(screen_bytes_list):
  65. """竖接长图(美团同款思路,PIL实现)"""
  66. imgs = []
  67. for b in screen_bytes_list:
  68. try:
  69. imgs.append(Image.open(io.BytesIO(b)).convert("RGB"))
  70. except Exception:
  71. pass
  72. if not imgs:
  73. return None
  74. w = max(i.width for i in imgs)
  75. total_h = sum(i.height for i in imgs)
  76. merged = Image.new("RGB", (w, total_h), (255, 255, 255))
  77. y = 0
  78. for i in imgs:
  79. merged.paste(i, (0, y))
  80. y += i.height
  81. i.close()
  82. return merged
  83. def collect_snapshot(driver, title: str = "", device_id: str = "", scroll_times: int = DEFAULT_SCROLLS) -> str:
  84. """
  85. 详情页滚动截图拼接 → 压缩 → 上传OSS → 删本地 → 返回OSS URL。
  86. 返回 (url, reason):url为空时 reason 是失败原因。
  87. """
  88. try:
  89. import cv2
  90. SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) # 中间帧截图落盘前先建目录
  91. w, h = driver.window_size()
  92. # 1. 滚动截图:首屏 + 1次上滑(共2屏,慢速、间隔充足)
  93. # 每屏落盘并用cv2验证PNG完整性(adb流式传输可能损坏,损坏则重试)
  94. screen_bytes_list = []
  95. for i in range(scroll_times + 1):
  96. shot = str(SCREENSHOT_DIR / f"_snap_{device_id}_{i}.png")
  97. ok = False
  98. for _try in range(3):
  99. driver.screenshot(shot)
  100. if cv2.imread(shot) is not None:
  101. ok = True
  102. break
  103. time.sleep(0.5)
  104. if not ok:
  105. print(f"[snapshot] 第{i+1}屏截图损坏,放弃快照")
  106. return "", f"第{i+1}屏截图损坏(重试3次)"
  107. with open(shot, "rb") as f:
  108. screen_bytes_list.append(f.read())
  109. os.remove(shot) # 中间帧用完即删,不占空间
  110. if i < scroll_times:
  111. # 拟人弧线滑动(手腕枢轴模型); TouchPipe→motionevent→直线 三级降级
  112. try:
  113. pts = wrist_arc_pts(w, h, int(h * 0.5), n=60, drift_range=(50, 110))
  114. if not touchpipe_drag(driver, pts):
  115. print("[snapshot] TouchPipe未生效,降级motionevent")
  116. pts10 = wrist_arc_pts(w, h, int(h * 0.5), n=10, drift_range=(50, 110))
  117. motionevent_drag(device_id, pts10)
  118. except Exception as e:
  119. print(f"[snapshot] 拟人滑动异常({e}),退回直线滑动")
  120. subprocess.run(
  121. ["adb", "-s", device_id, "shell", "input", "swipe",
  122. str(w // 2), str(int(h * 0.75)), str(w // 2), str(int(h * 0.25)), "600"],
  123. capture_output=True, timeout=10,
  124. )
  125. time.sleep(random.uniform(1.8, 2.4))
  126. print(f"[snapshot] 已截 {len(screen_bytes_list)} 屏,拼接中...")
  127. # 2. 拼接 + 压缩
  128. merged = _merge_screenshots(screen_bytes_list)
  129. if merged is None:
  130. print("[snapshot] 截图拼接失败")
  131. return "", "截图拼接失败"
  132. if 0 < RESIZE_RATIO < 1.0:
  133. merged = merged.resize(
  134. (int(merged.width * RESIZE_RATIO), int(merged.height * RESIZE_RATIO)),
  135. Image.LANCZOS,
  136. )
  137. # 3. 保存本地临时文件(命名与美团一致:时间戳_平台标识_设备ID_标题)
  138. SCREENSHOT_DIR.mkdir(exist_ok=True)
  139. ts = time.strftime("%Y%m%d_%H%M%S")
  140. safe_title = re.sub(r'[\\/*?:"<>|]', '_', str(title or "snapshot"))[:40]
  141. local_path = str(SCREENSHOT_DIR / f"{ts}_tbsg_{device_id}_{safe_title}.jpg")
  142. merged.save(local_path, format="JPEG", quality=JPEG_QUALITY)
  143. merged.close()
  144. # 4. 上传OSS
  145. url = _upload_to_oss(local_path)
  146. # 5. 上传成功后删除本地文件(美团同款:不占本地空间)
  147. try:
  148. os.remove(local_path)
  149. except Exception:
  150. pass
  151. if not url:
  152. print(f"[snapshot] 上传失败,本地文件保留: {local_path}")
  153. return "", f"OSS上传失败({local_path})"
  154. print(f"[snapshot] 上传成功: {url}")
  155. return url, ""
  156. except Exception as e:
  157. print(f"[snapshot] 快照采集失败: {e}")
  158. err_log.log_error(device_id, "snapshot_error", exc=e, extra={"title": title})
  159. return "", f"异常: {e}"