Day 13: 新闻网站爬取
版本基线:本文基于 Python 3.12+,requests 2.x / Scrapy 2.x / Selenium 4.x,更新于 2026-09。
实战目标站点
本章保留"资讯内容采集"主题,但演示站点改用 quotes.toscrape.com —— 一个由 Scrapy 官方维护的名言内容站,结构稳定、合法开放。新闻站与内容站的采集流程完全一致(列表页 → 详情页 → 清洗 → 存储 → 统计),下文所有选择器均已对照真实页面结构核实。
学习目标
- 掌握新闻网站的爬取策略
- 学会提取新闻标题、正文、图片
- 实现自动更新机制
- 了解新闻数据的存储和分析
技术原理
13.1 新闻网站特点
页面特点:
- 结构相对规范
- 更新频繁
- 包含多媒体内容
- 分类清晰
数据特点:
- 时效性强
- 内容结构化
- 包含元数据(时间、来源、作者)
13.2 爬取策略
列表页爬取:
- 提取新闻链接
- 处理分页
- 处理分类
详情页爬取:
- 提取标题
- 提取正文
- 提取图片
- 提取元数据
13.3 增量爬取
策略:
- 基于时间戳
- 基于 URL 去重
- 基于内容哈希
案例
案例1:名言列表爬取(quotes.toscrape.com)
python
import scrapy
class QuoteListSpider(scrapy.Spider):
"""名言列表爬虫(对应新闻站的“新闻列表爬取”)"""
name = 'quotes_list'
allowed_domains = ['quotes.toscrape.com']
start_urls = ['https://quotes.toscrape.com/']
def parse(self, response):
"""解析列表页"""
# 每条名言是 <div class="quote">(已对照真实站点核实)
quotes = response.css('div.quote')
for quote in quotes:
yield {
# 名言内容:<span class="text">…</span>
'text': quote.css('span.text::text').get(),
# 作者:<small class="author">Albert Einstein</small>
'author': quote.css('small.author::text').get(),
# 标签:<div class="tags"> 内的 <a class="tag">
'tags': quote.css('div.tags a.tag::text').getall(),
# 作者详情页链接((about) 链接,如 /author/Albert-Einstein/)
'author_url': response.urljoin(
quote.css('small.author + a::attr(href)').get()
),
}
# 处理分页:<ul class="pager"> 中 <li class="next"><a href="/page/2/">Next →</a></li>
next_page = response.css('ul.pager li.next a::attr(href)').get()
if next_page:
yield response.follow(next_page, callback=self.parse)站点结构速查(已核实)
- 名言条目:
div.quote - 名言内容:
span.text - 作者:
small.author - 标签:
div.tags a.tag - 分页:
ul.pager li.next a(绝对路径/page/2/,response.follow会自动拼接)
案例2:作者详情爬取(对应新闻详情页)
python
import scrapy
from bs4 import BeautifulSoup
class QuoteDetailSpider(scrapy.Spider):
"""作者详情爬虫:从列表页跟踪“about”链接,进入作者详情页"""
name = 'quotes_detail'
allowed_domains = ['quotes.toscrape.com']
start_urls = ['https://quotes.toscrape.com/']
def parse(self, response):
"""解析列表页并跟踪详情页链接"""
for quote in response.css('div.quote'):
# (about) 链接指向作者详情页,如 /author/Albert-Einstein/
author_url = response.urljoin(
quote.css('small.author + a::attr(href)').get()
)
yield response.follow(author_url, callback=self.parse_detail)
# 翻页
next_page = response.css('ul.pager li.next a::attr(href)').get()
if next_page:
yield response.follow(next_page, callback=self.parse)
def parse_detail(self, response):
"""解析作者详情页"""
# 标题:<h3 class="author-title">Albert Einstein</h3>
title = response.css('h3.author-title::text').get()
# 简介:<div class="author-description">…</div>
description_html = response.css('div.author-description').get()
description_text = self.extract_text(description_html)
# 元数据
born_date = response.css('span.author-born-date::text').get()
born_location = response.css('span.author-born-location::text').get()
yield {
'url': response.url,
'title': title.strip() if title else None,
'content': description_text,
'content_html': description_html,
'born_date': born_date,
'born_location': born_location,
}
def extract_text(self, html):
"""从HTML中提取纯文本"""
if not html:
return ''
soup = BeautifulSoup(html, 'html.parser')
# 去除script和style标签
for script in soup(['script', 'style']):
script.decompose()
# 获取文本
text = soup.get_text()
# 清理空白
lines = (line.strip() for line in text.splitlines())
chunks = (phrase.strip() for line in lines for phrase in line.split(' '))
text = '\n'.join(chunk for chunk in chunks if chunk)
return text应用场景
1. 新闻聚合
- 多源新闻聚合
- 个性化推荐
- 热点追踪
2. 舆情监控
- 新闻舆情分析
- 事件追踪
- 传播分析
3. 内容分析
- 新闻分类
- 关键词提取
- 情感分析
代码案例
案例3:增量爬取
python
import scrapy
import hashlib
from datetime import datetime
class IncrementalQuoteSpider(scrapy.Spider):
"""增量内容爬虫:基于 URL 去重,避免重复抓取"""
name = 'incremental_quotes'
allowed_domains = ['quotes.toscrape.com']
start_urls = ['https://quotes.toscrape.com/']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.seen_urls = set()
self.load_seen_urls()
def load_seen_urls(self):
"""加载已爬取的URL"""
try:
with open('seen_urls.txt', 'r') as f:
self.seen_urls = set(line.strip() for line in f)
except FileNotFoundError:
self.seen_urls = set()
def save_seen_urls(self):
"""保存已爬取的URL"""
with open('seen_urls.txt', 'w') as f:
for url in self.seen_urls:
f.write(url + '\n')
def parse(self, response):
"""解析列表页"""
quotes = response.css('div.quote')
for quote in quotes:
# 详情页 URL(作者页)
url = response.urljoin(
quote.css('small.author + a::attr(href)').get()
)
# 检查是否已爬取
if url in self.seen_urls:
self.logger.info(f'跳过已爬取: {url}')
continue
# 记录URL
self.seen_urls.add(url)
# 跟踪详情页
yield response.follow(url, callback=self.parse_detail)
# 保存已爬取URL
self.save_seen_urls()
def parse_detail(self, response):
"""解析详情页"""
# 计算内容哈希(用于检测内容是否变化)
content = response.css('div.author-description').get()
content_hash = hashlib.md5(content.encode()).hexdigest() if content else ''
yield {
'url': response.url,
'title': response.css('h3.author-title::text').get(),
'content': response.css('span.author-born-date::text').get(),
'description': content,
'content_hash': content_hash,
'crawl_time': datetime.now().isoformat(),
}
def closed(self, reason):
"""爬虫关闭时保存"""
self.save_seen_urls()案例4:多源内容爬虫
python
import scrapy
class MultiSourceSpider(scrapy.Spider):
"""多源内容爬虫:每个来源一套选择器配置"""
name = 'multi_source'
def __init__(self, sources=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.sources = sources.split(',') if sources else ['quotes', 'books']
def start_requests(self):
"""根据来源生成请求"""
# 多源爬虫的核心思想:每个站点各自维护一份
# “起始 URL + 选择器”配置,调度与存储逻辑完全复用
source_configs = {
'quotes': {
'url': 'https://quotes.toscrape.com/',
'callback': self.parse_quotes,
},
'books': {
'url': 'https://books.toscrape.com/',
'callback': self.parse_books,
},
}
for source in self.sources:
if source in source_configs:
config = source_configs[source]
yield scrapy.Request(
config['url'],
callback=config['callback'],
meta={'source': source}
)
def parse_quotes(self, response):
"""解析名言站(quotes.toscrape.com)"""
for quote in response.css('div.quote'):
yield {
'source': response.meta['source'],
'title': quote.css('span.text::text').get(),
'url': response.url,
'summary': quote.css('small.author::text').get(),
'tags': quote.css('div.tags a.tag::text').getall(),
}
def parse_books(self, response):
"""解析图书站(books.toscrape.com)"""
for product in response.css('article.product_pod'):
yield {
'source': response.meta['source'],
'title': product.css('h3 a::attr(title)').get(),
'url': response.urljoin(product.css('h3 a::attr(href)').get()),
'summary': product.css('p.price_color::text').get(),
}案例5:新闻数据存储
python
# pipelines.py
import pymysql
from datetime import datetime
class NewsPipeline:
"""新闻数据管道"""
def __init__(self, db_config):
self.db_config = db_config
self.connection = None
@classmethod
def from_crawler(cls, crawler):
db_config = {
'host': crawler.settings.get('MYSQL_HOST', 'localhost'),
'port': crawler.settings.getint('MYSQL_PORT', 3306),
'user': crawler.settings.get('MYSQL_USER', 'root'),
'password': crawler.settings.get('MYSQL_PASSWORD', ''),
'database': crawler.settings.get('MYSQL_DATABASE', 'news'),
}
return cls(db_config)
def open_spider(self, spider):
"""连接数据库"""
self.connection = pymysql.connect(**self.db_config)
self.create_tables()
def close_spider(self, spider):
"""关闭连接"""
if self.connection:
self.connection.close()
def create_tables(self):
"""创建表"""
cursor = self.connection.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS news (
id INT AUTO_INCREMENT PRIMARY KEY,
url VARCHAR(500) UNIQUE,
title VARCHAR(255),
content TEXT,
source VARCHAR(100),
author VARCHAR(100),
publish_time DATETIME,
category VARCHAR(50),
images JSON,
crawl_time DATETIME,
content_hash VARCHAR(32),
INDEX idx_url (url),
INDEX idx_publish_time (publish_time),
INDEX idx_source (source),
INDEX idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''')
self.connection.commit()
def process_item(self, item, spider):
"""存储数据"""
cursor = self.connection.cursor()
# 检查是否已存在
sql = 'SELECT id FROM news WHERE url = %s'
cursor.execute(sql, (item.get('url'),))
if cursor.fetchone():
spider.logger.info(f'新闻已存在: {item.get("url")}')
return item
# 插入数据
sql = '''
INSERT INTO news (url, title, content, source, author, publish_time,
category, images, crawl_time, content_hash)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
'''
import json
cursor.execute(sql, (
item.get('url'),
item.get('title'),
item.get('content'),
item.get('source'),
item.get('author'),
item.get('publish_time'),
item.get('category'),
json.dumps(item.get('images', [])),
item.get('crawl_time'),
item.get('content_hash'),
))
self.connection.commit()
return item
# settings.py
ITEM_PIPELINES = {
'myproject.pipelines.NewsPipeline': 300,
}
MYSQL_HOST = 'localhost'
MYSQL_PORT = 3306
MYSQL_USER = 'root'
MYSQL_PASSWORD = 'password'
MYSQL_DATABASE = 'news'案例6:定时爬取
python
# 定时任务配置
# 使用 scrapyd 或 cron 实现定时爬取
# scrapyd 部署
# scrapyd-deploy local -p myproject
# 定时任务脚本
import subprocess
import schedule
import time
def run_spider():
"""运行爬虫"""
subprocess.run(['scrapy', 'crawl', 'quotes_list', '-o', 'quotes.json'])
# 每小时运行一次
schedule.every(1).hours.do(run_spider)
while True:
schedule.run_pending()
time.sleep(60)案例7:新闻数据分析
python
import pandas as pd
from collections import Counter
import jieba
class NewsAnalyzer:
"""新闻数据分析器"""
def __init__(self, db_config):
import pymysql
self.connection = pymysql.connect(**db_config)
def load_news(self, days=7):
"""加载最近N天的新闻"""
sql = '''
SELECT * FROM news
WHERE publish_time >= DATE_SUB(NOW(), INTERVAL %s DAY)
ORDER BY publish_time DESC
'''
df = pd.read_sql(sql, self.connection, params=[days])
return df
def analyze_categories(self, df):
"""分析分类分布"""
category_counts = df['category'].value_counts()
return category_counts
def analyze_sources(self, df):
"""分析来源分布"""
source_counts = df['source'].value_counts()
return source_counts
def extract_keywords(self, df, top_k=20):
"""提取关键词"""
all_text = ' '.join(df['title'].tolist() + df['content'].tolist())
# 分词
words = jieba.cut(all_text)
# 过滤
stop_words = {'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一'}
filtered_words = [w for w in words if len(w) > 1 and w not in stop_words]
# 统计
word_counts = Counter(filtered_words)
return word_counts.most_common(top_k)
def analyze_time_distribution(self, df):
"""分析时间分布"""
df['hour'] = pd.to_datetime(df['publish_time']).dt.hour
hour_counts = df['hour'].value_counts().sort_index()
return hour_counts
def close(self):
"""关闭连接"""
self.connection.close()
# 使用示例
db_config = {
'host': 'localhost',
'user': 'root',
'password': 'password',
'database': 'news',
}
analyzer = NewsAnalyzer(db_config)
# 加载数据
df = analyzer.load_news(days=7)
print(f'加载 {len(df)} 条新闻')
# 分析分类
categories = analyzer.analyze_categories(df)
print(f'分类分布:\n{categories}')
# 提取关键词
keywords = analyzer.extract_keywords(df)
print(f'关键词: {keywords}')
analyzer.close()课后练习
练习1:统计作者与标签
爬取 quotes.toscrape.com 前 5 页,统计作者出现次数排行与最高频的 10 个标签。
练习2:实现内容推荐
基于标签匹配实现简单的相关内容推荐。
练习3:内容摘要生成
使用 NLP 技术对作者简介生成摘要。
常见问题
Q1: 如何处理新闻网站的反爬?
A: 使用代理 IP、控制请求频率、伪装请求头、使用 Selenium。
Q2: 如何处理新闻图片?
A: 使用 Scrapy 的 ImagesPipeline,或自定义下载逻辑。
Q3: 如何保证新闻的时效性?
A: 使用增量爬取、定时任务、监控更新频率。
WARNING
quotes.toscrape.com 与 books.toscrape.com 均为专供爬虫学习的公开沙盒站,可放心练习;真实新闻站请注意遵守版权与 robots.txt,控制抓取频率。