307 lines
11 KiB
Python
307 lines
11 KiB
Python
import os
|
||
import re
|
||
import json
|
||
import argparse
|
||
from pathlib import Path
|
||
from typing import Dict, Any, List, Optional
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
from selenium import webdriver
|
||
from selenium.webdriver.edge.options import Options
|
||
from selenium.webdriver.edge.service import Service
|
||
from selenium.webdriver.support.ui import WebDriverWait
|
||
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 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_
|
||
|
||
if tag_name == "a":
|
||
if any(w in features for w in ["btn", "button", "tab", "nav-btn", "menu-item", "action-btn"]):
|
||
return "按钮"
|
||
if "cm.bilibili.com" in href or "ad" in features:
|
||
return "广告链接"
|
||
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 in ("input", "textarea"):
|
||
return "输入控件"
|
||
if tag_name == "button":
|
||
return "按钮"
|
||
if tag_name == "select":
|
||
return "下拉选择"
|
||
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 extract_controls(driver) -> List[Dict[str, Any]]:
|
||
"""
|
||
无头模式下提取所有可交互元素(去重),
|
||
仅返回元素的尺寸(CSS 像素),不包含坐标。
|
||
"""
|
||
raw = driver.execute_script("""
|
||
const tags = ['a', 'input', 'button', 'select', 'textarea',
|
||
'div', 'li', 'span', 'img', 'td', 'label'];
|
||
let allElements = [];
|
||
|
||
// 1. 收集候选元素
|
||
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(elem);
|
||
}
|
||
}
|
||
|
||
// 2. 去重:只保留不被其他任何元素包含的最外层元素
|
||
let uniqueElements = [];
|
||
for (let elem of allElements) {
|
||
let isChild = false;
|
||
for (let other of allElements) {
|
||
if (other !== elem && other.contains(elem)) {
|
||
isChild = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!isChild) {
|
||
uniqueElements.push(elem);
|
||
}
|
||
}
|
||
|
||
// 3. 获取每个元素的属性、文本和尺寸
|
||
return uniqueElements.map(elem => {
|
||
let tag = elem.tagName.toLowerCase();
|
||
let attrs = {};
|
||
for (let attr of elem.attributes) {
|
||
attrs[attr.name] = attr.value;
|
||
}
|
||
let formAction = null;
|
||
if (tag === 'button' || (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) : '';
|
||
let rect = elem.getBoundingClientRect();
|
||
|
||
return {
|
||
tag: tag,
|
||
attributes: attrs,
|
||
text: text,
|
||
form_action: formAction,
|
||
is_interactive: true,
|
||
size: {
|
||
width: rect.width,
|
||
height: rect.height
|
||
}
|
||
};
|
||
});
|
||
""")
|
||
|
||
controls = []
|
||
for item in raw:
|
||
ctrl = {
|
||
"type": item["tag"],
|
||
"attributes": item["attributes"],
|
||
"text": item.get("text", ""),
|
||
"context": infer_context_from_attrs(item["tag"], item["attributes"], item.get("text", "")),
|
||
"form_action": item.get("form_action"),
|
||
"is_interactive": item.get("is_interactive", False),
|
||
"size": item.get("size", {})
|
||
}
|
||
controls.append(ctrl)
|
||
return controls
|
||
|
||
|
||
# -------------------- 人性化报告生成 --------------------
|
||
def short_text_for_control(ctrl: Dict[str, Any]) -> str:
|
||
tp = ctrl["type"]
|
||
attrs = ctrl["attributes"]
|
||
is_interactive = ctrl.get("is_interactive", False)
|
||
size = ctrl.get("size", {})
|
||
size_str = ""
|
||
if size:
|
||
w, h = size.get("width", 0), size.get("height", 0)
|
||
size_str = f" [大小:{w:.0f}x{h:.0f}]"
|
||
|
||
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}"
|
||
if href:
|
||
base += f" : {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 + size_str
|
||
|
||
|
||
def generate_report(url: str, controls: List[Dict[str, Any]]) -> str:
|
||
lines = [f"🌐 页面交互控件分析报告(静态单页·无头模式)\n页面: {url}\n" + "=" * 50]
|
||
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])
|
||
for context, items in sorted_groups:
|
||
lines.append(f"├── 【{context}】({len(items)}个)")
|
||
for item in items:
|
||
desc = short_text_for_control(item)
|
||
lines.append(f"│ ├── {desc}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# -------------------- 主程序 --------------------
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="静态单页交互控件分析(Edge·无头模式·仅元素大小)")
|
||
parser.add_argument("source", help="起始 URL 或本地 HTML 文件路径")
|
||
parser.add_argument("-o", "--output", help="输出报告文件路径,默认打印到控制台")
|
||
parser.add_argument("--json", action="store_true", help="同时保存机器可读的 JSON 文件(与 -o 同名 .json)")
|
||
args = parser.parse_args()
|
||
|
||
# 构造 URL
|
||
if args.source.startswith(('http://', 'https://')):
|
||
url = args.source
|
||
else:
|
||
abs_path = os.path.abspath(args.source)
|
||
url = Path(abs_path).as_uri()
|
||
|
||
# 无头模式 Edge,设置窗口大小
|
||
options = Options()
|
||
options.add_argument("--headless=new") # 新版无头模式
|
||
options.add_argument("--window-size=1920,1080") # 固定视口大小
|
||
options.add_argument("--disable-gpu") # 禁用 GPU 加速(无头下建议)
|
||
|
||
service = Service(EdgeChromiumDriverManager().install())
|
||
driver = webdriver.Edge(service=service, options=options)
|
||
|
||
try:
|
||
driver.get(url)
|
||
WebDriverWait(driver, 15).until(
|
||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||
)
|
||
controls = extract_controls(driver)
|
||
finally:
|
||
driver.quit()
|
||
|
||
report = generate_report(url, controls)
|
||
|
||
# 输出结果
|
||
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({
|
||
"url": url,
|
||
"controls": controls
|
||
}, jf, ensure_ascii=False, indent=2)
|
||
print(f"机器可读 JSON 已保存到 {json_path}")
|
||
else:
|
||
print(report)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|