基于大模型与Playwright的网页正文智能提取方案
1. 项目缘起:从信息焦虑到自动化提取
不知道你有没有过这样的经历:在网上看到一篇技术文章或者一篇深度报道,内容写得特别好,你想把它保存下来,方便以后查阅或者整理到自己的知识库里。但当你复制粘贴时,问题就来了——页面上那些导航栏、侧边栏、广告、评论区、推荐阅读,甚至页脚的各种版权声明,全都一股脑地被复制了下来。你得到的是一份夹杂着大量无用信息的“脏”文本,后期还得花时间去手动清理,非常影响效率。
这就是网页正文提取要解决的问题。它的目标很明确:从一个结构复杂的HTML页面中,精准地“抠”出我们真正需要的那部分核心内容,也就是文章的标题和正文,同时过滤掉所有无关的页面元素。对于经常需要做信息收集、内容分析或者知识管理的人来说,一个可靠的提取工具就是生产力倍增器。
市面上当然有现成的工具,比如Readability的算法、Newspaper3k这样的Python库,甚至一些在线API。但它们要么规则固定,面对千变万化的网页结构时提取效果不稳定;要么是闭源服务,有调用次数和费用限制。今天,我想分享一个我自己在用的方案:利用字节跳动的Doubao-Seed-Evolving大模型,结合Python和Playwright,零成本搭建一个高准确率、可定制的网页正文提取工具。这个方案的核心思路是,让大模型来理解网页的语义和结构,而不仅仅是依赖预设的规则或标签,从而获得更强的泛化能力和更高的准确度。
整个工具链完全是免费的,代码也足够简洁,非常适合作为Python自动化实践的一个有趣项目。下面,我就带你一步步实现它。
2. 核心工具选型与原理拆解
在动手写代码之前,我们先搞清楚要用到哪些“兵器”,以及它们各自扮演什么角色。整个流程可以概括为:一个浏览器自动化工具负责“抓取”原始网页,一个大模型负责“理解”并“提取”核心内容。
2.1 Playwright:新一代的浏览器自动化利器
我们的第一步是获取目标网页完整的HTML内容。你可能会想到requests库,但它只能获取初始的HTML,对于大量依赖JavaScript动态渲染内容的现代网站(如React、Vue构建的单页应用)就无能为力了。页面上的文章内容可能是通过JS异步加载的,直接用requests拿到的是一个几乎空白的骨架。
因此,我们需要一个能模拟真实用户操作、执行页面JavaScript并获取最终渲染结果的工具。这就是Playwright的用武之地。它由微软开发,支持Chromium、Firefox和WebKit三大浏览器引擎。相比于老牌的Selenium,Playwright在速度、稳定性和API设计上都有显著优势,特别是它对异步操作的原生支持,让代码写起来非常流畅。
注意:Playwright需要安装浏览器二进制文件。虽然第一次运行时会自动下载,但在某些网络环境下可能会比较慢。你可以通过设置环境变量
PLAYWRIGHT_DOWNLOAD_HOST来使用国内镜像加速,例如设置为https://npmmirror.com/mirrors/playwright/。
它的工作原理是启动一个无头浏览器(没有图形界面),加载目标网页,等待页面完全加载(包括网络请求和JS执行),然后将此时的DOM状态完整地提供给我们。这样,我们拿到的HTML就是用户最终看到的样子,包含了所有动态生成的内容。
2.2 Doubao-Seed-Evolving:免费的“智能大脑”
拿到完整的HTML后,最关键的一步是如何从中识别出正文。传统方法有:
- 基于规则/启发式算法:比如Readability,通过计算标签的密度、链接数量、类名等特征来猜测正文区域。优点是快,缺点是对不同网站模板的适应性差,规则需要不断维护。
- 基于机器学习/深度学习:训练模型来识别正文区域。效果通常更好,但需要标注数据和训练成本。
而我们这次采用的是一种更“取巧”且强大的方法:利用大语言模型(LLM)的语义理解能力。Doubao-Seed-Evolving(豆包-Seed-Evolving)是字节跳动提供的免费大模型API服务。我们不需要关心复杂的模型训练,只需要将网页的HTML内容(或经过简化的DOM文本)连同我们的指令(“请提取文章的标题和正文”)一起发送给它的API。
大模型会像一个人一样去“阅读”这段HTML,理解其中哪些部分是导航,哪些是广告,哪些才是真正的文章内容,然后按照我们要求的格式(比如Markdown)将提取出的内容返回。这种方法的核心优势在于泛化能力极强。无论网页结构多么奇特,只要模型能理解语义,就能大概率正确提取,省去了我们编写和维护复杂规则的成本。
2.3 为什么是Markdown格式?
在摘要描述和相关热词中,Markdown被多次提及。我们选择将提取的正文输出为Markdown格式,是因为MD是一种轻量级标记语言,它:
- 结构清晰:支持标题、列表、代码块、链接、图片等,能很好地保留原文的格式信息。
- 通用性强:几乎所有的笔记软件(如Obsidian、Notion)、博客平台和编辑器都支持Markdown。
- 便于后续处理:Markdown是纯文本,可以轻松地转换为HTML、PDF、Word等其他格式,也可以直接放入支持MD的系统中进行管理。
让大模型直接输出Markdown,相当于一步完成了“提取”和“格式化”两件事。
3. 环境搭建与依赖安装
工欲善其事,必先利其器。我们先来把开发环境配置好。我假设你已经在电脑上安装了Python(3.7或以上版本)和一个顺手的代码编辑器(如VSCode)。
首先,打开你的终端(命令行),创建一个新的项目目录,并进入该目录。
mkdir web-content-extractor cd web-content-extractor然后,创建一个Python虚拟环境来隔离项目依赖。这是最佳实践,可以避免不同项目之间的包版本冲突。
# 使用 venv 创建虚拟环境 python -m venv venv # 激活虚拟环境 # 在 Windows 上: venv\Scripts\activate # 在 macOS/Linux 上: source venv/bin/activate激活后,你的命令行提示符前应该会出现(venv)字样。接下来,安装我们所需的Python包。我们将主要用到playwright、用于调用豆包API的openai库(因为豆包API兼容OpenAI格式),以及用于处理HTTP请求的httpx或requests。
# 安装 Playwright 核心库 pip install playwright # 安装 Playwright 的浏览器(Chromium、Firefox、WebKit)。这一步耗时较长。 playwright install chromium # 通常只安装 Chromium 就足够了 # 安装 openai 库(用于调用兼容OpenAI API格式的服务) pip install openai # 安装 httpx 作为HTTP客户端(可选,但性能更好) pip install httpx这里有一个关键的细节:豆包Seed-Evolving模型的API端点(endpoint)和认证方式与OpenAI官方略有不同。我们需要知道它的API基础地址和如何获取API Key。
- 获取API Key:你需要访问豆包开放平台(通常可以在字节跳动的相关开发者站点找到),注册账号并创建一个应用,从而获得一个API Key。这个过程是免费的,但可能需要实名认证。请妥善保管你的API Key,不要泄露。
- API基础地址:豆包提供的API地址通常不是
https://api.openai.com/v1,而是一个特定的URL,例如https://ark.cn-beijing.volces.com/api/v3(具体地址请以豆包平台最新文档为准)。
4. 实战代码:分步构建提取工具
环境准备好了,钥匙(API Key)也拿到了,现在开始写代码。我会将整个过程拆解成几个函数,让逻辑更清晰。
4.1 第一步:用Playwright抓取动态渲染的HTML
我们先写一个函数,负责用Playwright打开网页并获取渲染后的HTML内容。这里我们会用到异步编程(async/await),因为Playwright的很多操作都是异步的,这样能提升效率。
import asyncio from playwright.async_api import async_playwright async def fetch_html_with_playwright(url: str, timeout: int = 30000) -> str: """ 使用 Playwright 获取完全渲染后的页面 HTML。 参数: url: 目标网页地址 timeout: 页面加载超时时间(毫秒) 返回: 页面的完整 HTML 字符串 """ async with async_playwright() as p: # 启动一个 Chromium 浏览器实例,使用无头模式(不显示界面) browser = await p.chromium.launch(headless=True) # 创建一个新的浏览器上下文(类似于一个独立的会话) context = await browser.new_context() # 在新上下文中打开一个页面 page = await context.new_page() try: # 导航到目标URL,并等待网络状态达到‘load’(即HTML文档加载完成) await page.goto(url, wait_until='networkidle', timeout=timeout) # 可选:可以在这里执行一些滚动操作,确保懒加载的内容被触发 # await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") # await page.wait_for_timeout(2000) # 等待2秒让内容加载 # 获取页面主体部分的HTML,也可以获取整个document # 这里获取整个文档的 outerHTML html_content = await page.content() return html_content except Exception as e: print(f"抓取页面时发生错误: {e}") return "" finally: # 无论如何,最后都要关闭浏览器,释放资源 await browser.close() # 同步函数包装,方便在非异步环境中调用 def fetch_html(url: str) -> str: """同步接口,内部调用异步函数""" return asyncio.run(fetch_html_with_playwright(url))代码解读与注意事项:
wait_until='networkidle':这个参数非常有用。它告诉Playwright,在至少500毫秒内没有超过2个网络连接时,才认为页面加载完成。这比单纯的'load'事件更能确保动态内容加载完毕。- 错误处理:网络不稳定、页面本身错误、超时等都可能导致抓取失败。我们用
try...except包裹核心代码,并在失败时返回空字符串,避免程序崩溃。 - 资源释放:
finally块中的await browser.close()至关重要。确保即使发生异常,浏览器进程也会被关闭,防止内存泄漏。 - 反爬策略:一些网站会检测自动化工具。Playwright虽然比Selenium更隐蔽,但并非完全无法检测。对于这类网站,可能需要更复杂的策略,如设置更真实的
user-agent、使用代理、添加随机延迟等。本项目以功能演示为主,暂不深入讨论反反爬。
4.2 第二步:设计给大模型的“指令”(Prompt)
直接向大模型扔过去一个几万行的HTML字符串,不仅效率低,还可能因为超出模型上下文长度而失败。我们需要对HTML进行一些预处理,并设计一个清晰的指令(Prompt)。
预处理的目标是精简HTML,保留可能包含正文的主要区域。一个简单有效的方法是,利用Playwright已经获取的DOM,通过CSS选择器移除一些明显无关的标签,如script,style,nav,footer,aside等。但更通用的做法是,直接将<body>标签内的文本内容提取出来,这已经能过滤掉所有标签属性,大大减少了噪音。
from html import unescape import re def extract_main_text_from_html(html: str) -> str: """ 从HTML中提取主要的文本内容,用于发送给大模型。 这是一个简化的预处理,目的是减少无关噪音。 """ if not html: return "" # 使用正则简单提取 <body> 标签内的内容(不包含标签本身) # 这是一个非常粗糙的方法,仅用于演示。在实际应用中,你可能需要使用更健壮的HTML解析器,如`lxml`或`bs4`。 body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL | re.IGNORECASE) if not body_match: # 如果没有找到body标签,返回整个HTML的文本部分(去除标签) text = re.sub(r'<[^>]+>', ' ', html) else: body_content = body_match.group(1) # 移除 script 和 style 标签及其内容 body_content = re.sub(r'<(script|style)[^>]*>.*?</\1>', '', body_content, flags=re.DOTALL | re.IGNORECASE) # 移除所有HTML标签,只保留文本 text = re.sub(r'<[^>]+>', ' ', body_content) # 合并多个空白字符为一个空格,并取消HTML实体转义(如 -> 空格) text = re.sub(r'\s+', ' ', text).strip() text = unescape(text) # 截断文本,避免超出模型上下文限制(例如,限制在12000字符内) max_length = 12000 if len(text) > max_length: print(f"警告:提取的文本过长({len(text)}字符),已截断至前{max_length}字符。") text = text[:max_length] + "...[内容已截断]" return text接下来是核心的Prompt设计。我们需要明确地告诉大模型我们的任务、输入格式和输出格式。
def build_extraction_prompt(web_text: str) -> str: """ 构建用于正文提取的提示词(Prompt)。 """ prompt = f""" 你是一个专业的网页内容提取助手。你的任务是从一段从网页中提取的文本中,识别出文章的**主标题**和**核心正文内容**,并忽略所有无关信息,如导航栏、侧边栏、广告、页脚、评论、推荐阅读、版权声明等。 请遵循以下要求: 1. **输出格式**:必须且只能输出一个标准的 Markdown 文档。 2. **文档结构**: - 第一行是文章的主标题,使用一级标题格式 (`# 标题`)。 - 从第二行开始,是文章的完整正文内容。 3. **内容处理**: - 只输出最终的文章内容,不要输出任何额外的解释、分析或提示词本身。 - 保留原文中的段落结构、列表、加粗、斜体等语义格式,并用对应的Markdown语法表示。 - 如果原文中有图片,请以Markdown图片语法 `` 的形式保留,并确保URL是完整的。 - 如果无法从给定文本中清晰识别出文章标题和正文,请输出 `# 提取失败`,并在第二行简要说明原因。 以下是从网页中获取的文本内容:{web_text}
现在,请开始提取并输出Markdown格式的文章内容: """ return prompt这个Prompt有几个关键点:
- 角色定义:让模型进入“专业助手”的角色。
- 任务明确:清晰说明了要“识别标题和正文,忽略无关信息”。
- 格式严格:强制要求输出纯Markdown,并规定了标题的格式。这有利于我们后续程序化地处理结果。
- 容错处理:给出了提取失败时的输出指令,让程序有判断依据。
4.3 第三步:调用Doubao-Seed-Evolving API
现在,我们将预处理后的文本和精心设计的Prompt发送给大模型。这里我们使用openai这个库,因为它提供了通用的接口,只要配置好不同的base_url和api_key,就可以对接多种兼容OpenAI API格式的服务。
from openai import OpenAI import os def extract_content_with_llm(cleaned_text: str, api_key: str, base_url: str, model: str = "doubao-seed-evolving") -> str: """ 调用 Doubao-Seed-Evolving API 提取正文内容。 参数: cleaned_text: 预处理后的网页文本 api_key: 豆包平台的API Key base_url: API端点地址 model: 模型名称,默认为 'doubao-seed-evolving' 返回: 大模型返回的文本内容(应为Markdown格式) """ # 构建完整的Prompt prompt = build_extraction_prompt(cleaned_text) # 初始化OpenAI客户端,指向豆包的API地址 client = OpenAI( api_key=api_key, base_url=base_url, # 例如 "https://ark.cn-beijing.volces.com/api/v3" ) try: response = client.chat.completions.create( model=model, messages=[ {"role": "user", "content": prompt} ], temperature=0.1, # 温度设低,使输出更确定、更稳定 max_tokens=4000, # 根据模型上下文和预期输出长度调整 ) # 提取模型返回的消息内容 extracted_content = response.choices[0].message.content.strip() return extracted_content except Exception as e: print(f"调用大模型API时发生错误: {e}") return f"# API调用失败\n错误信息: {e}"关键参数解析:
temperature:控制输出的随机性。范围0到2。值越低(如0.1),输出越确定、可重复;值越高,输出越有创造性、不可预测。对于提取任务,我们需要高确定性,所以设为较低值。max_tokens:限制模型生成的最大令牌数。一个中文汉字大约1-2个token。需要根据模型的最大上下文窗口和你输入文本的长度来设置,确保留出足够空间给输出。豆包Seed-Evolving模型的具体限制需查阅其官方文档。
4.4 第四步:整合与主流程
最后,我们将上述所有步骤串联起来,形成一个完整的工具函数。同时,添加一些简单的文件保存功能。
import json from pathlib import Path def extract_article_to_markdown(url: str, api_key: str, base_url: str, output_dir: str = "./output") -> dict: """ 主函数:从URL提取文章并保存为Markdown文件。 返回一个字典,包含状态和文件路径等信息。 """ result = {"url": url, "success": False, "output_path": None, "error": None} print(f"开始处理: {url}") # 1. 抓取HTML print("步骤1: 使用Playwright抓取页面...") html = fetch_html(url) if not html: result["error"] = "无法获取页面HTML" print("错误:页面抓取失败。") return result # 2. 预处理,提取主要文本 print("步骤2: 预处理HTML,提取主要文本...") cleaned_text = extract_main_text_from_html(html) if not cleaned_text: result["error"] = "从HTML中提取文本失败" print("错误:文本提取失败。") return result print(f"预处理后文本长度: {len(cleaned_text)} 字符") # 3. 调用大模型提取结构化内容 print("步骤3: 调用大模型进行智能提取...") markdown_content = extract_content_with_llm(cleaned_text, api_key, base_url) # 4. 检查输出并保存 if markdown_content.startswith("# 提取失败") or markdown_content.startswith("# API调用失败"): result["error"] = markdown_content print(f"提取失败: {markdown_content[:100]}...") else: # 创建输出目录 Path(output_dir).mkdir(parents=True, exist_ok=True) # 从Markdown内容中提取标题作为文件名(取第一行#后的内容) first_line = markdown_content.split('\n')[0] if first_line.startswith('# '): title_for_filename = first_line[2:].strip().replace('/', '_').replace('\\', '_')[:50] # 简单清理 else: title_for_filename = "extracted_article" # 生成安全的文件名 from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_filename = f"{timestamp}_{title_for_filename}.md" output_path = Path(output_dir) / safe_filename # 保存Markdown文件 with open(output_path, 'w', encoding='utf-8') as f: f.write(markdown_content) result["success"] = True result["output_path"] = str(output_path) print(f"成功!文章已保存至: {output_path}") return result # 示例用法 if __name__ == "__main__": # 请替换为你自己的API Key和Base URL YOUR_API_KEY = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 你的豆包API Key YOUR_API_BASE = "https://ark.cn-beijing.volces.com/api/v3" # 豆包API地址,请以官方文档为准 TARGET_URL = "https://example.com/some-article" # 你想提取的文章URL # 执行提取 res = extract_article_to_markdown(TARGET_URL, YOUR_API_KEY, YOUR_API_BASE) # 打印结果摘要 print(json.dumps(res, indent=2, ensure_ascii=False))5. 效果测试、优化与踩坑实录
代码写完了,是骡子是马得拉出来遛遛。我用自己的API Key测试了几个不同类型的网站。
5.1 测试案例与效果分析
我选取了三个有代表性的网站进行测试:
- 技术博客(静态渲染):一个用Hexo搭建的个人技术博客。Playwright顺利抓取,大模型完美提取了标题和正文,代码块也以Markdown格式保留得很好。
- 新闻门户(动态加载):一个主流新闻网站的某篇报道。页面有大量侧边栏、相关推荐和评论。我们的工具成功过滤了这些干扰,提取出了干净的新闻正文。但偶尔会把文章末尾的“编辑:XXX”也保留下来,不过这无伤大雅。
- 论坛帖子(复杂交互):一个技术论坛的帖子页面,包含楼主内容、多层回复、用户签名档。这是挑战最大的。大模型有时会把最热门的一条回复误判为正文的一部分。这说明对于极度非线性的页面结构,当前简单的Prompt可能不够。
总体效果:对于典型的博客、新闻文章页面,提取准确率非常高(估计在90%以上),远超传统的基于规则的方法。输出格式规整,直接可用于归档。
5.2 常见问题与优化策略
在实际运行中,你可能会遇到以下问题,这里是我的解决方案:
问题一:大模型返回内容不符合Markdown格式要求。
- 现象:返回的内容开头或结尾多了一些解释性文字,如“好的,我将为您提取...”。
- 原因:Prompt的指令不够强硬,或者模型没有严格遵守。
- 解决:强化Prompt中的指令。可以使用“必须且只能输出一个标准的Markdown文档”、“不要输出任何额外的解释”等措辞。也可以在代码后处理中,用正则匹配第一个
#开头到文件尾的内容。
问题二:提取某些长文章时,返回内容不完整或中途截断。
- 现象:文章后半部分丢失了。
- 原因:可能是
max_tokens参数设置得太小,模型输出被截断;也可能是输入给模型的cleaned_text本身就被截断了(我们的预处理函数有长度限制)。 - 解决:
- 检查并调大
max_tokens值。 - 优化预处理:对于超长文章,可以尝试只提取
<article>标签、或通过计算文本密度等启发式方法先定位正文区域,再送给模型,而不是发送全文。这需要更复杂的HTML解析(如使用BeautifulSoup)。
- 检查并调大
问题三:API调用速度慢或网络超时。
- 现象:程序卡在
extract_content_with_llm函数很久,然后报错。 - 原因:网络延迟或模型服务端响应慢。
- 解决:
- 为
httpx或openai客户端设置合理的超时参数。 - 考虑加入重试机制(如
tenacity库),对瞬时的网络波动进行容错。 - 如果批量处理大量URL,需要加入速率限制,避免对API服务造成冲击。
- 为
问题四:面对特定网站提取效果差。
- 现象:某个网站总是提取失败或提取错误。
- 原因:该网站结构特殊,或者我们的预处理去掉了关键信息。
- 解决:这是“大模型+规则”混合策略发挥优势的地方。你可以为这个特定网站编写一个小的预处理函数,在调用通用流程前,先用这个特定函数处理。例如,如果知道这个网站的正文都在
<div class="post-content">里,就可以先用BeautifulSoup提取这个div的内容,再送给大模型。这样就实现了“规则保底,AI增强”的灵活策略。
5.3 进阶优化思路
如果你想把这个工具变得更强大、更实用,可以考虑以下方向:
- 并行处理:使用
asyncio或concurrent.futures同时处理多个URL,大幅提升批量提取效率。注意要合理控制并发数,避免被封IP或超过API速率限制。 - 缓存机制:对已经成功提取的URL进行缓存(将结果保存到本地数据库或文件),下次再处理同一URL时直接读取缓存,节省API调用次数和时间。
- 内容后处理:对模型提取出的Markdown进行二次清洗,比如统一标题级别、修复错误的链接、去除多余的空行等。
- 图形界面(GUI):使用
tkinter、PyQt或streamlit为工具制作一个简单的图形界面,方便非技术用户使用。 - 集成到工作流:将工具作为命令行脚本,结合
cron(Linux)或任务计划程序(Windows)定时抓取特定网站的最新文章,并自动保存到笔记软件(如Obsidian)的指定目录。
这个项目最让我满意的地方在于,它用一种相对简单的方式,结合了浏览器自动化的“抓取力”和大语言模型的“理解力”,解决了一个实际且普遍的需求。虽然它依赖一个外部的API,但鉴于目前豆包提供的免费额度,对于个人和小规模使用来说是完全可行的。整个过程也是对Python异步编程、API调用和Prompt工程的一次很好的实践。