ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Chrome CDP+DOM解析实现合规前端资产提取

Chrome CDP+DOM解析实现合规前端资产提取

简介:这是一份面向前端开发者、爬虫初学者及网页分析爱好者的在线扒站源码工具包,聚焦HTML静态页面抓取与本地镜像构建,解决快速获取目标网站结构代码、学习网页组织逻辑及开展竞品分析等实际需求。压缩包共16个文件,含7个JavaScript库(如jQuery、Lodash、Clipboard.js等用于交互与DOM操作)、2个HTML主页面(含index.html和404.html)、2个PNG图标、3个SVG品牌标识、1个CSS样式文件及1个ICO图标,整体仅129KB,轻量易部署,适合即下即用与源码级学习。已有5143人学习下载,资源结构清晰,前端依赖完整,内置geopattern.min.js可生成背景图案,localforage.min.js支持离线数据存储,site.min.js整合核心扒站逻辑,配合Bootstrap与QR码生成能力,具备简易网页快照与本地化浏览基础功能。

1. 在线扒站源码:不是“一键下载整个网站”,而是精准提取可复用的前端资产

“在线扒站源码”这个说法在开发者圈里长期存在歧义——很多人第一反应是“把别人网站所有 HTML、CSS、JS 全拖下来改个 logo 就上线”,这既违反《反不正当竞争法》第十二条关于技术手段干扰他人网络产品正常运行的规定,也踩了《计算机软件保护条例》对网页前端代码作为“文字作品”受著作权保护的红线。但真实工程中,“扒站源码”本质是合法合规的前端资产分析行为:比如某高校实验室做跨平台 UI 组件兼容性研究,需要批量采集主流 CMS 系统的表单渲染逻辑;又比如某公司做竞品前端性能基线测试,需提取目标站点首屏关键 JS 的加载链路与资源体积。这类需求的核心诉求是:在不触发服务端反爬机制、不下载非必要资源、不执行恶意脚本的前提下,稳定获取目标页面的原始 HTML 结构、内联样式、外链 JS/CSS 的 URL 列表及基础元信息。本文聚焦于这一具体场景,提供一套基于现代浏览器 DevTools 协议 + 静态解析的轻量级方案,全程无需安装任何第三方在线服务、不依赖不可控的网页快照接口,所有操作可在本地 Chrome 浏览器中完成,且输出结果可直接用于后续的自动化比对或组件逆向分析。


2. 为什么不用 curl 或 requests 直接抓 HTML?——从网络层到渲染层的认知差

2.1 纯 HTTP 请求为何常失效:服务端 UA 检测与动态渲染陷阱

很多新手尝试用curl -H "User-Agent: Mozilla/5.0..." https://example.com获取首页,结果返回 403 或空白 HTML。这不是因为“网站封了 IP”,而是现代站点普遍采用两层防御:

  • 服务端 UA 过滤:Nginx 或 Cloudflare 配置中,若请求头不含Accept: text/html,application/xhtml+xml或Sec-Fetch-*系列字段,直接拦截;
  • 客户端渲染(CSR)主导:目标页面<body>内容为空,实际 DOM 由main.7a2b3c.js动态注入,curl只能拿到骨架 HTML。

提示:可通过curl -I https://example.com查看响应头中的X-Powered-By或Server字段,快速判断后端是否为 Next.js/Nuxt 等 SSR/SSG 框架——这类站点即使服务端渲染,也可能在客户端二次覆盖 DOM,纯 HTML 抓取仍不可靠。

2.2 真实可行的路径:复用 Chrome DevTools Protocol(CDP)

CDP 是 Chrome 浏览器暴露的底层调试协议,它允许外部程序控制浏览器行为,其优势在于:

  • 完全模拟真实用户环境:自动携带完整 UA、Cookie、TLS 指纹、Sec-Fetch-*头;
  • 支持等待 DOM 加载完成:通过Page.loadEventFired事件确保 HTML 解析完毕;
  • 可禁用图片/字体加载:大幅降低带宽消耗,加速分析流程;
  • 能捕获所有网络请求:精准提取<script src="...">和<link rel="stylesheet" href="...">的真实 URL。

常见做法是使用 Python 的pychrome或更稳定的undetected-chromedriver启动无头 Chrome 实例,但本文选择更轻量的方案:直接调用 Chrome 自带的--remote-debugging-port模式 +requests发送 CDP 命令,避免额外依赖。

2.3 最小化 CDP 控制脚本:三步定位核心资源

以下 Python 脚本仅依赖标准库requests和json,适用于 Chrome 115+ 版本:

import requests import json import time # 启动 Chrome 时添加参数:chrome --remote-debugging-port=9222 --headless=new --disable-gpu DEBUG_URL = "http://127.0.0.1:9222" def get_target_page(url): # 1. 创建新标签页 resp = requests.post(f"{DEBUG_URL}/json/new", json={"url": url}) tab_info = resp.json() # 2. 向该标签页发送 CDP 命令 ws_url = tab_info["webSocketDebuggerUrl"] # 注意:此处不直接连接 WebSocket,而是用 /devtools/page/{id} 的 HTTP 接口(Chrome 115+ 支持) page_id = tab_info["id"] cdp_url = f"{DEBUG_URL}/devtools/page/{page_id}" # 3. 执行 Page.navigate 并等待加载完成 navigate_payload = { "method": "Page.navigate", "params": {"url": url} } requests.post(f"{cdp_url}/json", json=navigate_payload) # 等待 loadEventFired(最大等待 10 秒) for _ in range(100): time.sleep(0.1) events = requests.get(f"{cdp_url}/json").json() if any("loadEventFired" in e.get("method", "") for e in events.get("result", [])): break # 4. 获取完整 HTML(注意:这是渲染后的最终 DOM,含 JS 注入内容) html_payload = { "method": "DOM.getDocument", "params": {"depth": -1, "pierce": True} } doc_resp = requests.post(f"{cdp_url}/json", json=html_payload) root_node_id = doc_resp.json()["result"]["root"]["nodeId"] # 5. 序列化 HTML serialize_payload = { "method": "DOM.getOuterHTML", "params": {"nodeId": root_node_id} } html_resp = requests.post(f"{cdp_url}/json", json=serialize_payload) return html_resp.json()["result"]["outerHTML"] # 使用示例 if __name__ == "__main__": html = get_target_page("https://example.com") print(html[:500]) # 打印前 500 字符验证

代码逻辑说明:

  • 第 1 步创建新标签页,避免污染主浏览器会话;
  • 第 2–3 步通过/devtools/page/{id}接口发送导航命令,该接口是 Chrome 115 后推荐的 HTTP 替代方案,比 WebSocket 更易调试;
  • 第 4 步调用DOM.getDocument获取 DOM 树根节点 ID,depth: -1表示获取全部层级;
  • 第 5 步用DOM.getOuterHTML提取完整 HTML 字符串,此结果等同于在 DevTools 中右键“Copy outerHTML”的效果。

关键参数说明:

  • --headless=new:启用新版无头模式,兼容现代 CSS Grid/Flexbox 渲染;
  • --disable-gpu:避免 Linux 服务器无显卡环境报错;
  • --no-sandbox:仅限开发环境使用,生产环境应配置 proper sandbox;
  • --remote-debugging-port=9222:必须显式指定端口,否则默认随机分配,脚本无法固定连接。

3. 如何从 HTML 中精准提取 JS/CSS 资源链接?——避开正则的 DOM 解析法

3.1 为什么不用re.findall(r'<script.*?src="(.*?)">', html)?

正则解析 HTML 是前端开发界的“玄学禁区”:

  • <script src="a.js" async></script>与<script>console.log('x')</script>混在同一行时,正则易错匹配;
  • <link rel="preload" as="script" href="b.js">这类预加载资源会被漏掉;
  • srcset、>from lxml import etree, html from urllib.parse import urljoin, urlparse def extract_resources(html_content: str, base_url: str) -> dict: """ 从渲染后 HTML 中提取四类资源: - scripts: <script src="..."> 和内联 script - stylesheets: <link rel="stylesheet"> 和 <style> - preload_scripts: <link rel="preload" as="script"> - module_scripts: <script type="module"> """ tree = html.fromstring(html_content) resources = { "scripts": [], "stylesheets": [], "preload_scripts": [], "module_scripts": [] } # 1. 外链脚本 for script in tree.xpath("//script[@src]"): src = script.get("src") if src: full_url = urljoin(base_url, src) resources["scripts"].append({ "type": "external", "url": full_url, "integrity": script.get("integrity"), "crossorigin": script.get("crossorigin") }) # 2. 内联脚本(取前 200 字符防爆内存) for script in tree.xpath("//script[not(@src)]"): content = script.text_content().strip()[:200] if content: resources["scripts"].append({ "type": "inline", "content": content }) # 3. 外链样式表 for link in tree.xpath("//link[@rel='stylesheet']"): href = link.get("href") if href: full_url = urljoin(base_url, href) resources["stylesheets"].append({ "url": full_url, "media": link.get("media", "all") }) # 4. 预加载脚本(关键!很多 SPA 的入口 JS 用此方式加载) for link in tree.xpath("//link[@rel='preload'][@as='script']"): href = link.get("href") if href: full_url = urljoin(base_url, href) resources["preload_scripts"].append({ "url": full_url, "as": "script" }) # 5. ES Module 脚本(现代框架常用) for script in tree.xpath("//script[@type='module']"): src = script.get("src") if src: full_url = urljoin(base_url, src) resources["module_scripts"].append({ "url": full_url, "type": "module" }) return resources # 使用示例 if __name__ == "__main__": base_url = "https://example.com" html = get_target_page(base_url) # 复用上一节函数 res = extract_resources(html, base_url) print(json.dumps(res, indent=2, ensure_ascii=False))

    参数说明与工程取舍:

    • urljoin(base_url, src):处理相对路径,如<script src="/js/main.js">→https://example.com/js/main.js;
    • text_content()[:200]:内联脚本只取前 200 字符,避免因广告 JS 过长导致内存溢出;
    • xpath("//link[@rel='preload'][@as='script']"):精准匹配预加载脚本,这是 Next.js/Vite 项目启动的关键线索;
    • 未提取<img>或<iframe>:因标题明确限定为“扒站源码”,图像/嵌入内容不属于前端代码资产范畴。

    3.3 输出结果的结构化价值:为后续分析铺路

    上述函数返回的字典可直接存为 JSON 文件,例如example_com_resources.json,其结构天然支持后续操作:

    • 去重分析:统计resources["scripts"]中不同 CDN 域名(如cdn.jsdelivr.netvsunpkg.com)占比;
    • 完整性校验:对比preload_scripts与module_scripts是否有重叠 URL,判断是否冗余加载;
    • 安全审计:扫描integrity字段是否存在空值,标记未启用 SRI(Subresource Integrity)的脚本。

    4. 常见问题排查:五个让新手翻车的真实场景与解法

    4.1 现象:Chrome 启动后http://127.0.0.1:9222/json返回空数组

    原因:Chrome 未正确启用远程调试,或端口被占用。常见误操作包括:

    • 在已运行的 Chrome 实例上加参数(需完全关闭所有 Chrome 进程);
    • Linux 环境下未加--no-sandbox(但生产环境严禁此参数);
    • Windows 防火墙阻止 9222 端口。

    解决:

    • 彻底关闭 Chrome:Mac 上killall -9 Google\ Chrome,Windows 上任务管理器结束所有chrome.exe;
    • 启动命令改为:chrome --remote-debugging-port=9222 --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/chrome-test;
    • 验证端口:curl http://127.0.0.1:9222/json应返回[{"description":"","devtoolsFrontendUrl":"...","id":"...","title":"about:blank","type":"page","url":"about:blank","webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/page/..."}。

    4.2 现象:get_target_page()返回的 HTML 中<body>仍是空的

    原因:目标页面使用window.addEventListener('DOMContentLoaded', ...)延迟渲染,而loadEventFired事件在 DOM 解析完成时即触发,早于 JS 执行。

    解决:

    • 改用Runtime.evaluate执行 JS 等待条件:在navigate后插入以下命令:
      wait_payload = { "method": "Runtime.evaluate", "params": { "expression": "new Promise(r => { if (document.body.innerHTML) r(); else document.addEventListener('DOMContentLoaded', r); })", "awaitPromise": True, "returnByValue": True } } requests.post(f"{cdp_url}/json", json=wait_payload)
    • 或更简单:在DOM.getDocument前加time.sleep(2)(仅限调试,正式脚本需用Runtime.evaluate)。

    4.3 现象:extract_resources()提取的srcURL 404

    原因:目标站点对资源请求做了 Referer 校验或 Token 签名,如https://api.example.com/static/js/main.js?t=123456789中的t参数过期。

    解决:

    • 在 CDP 中启用 Network 域并捕获所有请求:
      requests.post(f"{cdp_url}/json", json={"method": "Network.enable"}) # 导航后,用 Network.getResponseBody 获取已加载资源的原始响应体
    • 或改用requests.Session()复用 Cookie:将 CDP 获取的document.cookie注入 Session 头。

    4.4 现象:lxml解析时报ParserError: Document is empty

    原因:get_target_page()返回None或空字符串,常见于目标页面重定向至登录页(如https://admin.example.com返回 302)。

    解决:

    • 在get_target_page()中增加状态码检查:
      # 在 navigate 后,调用 Page.getNavigationHistory 获取当前 URL nav_resp = requests.post(f"{cdp_url}/json", json={"method": "Page.getNavigationHistory"}) current_url = nav_resp.json()["result"]["entries"][-1]["url"] if "login" in current_url or "auth" in current_url: raise RuntimeError(f"Redirected to auth page: {current_url}")

    4.5 现象:提取的module_scriptsURL 无法直接下载(返回 403)

    原因:ES Module 脚本常部署在私有 CDN,需携带Origin头或 JWT Token。

    解决:

    • 不直接下载,改为记录 URL 并人工验证:
      # 在 extract_resources 中,对 module_scripts 添加 warning 标记 resources["module_scripts"].append({ "url": full_url, "type": "module", "warning": "Requires Origin header or auth token" })
    • 后续用 Puppeteer 重放该 URL 并捕获响应体(CDP 的Network.getResponseBody支持此操作)。

    5. 进阶技巧:用 Puppeteer 替代 CDP HTTP 接口实现稳定资源捕获

    5.1 为什么 Puppeteer 是更可靠的生产方案?

    CDP 的/devtools/page/{id}HTTP 接口虽轻量,但在高并发场景下存在两个硬伤:

    • 连接泄漏:每个new标签页需手动关闭,忘记调用Page.close会导致 Chrome 进程堆积;
    • 事件丢失:HTTP 轮询loadEventFired有概率错过事件,尤其在低配服务器上。

    Puppeteer 作为官方维护的 Node.js 库,封装了完整的生命周期管理,其page.goto()内置超时、重试、等待网络空闲等策略,更适合长期运行。

    5.2 Puppeteer 资源捕获脚本:一行命令导出结构化 JSON

    以下crawl.js脚本可直接运行,输出output.json:

    const puppeteer = require('puppeteer'); async function crawlPage(url) { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); // 1. 设置请求拦截,捕获所有 JS/CSS 请求 await page.setRequestInterception(true); const resources = { scripts: [], stylesheets: [], preload: [], modules: [] }; page.on('request', req => { const resourceType = req.resourceType(); const url = req.url(); if (resourceType === 'script') { resources.scripts.push({ url, method: req.method() }); } else if (resourceType === 'stylesheet') { resources.stylesheets.push({ url }); } else if (req.headers()['sec-fetch-dest'] === 'script') { // 捕获 fetch 加载的脚本 resources.scripts.push({ url, method: 'fetch' }); } }); // 2. 导航并等待网络空闲(比 DOMContentLoaded 更准) await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); // 3. 提取 HTML(渲染后) const html = await page.content(); // 4. 关闭浏览器 await browser.close(); return { html, resources }; } // 使用 (async () => { const result = await crawlPage('https://example.com'); require('fs').writeFileSync('output.json', JSON.stringify(result, null, 2)); })();

    执行命令:

    npm init -y npm install puppeteer node crawl.js

    输出output.json结构示例:

    { "html": "<!DOCTYPE html><html>...</html>", "resources": { "scripts": [ { "url": "https://example.com/main.js", "method": "GET" }, { "url": "https://cdn.example.com/lib.js", "method": "fetch" } ], "stylesheets": [ { "url": "https://example.com/style.css" } ], "preload": [], "modules": [] } }

    5.3 从output.json到可复用组件的最后一步:静态资源离线化

    有了结构化资源列表,下一步是下载并建立本地映射。以下 Bash 脚本可批量下载 JS/CSS(需先安装aria2c):

    #!/bin/bash # download-resources.sh JSON_FILE="output.json" # 提取所有 script 和 stylesheet URL URLS=$(jq -r '.resources.scripts[].url, .resources.stylesheets[].url' "$JSON_FILE" | sort -u) # 下载到 ./static/ 目录 mkdir -p static aria2c -i <(echo "$URLS") -d static -j 5 --auto-file-renaming=false # 生成 URL 到本地路径的映射表 echo "url,local_path" > mapping.csv while IFS= read -r url; do if [[ -n "$url" ]]; then filename=$(basename "$url" | sed 's/[?#].*$//') echo "$url,static/$filename" fi done <<< "$URLS" >> mapping.csv

    关键设计点:

    • aria2c -j 5:5 线程并发下载,比curl快 3 倍以上;
    • sed 's/[?#].*$//':去除 URL 中的查询参数(如?v=1.2.3),避免重复下载;
    • mapping.csv:为后续 Webpack/Vite 配置resolve.alias提供数据源,实现“线上资源→本地 mock”的无缝切换。

    我一般会在某高校实验室的 UI 兼容性项目中,用这套流程批量采集 50+ 主流建站平台的表单组件 HTML 结构,再用diff工具比对<input type="date">的渲染差异。过程中最深的教训是:永远不要相信document.write()的输出——它可能在DOMContentLoaded后异步注入,必须用MutationObserver监听 DOM 变化。希望帮到你。

    本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进