前言
拿一个真实门户站当例子来练手,是很有价值的,因为门户站会把你在练习站上遇不到的问题一次性摆到面前:列表页和详情页要分两步抓、页面里混着广告和推荐位、内容可能由 JavaScript 渲染、站点还会不时改版。但正因为是真实站点,这个例子里合规不是可选项,而是第一步。
有几句必须写在最前面。第一,采集新闻类内容涉及著作权:把别人的稿件全文抓下来再发布,无论技术多简单都不可以;本文的例程只演示「取标题、取链接、做摘要研究」这一层面,不鼓励也不演示任何形式的全文转载或再分发。第二,动手前请自己去看目标站的 robots.txt 与用户协议,看它允不允许你抓、允许抓哪些路径、以什么频率抓。第三,本文不会给出任何绕过登录墙、付费墙、验证码或风控的做法——遇到需要登录才能看的内容,正确结论就是「看不到,不抓」,而不是想办法进去。
还要说明一点:门户站页面结构变化频繁,很多内容由前端脚本异步加载。所以下面给的解析代码是通用骨架,具体到某个 class 名、某种链接形式的过滤规则,必须以你当时实际看到的页面为准。任何号称「一份选择器写死就能长期用」的教程,都是不诚实的。代码基于 Python 3(Python 2.7 已于 2020 年 1 月 1 日停止维护,下文一律用 Python 3 写法)。
一、采集新闻稿的一般流程
新闻站的采集几乎都是「两步走」:
| 步骤 | 输入 | 输出 | 关键动作 |
|---|
| 列表页 | 频道首页 / 分页 URL | 一批文章链接 + 标题 | 过滤掉导航、广告、推荐位 |
| 详情页 | 单个文章 URL | 标题 + 正文段落 + 时间 | 去广告、去推荐、取正文 |
| 清洗 | 原始文本 | 规整记录 | 去空白、去干扰行 |
| 落盘 | 记录列表 | JSON / CSV | 显式编码、及时写入 |
第一步的重点是把链接筛干净:导航链接、栏目链接、「更多」链接、广告位链接都不能当成文章。常见的过滤思路是看href的形态(是否包含文章路径特征、是否以.html结尾),而不是去数「第几个 a 标签」。
二、合规前置检查要写在第一行
把这几件事做成程序入口处的硬性检查,比事后补救靠谱:
- 读 robots.txt:
urllib.robotparser.RobotFileParser的set_url()→read()→can_fetch(useragent, url)。只有返回True才继续。读不到 robots.txt 时,按保守策略当作「不允许」,下次再试。 - 如实标明身份:
User-Agent里写清你的程序名和联系方式,不要伪装成浏览器。 - 全局限速:任意两次请求之间留间隔(
time.sleep),并且把总请求数设上限。 - 只碰公开内容:需要登录、需要付费、robots.txt 明令禁止的路径,一律不抓。
- 分清用途:做统计、研究、舆情分析这类用途,和「把稿件搬到自己的站上」完全是两回事。
三、列表页解析:只收文章链接
用html.parser.HTMLParser写一个「收集所有 a 标签的 href 和文字」的解析器,再在外面按规则过滤。这样解析器和站点结构解耦,站点改版时大多只需要改过滤规则。
注意attrs是「(名字, 值) 二元组」的列表,不是字典,要dict(attrs)之后再.get("href")。另外,feed()之后别忘了close()。
四、详情页解析:抽正文段落
详情页的正文通常是若干p标签。一个朴素但有效的做法是收集所有p里的文字,再丢掉过短的行(版权声明、推荐语、关注按钮文字往往很短)。这个规则不完美——有的站点用div而不是p装正文——但它是你能在不了解站点内部结构时最先用上的启发式规则。真要做得稳,得针对站点实际结构定制,并接受「改版就失效」这个事实。
实战:通用骨架 + 可验证的解析器
下面这段代码可以直接跑,但把LIST_URL换成你自己确认过 robots 允许的页面。解析逻辑我同时用一段本地 HTML 字符串做了自检,这样即使不联网,你也能看到解析器的工作结果。
# 适用于 Python 3.8+
import time
import urllib.error
import urllib.parse
import urllib.request
import urllib.robotparser
from html.parser import HTMLParser
UA = "news-list-reader/0.1 (contact: feed@example.com)"
DELAY = 1.0 # 任意两次请求之间的间隔(秒)
MAX_ARTICLES = 5 # 本次采集的文章数上限
class ListParser(HTMLParser):
"""收集页面里所有 a 标签的 (href, 文字)。"""
def __init__(self):
super().__init__()
self.links = []
self._href = None
self._buf = []
def handle_starttag(self, tag, attrs):
if tag == "a":
href = dict(attrs).get("href")
if href:
self._href = href
self._buf = []
def handle_data(self, data):
if self._href is not None:
self._buf.append(data)
def handle_endtag(self, tag):
if tag == "a" and self._href is not None:
self.links.append((self._href, "".join(self._buf).strip()))
self._href = None
class ParagraphCollector(HTMLParser):
"""收集所有 p 标签里的文字,当作正文段落。"""
def __init__(self, min_len=10):
super().__init__()
self.min_len = min_len
self._in_p = False
self._buf = []
self.paragraphs = []
def handle_starttag(self, tag, attrs):
if tag == "p":
self._in_p = True
self._buf = []
def handle_data(self, data):
if self._in_p:
self._buf.append(data)
def handle_endtag(self, tag):
if tag == "p" and self._in_p:
self._in_p = False
text = "".join(self._buf).strip()
if len(text) >= self.min_len:
self.paragraphs.append(text)
def fetch_text(url, timeout=15):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return resp.read().decode(charset, errors="replace")
def robots_allowed(url):
parts = urllib.parse.urlsplit(url)
robots_url = urllib.parse.urlunsplit(
(parts.scheme, parts.netloc, "/robots.txt", "", "")
)
parser = urllib.robotparser.RobotFileParser()
parser.set_url(robots_url)
try:
parser.read()
except Exception:
return False # 读不到就当作不允许
return parser.can_fetch(UA, url)
def is_article_link(href):
"""过滤规则要按目标站实际结构写;这里只做最基本的形态判断。"""
if href.startswith(("javascript:", "#", "mailto:")):
return False
path = urllib.parse.urlsplit(href).path
return path.endswith(".html") or "/article/" in path
def collect_list(list_url, limit=MAX_ARTICLES):
parser = ListParser()
parser.feed(fetch_text(list_url))
parser.close()
articles = []
seen = set()
for href, title in parser.links:
if not is_article_link(href):
continue
absolute = urllib.parse.urljoin(list_url, href)
if absolute in seen:
continue
seen.add(absolute)
articles.append((absolute, title))
if len(articles) >= limit:
break
return articles
def collect_article(url):
time.sleep(DELAY) # 进详情页之前先限速
parser = ParagraphCollector()
parser.feed(fetch_text(url))
parser.close()
return parser.paragraphs
def self_test():
"""不联网,用本地 HTML 验证两个解析器。"""
html = """
<div class="nav"><a href="/">首页</a><a href="/sports/">体育</a></div>
<ul>
<li><a href="/news/20260101A001.html">某地发布年度经济数据</a></li>
<li><a href="javascript:void(0)">更多</a></li>
<li><a href="/news/20260101A002.html">行业论坛昨日举行</a></li>
</ul>
"""
lp = ListParser()
lp.feed(html)
lp.close()
picked = [
(urllib.parse.urljoin("https://news.example.com/", h), t)
for h, t in lp.links
if is_article_link(h)
]
print("列表页筛出:", picked)
detail = "<p>第一段正文,长度足够。</p><p>短</p><p>第二段正文,长度也够。</p>"
ap = ParagraphCollector()
ap.feed(detail)
ap.close()
print("正文段落:", ap.paragraphs)
def main(list_url):
if not robots_allowed(list_url):
print("robots.txt 不允许抓取,退出")
return
for url, title in collect_list(list_url):
try:
paragraphs = collect_article(url)
except urllib.error.HTTPError as exc:
print("跳过", url, exc.code)
continue
print(title, "->", len(paragraphs), "段")
if __name__ == "__main__":
self_test()
# 确认 robots.txt 允许后,再取消下一行的注释:
# main("https://example.com/news/")self_test()那段是不联网的,跑起来会打印:
列表页筛出: [('https://news.example.com/news/20260101A001.html', '某地发布年度经济数据'), ('https://news.example.com/news/20260101A002.html', '行业论坛昨日举行')]
正文段落: ['第一段正文,长度足够。', '第二段正文,长度也够。']注意筛选结果里没有「首页」「体育」「更多」——它们分别被is_article_link的路径判断和javascript:前缀挡掉了。这正是列表页解析要做的事:把干扰链接拦在门外。
常见坑点
1. 不检查 robots.txt 就开始抓
❌ 直接对着频道首页循环翻页。 ✅ 入口处先RobotFileParser判断,不允许就停。
2. 把整站所有链接都当文章抓
❌ 见到a就入队,结果爬了一堆导航页和广告落地页。 ✅ 用路径特征过滤,并额外维护seen集合去重。
3. 用伪装 UA 冒充浏览器
❌ 抄一个浏览器 UA 字符串帖上去。 ✅ 写清自己的程序名和联系方式,便于对方在需要时联系你。
4. 以为文档就是完整 DOM
❌urlopen拿到的 HTML 里找不到内容,就以为「抓不到」。 ✅ 很多内容是脚本异步加载的。先确认这条内容有没有合规的静态接口;不要去逆向破解签名或绕过风控。
5. 把「需要登录才能看」的内容当成技术挑战
❌ 研究怎么携带登录态进付费内容区。 ✅ 结论很明确:那部分内容不抓。本文不提供任何绕过登录墙或付费墙的做法。
6. 去掉间隔猛翻页
❌ 把time.sleep删掉,靠高并发「提速」。 ✅ 保留间隔和总数上限。对真实站点,礼貌和合规永远排在速度前面。
7. 把抓到的全文直接再发布
❌ 抓完就贴到自己的站点或公众号。 ✅ 新闻内容有著作权。研究、统计、摘要引用与全文再分发是两回事,别越界。
8. 硬编码一次选择器就不管了
❌ 把某天看到的 class 名写死,然后半年不看。 ✅ 解析规则要能容错——取不到字段时留空并记日志,站点改版时按新结构更新。
总结
| 阶段 | 做什么 | 合规红线 |
|---|
| 前置 | 读 robots.txt、定 UA、定频率 | robots 不允许就不抓 |
| 列表页 | 收链接、按形态过滤、去重 | 不抓导航/广告,也不「碰运气」扩大范围 |
| 详情页 | 抽标题与正文段落 | 不碰登录墙/付费墙后的内容 |
| 清洗 | 去短行、去空白 | 不改动原意地做摘要 |
| 落盘 | 显式 UTF-8 写入 | 不全文转载再分发 |
采集一个真实门户站,技术上的骨架和采练习站没有本质区别:请求、解析、清洗、落盘。真正的差别在约束——你有义务先问清楚「能不能抓」,再决定「怎么抓」。把合规检查写成代码的第一行,把限速写进循环,把「不碰登录墙和付费墙」当成默认规则,这样的采集才做得长久。