# 新闻网站文字爬取进阶指南:避开三个典型陷阱,打造健壮数据管道
如果你已经用Python写过几个基础的网页爬虫,体验过从静态页面抓取文字的成就感,那么当你把目光投向新闻网站时,可能会发现事情变得复杂起来。新闻网站往往不是简单的HTML文档,它们充斥着动态加载的内容、精心设计的反爬机制,以及为了用户体验而嵌套的各种特殊标签。一个看似简单的`requests.get()`加上`BeautifulSoup.get_text()`组合,在实际操作中可能会让你频频碰壁,返回的要么是空荡荡的文本,要么是夹杂着大量无关广告和脚本代码的混乱内容。
这篇文章不是另一个“从零开始”的教程。我们假设你已经掌握了Python爬虫的基础,知道如何使用`requests`和`BeautifulSoup`。我们的目标是**提升你爬虫的健壮性**,让它能够从容应对新闻网站的特殊性。我们将从三个最常见的“坑”入手,这些坑是我在长期数据采集项目中反复踩过,并总结出解决方案的实战经验。通过绕过这些陷阱,你将能构建一个更可靠、更高效的数据抓取管道,真正把新闻网站的文字内容“驯服”到你的本地txt文件中。
## 1. 第一个坑:动态加载与请求伪装——你以为的页面不是你以为的
很多新手遇到的第一个挫折是:代码运行成功,状态码返回200,但`soup.get_text()`得到的内容却少得可怜,或者根本没有核心新闻正文。这通常不是你代码写错了,而是你请求的页面和你用浏览器看到的页面,根本不是一回事。
现代新闻网站大量使用JavaScript进行动态渲染。初始的HTML骨架可能只包含一个加载动画或一个空容器,真正的文章内容是通过后续的AJAX请求异步加载的。你用`requests`获取的只是那个“空壳”。此外,服务器会检查请求头,尤其是`User-Agent`,来识别访问者是真实浏览器还是爬虫脚本。
### 1.1 识别动态内容与寻找真实数据源
首先,你需要判断内容是否为动态加载。最直接的方法是使用浏览器的开发者工具(F12):
1. 打开目标新闻页面。
2. 在“网络”(Network)标签页中,刷新页面。
3. 筛选XHR或Fetch请求,观察在页面加载过程中,是否有新的请求返回了类似JSON格式的数据,其中包含了文章标题、正文等内容。
如果找到了这样的API接口,恭喜你,这往往是更稳定、更干净的数据源。直接请求这个接口,解析JSON数据,比解析复杂的HTML要简单高效得多。
例如,某个新闻网站的真实数据接口可能隐藏在类似下面的请求中:
```http
GET https://api.news-site.com/v1/article/content?id=12345678
Headers:
Accept: application/json
Referer: https://www.news-site.com/article/12345678
```
你可以用Python轻松模拟这个请求:
```python
import requests
import json
api_url = "https://api.news-site.com/v1/article/content"
params = {'id': '12345678'}
headers = {
'Accept': 'application/json',
'Referer': 'https://www.news-site.com/article/12345678',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...' # 一个完整的UA
}
response = requests.get(api_url, params=params, headers=headers)
if response.status_code == 200:
article_data = response.json()
# 从 article_data 中提取 title 和 content
title = article_data.get('title')
content = article_data.get('bodyText')
# 然后保存为txt
```
> **注意**:直接使用API接口需要遵守网站的`robots.txt`协议和服务条款。过度频繁的请求可能导致IP被封。务必设置合理的请求间隔(如`time.sleep(2)`)。
### 1.2 构建“以假乱真”的请求头
如果找不到清晰的API,或者网站采用了更复杂的动态技术(如React、Vue.js完全渲染),你可能需要模拟更完整的浏览器行为。这时,一个精心构造的请求头是关键。它不仅仅是`User-Agent`。
下面是一个模拟Chrome浏览器的较完整请求头字典示例,你可以根据实际情况调整:
```python
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br', # 注意:requests自动处理gzip,但写上更真实
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0',
}
```
**`User-Agent`是核心**,但`Accept`、`Accept-Language`等字段也能帮助你的请求更像来自一个真实用户的浏览器。你可以从浏览器开发者工具中,复制任意一个页面请求的请求头,作为模板。
### 1.3 当简单请求无效时:引入Selenium或Playwright
对于重度依赖JavaScript渲染的网站,上述方法可能依然无效。此时,你需要一个能真正执行JavaScript的浏览器自动化工具,如Selenium或Playwright。
以Playwright为例,它可以无头模式启动一个真实的Chromium浏览器,等待页面完全加载(包括所有动态内容)后再获取HTML源码:
```python
from playwright.sync_api import sync_playwright
def get_dynamic_html(url):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # 无头模式,不显示浏览器窗口
page = browser.new_page()
page.goto(url)
# 等待特定内容加载,例如等待正文标签出现
page.wait_for_selector('article') # 假设正文在<article>标签内
html_content = page.content()
browser.close()
return html_content
# 获取动态渲染后的HTML
dynamic_html = get_dynamic_html('https://news-site.com/article')
# 然后就可以用BeautifulSoup解析dynamic_html了
soup = BeautifulSoup(dynamic_html, 'html.parser')
```
这种方法功能强大,但代价是速度慢、资源消耗大。它更适合处理少量难以攻克的页面,或作为最后的手段。
## 2. 第二个坑:混乱的HTML结构与精准内容提取
假设你已经成功获取了包含完整内容的HTML。直接用`soup.get_text()`会带来第二个问题:**文本污染**。你会得到导航栏文字、侧边栏推荐文章、广告文案、页脚版权声明、JavaScript代码片段……所有东西都混在一起,而真正的新闻正文可能被淹没其中。
`soup.get_text()`是“偷懒”的方法,它提取所有标签的文本。对于新闻网站,我们需要**外科手术式的精准提取**。
### 2.1 定位核心内容容器
新闻网站的文章正文通常被包裹在具有特定语义或样式的HTML容器中。你需要使用浏览器的“检查元素”功能,找到这个容器的独特标识。
常见的容器选择策略(按优先级):
1. **语义化标签**:现代网站倾向于使用`<article>`、`<main>`标签包裹主要内容。可以尝试`soup.find('article')`。
2. **特定CSS类或ID**:查看正文区域的`class`或`id`属性。例如,可能是`<div class="article-content">`或`<div id="content-body">`。使用`soup.find('div', class_='article-content')`或`soup.select_one('#content-body')`。
3. **组合选择器**:有时需要更精确的路径,比如`soup.select('div.container > div.row > div.main-col > article')`。
一个实战代码片段可能长这样:
```python
def extract_main_content(soup):
# 尝试多种选择器,提高容错率
content_selectors = [
'article',
'div.article-content',
'div#article-body',
'div[itemprop="articleBody"]',
'main .content',
]
for selector in content_selectors:
element = soup.select_one(selector)
if element and len(element.get_text(strip=True)) > 200: # 简单长度过滤,避免抓到空容器
return element
# 如果都没找到,回退到body,但后续需要更复杂的清洗
return soup.body
```
### 2.2 清洗与格式化提取的文本
找到容器后,直接对其使用`.get_text()`仍然可能包含容器内嵌套的广告、无关链接文本等。我们需要进一步清洗:
- **移除特定元素**:在提取容器文本前,先移除已知的无关标签。
```python
main_container = soup.find('article')
# 移除脚本、样式、广告单元、分享按钮等
for tag in main_container(['script', 'style', 'aside', 'div.ad', 'div.social-share', 'ins']):
tag.decompose() # 彻底从树中删除该标签
```
- **处理空白字符**:`.get_text()`的`strip`和`separator`参数很有用。
```python
# 去除首尾空白,并用单个空格替换连续的空白符(包括换行)
clean_text = main_container.get_text(separator=' ', strip=True)
```
- **段落重组**:新闻正文由多个`<p>`段落组成。直接`get_text`会把它们连成一大段。有时我们需要保留段落结构:
```python
paragraphs = main_container.find_all('p')
article_text = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
# 这样保存的txt文件,段落之间会有空行,更易读。
```
### 2.3 处理特殊编码与字符
新闻中可能包含引号、破折号、版权符号等特殊字符。确保在整个流程中使用UTF-8编码。
- 在请求时,如果响应头没有指定编码或编码错误,可以手动设置:
```python
response.encoding = 'utf-8' # 或者根据网站实际情况设置为 'gbk', 'gb2312'
```
- 在保存文件时,明确指定编码:
```python
with open('news.txt', 'w', encoding='utf-8') as f:
f.write(article_text)
```
## 3. 第三个坑:反爬机制与异常处理——让你的爬虫“活下去”
即使你完美解决了前两个问题,爬虫在长时间、大批量运行时依然可能突然“死亡”。新闻网站为了保护资源和防止滥用,部署了各种反爬措施。一个健壮的爬虫必须有完善的**异常处理**和**反反爬策略**。
### 3.1 优雅地处理HTTP状态码
`requests.get()`默认不会在状态码非200时抛出异常(除非你调用`response.raise_for_status()`)。但我们需要对不同状态码做出不同反应。
```python
import requests
from time import sleep
def robust_request(url, headers, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
return response
elif response.status_code == 403:
print(f"访问被拒绝(403),可能触发了反爬。尝试更换User-Agent或IP。")
# 这里可以触发更换代理IP的逻辑
break
elif response.status_code == 404:
print(f"页面不存在(404): {url}")
return None
elif response.status_code == 429:
print(f"请求过于频繁(429),第{attempt+1}次重试前等待...")
sleep_time = (attempt + 1) * 10 # 等待时间递增
sleep(sleep_time)
continue
elif response.status_code == 503:
print(f"服务暂时不可用(503),等待后重试...")
sleep(30)
continue
else:
print(f"未知状态码 {response.status_code},停止重试。")
break
except requests.exceptions.Timeout:
print(f"请求超时,第{attempt+1}次重试...")
sleep(5)
except requests.exceptions.ConnectionError:
print(f"连接错误,检查网络。第{attempt+1}次重试...")
sleep(10)
return None # 所有重试都失败
```
### 3.2 应对IP限制与验证码
这是最棘手的部分。如果单个IP请求过快,很容易被暂时或永久封禁。
- **降低请求频率**:在循环抓取时,务必使用`time.sleep(random.uniform(1, 3))`来模拟人类阅读间隔。随机化等待时间更安全。
- **使用代理IP池**:对于大规模抓取,这是必备方案。你可以订阅付费代理服务,或者使用一些免费的代理IP(但稳定性差)。代码需要改造为每次请求随机或轮换使用不同的代理。
```python
proxies_list = [
{'http': 'http://user:pass@proxy1:port', 'https': 'https://user:pass@proxy1:port'},
{'http': 'http://proxy2:port', 'https': 'https://proxy2:port'},
# ...
]
import random
proxy = random.choice(proxies_list)
response = requests.get(url, headers=headers, proxies=proxy, timeout=10)
```
- **处理验证码**:如果遇到验证码,自动化解决非常困难。可以考虑:
1. 识别触发验证码的阈值,通过降低频率来避免。
2. 使用第三方打码平台API(需要付费)。
3. 对于必须登录的网站,考虑维护一个会话(Session),并妥善保存Cookies。
### 3.3 设计可中断与可恢复的爬虫
爬取大量新闻时,程序可能因网络波动、异常等原因中断。从头开始爬是低效的。一个好的实践是:
1. **记录爬取状态**:将成功抓取并保存的新闻URL记录到一个文件或数据库中。
2. **增量爬取**:每次启动时,先加载已抓取的URL列表,过滤掉已经处理过的。
3. **分步保存**:不要等所有内容都处理完才保存。每成功处理一篇文章,就立即将文本保存为独立的txt文件(文件名可以用标题或URL的哈希值),并更新状态记录。
```python
import hashlib
import json
def save_article(article_title, article_content, output_dir='articles'):
os.makedirs(output_dir, exist_ok=True)
# 用标题和内容的哈希生成唯一文件名,避免特殊字符问题
filename_hash = hashlib.md5(f"{article_title}{article_content[:100]}".encode()).hexdigest()[:8]
safe_title = "".join([c for c in article_title if c.isalnum() or c in (' ', '-', '_')]).rstrip()[:50]
filename = f"{filename_hash}_{safe_title}.txt"
filepath = os.path.join(output_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"标题: {article_title}\n")
f.write(f"来源: {url}\n")
f.write("-"*50 + "\n\n")
f.write(article_content)
# 记录到日志文件
with open('crawled.log', 'a', encoding='utf-8') as log:
log.write(f"{url}\n")
print(f"已保存: {filepath}")
```
## 4. 实战整合:构建一个健壮的新闻爬取脚本
让我们把上面的所有策略整合到一个更完整的、模块化的示例脚本框架中。这个脚本仍然是一个示例,你需要根据目标网站的具体情况填充细节。
```python
import requests
from bs4 import BeautifulSoup
import time
import random
import os
from urllib.parse import urlparse
import logging
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class NewsCrawler:
def __init__(self, delay_range=(1, 4)):
self.session = requests.Session()
self.delay_range = delay_range
self.setup_headers()
def setup_headers(self):
"""设置默认请求头"""
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9',
}
self.session.headers.update(self.headers)
def respectful_delay(self):
"""随机延迟,尊重网站"""
time.sleep(random.uniform(*self.delay_range))
def fetch_page(self, url, max_retries=2):
"""健壮的页面获取函数"""
for retry in range(max_retries):
try:
self.respectful_delay()
resp = self.session.get(url, timeout=15)
resp.encoding = 'utf-8' # 根据实际情况调整
if resp.status_code == 200:
return resp.text
elif resp.status_code == 429:
wait_time = (retry + 1) * 30
logger.warning(f"速率限制,等待 {wait_time} 秒后重试 {url}")
time.sleep(wait_time)
else:
logger.error(f"获取 {url} 失败,状态码: {resp.status_code}")
break
except Exception as e:
logger.error(f"请求 {url} 时发生异常 (尝试 {retry+1}/{max_retries}): {e}")
if retry == max_retries - 1:
return None
return None
def parse_article(self, html, url):
"""解析文章HTML,提取标题和正文"""
soup = BeautifulSoup(html, 'html.parser')
# 1. 提取标题 (示例,需自定义)
title_elem = soup.find('h1') or soup.find('title')
title = title_elem.get_text(strip=True) if title_elem else f"未找到标题_{urlparse(url).path}"
# 2. 定位正文容器 (示例,需自定义)
content_selectors = [
'article',
'div.article-content',
'div.content',
'main',
]
content_container = None
for sel in content_selectors:
container = soup.select_one(sel)
if container and len(container.get_text(strip=True)) > 100:
content_container = container
break
if not content_container:
logger.warning(f"未找到明确正文容器,回退到body: {url}")
content_container = soup.body
# 3. 清洗容器
for noise in content_container(['script', 'style', 'aside', 'nav', 'footer', 'form', 'iframe', 'button']):
noise.decompose()
# 4. 提取并格式化文本
# 方法A: 保留段落结构
paragraphs = content_container.find_all('p')
if len(paragraphs) > 3: # 假设有足够多的<p>标签
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
else:
# 方法B: 整体提取
content = content_container.get_text(separator='\n', strip=True)
return title, content
def save_as_txt(self, title, content, url, base_dir='downloaded_news'):
"""保存文章到txt文件"""
os.makedirs(base_dir, exist_ok=True)
# 生成安全文件名
safe_title = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_', ',', '。')]).strip()
safe_title = safe_title[:100] # 限制长度
filename = f"{safe_title}.txt"
filepath = os.path.join(base_dir, filename)
# 如果文件名已存在,添加后缀
counter = 1
while os.path.exists(filepath):
name, ext = os.path.splitext(filename)
filename = f"{name}_{counter}{ext}"
filepath = os.path.join(base_dir, filename)
counter += 1
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"标题: {title}\n")
f.write(f"原文链接: {url}\n")
f.write(f"抓取时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write("="*60 + "\n\n")
f.write(content)
logger.info(f"文章已保存至: {filepath}")
return filepath
def crawl(self, url_list):
"""主爬取流程"""
for url in url_list:
logger.info(f"开始处理: {url}")
html = self.fetch_page(url)
if not html:
continue
title, content = self.parse_article(html, url)
if content and len(content.strip()) > 50: # 简单的内容有效性检查
self.save_as_txt(title, content, url)
else:
logger.warning(f"从 {url} 提取的内容过少或为空,可能解析失败。")
# 使用示例
if __name__ == '__main__':
crawler = NewsCrawler(delay_range=(2, 5)) # 设置较长的延迟区间
# 这里替换成你的目标新闻URL列表
news_urls = [
'https://example-news-site.com/article/1',
'https://example-news-site.com/article/2',
# ...
]
crawler.crawl(news_urls)
logger.info("所有任务处理完毕。")
```
这个脚本框架提供了健壮性所需的核心组件:可配置的延迟、错误重试、内容解析、清洗和保存。**最关键的一步**,即`parse_article`方法中的选择器(`content_selectors`和标题查找),需要你针对**每一个不同的新闻网站**进行单独分析和定制。没有放之四海而皆准的规则,这正是爬虫工作既有挑战性又有趣味性的地方。你可以为不同的网站编写不同的解析函数,甚至将它们配置化,存入一个JSON或YAML文件,让爬虫根据域名自动加载对应的解析规则。