diff --git a/Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc b/Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc index a8bcfab..0d8c1b5 100644 Binary files a/Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc and b/Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc differ diff --git a/Apps/XinDianTu/Crawler.py b/Apps/XinDianTu/Crawler.py index 5c6a253..f970b3c 100644 --- a/Apps/XinDianTu/Crawler.py +++ b/Apps/XinDianTu/Crawler.py @@ -156,12 +156,10 @@ async def get_station_list(d, service, uploader, max_stations_count=MAX_STATIONS image_uuid = str(uuid.uuid4()) screenshot_path = take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR) - # 【优化】不再在每页滚动时检查兔子广告,仅在 Opener 进入时检查一次 - # 如果后续发现有其它非兔子广告弹出,可在此处恢复非兔子广告的检测逻辑 ad_top_y_norm = 0.78 # 默认的点击边界 (0.78) - # 新策略:滚动后的页面完全依赖“速度躲兔子”,不再因为靠近底部而丢弃卡片 if scroll_count > 1: ad_top_y_norm = 1.0 + logger.info(f"当前列表页广告安全下边界: {ad_top_y_norm:.2f} (scroll_count={scroll_count})") # 检查是否已经滚动到底部 (排除状态栏后,内容与上次一致) current_md5 = Kit.get_image_content_md5( diff --git a/Apps/XinDianTu/Kit.py b/Apps/XinDianTu/Kit.py index 5706899..af634fb 100644 --- a/Apps/XinDianTu/Kit.py +++ b/Apps/XinDianTu/Kit.py @@ -4,7 +4,7 @@ import cv2 import numpy as np import time import hashlib -from Apps.XinDianTu.Config.Setting import BOTTOM_SAFE_EXCLUDE_RATIO +from Apps.XinDianTu.Config.Setting import SAFE_EXCLUDE_RATIO, BOTTOM_SAFE_EXCLUDE_RATIO from Config.Config import TEMP_IMAGE_DIR logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") @@ -748,26 +748,7 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): logger.info(f" Found {len(groups)} segment groups.") - # Select the Target Group - # Strategy: Select the LAST group that has at least one 'substantial' segment (H > 150). - # This assumes the Station List is the main content and usually at the bottom (scrolling area). - target_group = [] - for g in reversed(groups): - # Check if group has substantial content - has_content = False - for (y1, y2) in g: - if (y2 - y1) > 150: - has_content = True - break - - if has_content: - target_group = g - break - - if not target_group and segments: - # Fallback to all segments if no substantial group found - target_group = segments - + target_group = segments logger.info(f" Selected group with {len(target_group)} segments.") # 2. 确定每个卡片的左右边界 (Width Refinement) @@ -881,10 +862,7 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): max_h = max(heights) median_h = np.median(heights) - # 策略:如果高度差异较大,丢弃过小的卡片 - # 阈值设定为 Median 的 75% (允许一定程度的偏差,如 220 vs 258 是允许的,但 150 vs 250 应被过滤) - # 使用 Median 比 Max 更稳健,避免被单个超高卡片(如广告)带偏 - threshold_h = median_h * 0.70 + threshold_h = median_h * 0.80 for (y1, y2), card_h in zip(temp_valid_segments, heights): if card_h < threshold_h: @@ -914,6 +892,19 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): "cards": [] } + # 顶部安全过滤:丢弃中心落在 SAFE_EXCLUDE_RATIO 以上区域的卡片 + filtered_cards = [] + for y1, y2, x1, x2 in final_cards: + center_y = (y1 + y2) / 2.0 + if center_y < h * SAFE_EXCLUDE_RATIO: + logger.info( + f" Dropping card Y={y1}-{y2} (center={center_y:.1f}) " + f"because it is within top safe area ({SAFE_EXCLUDE_RATIO*100:.0f}%)." + ) + continue + filtered_cards.append((y1, y2, x1, x2)) + final_cards = filtered_cards + # 准备 _vl.jpg (只画框,不画红点) vl_img = img.copy() diff --git a/Apps/XinDianTu/ReadImageKit.py b/Apps/XinDianTu/ReadImageKit.py index cc29bb7..0578226 100644 --- a/Apps/XinDianTu/ReadImageKit.py +++ b/Apps/XinDianTu/ReadImageKit.py @@ -1144,40 +1144,89 @@ class ReadImageKit: # 寻找按钮坐标任务 button_task = asyncio.create_task(ReadImageKit.find_all_time_button_coordinate(image_url_or_path, device_info)) - prompt = ( - "仅输出JSON对象(不含任何说明文字)。" - "任务1:识别图片中充电站的完整名称(full_station_name)。" - f"提示:列表中看到的名称可能是截断的(例如“{station_name}”),请在图片上方找到最匹配的完整名称。" - "任务2:识别充电站的详细地址(address)。" - "寻找规则:地址通常紧跟在场站名称下方,或者在‘距离’图标(定位小图表)附近,或者在带有‘导航’按钮的同一行。" - "返回包含 full_station_name 和 address 字段的JSON对象,例如 {\"full_station_name\": \"完整名称\", \"address\": \"详细地址\"}。" - "如果找不到,对应字段返回空字符串。" - "严格返回纯JSON格式。" - ) + prompt = ( + "仅输出JSON对象(不含任何说明文字)。" + "任务1:识别图片中充电站的完整名称(full_station_name)。" + f"提示:列表中看到的名称可能是截断的(例如“{station_name}”),请在图片上方找到最匹配的完整名称。" + "任务2:识别充电站的详细地址(address)。" + "寻找规则:地址通常紧跟在场站名称下方,或者在“距离”图标(定位小图标)附近,或者在带有“导航”按钮的同一行。" + "若地址跨多行,请合并为一行返回,不要省略。" + "返回包含 full_station_name 和 address 字段的JSON对象,例如 {\"full_station_name\": \"完整名称\", \"address\": \"详细地址\"}。" + "如果图片中能看到任何疑似地址的文字(如街道名、路名、园区名),请尽量提取出来并填入 address,而不是返回空字符串。" + "实在无法确定时,对应字段返回空字符串。" + "严格返回纯JSON格式。" + ) + + result = {} + try: + resp = await ReadImageKit._client.chat.completions.create( + model=VL_MODEL_NAME, + timeout=30, + messages=[ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": img_input}, + {"type": "text", "text": json.dumps(device_info, ensure_ascii=False)}, + {"type": "text", "text": prompt}, + ], + }, + ], + ) + content = resp.choices[0].message.content or "" + raw = ReadImageKit._extract_json(content) + result = json.loads(raw) + except Exception as e: + logger.error(f"解析地址失败 ({station_name}): {e}") + + # 首次结果兜底与必要时的重试 + if not isinstance(result, dict): + result = {} + + full_name = str(result.get("full_station_name") or "").strip() + address = str(result.get("address") or "").strip() + + if not full_name and not address: + try: + logger.info(f"地址首次解析为空,针对场站“{station_name}”进行重试。") + retry_prompt = ( + "仅输出JSON对象(不含任何说明文字)。" + "请再次仔细查找图片中的充电站名称和详细地址,尽量不要返回空字符串。" + "即便地址不完整,也请返回你能看到的主要关键信息(如道路名、园区名、街道名等)。" + "返回格式仍为 {\"full_station_name\": \"完整名称或列表中的截断名称\", \"address\": \"尽量完整的详细地址\"}。" + "严格返回纯JSON格式。" + ) + resp = await ReadImageKit._client.chat.completions.create( + model=VL_MODEL_NAME, + timeout=30, + messages=[ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": img_input}, + {"type": "text", "text": json.dumps(device_info, ensure_ascii=False)}, + {"type": "text", "text": retry_prompt}, + ], + }, + ], + ) + content = resp.choices[0].message.content or "" + raw = ReadImageKit._extract_json(content) + retry_result = json.loads(raw) + if isinstance(retry_result, dict): + result = retry_result + full_name = str(result.get("full_station_name") or "").strip() + address = str(result.get("address") or "").strip() + except Exception as e: + logger.error(f"地址重试解析失败 ({station_name}): {e}") + + # 最终兜底:至少回填一个名称,避免 None + if not full_name: + result["full_station_name"] = station_name or "" + if "address" not in result: + result["address"] = address - result = {} - try: - resp = await ReadImageKit._client.chat.completions.create( - model=VL_MODEL_NAME, - timeout=30, - messages=[ - { - "role": "user", - "content": [ - {"type": "image_url", "image_url": img_input}, - {"type": "text", "text": json.dumps(device_info, ensure_ascii=False)}, - {"type": "text", "text": prompt}, - ], - }, - ], - ) - content = resp.choices[0].message.content or "" - raw = ReadImageKit._extract_json(content) - result = json.loads(raw) - except Exception as e: - logger.error(f"解析地址失败 ({station_name}): {e}") - - # 合并按钮坐标 + # 合并按钮坐标 try: button_result = await button_task if button_result: diff --git a/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc index 6add311..111133e 100644 Binary files a/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc differ diff --git a/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc index 9cb1e9b..97835dc 100644 Binary files a/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc differ diff --git a/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc b/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc index 4b24f59..f595d90 100644 Binary files a/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc and b/Apps/XinDianTu/__pycache__/ReadImageKit.cpython-310.pyc differ diff --git a/Tools/T_XinDianTu_ListDebug.py b/Tools/T_XinDianTu_ListDebug.py new file mode 100644 index 0000000..c1a5ce6 --- /dev/null +++ b/Tools/T_XinDianTu_ListDebug.py @@ -0,0 +1,141 @@ +import asyncio +import json +import os +import time + +import uiautomator2 as u2 + +from Apps.XinDianTu.Kit import setup_logger, take_screenshot, crop_cards_from_image, clear_temp_dir +from Apps.XinDianTu.ReadImageKit import ReadImageKit +from Apps.XinDianTu.Config.Setting import SCROLL_DISTANCE_RATIO +from Config.Config import TEMP_IMAGE_DIR + + +logger = setup_logger("XinDianTu.ListDebug") + + +async def _debug_from_list_image(d, device_info, list_image_path: str, step_prefix: str): + if not os.path.exists(list_image_path): + logger.error(f"[{step_prefix}] 列表截图不存在: {list_image_path}") + return + + logger.info(f"[{step_prefix}] 使用列表截图: {list_image_path}") + + crop_cards_from_image(list_image_path) + + json_path = list_image_path.replace(".jpg", ".json") + vl_img_path = list_image_path.replace(".jpg", "_vl.jpg") + + if not os.path.exists(json_path) or not os.path.exists(vl_img_path): + logger.error(f"[{step_prefix}] 未生成 JSON 或 _vl 调试图: {json_path}, {vl_img_path}") + return + + logger.info(f"[{step_prefix}] 绿框调试图路径: {vl_img_path}") + + with open(json_path, "r", encoding="utf-8") as f: + json_metadata = json.load(f) + + cards = json_metadata.get("cards") or [] + if not cards: + logger.error(f"[{step_prefix}] JSON 中未找到任何卡片元数据") + return + + stations = await ReadImageKit.parse_vl_image(vl_img_path, json_metadata, device_info=device_info) + if not stations: + logger.error(f"[{step_prefix}] VL 未识别出任何场站") + return + + target_station = None + for st in stations: + if st and st.get("station_name"): + target_station = st + break + + if not target_station: + logger.error(f"[{step_prefix}] 未找到带有效名称的场站") + return + + station_name = target_station.get("station_name") + click_x = target_station.get("uia_center_x") + click_y = target_station.get("uia_center_y") + + if click_x is None or click_y is None: + logger.error(f"[{step_prefix}] 目标场站缺少坐标信息: {target_station}") + return + + logger.info( + f"[{step_prefix}] 准备点击场站: '{station_name}', 点击坐标: ({click_x}, {click_y})" + ) + + d.click(int(click_x), int(click_y)) + + await asyncio.sleep(2.0) + + detail_uuid = f"xdt_debug_{step_prefix}_detail_{int(time.time())}" + detail_path = take_screenshot(d, detail_uuid, save_dir=TEMP_IMAGE_DIR) + logger.info(f"[{step_prefix}] 二级详情页截图路径: {detail_path}") + + address_data = await ReadImageKit.parse_address(station_name, detail_path, device_info=device_info) + full_name = address_data.get("full_station_name", "") + address = address_data.get("address", "") + + logger.info( + f"[{step_prefix}] VLM 地址识别结果:" + f" full_station_name='{full_name}', address='{address}'" + ) + + d.press("back") + await asyncio.sleep(1.0) + + +async def run_debug(): + logger.info("=== 新电途 列表页兔子与地址识别调试脚本启动 ===") + logger.info("请先手动打开微信并进入新电途小程序的场站列表第一页。") + + clear_temp_dir() + + d = u2.connect() + w, h = d.window_size() + + device_info = d.info or {} + device_info["width"] = w + device_info["height"] = h + + logger.info(f"当前设备: {device_info.get('productName')} | 分辨率: {w}x{h}") + + image_uuid_1 = f"xdt_debug_step1_{int(time.time())}" + list_path_1 = take_screenshot(d, image_uuid_1, save_dir=TEMP_IMAGE_DIR) + logger.info(f"[step1] 列表第一页原始截图路径: {list_path_1}") + + await _debug_from_list_image(d, device_info, list_path_1, step_prefix="step1") + + logger.info("[step2] 向上滑动,让兔子躲起来,然后 1 秒后截图...") + d.swipe_ext("up", scale=SCROLL_DISTANCE_RATIO) + await asyncio.sleep(1.0) + + image_uuid_2 = f"xdt_debug_step2_{int(time.time())}" + list_path_2 = take_screenshot(d, image_uuid_2, save_dir=TEMP_IMAGE_DIR) + logger.info(f"[step2] 滑动后 1 秒截图(预计兔子已躲藏): {list_path_2}") + + await _debug_from_list_image(d, device_info, list_path_2, step_prefix="step2") + + logger.info("[step3] 再次向上滑动,进入第3页列表,然后 1 秒后截图...") + d.swipe_ext("up", scale=SCROLL_DISTANCE_RATIO) + await asyncio.sleep(1.0) + + image_uuid_3 = f"xdt_debug_step3_{int(time.time())}" + list_path_3 = take_screenshot(d, image_uuid_3, save_dir=TEMP_IMAGE_DIR) + logger.info(f"[step3] 第3页列表截图路径: {list_path_3}") + + await _debug_from_list_image(d, device_info, list_path_3, step_prefix="step3") + + logger.info("=== 调试脚本完成:已按步骤执行三次进入详情并做地址识别,已包含第3页 ===") + + +if __name__ == "__main__": + try: + asyncio.run(run_debug()) + except KeyboardInterrupt: + logger.info("用户手动中断调试脚本。") + except Exception as e: + logger.exception(f"调试脚本运行异常: {e}") diff --git a/Tools/__pycache__/T_XinDianTu_ListDebug.cpython-310.pyc b/Tools/__pycache__/T_XinDianTu_ListDebug.cpython-310.pyc new file mode 100644 index 0000000..ef4ba51 Binary files /dev/null and b/Tools/__pycache__/T_XinDianTu_ListDebug.cpython-310.pyc differ