1. 项目概述与核心价值

最近在分析内容趋势时,发现很多朋友对抓取今日头条这类资讯平台的热门文章有需求。无论是做市场分析、舆情监控,还是内容创作寻找灵感,能自动化地获取热门内容,效率会高很多。我自己在做一些内容研究项目时,也经常需要这个能力。今天就来详细聊聊,如何用Python构建一个稳定、高效的今日头条热门文章爬虫。

这个项目听起来简单,但实际操作中会遇到不少“坑”,比如今日头条的动态加载、反爬机制、数据清洗等。网上很多教程要么过于简单只讲基础请求,要么代码已经过时无法运行。我会结合最新的网络环境(2023年底至2024年初)和实战经验,从环境搭建、请求分析、数据解析到数据存储,一步步拆解,并重点分享那些教程里不会写的避坑技巧和性能优化思路。无论你是刚学Python爬虫的新手,还是想寻找一个更稳健方案的老手,这篇文章都能给你提供可直接复现的代码和思路。

2. 环境准备与工具选型

工欲善其事,必先利其器。在开始写代码之前,选择合适的工具和配置好环境是成功的第一步。这里的选择不仅关乎代码能否运行,更关系到后续爬虫的稳定性、可维护性和反爬对抗能力。

2.1 Python环境与核心库安装

首先确保你的Python版本在3.8及以上,这是目前多数库稳定支持的主流版本。不建议使用过老的Python 2.7或3.6,可能会遇到依赖库兼容性问题。

核心库我们主要依赖以下几个:

  • requests : 用于发送HTTP请求。这是最基础、最常用的库,比Python内置的urllib更友好、功能更强大。
  • BeautifulSoup4 (bs4) : 用于解析HTML文档,从复杂的网页标签中提取我们需要的数据。它语法简洁,学习成本低。
  • lxml : 这是一个解析器,BeautifulSoup可以使用它作为后端引擎,其解析速度比Python标准库中的 html.parser 快很多,特别是在处理大量数据时优势明显。
  • pandas : 非必须,但强烈推荐。用于将爬取到的结构化数据(列表、字典)轻松地转换为DataFrame,并导出为CSV或Excel文件,方便后续分析。
  • json : Python标准库,用于处理今日头条接口返回的JSON格式数据。

安装命令非常简单,打开你的终端(Windows的CMD或PowerShell,Mac/Linux的Terminal)执行以下命令:

pip install requests beautifulsoup4 lxml pandas

如果你遇到网络问题导致下载慢或失败,可以使用国内的镜像源,例如清华源:

pip install requests beautifulsoup4 lxml pandas -i https://pypi.tuna.tsinghua.edu.cn/simple

注意 :有些教程可能会推荐使用 selenium playwright 等浏览器自动化工具。对于今日头条的热门文章列表页, 绝大多数情况下我们不需要它们 。这些工具模拟浏览器,资源消耗大、速度慢,更适合处理需要执行JavaScript才能渲染出内容的页面。而今日头条的热门文章数据通常通过接口(API)直接返回,用轻量级的 requests 库足矣。盲目使用重型工具只会增加复杂度和不稳定性。

2.2 开发工具与辅助配置

  • 代码编辑器 :VSCode或PyCharm都是极好的选择。VSCode轻量、插件丰富;PyCharm是专业的Python IDE,对代码提示、调试支持更完善。选择你顺手的即可。
  • 浏览器开发者工具 :这是爬虫工程师的“眼睛”。Chrome或Edge浏览器的F12开发者工具是关键。我们主要使用 “网络”(Network) 选项卡来观察页面加载过程中浏览器发送了哪些请求,特别是XHR/Fetch请求,这些往往就是获取数据的接口。

一个关键的前置操作 :在浏览器中打开今日头条的官网,并进入“热榜”或“推荐”页面。打开开发者工具(F12),切换到“网络”选项卡,然后刷新页面。你会看到大量请求记录。我们的目标就是从中找到那个真正返回文章列表数据的请求。这个过程称为“抓包分析”。

3. 核心思路与请求分析

爬虫的核心在于找到数据源。对于现代网页,数据往往不是直接写在HTML里,而是通过JavaScript异步加载的。因此,直接爬取网页HTML(用 requests.get )很可能拿不到文章列表。

3.1 寻找数据接口

  1. 在浏览器中打开今日头条的“热榜”页面(例如 https://www.toutiao.com/hot-event/hot-board/ 或首页推荐流)。
  2. 按F12打开开发者工具,确保“网络”选项卡是开启状态,并勾选上“保留日志”(Preserve log)。
  3. 刷新页面,观察“网络”选项卡中出现的所有请求。
  4. 在请求列表上方的筛选器里,点击“Fetch/XHR”。这会将请求过滤为更可能包含数据的AJAX请求。
  5. 仔细查看这些请求的“名称”(Name)和“响应”(Response)预览。你会看到一些请求的响应内容是清晰的JSON格式,里面包含了文章标题、链接、阅读量等信息。

如何识别正确的接口?

  • 看URL :接口URL可能包含 feed list hot api 等关键词。
  • 看响应 :点击一个请求,在“响应”标签页里查看内容。如果看到 title source comments_count read_count 等字段,那基本就是它了。
  • 看请求头 :重点关注该请求的 Request Headers ,特别是 User-Agent Referer 和可能的 Cookie 。我们写代码时需要模拟这些信息。

以我最近一次分析为例,我找到了一个类似 https://www.toutiao.com/api/pc/list/feed?category=hot_board&... 的接口。它的响应结构大致如下:

{
  "data": [
    {
      "title": "文章标题1",
      "source": "来源媒体",
      "comments_count": 123,
      "read_count": 45678,
      "detail_url": "https://www.toutiao.com/article/123456789/",
      "behot_time": 1678888888
    },
    // ... 更多文章
  ],
  "has_more": true
}

请注意 :今日头条的接口地址和参数可能会随时间变化,上述URL仅为示例。你必须以自己当时在开发者工具中分析到的实际接口为准。

3.2 模拟请求与参数解析

找到接口后,我们需要在Python代码中模拟这个请求。关键点在于构造请求头(Headers)和查询参数(Params)。

从开发者工具中,复制该接口请求的 cURL 命令是最快的方式。在请求上右键 -> 复制 -> 复制为cURL (bash)。然后,你可以利用在线工具或手动解析,将其转化为Python requests 的代码。

请求头中最关键的几项:

  • User-Agent : 标识客户端类型。必须设置一个常见的浏览器UA,否则服务器可能直接拒绝。例如: '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'
  • Referer : 表示请求的来源页面。对于今日头条,通常设置为其主域名即可,如 'Referer': 'https://www.toutiao.com/' 。这能增加请求的合法性。
  • Cookie : 用户会话标识。对于公开的热门数据,有时不需要Cookie也能获取;但如果请求被拒,可能需要从浏览器中复制一份有效的Cookie填入。 注意:不要泄露自己的个人Cookie

接口URL通常带有查询参数,例如: ?category=hot_board&aid=24&app_name=toutiao_web&offset=0&count=20&... 这些参数控制了数据的类型、分页等。其中 offset count 很可能控制分页(起始位置和获取数量)。在代码中,我们可以将这些参数放在一个字典里,传递给 requests.get() params 参数。

4. 完整爬虫代码实现与解析

下面,我将结合上述分析,给出一个完整的、可运行的爬虫示例。这个示例包含了请求发送、数据处理、异常处理和简单存储。

4.1 基础爬取函数

首先,我们实现核心的爬取函数 fetch_hot_articles

import requests
import json
import time
import pandas as pd
from typing import List, Dict, Optional

def fetch_hot_articles(offset: int = 0, count: int = 20) -> Optional[List[Dict]]:
    """
    从今日头条接口获取热门文章列表

    Args:
        offset: 分页偏移量
        count: 每次请求获取的文章数量

    Returns:
        文章字典列表,失败则返回None
    """
    # 目标API接口 (请根据你实际分析得到的接口替换此URL)
    url = "https://www.toutiao.com/api/pc/list/feed"

    # 请求头,模拟浏览器访问
    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',
        'Referer': 'https://www.toutiao.com/',
        # 如有需要,可在此处添加Cookie,但公开数据通常不需要
        # 'Cookie': '你的Cookie字符串'
    }

    # 查询参数
    params = {
        'category': 'hot_board',  # 热点类别
        'aid': '24',
        'app_name': 'toutiao_web',
        'offset': offset,         # 分页偏移
        'count': count,           # 每页数量
        'min_behot_time': '0',    # 控制时间范围,0表示最新
        # 可能还有其他动态参数,需根据实际情况调整
    }

    try:
        response = requests.get(url, headers=headers, params=params, timeout=10)
        # 检查HTTP状态码
        response.raise_for_status()

        # 解析JSON响应
        data = response.json()

        # 检查API返回的业务状态码 (今日头条的接口可能在`data`字段或根层级有状态码)
        # 这里需要根据实际接口响应结构调整判断逻辑
        if data.get('message') == 'success' and 'data' in data:
            articles_raw = data['data']
            processed_articles = []
            for item in articles_raw:
                # 提取所需字段,字段名需根据实际接口响应确定
                article = {
                    'title': item.get('title', ''),
                    'source': item.get('source', ''),
                    'comments_count': item.get('comments_count', 0),
                    'read_count': item.get('read_count', 0),
                    'detail_url': item.get('detail_url', ''),
                    # 时间戳转换
                    'behot_time': item.get('behot_time', 0),
                    'datetime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item.get('behot_time', 0))) if item.get('behot_time') else ''
                }
                # 确保detail_url是完整链接
                if article['detail_url'] and not article['detail_url'].startswith('http'):
                    article['detail_url'] = 'https://www.toutiao.com' + article['detail_url']
                processed_articles.append(article)
            return processed_articles
        else:
            print(f"API返回数据格式异常或失败: {data.get('message')}")
            return None

    except requests.exceptions.RequestException as e:
        print(f"网络请求失败: {e}")
        return None
    except json.JSONDecodeError as e:
        print(f"JSON解析失败: {e}")
        print(f"响应文本: {response.text[:200]}")  # 打印前200字符辅助调试
        return None

代码关键点解析:

  1. 超时设置 timeout=10 非常重要。它防止了因网络慢或服务器无响应导致的程序长时间卡死。
  2. 状态检查 response.raise_for_status() 会在HTTP状态码不是200时抛出异常,让我们能及时处理网络错误。
  3. JSON解析 :使用 response.json() 直接解析。一定要用 try-except 包裹,因为服务器可能返回非JSON内容(如反爬验证页面)。
  4. 数据提取 :使用 .get() 方法安全地获取字典值,避免因字段缺失导致 KeyError
  5. URL补全 :检查 detail_url 是否为完整URL,如果不是,拼接上域名前缀。

4.2 分页爬取与数据存储

单次请求获取的数据有限,我们需要实现分页爬取,并将所有数据保存下来。

def crawl_multiple_pages(total_pages: int = 5, per_page: int = 20) -> List[Dict]:
    """
    分页爬取多页热门文章

    Args:
        total_pages: 需要爬取的总页数
        per_page: 每页文章数量

    Returns:
        所有爬取到的文章列表
    """
    all_articles = []
    for page in range(total_pages):
        print(f"正在爬取第 {page + 1} 页...")
        offset = page * per_page
        articles = fetch_hot_articles(offset=offset, count=per_page)

        if articles:
            all_articles.extend(articles)
            print(f"  成功获取 {len(articles)} 篇文章。")
        else:
            print(f"  第 {page + 1} 页爬取失败,停止。")
            break

        # 礼貌性延迟,避免请求过快
        time.sleep(2)

    print(f"爬取结束,共获取 {len(all_articles)} 篇文章。")
    return all_articles

def save_to_csv(articles: List[Dict], filename: str = 'toutiao_hot_articles.csv'):
    """
    将文章列表保存为CSV文件

    Args:
        articles: 文章字典列表
        filename: 输出文件名
    """
    if not articles:
        print("没有数据可保存。")
        return

    df = pd.DataFrame(articles)
    # 选择要保存的列并排序
    columns_to_save = ['title', 'source', 'read_count', 'comments_count', 'datetime', 'detail_url']
    df = df[columns_to_save]
    df.to_csv(filename, index=False, encoding='utf-8-sig')  # utf-8-sig支持Excel直接打开无乱码
    print(f"数据已保存至 {filename}")

# 主程序
if __name__ == '__main__':
    # 爬取5页,每页20条数据
    hot_articles = crawl_multiple_pages(total_pages=5, per_page=20)

    if hot_articles:
        save_to_csv(hot_articles)
        # 也可以打印前几条看看
        for i, article in enumerate(hot_articles[:3]):
            print(f"{i+1}. {article['title']} | 阅读:{article['read_count']} | 来源:{article['source']}")

分页逻辑说明 :分页的核心是 offset 参数。第一页 offset=0 ,第二页 offset=20 ,以此类推。 time.sleep(2) 是一个简单的延迟,降低请求频率,是对目标网站的一种礼貌,也能减少触发反爬的风险。

5. 高级技巧与反爬策略应对

直接使用上面的代码,在短时间内可能可以运行。但今日头条作为大型平台,肯定有反爬机制。以下是几种常见情况及应对策略。

5.1 请求被拒与签名验证

现象 :代码运行后,返回的数据为空,或者响应内容是一个提示“请求异常”的JSON,甚至直接返回验证页面(如滑块验证码)。

原因分析 :现代Web API,尤其是移动端或重要数据接口,常采用签名机制。服务器会要求请求中包含一个由特定算法生成的签名( signature _signature ),该签名通常由URL路径、查询参数、时间戳和一个密钥(salt)通过加密算法(如MD5, SHA1, HMAC)计算得出。客户端(浏览器或App)的JavaScript代码会实时计算这个签名并附加到请求中。我们的Python脚本如果没有计算并发送正确的签名,请求就会被拒绝。

应对策略

  1. 逆向JavaScript :这是最根本但也是最复杂的方法。在开发者工具的“源代码”(Sources)选项卡中,搜索包含 sign encrypt token 等关键词的JS文件,尝试找到生成签名的函数,并用Python重写该算法。这需要一定的JavaScript逆向工程能力。
  2. 寻找替代接口 :有时,网站的不同入口或不同版本(如WAP版、旧版API)可能签名验证较弱甚至没有。可以尝试寻找其他数据源。
  3. 使用自动化工具 :如果逆向难度太大,可以考虑使用 selenium playwright 控制真实浏览器去加载页面,然后从页面中提取已经渲染好的数据。但这种方法效率低、资源消耗大,仅作为备选。
  4. 谨慎使用代理IP和User-Agent池 :如果是因为单个IP请求频率过高被封,可以考虑使用代理IP池和随机切换User-Agent来分散请求。但这无法解决签名问题。

实操心得 :对于今日头条的热门列表,在我撰写本文时,上述示例中的基础接口可能仍有效,或者签名逻辑相对简单。如果失效,第一步应该是重新“抓包”,看看最新的请求参数比我们代码里多了哪些,特别是那些长串的无规律字符,那很可能就是签名。可以尝试将浏览器中成功请求的所有参数原封不动地复制到Python代码中,如果能成功,再逐个参数分析哪些是固定的,哪些是动态生成的。

5.2 数据解析与清洗

获取到数据后,原始数据可能比较杂乱,需要进行清洗。

  • 字段缺失处理 :如代码中所用 .get(key, default) ,为可能缺失的字段提供默认值。
  • 去重 :由于分页或热点更新,可能爬取到重复文章。可以根据 detail_url title 进行去重。
  • 数据格式化 :时间戳转换为可读日期;数字字符串转换为整型;清理标题中的特殊字符或空格。
  • 过滤无效数据 :有些条目可能不是文章(可能是广告、视频等),可以根据 source 字段或URL特征进行过滤。

增强的数据清洗示例

def clean_article_data(articles: List[Dict]) -> List[Dict]:
    cleaned = []
    seen_urls = set()  # 用于URL去重
    for article in articles:
        url = article.get('detail_url', '')
        title = article.get('title', '').strip()

        # 1. 基础有效性检查
        if not url or not title:
            continue
        # 2. URL去重
        if url in seen_urls:
            continue
        seen_urls.add(url)

        # 3. 过滤非文章内容(示例:根据来源或URL模式)
        if 'ad' in article.get('source', '').lower():
            continue
        # 4. 转换阅读量为整数
        try:
            article['read_count'] = int(article.get('read_count', 0))
        except (ValueError, TypeError):
            article['read_count'] = 0

        cleaned.append(article)
    return cleaned

5.3 提升爬虫的健壮性

  1. 异常重试机制 :网络请求可能因各种原因失败。可以引入重试逻辑,例如使用 tenacity 库或自己实现一个简单的重试循环。
    import requests
    from tenacity import retry, stop_after_attempt, wait_exponential
    
    @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
    def fetch_with_retry(url, headers, params):
        response = requests.get(url, headers=headers, params=params, timeout=15)
        response.raise_for_status()
        return response
    
  2. 日志记录 :使用Python的 logging 模块替代 print ,可以更好地记录程序运行状态、错误信息,方便后期排查问题。
  3. 配置文件 :将URL、请求头、参数等配置信息写入单独的配置文件(如 config.py config.yaml ),使代码更清晰,易于修改。

6. 常见问题与排查指南

在实际操作中,你几乎一定会遇到一些问题。下面是一个快速排查清单。

问题现象 可能原因 排查步骤与解决方案
返回 <html> 页面或验证码 1. 请求头不完整或错误。
2. IP被暂时限制。
3. 缺少关键参数(如签名)。
1. 核对并补全 User-Agent , Referer
2. 暂停程序,等待几分钟或更换网络环境再试。
3. 重新抓包,对比浏览器请求与代码请求的所有参数是否完全一致。
返回 {"message":"error"} 等JSON 接口参数错误或过期。 1. 检查 category , aid 等参数值是否正确。
2. 检查是否有时间戳( _ )或签名( signature )参数,它们可能已更新。
json.decoder.JSONDecodeError 服务器返回的不是JSON,可能是HTML反爬页面。 1. 打印 response.text 的前500字符查看实际返回内容。
2. 检查请求是否被重定向。
数据为空( data 列表为空) 1. 分页参数 offset 超出范围。
2. 请求的类别( category )不对。
1. 尝试将 offset 设为0, count 设小一点。
2. 在浏览器中手动修改URL参数,测试哪个 category 值能返回数据。
只能爬到少量数据 接口有最大数量限制。 1. 尝试减小 count 参数(如设为10)。
2. 通过多次请求不同时间范围( min_behot_time )的数据来获取更多。
程序运行缓慢 1. 网络延迟。
2. 没有使用连接池。
3. 同步请求阻塞。
1. 适当增加 timeout
2. 考虑使用 requests.Session() 复用连接。
3. 对于大规模爬取,可考虑异步库 aiohttp (进阶内容)。

一个关键的调试技巧 :在代码中,将你构造的最终请求URL和头部信息打印出来,与浏览器开发者工具里看到的进行 逐字对比 。一个多余的逗号、一个缺失的引号都可能导致失败。可以使用如下代码辅助调试:

prepared_req = requests.Request('GET', url, headers=headers, params=params).prepare()
print(f"请求URL: {prepared_req.url}")
print(f"请求头: {dict(prepared_req.headers)}")

7. 项目扩展与进阶思路

基础爬虫完成后,你可以根据需求进行扩展:

  1. 定时任务 :使用 schedule 库或操作系统的 cron / Task Scheduler ,让爬虫每隔一段时间(如每小时)自动运行一次,追踪热点变化。
  2. 数据入库 :将数据保存到数据库(如SQLite, MySQL, MongoDB)而非CSV,便于复杂查询和分析。
  3. 内容深度爬取 :上述代码只爬取了文章列表。你可以根据 detail_url 进一步爬取每篇文章的详细正文、评论等。注意,这会产生大量请求,务必遵守 robots.txt ,并添加更长的延迟。
  4. 数据分析与可视化 :用 pandas matplotlib 等库对爬取的数据进行分析,比如统计哪个来源的热文最多、阅读量和评论数的关系、热点话题随时间的变化趋势等。
  5. 构建简单API或界面 :使用 Flask FastAPI 将爬虫包装成一个服务,提供数据查询接口,或者用 streamlit 快速构建一个数据看板。

最后一点个人体会 :爬虫技术是与网站反爬措施不断博弈的过程。今日头条的接口今天有效,明天可能就变了。因此,最核心的能力不是记住某段代码,而是掌握“抓包分析 -> 模拟请求 -> 解析数据 -> 处理异常”这一套方法论。保持耐心,细心观察,多动手尝试,你就能应对大多数类似的爬虫需求。上面的代码为你提供了一个坚实的起点和清晰的框架,当你遇到新问题时,知道该从哪里入手去分析和解决。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐