Files
auto_agent/dongtai.py
T

660 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import json
import argparse
import subprocess
import time
import socket
import re
from pathlib import Path
from urllib.parse import urljoin, urlparse
from typing import List, Dict, Any, Optional
from selenium import webdriver
from selenium.webdriver.edge.options import Options
from selenium.webdriver.edge.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.microsoft import EdgeChromiumDriverManager
# ---------------------------- 工具函数 ----------------------------
def get_absolute_url(base_url: str, href: str) -> Optional[str]:
if not href:
return None
href = href.strip()
if href.startswith(('javascript:', 'mailto:', 'tel:', '#')):
return None
absolute = urljoin(base_url, href)
parsed = urlparse(absolute)
if parsed.scheme in ('http', 'https', 'file'):
return absolute
return None
def sanitize_filename(url: str) -> str:
"""根据 URL 生成合法的文件名(不含扩展名)"""
parsed = urlparse(url)
path = parsed.path.strip('/')
if not path:
path = "index"
safe = re.sub(r'[\\/*?:"<>|]', '_', path)
if len(safe) > 100:
safe = safe[:100]
return safe
# ---------------------------- 语义上下文推断(加强版) ----------------------------
def infer_context_from_attrs(tag_name: str, attrs: Dict[str, str], text: str = "") -> str:
class_list = attrs.get("class", "").split()
id_ = attrs.get("id", "")
href = attrs.get("href", "")
features = " ".join(class_list) + " " + id_
# 对 <a> 标签的判断
if tag_name == "a":
if any(w in features for w in ["btn", "button", "tab", "nav-btn", "menu-item", "action-btn"]):
return "按钮"
# 通用特征判断
if "search" in features or "nav-search" in features:
return "搜索框"
if any(w in features for w in ["header", "top-bar", "masthead", "nav"]):
return "顶部导航"
if any(w in features for w in ["sidebar", "aside", "right-entry"]):
return "侧边栏"
if "footer" in features:
return "页脚"
if any(w in features for w in ["channel", "partition", "channel-icons"]):
return "频道/分区"
if any(w in features for w in ["carousel", "banner", "slider"]):
return "轮播/横幅"
if any(w in features for w in ["video-card", "feed-card", "card-item", "card"]):
return "视频卡片"
if "main" in features or "content" in features:
return "主要内容区"
if tag_name == "a":
if "cm.bilibili.com" in href or "ad" in features:
return "广告链接"
return "普通链接"
if tag_name in ("input", "textarea"):
return "输入控件"
if tag_name == "button":
return "按钮"
if tag_name == "select":
return "下拉选择"
# 对新加入的交互元素(div/li/span/img等)做一些推断
if tag_name in ("div", "li", "span", "img", "td", "label"):
if any(w in features for w in ["btn", "button", "action", "clickable", "tab", "menu-item"]):
return "按钮"
if any(w in features for w in ["card", "item", "cell", "list-item"]):
return "卡片/列表项"
if "dropdown" in features or "menu" in features:
return "菜单"
if "dialog" in features or "modal" in features:
return "弹窗"
if "icon" in features:
return "图标"
if tag_name == "img":
return "图片"
return "交互区域"
return "其他"
# ---------------------------- 事件绑定与递归(增强版:模拟点击所有可交互控件) ----------------------------
def add_events_and_recurse(controls: List[Dict[str, Any]], base_url: str,
depth: int, max_depth: int, visited: set, driver):
"""
增强版:对于点击后 URL 不变的控件,会检测页面中动态出现的新控件,
并对新控件(如下拉菜单项)进行递归导航抓取。
"""
def get_control_signature(ctrl):
"""生成控件的唯一签名"""
tp = ctrl["type"]
attrs = ctrl["attributes"]
text = ctrl.get("text", "")
cls = attrs.get("class", "")
id_ = attrs.get("id", "")
return f"{tp}|{text}|{cls}|{id_}"
original_signatures = {get_control_signature(c) for c in controls}
for ctrl in controls:
tp = ctrl["type"]
attrs = ctrl["attributes"]
idx = ctrl.get("index")
events = []
navigate_url = None
reason = ""
clicked_for_navigation = False
# ---------- URL 预判 ----------
if tp == "a":
href = attrs.get("href")
if href:
abs_url = get_absolute_url(base_url, href)
if abs_url:
navigate_url = abs_url
reason = "点击链接跳转"
if tp == "button" or (tp == "input" and attrs.get("type", "").lower() in ("submit", "button", "image")):
form_action = ctrl.get("form_action")
if form_action:
abs_action = get_absolute_url(base_url, form_action)
if abs_action:
navigate_url = abs_action
reason = "提交表单跳转"
else:
events.append({"type": "submit", "description": "提交表单(无明确跳转)"})
else:
events.append({"type": "submit", "description": "提交表单(无 action)"})
if "alert" in attrs.get("onclick", "") or "confirm" in attrs.get("onclick", ""):
events.append({"type": "popup", "description": "触发弹窗"})
if tp == "input":
input_type = attrs.get("type", "text").lower()
if input_type in ("text", "password", "email", "search", "number", "tel", "url", ""):
events.append({"type": "input", "description": "输入文字"})
events.append({"type": "change", "description": "内容改变"})
elif input_type == "range":
events.append({"type": "input", "description": "滑动改变值"})
elif input_type in ("checkbox", "radio"):
events.append({"type": "change", "description": "切换状态"})
if tp == "select":
events.append({"type": "change", "description": "选择项改变"})
if tp == "textarea":
events.append({"type": "input", "description": "输入多行文字"})
events.append({"type": "change", "description": "内容改变"})
if tp in ("div", "li", "span", "img", "td", "label") and ctrl.get("is_interactive"):
onclick = attrs.get("onclick", "")
data_href = attrs.get("data-href", "")
if data_href:
abs_url = get_absolute_url(base_url, data_href)
if abs_url:
navigate_url = abs_url
reason = "点击交互跳转"
elif "location.href" in onclick or "window.open" in onclick:
match = re.search(r"""['"]([^'"]+)['"]""", onclick)
if match:
abs_url = get_absolute_url(base_url, match.group(1))
if abs_url:
navigate_url = abs_url
reason = "点击交互跳转"
# 弹窗兜底
if tp in ("input", "button") or (tp in ("div", "li", "span", "img") and ctrl.get("is_interactive")):
if ("alert" in attrs.get("onclick", "") or "confirm" in attrs.get("onclick", "")) and \
not any(e["type"] == "popup" for e in events):
events.append({"type": "popup", "description": "触发弹窗"})
# ========== 模拟点击与动态内容探索 ==========
if depth < max_depth and idx is not None:
# 情况1:有预知 URL
if navigate_url and navigate_url not in visited:
try:
target_element = driver.find_element(By.CSS_SELECTOR, f'[data-ctrl-idx="{idx}"]')
pre_url = driver.current_url
target_element.click()
time.sleep(1)
WebDriverWait(driver, 10).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
handles = driver.window_handles
if len(handles) > 1:
original_handle = driver.current_window_handle
for handle in handles:
if handle != original_handle:
driver.switch_to.window(handle)
break
new_url = driver.current_url
if new_url not in visited:
visited.add(new_url)
target_page = process_source(new_url, depth + 1, max_depth, visited, driver)
event = {"type": "navigate", "url": new_url, "description": reason,
"target_page": target_page}
events.append(event)
driver.close()
driver.switch_to.window(original_handle)
else:
new_url = driver.current_url
if new_url != pre_url and new_url not in visited:
visited.add(new_url)
target_page = process_source(new_url, depth + 1, max_depth, visited, driver)
event = {"type": "navigate", "url": new_url, "description": reason,
"target_page": target_page}
events.append(event)
driver.back()
WebDriverWait(driver, 10).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
clicked_for_navigation = True
except Exception as e:
events.append({"type": "navigate", "url": navigate_url, "description": reason,
"target_page": {"url": navigate_url, "error": str(e)}})
clicked_for_navigation = True
# 情况2:无预知 URL,但可交互 → 尝试点击并检查是否出现下拉菜单
elif ctrl.get("is_interactive") and not clicked_for_navigation:
try:
target_element = driver.find_element(By.CSS_SELECTOR, f'[data-ctrl-idx="{idx}"]')
pre_url = driver.current_url
target_element.click()
time.sleep(0.8)
WebDriverWait(driver, 10).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
new_url = driver.current_url
if new_url != pre_url and new_url not in visited:
# 直接跳转了
visited.add(new_url)
target_page = process_source(new_url, depth + 1, max_depth, visited, driver)
event = {"type": "navigate", "url": new_url, "description": "点击交互跳转(运行时发现)",
"target_page": target_page}
events.append(event)
driver.back()
WebDriverWait(driver, 10).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
clicked_for_navigation = True
else:
# URL 没变,检测动态出现的下拉菜单项
# 使用更可靠的方法:直接查找可能是下拉项的 <li> 或 <a>,并排除原始控件
sub_items = driver.execute_script("""
let candidates = [];
// 查找常见菜单结构:ul.submenu, div.dropdown-menu 下的 li/a
let menus = document.querySelectorAll('ul, div[class*="menu"], div[class*="dropdown"]');
for (let menu of menus) {
let items = menu.querySelectorAll('li, a');
for (let item of items) {
// 跳过隐藏元素
if (item.offsetParent === null) continue;
let text = item.innerText.trim().substring(0, 50);
if (!text) continue;
// 排除与原始控件文本相同的元素(父菜单本身可能被包含)
candidates.push({
tag: item.tagName.toLowerCase(),
text: text,
outerHTML: item.outerHTML.substring(0, 200)
});
}
}
return candidates;
""")
# 去重,只保留之前没有的项
clicked_sub_items = set()
for item in sub_items:
sig = f"{item['tag']}|{item['text']}"
if sig not in original_signatures and sig not in clicked_sub_items:
# 尝试定位并点击
try:
# 用文本查找具体元素(更稳定)
elem = driver.find_element(By.XPATH,
f"//{item['tag']}[normalize-space()='{item['text']}']")
elem.click()
time.sleep(0.8)
WebDriverWait(driver, 10).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
new_url2 = driver.current_url
if new_url2 != pre_url and new_url2 not in visited:
visited.add(new_url2)
sub_target = process_source(new_url2, depth + 1, max_depth, visited, driver)
event = {"type": "navigate", "url": new_url2,
"description": f"下拉菜单项: {item['text']}",
"target_page": sub_target}
events.append(event)
driver.back()
WebDriverWait(driver, 10).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
# 回退后重新点击父菜单,确保下拉菜单仍然展开
target_element = driver.find_element(By.CSS_SELECTOR, f'[data-ctrl-idx="{idx}"]')
target_element.click()
time.sleep(0.5)
clicked_sub_items.add(sig)
except Exception:
continue
except Exception as e:
# 可以在这里打印调试信息,但不影响流程
pass
if not events:
events.append({"type": "unknown", "description": "未识别的事件"})
ctrl["events"] = events
# ---------------------------- 核心抓取逻辑(增强版:去重+注入索引) ----------------------------
def build_page_tree_from_url(driver, url: str, depth: int, max_depth: int,
visited: set) -> Dict[str, Any]:
print(f"正在抓取 (深度 {depth}/{max_depth}): {url}")
visited.add(url)
try:
driver.get(url)
WebDriverWait(driver, 15).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
driver.implicitly_wait(2)
except Exception as e:
return {"url": url, "error": str(e)}
# ---------- 提取所有交互元素(去重:只保留最外层交互元素,并注入索引) ----------
try:
raw_controls = driver.execute_script("""
const tags = ['a', 'input', 'button', 'select', 'textarea',
'div', 'li', 'span', 'img', 'td', 'label'];
let allElements = [];
for (let tag of tags) {
let elems = document.querySelectorAll(tag);
for (let elem of elems) {
if (tag === 'input' && elem.type === 'hidden') continue;
if (tag === 'a' && !elem.href && !elem.onclick) continue;
let isInteractive = false;
if (['div', 'li', 'span', 'img', 'td', 'label'].includes(tag)) {
if (elem.onclick) isInteractive = true;
let role = elem.getAttribute('role');
if (role && ['button', 'link', 'tab', 'menuitem'].includes(role)) isInteractive = true;
if (elem.hasAttribute('tabindex') && elem.getAttribute('tabindex') !== '-1') isInteractive = true;
if (elem.hasAttribute('data-action') || elem.hasAttribute('data-href')) isInteractive = true;
let cls = elem.className || '';
if (/(btn|button|clickable|action|link|tab|menu-item|card|cell)/i.test(cls)) {
isInteractive = true;
}
if (!isInteractive) continue;
}
allElements.push({element: elem, tag: tag, isInteractive: isInteractive});
}
}
// 去重:如果某元素被另一个更外层的交互元素包含,则丢弃它
let filtered = [];
let idx = 0;
for (let item of allElements) {
let elem = item.element;
let isChild = false;
// 检查是否被之前的某个元素包含
for (let prev of filtered) {
if (prev.element.contains(elem) && prev.element !== elem) {
isChild = true;
break;
}
}
if (!isChild) {
// 注入唯一索引
elem.setAttribute('data-ctrl-idx', idx);
let attrs = {};
for (let attr of elem.attributes) {
attrs[attr.name] = attr.value;
}
let formAction = null;
if (item.tag === 'button' || (item.tag === 'input' && ['submit','button','image'].includes(elem.type))) {
let form = elem.closest('form');
if (form) formAction = form.getAttribute('action') || '';
}
let text = elem.innerText ? elem.innerText.trim().substring(0, 50) : '';
filtered.push({
element: elem, // 用于后续 contains 检查
tag: item.tag,
attributes: attrs,
text: text,
form_action: formAction,
is_interactive: item.isInteractive,
index: idx
});
idx++;
}
}
// 只返回需要的数据(去掉 DOM 引用)
return filtered.map(({tag, attributes, text, form_action, is_interactive, index}) => ({
tag, attributes, text, form_action, is_interactive, index
}));
""")
except Exception as e:
return {"url": url, "error": f"JavaScript提取失败: {str(e)}"}
# 转换为统一格式
controls = []
for rc in raw_controls:
ctrl = {
"type": rc["tag"],
"attributes": rc["attributes"],
"text": rc.get("text", ""),
"context": infer_context_from_attrs(rc["tag"], rc["attributes"], rc.get("text", "")),
"events": [],
"form_action": rc.get("form_action"),
"is_interactive": rc.get("is_interactive", False),
"index": rc.get("index") # 保存索引,用于模拟点击
}
controls.append(ctrl)
# 填充事件和递归(将 driver 传入,实现模拟点击)
add_events_and_recurse(controls, url, depth, max_depth, visited, driver)
# 删除内部使用的字段
for ctrl in controls:
ctrl.pop("form_action", None)
if not ctrl["is_interactive"]:
del ctrl["is_interactive"]
return {"url": url, "controls": controls}
def process_source(source: str, depth: int, max_depth: int,
visited: set, driver) -> Dict[str, Any]:
if source.startswith(('http://', 'https://', 'file://')):
url = source
else:
abs_path = os.path.abspath(source)
url = Path(abs_path).as_uri()
return build_page_tree_from_url(driver, url, depth, max_depth, visited)
# ---------------------------- 调试浏览器管理 ----------------------------
def is_port_open(host, port):
try:
with socket.create_connection((host, port), timeout=1):
return True
except OSError:
return False
def ensure_debug_browser():
debug_host = "127.0.0.1"
debug_port = 9222
if not is_port_open(debug_host, debug_port):
print("未检测到调试浏览器,正在启动 Edge 调试窗口...")
edge_path = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
user_data_dir = r"C:\temp\edge_debug_profile"
try:
subprocess.Popen([
edge_path,
f"--remote-debugging-port={debug_port}",
f"--user-data-dir={user_data_dir}"
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
edge_path = "msedge"
subprocess.Popen([
edge_path,
f"--remote-debugging-port={debug_port}",
f"--user-data-dir={user_data_dir}"
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("请在新打开的 Edge 窗口中访问目标网站并完成登录,然后回到这里按 Enter 继续...")
input()
else:
print("检测到已有的调试浏览器,将直接使用。")
if not is_port_open(debug_host, debug_port):
raise RuntimeError("无法连接到调试端口,请检查 Edge 是否正常启动。")
# ---------------------------- 人性化报告生成 ----------------------------
def short_text_for_control(ctrl: Dict[str, Any]) -> str:
tp = ctrl["type"]
attrs = ctrl["attributes"]
is_interactive = ctrl.get("is_interactive", False)
base = ""
if tp == "a":
href = attrs.get("href", "")
text = ctrl.get("text") or attrs.get("title") or attrs.get("aria-label") or href[:60]
base = f"链接 →{text} : {href}"
elif tp == "button":
text = ctrl.get("text") or attrs.get("value") or attrs.get("aria-label") or "按钮"
base = f"按钮: {text}"
elif tp == "input":
input_type = attrs.get("type", "text")
text = ctrl.get("text") or attrs.get("value") or attrs.get("placeholder") or ""
base = f"输入框 ({input_type})"
if text:
base += f" [{text}]"
elif tp == "textarea":
text = ctrl.get("text") or attrs.get("placeholder") or ""
base = "多行文本框"
if text:
base += f" [{text}]"
elif tp == "select":
text = ctrl.get("text") or ""
base = "下拉选择框"
if text:
base += f" [{text}]"
elif is_interactive:
text = ctrl.get("text") or ""
role = attrs.get("role", "")
tag_desc = f"{tp}控件"
if role == "button":
tag_desc = "按钮(ARIA)"
elif role == "link":
tag_desc = "链接(ARIA)"
elif "card" in (attrs.get("class", "") + attrs.get("id", "")):
tag_desc = "卡片"
base = f"可交互{tag_desc}"
if text:
base += f" [{text}]"
if attrs.get("data-href"):
base += f" (目标: {attrs['data-href']})"
else:
text = ctrl.get("text") or ""
base = f"{tp} 控件"
if text:
base += f" [{text}]"
return base
def describe_page_tree(page: Dict[str, Any], prefix: str = "",
max_depth: int = 1, current_depth: int = 1,
is_last: bool = True) -> str:
url = page.get("url", "未知页面")
if "error" in page:
return f"{prefix}页面 {url} 抓取失败: {page['error']}\n"
controls = page.get("controls", [])
lines = []
connector = "└── " if is_last else "├── "
lines.append(f"{prefix}{connector}页面: {url}")
groups: Dict[str, List[Dict]] = {}
for ctrl in controls:
ctx = ctrl["context"]
groups.setdefault(ctx, []).append(ctrl)
sorted_groups = sorted(groups.items(), key=lambda x: x[0])
group_count = len(sorted_groups)
for idx, (context, items) in enumerate(sorted_groups):
is_last_group = (idx == group_count - 1)
group_connector = "└── " if is_last_group else "├── "
group_line = f"{prefix}{' ' if is_last else '│ '}{group_connector}【{context}】({len(items)}个)"
lines.append(group_line)
item_prefix = prefix + (" " if is_last else "│ ") + (" " if is_last_group else "│ ")
for j, ctrl in enumerate(items):
is_last_item = (j == len(items) - 1)
item_connector = "└── " if is_last_item else "├── "
desc = short_text_for_control(ctrl)
lines.append(f"{item_prefix}{item_connector}{desc}")
for event in ctrl.get("events", []):
if "target_page" in event and current_depth < max_depth:
sub_page = event["target_page"]
new_prefix = item_prefix + (" " if is_last_item else "│ ")
sub_desc = describe_page_tree(
sub_page,
prefix=new_prefix,
max_depth=max_depth,
current_depth=current_depth + 1,
is_last=True
)
lines.append(sub_desc)
return "\n".join(lines) + "\n"
def generate_human_report(root_page: Dict[str, Any], max_depth: int) -> str:
header = f"🌐 页面交互控件分析报告(增强版)\n深度限制: {max_depth}\n" + "=" * 40 + "\n"
tree = describe_page_tree(root_page, prefix="", max_depth=max_depth,
current_depth=1, is_last=True)
return header + tree
# ---------------------------- 命令行入口 ----------------------------
def main():
parser = argparse.ArgumentParser(description="通用网页交互控件分析工具(增强版:模拟点击、去重)")
parser.add_argument("source", help="起始 URL 或本地 HTML 文件路径")
parser.add_argument("-N", "--depth", type=int, default=1, help="最大递归深度(默认1)")
parser.add_argument("-o", "--output", help="输出文本文件路径,默认打印到控制台")
parser.add_argument("--json", action="store_true", help="同时保存机器可读的 JSON 文件(与 -o 同名 .json)")
args = parser.parse_args()
max_depth = max(1, args.depth)
if args.source.startswith(('http://', 'https://')):
start_url = args.source
else:
abs_path = os.path.abspath(args.source)
start_url = Path(abs_path).as_uri()
visited = {start_url}
ensure_debug_browser()
service = Service(EdgeChromiumDriverManager().install())
options = Options()
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Edge(service=service, options=options)
driver.implicitly_wait(5)
try:
tree = process_source(start_url, depth=1, max_depth=max_depth,
visited=visited, driver=driver)
finally:
driver.quit()
report = generate_human_report(tree, max_depth)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(report)
print(f"人性化报告已保存到 {args.output}")
if args.json:
json_path = args.output.rsplit('.', 1)[0] + '.json'
with open(json_path, 'w', encoding='utf-8') as jf:
json.dump(tree, jf, ensure_ascii=False, indent=2)
print(f"机器可读 JSON 已保存到 {json_path}")
else:
print(report)
if __name__ == "__main__":
main()