1. 项目缘起:当AI“读不懂”网页时
作为一名长期在AI应用开发一线的工程师,我每天的工作都离不开让AI模型去“理解”和“处理”各种网页内容。无论是构建智能客服的知识库,还是开发自动化的信息聚合工具,一个核心且基础的任务就是:把网页上的信息准确、结构化地“喂”给AI模型。听起来很简单,对吧?不就是抓取HTML,提取文本吗?但现实情况是,我一次又一次地目睹了灾难性的“误读”。
我见过AI把网页侧边栏的“相关文章”推荐,当成了正文的核心论点;也见过它把页脚的版权声明和密密麻麻的导航链接,与主要内容混为一谈,生成了一堆毫无逻辑的摘要。更令人头疼的是,那些通过JavaScript动态加载的内容、复杂的表格数据、甚至是图片中的文字,对于传统的简单抓取方式来说,简直就是一片盲区。模型接收到的是一堆杂乱无章的标签和文本碎片,它怎么可能做出准确的理解和回应?
这种“误读”的后果是直接的:基于错误信息生成的回答会误导用户;数据分析的结论会完全偏离事实;整个自动化流程的可靠性大打折扣。我意识到,问题不在于AI模型本身不够聪明,而在于我们提供给它的“食物”——网页内容——没有经过恰当的“预处理”。我们需要一个更智能的“厨师”,能把原始的、混乱的网页HTML,烹饪成干净、结构化、富含语义的“营养餐”,再端给AI。
这就是我动手构建AiVIS的核心动机。AiVIS 这个名字,是AI-optimized Visual Information Structure的缩写,直译过来就是“为AI优化的视觉信息结构”。它的目标非常明确:模拟人类看网页的方式,理解网页的视觉布局和信息层级,从而精准地剥离出核心内容,为下游的AI任务提供最高质量的输入。
1.1 核心需求解析:AI需要什么样的“网页理解”?
要解决“误读”问题,我们首先要拆解AI在处理网页时到底需要什么。这不仅仅是“提取文本”那么简单。
第一,需要语义连贯的纯文本块。AI模型,尤其是大语言模型,擅长处理的是成段、成章的连贯文本。一个理想的输入应该是一篇完整的文章,而不是夹杂着<div>、<a href="#">、<script>的HTML标签汤。我们需要剥离所有渲染标签,但保留段落、标题的层次关系。
第二,需要理解视觉布局与内容优先级。人类浏览网页时,一眼就能抓住中心区域的标题和正文,知道侧边栏是辅助内容,页脚是法律信息。这种基于视觉的优先级判断,对于提取核心内容至关重要。一个靠CSS定位在页面中央的大号标题,其重要性远高于角落里的一个小链接。
第三,需要处理动态与现代网页技术。如今的网页大量使用JavaScript渲染内容。传统的基于静态HTML解析的工具(如BeautifulSoup的简单用法)对此无能为力,它们只能看到一个几乎空白的骨架。我们需要一个能真正“运行”网页,等待其完全加载后再进行内容抓取的能力。
第四,需要排除干扰与噪声。广告、导航栏、评论 widget、社交分享按钮、cookie同意弹窗……这些元素对用户可能有必要,但对AI理解核心内容纯属噪声。一个健壮的提取器必须能识别并过滤掉这些非主要内容区域。
第五,需要适度的结构化信息。有时,我们不仅需要正文,还需要知道文章的发布时间、作者、分类标签等元数据。这些信息通常以特定的模式嵌入在HTML中(如<meta>标签、特定的CSS类名),需要被有选择地提取出来。
AiVIS 的设计,就是围绕这五个核心需求展开的。它不是一个简单的爬虫,而是一个集成了浏览器自动化、计算机视觉启发式分析、语义规则引擎的网页内容理解与净化管道。
2. 技术架构选型:为什么是Puppeteer + 视觉启发式算法?
明确了需求,下一步就是技术选型。市面上有很多网页抓取库,为什么AiVIS选择了这样的技术栈?每一个选择背后,都是对上述需求痛点的直接回应。
2.1 浏览器自动化:Puppeteer 的必要性
首先,要解决动态内容加载问题,我们必须使用一个无头浏览器。它能够像真实用户一样访问网页,执行JavaScript,等待AJAX请求完成,并渲染出完整的DOM。在Node.js生态中,Puppeteer是首选。
注意:虽然Playwright也是一个优秀的替代品,并且支持多浏览器,但Puppeteer与Chrome DevTools Protocol的深度集成、更简洁的API以及对Chrome最新特性的快速跟进,使其在网页自动化抓取场景下依然拥有显著优势。社区生态和资料也更丰富。
通过Puppeteer,我们可以:
- 等待页面到达某个加载状态(如
networkidle2),确保动态内容加载完毕。 - 执行自定义脚本,与页面交互(例如,关闭弹窗、点击“加载更多”)。
- 获取渲染后的完整DOM,包括由JS生成的内容。
- 甚至可以截图,为后续的视觉分析提供素材。
2.2 从纯DOM解析到视觉规则辅助
有了完整的DOM,传统做法是直接用类似cheerio这样的库进行解析,通过标签和类名规则提取内容。这种方法在结构良好、语义化标准的网站上有效,但极度脆弱。一旦网站改版,CSS类名变化,规则就失效了。
AiVIS 的思路是引入视觉启发式规则。我们不仅分析DOM树,还分析元素在渲染页面中的视觉特征:
- 位置与尺寸:占据页面宽度大部分、位于页面垂直方向中部的元素,更可能是主体内容。
- 字体特征:字体大小、加粗程度。通常,
<h1>的视觉表现比普通<p>大且粗。 - 内容密度:正文区域通常包含高密度的文本节点,而导航栏则包含许多链接和较短的文本。
- 复合评分:综合计算一个元素的面积、文本长度、字体大小、位置居中程度等,给出一个“内容置信度”分数。
这种方法的鲁棒性更强。即使网站的HTML结构翻天覆地,只要其视觉布局保持“标题在上、正文在中间”的基本范式,我们的算法依然能大概率定位到正确的内容区域。
2.3 核心算法流程设计
AiVIS的核心处理流程可以概括为以下几个步骤:
- 启动与加载:通过Puppeteer启动无头浏览器,导航至目标URL,并等待页面充分加载。
- DOM与视觉信息获取:获取页面的完整HTML,同时通过Puppeteer的API获取所有重要元素的视觉边界框(bounding box)、计算样式(如font-size, font-weight)。
- 噪声预过滤:基于一套规则库,快速移除已知的噪声元素。例如,所有
<script>、<style>标签;role属性为banner、navigation、contentinfo(对应页眉、导航、页脚)的ARIA地标元素;以及通过常见类名(如包含ad、sidebar、cookie、modal)匹配的元素。 - 主体内容区域探测:
- 将页面在垂直方向上划分为若干区块。
- 对每个区块内的元素,根据其视觉特征(面积、文本量、字体大小)计算“内容得分”。
- 寻找得分连续高的区块区域,这很可能就是正文区域。
- 采用类似“滑动窗口”的算法,找到得分之和最高的连续区域。
- 内容提取与净化:
- 锁定主体内容区域对应的DOM节点子树。
- 遍历该子树,提取所有文本节点。
- 在提取过程中,进行语义化清理:将多个连续的
<p>标签合并为段落,保留<h1>-<h6>标签作为标题层级,将<li>项目合理转换为列表文本,同时彻底移除剩余的<a>、<span>、<div>等标签,仅保留其内部文本。 - 提取过程中,同步记录元数据:从
<meta property="og:title">、<meta name="author">等标签中尝试获取标题、作者、发布时间。
- 后处理与输出:对提取出的纯文本进行后处理,如合并多余的空行、修剪首尾空格。最终输出一个结构化的JSON对象,包含
title、author、publish_date、cleaned_content等字段。
这个流程融合了静态规则、视觉分析和语义化处理,旨在最大程度地还原网页的“阅读视角”内容。
3. 实操构建:一步步实现AiVIS核心引擎
理论说再多,不如一行代码。让我们深入到AiVIS的核心实现部分。我将以Node.js环境为例,展示关键模块的构建。
3.1 项目初始化与依赖安装
首先,创建一个新项目并安装核心依赖。
mkdir aivis-engine && cd aivis-engine npm init -y npm install puppeteer cheeriopuppeteer: 用于控制无头浏览器,获取渲染后的页面。cheerio: 一个在服务器端使用的、类似jQuery的库,用于高效地解析和操作我们获取到的HTML。
3.2 核心类设计:WebPageReader
我们创建一个主类WebPageReader,它封装了从URL到清洁内容的整个流程。
// src/WebPageReader.js const puppeteer = require('puppeteer'); const cheerio = require('cheerio'); class WebPageReader { constructor(options = {}) { this.options = { headless: 'new', // 使用新的无头模式,性能更好 timeout: 30000, // 页面加载超时时间 waitUntil: 'networkidle2', // 等待网络空闲,确保动态内容加载 viewport: { width: 1920, height: 1080 }, // 设置视口大小,影响渲染布局 ...options }; this.browser = null; this.page = null; } async init() { this.browser = await puppeteer.launch(this.options); this.page = await this.browser.newPage(); await this.page.setViewport(this.options.viewport); // 可以设置User-Agent,模拟真实浏览器 await this.page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...'); } async cleanup() { if (this.page) await this.page.close(); if (this.browser) await this.browser.close(); } }3.3 实现页面加载与视觉信息获取
这是最关键的一步。我们需要获取DOM,同时拿到元素的视觉信息。
class WebPageReader { // ... 之前的构造函数和init方法 async fetchPage(url) { if (!this.page) await this.init(); try { await this.page.goto(url, { timeout: this.options.timeout, waitUntil: this.options.waitUntil }); // 可选:执行一些页面内脚本,比如移除浮动元素 await this.page.evaluate(() => { // 示例:尝试关闭常见弹窗 const selectors = ['.modal', '.popup', '.cookie-banner', '[aria-modal="true"]']; selectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => el.style.display = 'none'); }); }); // 等待一小段时间,确保移除操作生效 await this.page.waitForTimeout(1000); } catch (error) { throw new Error(`Failed to load page ${url}: ${error.message}`); } } async getPageData() { // 1. 获取完整的HTML const html = await this.page.content(); const $ = cheerio.load(html); // 2. 通过Puppeteer API获取所有非隐藏元素的视觉信息 const elementsWithBox = await this.page.evaluate(() => { const items = []; // 选择一个有代表性的元素集合,比如所有包含文本的块级和内联元素 const allElements = document.querySelectorAll('body *'); allElements.forEach(el => { const style = window.getComputedStyle(el); // 过滤掉不可见元素 if (style.display === 'none' || style.visibility === 'hidden' || el.offsetWidth === 0 || el.offsetHeight === 0) { return; } const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { items.push({ tagName: el.tagName.toLowerCase(), className: el.className, id: el.id, textLength: (el.textContent || '').trim().length, // 视觉信息 top: rect.top, left: rect.left, width: rect.width, height: rect.height, fontSize: parseFloat(style.fontSize) || 16, // 获取计算后的字体大小 fontWeight: style.fontWeight, // 简单的“是否可能是内容”启发式评分(后续会完善) score: 0 }); } }); return items; }); return { html, $, elementsWithBox }; } }3.4 实现视觉启发式内容区域探测算法
现在,我们有了所有元素的视觉数据。接下来是实现算法的核心。
class WebPageReader { // ... 之前的方法 findMainContentRegion(elements) { // 第一步:为每个元素计算一个初步的内容得分 const scoredElements = elements.map(el => { let score = 0; const area = el.width * el.height; const textDensity = el.textLength / (area || 1); // 避免除零 // 启发式规则1:面积和文本长度是基础 score += Math.log(area + 1) * 0.5; score += Math.log(el.textLength + 1) * 2; // 文本长度权重更高 // 启发式规则2:字体大小越大,越可能是标题或重要内容 score += (el.fontSize / 16) * 1.5; // 以16px为基准 // 启发式规则3:位置权重。页面垂直中部(例如视口高度的30%-70%)的内容更可能是正文 const viewportHeight = this.options.viewport.height; const verticalCenter = viewportHeight / 2; const distanceFromCenter = Math.abs((el.top + el.height/2) - verticalCenter); // 距离中心越近,得分加成越高(使用高斯衰减近似) const positionBonus = Math.exp(-Math.pow(distanceFromCenter, 2) / (2 * Math.pow(viewportHeight * 0.2, 2))); score += positionBonus * 3; // 启发式规则4:惩罚可能是广告或导航的元素(通过类名、ID简单判断) const noiseRegex = /(ad|banner|sidebar|nav|footer|menu|modal|popup)/i; if (noiseRegex.test(el.className) || noiseRegex.test(el.id)) { score *= 0.3; // 大幅降低分数 } // 启发式规则5:特定标签加分(如article, main)或减分(如script, style) if (['article', 'main'].includes(el.tagName)) score += 10; if (['script', 'style', 'svg', 'path'].includes(el.tagName)) score = -Infinity; return { ...el, score }; }).filter(el => el.score > 0); // 过滤掉得分为负或零的元素 // 第二步:将页面垂直划分为“带”(strip),计算每个带的得分 const stripHeight = 50; // 像素,每个垂直带的高度 const numStrips = Math.ceil(this.options.viewport.height / stripHeight); const stripScores = new Array(numStrips).fill(0); scoredElements.forEach(el => { const startStrip = Math.floor(el.top / stripHeight); const endStrip = Math.floor((el.top + el.height) / stripHeight); for (let i = startStrip; i <= endStrip && i < numStrips; i++) { // 元素贡献的分数按其在带中的高度比例分配 const overlapTop = Math.max(el.top, i * stripHeight); const overlapBottom = Math.min(el.top + el.height, (i + 1) * stripHeight); const overlapHeight = Math.max(0, overlapBottom - overlapTop); const contribution = (overlapHeight / el.height) * el.score; stripScores[i] += contribution; } }); // 第三步:使用滑动窗口找到得分最高的连续区域 const windowSize = Math.max(5, Math.floor(numStrips * 0.3)); // 窗口大小约为视口的30% let maxSum = 0; let maxStart = 0; for (let i = 0; i <= numStrips - windowSize; i++) { let windowSum = 0; for (let j = 0; j < windowSize; j++) { windowSum += stripScores[i + j]; } if (windowSum > maxSum) { maxSum = windowSum; maxStart = i; } } // 第四步:根据最高分区域,反推对应的DOM元素 const contentTop = maxStart * stripHeight; const contentBottom = (maxStart + windowSize) * stripHeight; const mainContentElements = scoredElements.filter(el => { const elCenterY = el.top + el.height / 2; return elCenterY >= contentTop && elCenterY <= contentBottom; }); // 找到这些元素中,在DOM树里最顶层的共同容器(简化处理) // 在实际项目中,这里可能需要更复杂的DOM树分析来找到最佳根节点 if (mainContentElements.length > 0) { // 这里我们返回一个代表内容区域边界的简单描述,以及高分元素列表 return { top: contentTop, bottom: contentBottom, elements: mainContentElements }; } return null; } }3.5 基于区域的内容提取与净化
找到疑似主体区域后,我们需要从原始HTML中精准提取该区域对应的内容,并进行深度清理。
class WebPageReader { // ... 之前的方法 extractAndCleanContent(html, $, contentRegion) { // 1. 首先,使用一个简单的方法:直接基于高分元素的共同祖先来定位 // 在实际代码中,你可能需要将`elementsWithBox`中的元素与cheerio对象关联起来。 // 这里我们采用一个备用策略:如果视觉分析找到了明显的`<article>`或`<main>`标签,优先使用。 let $mainContent = $('body'); const $article = $('article').first(); const $main = $('main').first(); if ($article.length) { $mainContent = $article; console.log('Using <article> tag as content root.'); } else if ($main.length) { $mainContent = $main; console.log('Using <main> tag as content root.'); } else { // 如果没有语义化标签,则回退到基于视觉区域选择元素 // 这里简化处理:选择所有在视觉区域内的元素,并找到他们的一个公共容器 console.log('No semantic tag found, relying on visual heuristics.'); // 在实际实现中,这里需要更复杂的逻辑来映射视觉元素回DOM节点。 // 为了示例,我们使用一个备用方案:尝试查找包含大量文本的容器。 const candidates = $('body > *').toArray().map(el => $(el)); let bestCandidate = $('body'); let maxTextLength = 0; candidates.forEach($candidate => { const text = $candidate.text().replace(/\s+/g, ' ').trim(); if (text.length > maxTextLength && text.length > 200) { // 假设正文至少200字符 maxTextLength = text.length; bestCandidate = $candidate; } }); $mainContent = bestCandidate; } // 2. 深度清理$mainContent内的HTML // 移除所有脚本、样式、表单、广告等无关标签 const selectorsToRemove = [ 'script', 'style', 'iframe', 'object', 'embed', 'form', 'button', 'input', 'select', 'textarea', '.ad', '[class*="ad-"]', '[id*="ad-"]', '.social-share', '.share-buttons', '.related-posts', '.sidebar', '.navigation', '.comments', '.comment-section', 'nav', 'footer', 'header:not(h1, h2, h3, h4, h5, h6)' ]; $mainContent.find(selectorsToRemove.join(',')).remove(); // 3. 语义化转换与文本提取 // 定义一个递归函数来处理节点,保留段落和标题结构 function extractTextFromNode($node) { let output = ''; const children = $node.contents().toArray(); for (const child of children) { if (child.type === 'text') { output += child.data; } else if (child.type === 'tag') { const $child = $(child); const tagName = child.name; const childText = extractTextFromNode($child).trim(); if (childText) { if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) { output += `\n\n${'#'.repeat(parseInt(tagName[1]))} ${childText}\n`; } else if (tagName === 'p') { output += `\n\n${childText}`; } else if (tagName === 'li') { output += `\n- ${childText}`; } else if (tagName === 'br') { output += '\n'; } else { // 对于其他内联标签(如a, strong, em, span),直接拼接其内部文本 output += childText; } } } } return output; } let cleanedText = extractTextFromNode($mainContent).trim(); // 4. 后处理:合并多余空行,清理空格 cleanedText = cleanedText.replace(/\n\s*\n\s*\n/g, '\n\n'); // 将三个及以上空行替换为两个 cleanedText = cleanedText.replace(/[ \t]+\n/g, '\n'); // 移除行尾空格 // 5. 提取元数据 const metadata = { title: $('meta[property="og:title"]').attr('content') || $('title').text() || '', author: $('meta[name="author"]').attr('content') || '', publishTime: $('meta[property="article:published_time"]').attr('content') || '', description: $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || '' }; return { metadata, content: cleanedText }; } }3.6 整合与主流程
最后,我们将所有步骤串联起来,形成一个完整的read方法。
class WebPageReader { // ... 之前的所有方法 async read(url) { await this.fetchPage(url); const { html, $, elementsWithBox } = await this.getPageData(); const contentRegion = this.findMainContentRegion(elementsWithBox); console.log(`Content region identified between Y=${contentRegion?.top} and Y=${contentRegion?.bottom}`); const result = this.extractAndCleanContent(html, $, contentRegion); // 可选:如果清理后的内容过短,可能是提取失败,可以尝试备用方案(如直接提取body) if (result.content.length < 300) { console.warn('Extracted content seems too short. Falling back to simpler extraction.'); // 备用方案:使用readability类似的算法或更简单的正文提取 } return result; } } // 使用示例 (async () => { const reader = new WebPageReader(); try { const article = await reader.read('https://example.com/blog/some-article'); console.log('Title:', article.metadata.title); console.log('Content (first 500 chars):', article.content.substring(0, 500) + '...'); } catch (error) { console.error('Error:', error); } finally { await reader.cleanup(); } })();4. 避坑指南与实战优化技巧
在开发和实际使用AiVIS的过程中,我踩过不少坑,也总结出一些能显著提升效果和稳定性的技巧。
4.1 动态内容加载的等待策略
waitUntil: 'networkidle2'是一个很好的默认值,但它并不总是足够。有些网站使用懒加载,或者有复杂的异步数据流。
- 技巧1:等待特定元素出现:对于已知结构的网站,使用
await page.waitForSelector('.article-content')是更可靠的方法。 - 技巧2:自定义等待函数:可以编写一个函数,轮询检查页面内容是否已稳定(例如,连续两次检查,正文区域的文本长度不再变化)。
async waitForContentStable(page, selector, interval = 1000, maxAttempts = 10) { let prevLength = 0; for (let i = 0; i < maxAttempts; i++) { await page.waitForTimeout(interval); const currLength = await page.evaluate((sel) => { const el = document.querySelector(sel); return el ? el.textContent.length : 0; }, selector); if (currLength > 0 && currLength === prevLength) { return true; } prevLength = currLength; } return false; // 内容可能仍在加载或超时 }
4.2 对抗反爬虫机制
许多网站会检测无头浏览器。
- 技巧1:完善浏览器指纹:除了设置User-Agent,还可以通过
page.evaluateOnNewDocument注入脚本,覆盖navigator.webdriver、plugins等属性。 - 技巧2:模拟人类行为:在关键操作(如滚动、点击)前加入随机延迟
page.waitForTimeout(Math.random() * 1000 + 500)。 - 技巧3:使用代理IP池:对于大规模抓取,轮换IP地址是必须的。Puppeteer可以通过
--proxy-server启动参数来配置代理。 - 技巧4:处理验证码:这是一个难题。对于简单项目,遇到验证码的网站可能直接跳过。对于商业级应用,需要考虑集成第三方验证码识别服务。
4.3 视觉启发式算法的调优
算法的效果高度依赖于权重参数。
- 不要追求通用完美:没有一个参数集能完美适用于所有网站。AiVIS的核心优势在于其可调优性。你可以为不同类型的网站(新闻博客、电商产品页、文档)预设不同的参数配置文件。
- 建立测试集与评估指标:手动收集几十个不同样式的网页,标注出正确的主体内容。然后编写一个评估脚本,计算提取内容的准确率(如与标注内容的文本重叠度F1-score)。用这个测试集来系统地调整算法中的各项权重(如文本长度权重、字体大小权重、位置权重等)。
- 引入机器学习(进阶):可以将每个DOM元素视为一个样本,其视觉特征和简单文本特征作为输入,是否属于主体内容作为标签。收集足够的数据后,可以训练一个简单的分类模型(如逻辑回归、XGBoost)来替代手写的启发式评分规则。这能极大提升准确率和泛化能力。
4.4 性能优化
Puppeteer启动浏览器和加载页面开销较大。
- 技巧1:复用浏览器实例:
WebPageReader类已经设计为在多次read调用中复用同一个浏览器实例,只创建新页面。这比每次调用都启动/关闭浏览器快一个数量级。 - 技巧2:并行处理:可以使用
puppeteer-cluster这类库来管理一个浏览器池,并行处理多个URL,充分利用多核CPU。 - 技巧3:资源拦截:如果只关心文本,可以拦截并阻止图片、字体、媒体等资源的加载,大幅提升页面加载速度。
await page.setRequestInterception(true); page.on('request', (req) => { const resourceType = req.resourceType(); if (['image', 'stylesheet', 'font', 'media'].includes(resourceType)) { req.abort(); } else { req.continue(); } });
4.5 内容提取的健壮性
- 处理分页内容:很多文章或列表有“下一页”。可以在提取后检查是否存在“下一页”链接,并递归抓取,将内容合并。
- 保留链接的语义:有时AI需要知道文中的某个词是链接。在深度清理时,可以选择性地保留链接的锚文本,并以特殊标记注明,例如
[链接文本](URL),而不是直接丢弃。 - 处理代码块和表格:技术博客中的代码块和表格包含重要信息。在清理时,需要特殊处理
<pre>、<code>和<table>标签,保留其格式(如缩进、换行)或转换为Markdown/纯文本表格。
构建AiVIS的过程,是一个不断与网页的复杂性和多样性作斗争的过程。没有一劳永逸的银弹,但通过结合可靠的浏览器自动化、合理的视觉启发式规则以及持续的策略调优,我们能够为AI模型提供一个远比原始HTML清洁、准确得多的信息源。这直接提升了所有下游AI任务——无论是摘要、问答、翻译还是知识图谱构建——的质量和可靠性。当你看到你的AI应用不再胡言乱语,而是能精准地基于网页核心内容进行回答时,你就会觉得这一切的折腾都是值得的。