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()