跳至主要内容

使用 Python 和 BeautifulSoup 4 爬取 Reddit

在本教程中,您将学习如何使用 requests 获取网页、在浏览器中分析网页,以及使用 BeautifulSoup 从原始 HTML 中提取信息。
· 13分钟 阅读

使用 AI 探索

Scraping reddit illustration

您可以在此处找到我们将要编写脚本的完整可运行示例。

什么是网络爬取(Web Scraping)?

好,那么网络爬取到底是什么?顾名思义,它是一种从网页“抓取”或提取数据的方法。您在浏览器中能看到的任何内容(包括本教程)都可以被抓取到本地硬盘上。

网络爬取有许多用途。对于任何数据分析,第一步都是数据获取。互联网是人类历史与知识的巨大存储库,您可以提取所需的任意信息,并据此开展您的工作。

在本教程中,我们将使用 Python 和 BeautifulSoup 4 包从一个 subreddit 获取信息。我们关注的是datascience 子版块。我们希望获取该子版块的前 1000 篇帖子并将其导出到 CSV 文件中。我们想知道是谁发的帖,以及它的点赞数和评论数。

本教程将涵盖以下内容:

  • 使用 requests 获取网页
  • 在浏览器中分析网页以查找信息
  • 使用 BeautifulSoup 从原始 HTML 中提取信息

注意:我们将使用 Reddit 网站的旧版本,因为它更轻量,加载更快,对您的机器负担更小。

前置要求

本教程假设您了解以下内容:

  • 在您的计算机上运行 Python 脚本
  • 具备 HTML 结构的基础知识

您可以在 DataCamp 的Python 初学者课程中学习上述技能。话虽如此,这里涉及的概念非常基础,即使只掌握少量 Python 知识也能读懂。

既然准备工作就绪,我们可以进入构建网络爬虫的第一步了。事实上,这也是编写任何 Python 脚本的第一步:导入。

在我们的爬虫中,将使用以下包:

  • requests
  • beautifulsoup4

您可以当然使用 pip 安装这些包,如下所示:

pip install package_name

下载完这些包后,请将它们导入到您的代码中。

import requests
import csv
import time
from bs4 import BeautifulSoup4

我们将使用 Python 内置的 csv 模块将结果写入 CSV 文件。您或许注意到上面代码片段中有一点“古怪”。也就是我们下载了一个名为 beautifulsoup4 的包,却从一个名为 bs4 的模块中导入。这在 Python 中是合法的,尽管通常不被推崇,但并不“违法”。

关于平台政策的重要说明

在使用我接下来展示的爬取技巧之前,请务必查看 Reddit 或您计划抓取的任何其他网站的最新服务条款。网站政策会随着时间变化。2024 年 9 月的Reddit 用户协议禁止未经事先书面同意的抓取(见第 7 条)。在继续之前,请务必确保您遵守该网站的最新使用指南。

爬取 Reddit 的第一步

我们的环境已经就绪。接下来,我们需要目标网页的 URL。在本教程中,我们使用 Reddit 的“datascience”子版块。在开始写脚本之前,我们需要做一些“实地考察”。打开浏览器,进入相关子版块。

请注意,我们将使用该子版块的旧版页面进行爬取。新版页面将一些关键信息隐藏在网页内部。虽然同样可以从新版网站中提取这些信息,但出于简化考虑,我们将使用信息更直观的旧版页面。

打开链接后,您会面对信息量的“洪流”。在这些内容里我们需要什么?通过梳理所有链接,您会发现 Reddit 帖子分为两类:

  • 站内(Inbound)
  • 站外(Outbound)

站内链接指向 Reddit 自身的内容,站外链接则相反。之所以重要,是因为我们只想要站内链接。也就是包含用户撰写文本的帖子,而不是仅指向其他网站的链接。

那么,知道了目标,如何提取呢?如果您查看帖子的标题,会看到其后跟着方括号中的一些文本。我们感兴趣的帖子后面跟着“(self.datascience)”。从字面上,我们可以推断“self”指代 Reddit 根目录,“.datascience”指代该子版块。

很好,这样我们就能区分站内与站外帖子了。接下来需要在 DOM 结构中定位它。是否可以通过在 DOM 结构中搜索标签、class 或 id 来找到这些标识符?当然可以!

在浏览器中,右键任意一个“self.datascience”链接,点击“检查元素”。大多数现代浏览器都有这个强大的工具,允许开发者动态遍历网页源代码中有意义的部分。如果您使用 Safari,请确保在 Safari 偏好设置中启用了开发者工具。您会看到一个面板,它已经定位到负责显示“(self.datascience)”标识符的源码部分。

在该面板中,您会看到这个标识符实际上是由一个锚点标签加载的。

<a href="/r/datascience">self.datascience</a>

但它被一个 span 标签包裹着。

<span class="domain">
"("
<a href="/r/datascience">self.datascience</a>
")"
</span>

这个 span 标签包含了我们在页面上看到的文本,即“(self.datascience)”。如您所见,它带有“domain”这个 class。您可以检查其他链接是否遵循相同格式,结果是——确实如此。

使用 BeautifulSoup 获取页面

我们知道页面上要找什么,这很好,但如何用 Python 读取页面内容?其实它的工作方式与人类通过浏览器读取页面内容非常相似。

首先,我们需要使用“requests”库请求网页。

url = "https://old.reddit.com/r/datascience"
# Headers to mimic a browser visit
headers = {'User-Agent': 'Mozilla/5.0'}

# Returns a requests.models.Response object
page = requests.get(url, headers=headers)

现在我们有了一个 Response 对象,其中包含网页的原始文本。就目前而言,我们还无法对其做什么。我们手里只是一个包含整个 HTML 源代码的巨大字符串。要理解它,我们需要使用 BeautifulSoup 4。

请求头允许我们模拟浏览器访问。由于对机器人的响应与对浏览器的响应不同,而我们的参考对象是浏览器,因此最好获取浏览器的响应。

BeautifulSoup 允许我们通过搜索任意组合的 class、id 或标签名来查找特定标签。这是通过创建一棵语法树来实现的,不过其细节与我们的目标无关(也超出本教程范围)。

让我们来创建这棵语法树。

soup = BeautifulSoup(page.text, 'html.parser')

soup 就是一个 BeautifulSoup 对象,由一段原始源代码字符串创建。请注意我们需要指定 HTML 解析器。这是因为 BeautifulSoup 也可以从 XML 创建 soup。

查找目标标签

我们知道要找哪些标签(class 为“domain”的 span 标签),也有了 soup。接下来就是遍历 soup 并找到这些标签的所有实例。用 BeautifulSoup 来做这件事简单得令人发笑。

domains = soup.find_all("span", class_="domain")

我们刚刚做了什么?我们在 soup 对象上调用了“find_all”方法,它会在自身中查找与传入参数匹配的锚点标签作为第二个参数。我们这里只传入了一个限制条件(class 必须为“domain”),但也可以与其他条件结合,或仅使用 id。

我们使用“class_=”是因为“class”是 Python 的保留关键字,用于定义类等。

如果您想传入多个参数,只需将第二个参数写成包含所需参数的字典,如下所示:

soup.find_all("span", {"class": "domain", "height", "100px"})

我们已经把所有 span 标签放进了“domains”列表,但我们想要的是“(self.datascience)”这样的域名。

for domain in domains:
    if domain != "(self.datascience)":
        continue

    print(domain.text)

对,现在您应该能看到页面上帖子类型的列表,并排除了不是“(self.datascience)”的那些。但我们基本上已经得到了想要的一切。我们现在持有所有站内帖子的引用。

查找所需信息

打印几行“(self.datascience)”固然不错,但下一步呢?回想我们的初衷:获取帖子标题、作者、点赞数和评论数。

为此,我们需要回到浏览器。如果再次在标识符上打开检查工具,您会看到我们的 span 标签嵌套在一个 div 标签内……而这个 div 自身又嵌套在另一个 div 中,依此类推。不断向上直到找到整条帖子的父级 div。

<div class=" thing id-t3_8qccuv even  link self" ...>
    ...
    <div class="entry unvoted">
        <div class="top-matter">
            <p class="title">
                ...
                <span class="domain">
                ...
            </p>
        </div>
    </div>
</div>

如您所见,它们的共同父级在 DOM 结构中高出 4 层。省略号(…)代表不必要的“噪音”。但我们现在需要的是:为 domains 列表中的每个条目找到其对应帖子的父级 div 的引用。我们可以使用 BeautifulSoup 的方法来查找任何元素的父级。

for domain in soup.find_all("span", class_="domain"):
    if domain != "(self.datascience)":
        continue

    parent_div = domain.parent.parent.parent.parent
    print(parent_div.text)

运行脚本会打印所有站内帖子的全部文本。但您可能觉得这段代码看起来“不太好”。您是对的。仅依赖 DOM 的结构完整性从来都不安全。这是一种“刀耕火种”的解决方案,需要不断维护更新,是代码版的“定时炸弹”。

相反,我们来看看每个帖子的父级 div,看看能否直接在父级层面区分站内与站外链接。每个父级 div 都有一个名为“data-domain”的属性,而其值正是我们想要的!所有站内帖子都将 data-domain 设为“self.datascience”。

正如之前所说,我们可以用 BeautifulSoup 通过属性组合来搜索标签。幸运的是,Reddit 给每个帖子的父级 div 都加上了“thing”这个 class。

attrs = {'class': 'thing', 'data-domain': 'self.datascience'}

for post in soup.find_all('div', attrs=attrs):
    print(post.attrs['data-domain'])

“attrs”变量是一个字典,包含我们要搜索的属性及其对应的值。这种查找帖子的方法更安全,因为牵涉的可变因素更少。既然我们在搜索时已经将“self.datascience”作为参数,就不必再用 if 语句去跳过某些迭代了,因为我们保证只会拿到 data-domain 为“self.datascience”的帖子。

既然我们已经拿到了所需的帖子集合,接下来要做的就是从其子元素中提取信息。顺便说一句,这事确实就像您想的那样简单。

从 Reddit 提取信息

对于每个帖子,我们需要 4 条信息:

  • 标题
  • 作者
  • 点赞
  • 评论

查看帖子 div 的结构,我们可以找到所有这些信息。

标题

这是最简单的。在每个帖子 div 中,有一个段落标签,嵌套在几层 div 之下。它很好找,因为带有“title”这个 class。

title = post.find('p', class_="title").text

post 对象来自我们之前的 for 循环。它同样是一个 BeautifulSoup 对象,但只包含帖子 div 内部的 DOM 结构。“find”方法只返回一个对象,而“find_all”会返回满足条件的对象列表。找到标签后,我们只需要其中的字符串标题,因此使用其 text 属性来读取。我们也可以通过“object.attrs['attribute']”来读取其他属性。

作者

这也相对简单,在帖子标题下方对任意作者名打开检查工具。您会看到作者名位于一个带有“author” class 的锚点标签中。

author = post.find('a', class_='author').text

评论

这个需要一点额外处理,但仍然简单。我们可以用与查找标题和作者相同的方法找到评论。

comments = post.find('a', class_='comments').text

当您运行这段代码时,会得到类似“49 comments”的结果。这也可以,但如果只得到数字会更好。为此,我们需要再用一些 Python。

如果我们使用 Python 的“str.split()”函数,它会返回一个按空格分隔的字符串数组。在我们的例子中,将得到列表“["49", "comments"]”。很好!现在我们只需取第一个元素并保存。我们只需把函数链式追加到这一行即可。

comments = post.find('a', class_='comments').text.split()[0]

但我们还没完,因为有时我们拿到的并不是数字,而是“comment”。当帖子没有任何评论时就会出现这种情况。既然我们知道这一点,只需检查结果是否为“comments”,并将其替换为“0”。

if comments == "comment":
    comments = 0

点赞

获取点赞数很容易,逻辑与上面类似。

likes = post.find("div", attrs={"class": "score likes"}).text

不过这里您会注意到我们使用了两个 class 的组合。这只是因为还有多个 div 具有“score”或“likes”这类 class,但只有“score”和“likes”的组合是唯一的。

如果点赞数为 0,我们会得到 0。但如果帖子非常新、尚无任何点赞,就会出现“•”。为了避免在最终结果中引起混淆,我们将其替换为“None”。

if likes == "•":
    likes = "None"

将结果写入 CSV

到目前为止,我们已经有一个循环,可以为网页上的每个帖子提取标题、作者、点赞和评论。Python 提供了一个名为“csv”的优秀内置模块来读写 CSV 文件,秉承了 Python 的简单之道。无论如何,我们只需在循环块末尾添加一行,将帖子的详情追加到 CSV 文件中。

counter = 1
for post in posts:
    ...
    post_line = [counter, title, author, likes, comments]
    with open('output.csv', 'a') as f:
        writer = csv.writer(f)
        writer.writerow(post_line)

    counter += 1

很直观吧?post_line 用于创建一个数组,存储需要用逗号分隔的元素。counter 变量用于记录我们已保存了多少条帖子。

翻到下一页

既然我们在统计已保存的帖子数量,只需将整个逻辑再包裹在一个循环里,请求新页面,直到记录了指定数量的帖子为止。

counter = 1

while (counter <= 100):
    for post in posts:
        # Getting the title, author, ...
        # Writing to CSV and incrementing counter...

    next_button = soup.find("span", class_="next-button")
    next_page_link = next_button.find("a").attrs['href']
    time.sleep(2)
    page = requests.get(next_page_link, headers=headers)
    soup = BeautifulSoup(page.text, 'html.parser')

我们一下子做了不少事,是吧?逐行看一下。首先,我们把 counter 变量上移了一层作用域,以便 while 循环可以使用它。

接着,我们知道 for 循环做什么了,那么其他行呢?“next_button”变量用于查找并保存“下一页”按钮。之后,我们可以在其中找到锚点标签并获取其“href”属性,将其保存到“next_page_link”。

然后,我们使用这个链接请求下一页,将结果重新保存到“page”,并用 BeautifulSoup 再生成一个新的 soup。

上面的代码在 100 条后停止,但您可以根据需要设置在任意帖子数量后停止。

负责任地抓取

上面我们没有讨论的一行是“time.sleep(2)”。这是因为它值得单独成节。任何 Web 服务器的资源都是有限的,因此我们必须确保不要耗尽它们。在几秒钟内请求数百个页面不仅可能导致您被封禁,而且也并不友好。

请记住,网站允许您进行爬取已经相当“友好”了;如果他们愿意,可以在前 10 到 20 次请求就检测到机器人,或者基于 Python 发送的请求对象对您进行识别。既然他们允许您在无需更换 IP 的情况下进行爬取,您就应该礼貌地“放慢”您的机器人。

您可以通过查看网站的 robots.txt 文件来了解其抓取策略。该文件通常位于网站根目录(例如:http://www.reddit.com/robots.txt)。

下一步做什么?

随您所愿。正如我们刚刚证明的,您在网页上能看到的任何内容都可以被抓取并保存在本地。您可以将我们刚获取的信息用于多种目的。仅凭我们找到的这四项信息,就能得出许多结论。

进一步分析这些信息,我们可以找出哪些类型的 Reddit 帖子更容易获得点赞、它们包含哪些词、由谁发布。或者反过来,我们可以通过分析点赞与评论的比值,制作一个“争议指数”计算器。

想象一条评论很多但几乎没有点赞的帖子。这很可能意味着该帖子包含能引发人们强烈反应的内容,但并非他们“喜欢”的内容。

可能性很多,如何使用这些信息取决于您。

如果您喜欢本教程,欢迎查看我们的使用 BeautifulSoup 爬取亚马逊指南,以及我们的Python 课程合集。

主题