Skip to content
Go back

爬虫学习笔记:实战之爬取《红楼梦》

Published:  at  05:00 PM

问: 我在抓取网站的时候遇到了这样的问题:

抓到的内容(部分):

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=gb2312">
<meta http-equiv="keywords" content="ºìÂ¥ÃÎ, ¹ÅµäС˵ºìÂ¥ÃÎ, С˵ºìÂ¥ÃÎ, ºìÂ¥ÃÎÔÚÏßÔĶÁ, ºìÂ¥ÃÎÔÚÏß, ºìÂ¥ÃÎÔĶÁ, ÔĶÁºìÂ¥ÃÎ, ²ÜÑ©ÇÛ, ¸ßðÊ">
<meta http-equiv="description" content="¡¶ºìÂ¥ÃΡ·¡ª¡ªÖйú¹ÅµäС˵µÇ·åÖ®×÷ÔÚÏßÔĶÁ¡£Ô­Öø£º²ÜÑ©ÇÛ£¬¸ßðÊ Ðø¡£">
<title>¡¶ºìÂ¥ÃΡ· µÚÒ»»Ø ÕçÊ¿ÒþÃλÃʶͨÁé ¼ÖÓê´å·ç³¾»³¹ëÐã</title>
<META NAME="copyright" CONTENT="2006, purepen.com"></head>
<body vLink=#0000ff aLink=#0000ff link=#0000ff bgcolor="#FFFFFF">
<p>&nbsp;&gt;&gt;&gt;&nbsp;<a href="../index.html">´¿ÎÄÑ§ÍøÕ¾Ê×Ò³</a>&nbsp;&nbsp;&nbsp;&gt;&gt;&gt;&nbsp;<a href="index.htm">¡¶ºìÂ¥ÃΡ·Ä¿Â¼</a></p>

似乎是字符集的问题。

你遇到的确实是典型的字符集编码问题 (Character Encoding Issue),也常被戏称为“乱码”。

从你提供的 HTML 片段中,这一行是关键: <meta http-equiv="Content-Type" content="text/html; charset=gb2312">

这行代码告诉浏览器(以及我们的爬虫),这个网页的内容是使用 gb2312 编码的。gb2312 是一种主要用于简体中文的字符集。

当你的 Python 脚本(特别是 requests 库)在接收到网页数据后,如果它错误地尝试用另一种编码(比如默认的 utf-8 或者 latin-1)去解码这些 gb2312 编码的字节时,就会出现你看到的 “ºìÂ¥ÃΔ 这样的乱码。

如何解决?

你需要告诉 requests 库,这个网页的正确编码是 gb2312,这样它才能正确地将服务器返回的字节流转换成我们能看懂的文本。

核心步骤: 在获取到 response 对象后,但在你访问 response.text 之前,手动设置 response.encoding 属性。

下面是修改后的代码示例,假设你之前的代码是这样的:

import requests
from bs4 import BeautifulSoup

# 假设这是你目标网站的 URL
url = "目标网站的URL" # 请替换成实际的 URL

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}

response = requests.get(url, headers=headers)

# !!! 关键步骤在这里 !!!
# 根据网页声明的 charset,手动设置响应的编码
response.encoding = 'gb2312'
# 或者尝试 'gbk',它是 gb2312 的超集,兼容性可能更好一些
# response.encoding = 'gbk'

# 现在,当你访问 response.text 时,requests 会使用你指定的编码进行解码
html_content = response.text

soup = BeautifulSoup(html_content, 'lxml')

# 尝试打印标题,看是否还乱码
title_tag = soup.find('title')
if title_tag:
    print("解码后的标题:", title_tag.text)
else:
    print("未能找到标题标签")

# 你可以继续解析其他内容
# 例如,打印所有链接的文本
# for link in soup.find_all('a'):
#     print(link.text)

解释:

  1. response = requests.get(url, headers=headers): 这行代码从服务器获取数据。此时,response.content 里存储的是原始的字节流 (bytes)。
  2. response.encoding = 'gb2312': requests 库有一个 encoding 属性,它会尝试从 HTTP 头中猜测编码。如果猜错了,或者像现在这样,编码是在 HTML meta 标签里声明的,requests 可能不会自动正确设置它。我们通过这一行手动将 response.encoding 设置为网页实际使用的 gb2312
  3. html_content = response.text: 当你访问 response.text 时,requests 会根据 response.encoding 的值(现在是 gb2312)来解码 response.content(原始字节流)并返回一个 Unicode 字符串。如果 response.encoding 设置正确,这里的 html_content 就应该是正常的、没有乱码的文本了。
  4. soup = BeautifulSoup(html_content, 'lxml'): BeautifulSoup 接收这个正确的 Unicode 字符串进行解析。

进一步的建议:

通过正确设置 response.encoding,你应该就能解决这个乱码问题,顺利抓取到可读的中文内容了。


问: 那我如果要 CSS 选择器匹配内容呢?

现在是这么一个情况,第一章只有章节目录+下一回,一般都是上一回+章节目录+下一回,所以 soup.select_one('body > p:nth-child(4) > a:nth-child(3)') 不能用。

你说得完全正确!nth-child 这种依赖于元素在父节点中固定“排第几个”的选择器,对于动态变化的网页结构来说,是非常脆弱的。 只要前面多一个或少一个元素,你的选择器就失效了。

爬虫的黄金法则是:尽可能使用描述元素“身份”或“内容”的选择器,而不是描述其“位置”的选择器。

对于“下一页”、“上一页”这种功能性链接,最好的定位方式通常是:

  1. 通过链接的文本内容:比如链接文字明确写着 “下一章”、“下一回”、“Next Page” 等。
  2. 通过链接的特定 classid (如果开发者提供了的话,但对于简单链接可能没有)。
  3. 通过链接的 rel 属性 (如 rel="next"),但这不常见。

你提出的思路——“只需要抓取这个文本,前面接上相同的域名部分,更新 URL,继续抓取就可以了,直到在某一页找不到‘Next’(下一页)按钮对应的 CSS 选择器 li.next a 为止”——如果这里的 li.next a 能够稳定地定位到“下一页”的链接(比如这个 class="next" 是专门给“下一页”按钮的 <li> 元素的),那当然很好。

但如果连 li.next a 这样的选择器也因为第一章的特殊结构而不稳定(比如第一章的“下一页”可能没有 li 标签包裹,或者 class 不一样),那么我们就需要更通用的方法,即通过链接的文本内容来判断。

通过链接文本内容匹配“下一页”

这种方法更加健壮,因为它不依赖于链接在 HTML 结构中的具体位置或其父元素的 class

核心思路:

  1. 获取页面上所有可能的导航链接(通常是 <a> 标签)。
  2. 遍历这些链接。
  3. 检查每个链接的文本内容,看是否包含我们定义的“下一页”关键词(如“下一章”, “下一回”, “下一页” 等)。
  4. 如果找到,就获取其 href 属性。

下面是如何在你的代码中实现这个逻辑:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time

def find_next_page_url(soup, base_url):
    """
    在 BeautifulSoup 对象中查找指向“下一页”的链接。
    优先尝试通过特定 class(如果已知且可靠),然后通过链接文本。
    """
    # --- 尝试1: 如果你知道一个相对可靠的 class 组合 (比如你之前用的 li.next a) ---
    # (但根据你的描述,这个可能在第一页失效,所以我们把它作为一种尝试)
    # next_button_tag_by_class = soup.select_one("li.next a") # 或者其他你观察到的特定结构
    # if next_button_tag_by_class and next_button_tag_by_class.get('href'):
    # print("通过特定 class 找到下一页")
    # return urljoin(base_url, next_button_tag_by_class['href'])

    # --- 尝试2: 通过链接的文本内容 (更通用的方法) ---
    # 定义可能的“下一页”链接文本
    possible_next_texts = ["下一章", "下一回", "下一页", "Next Chapter", "Next Page", "下页", "后一页", "下一篇"]

    all_links = soup.find_all('a', href=True) # 获取页面上所有带 href 属性的 <a> 标签

    # 为了更精确,你可能需要先定位到一个包含导航链接的特定区域
    # 例如,如果导航链接都在 <div class="page-nav">...</div> 中:
    # nav_area = soup.select_one(".page-nav") # 或者其他更合适的选择器
    # if nav_area:
    # all_links = nav_area.find_all('a', href=True)
    # else:
    # all_links = soup.find_all('a', href=True) # 如果找不到特定区域,就搜全局

    for link_tag in all_links:
        link_text = link_tag.text.strip() # 获取链接文本并去除首尾空格
        for next_text_keyword in possible_next_texts:
            if next_text_keyword in link_text: # 如果链接文本包含关键词
                # 确认一下这个链接不是指向目录或者其他不相关的链接
                # 简单的判断:如果链接文本非常短,且只包含关键词,可能就是它
                # 更复杂的判断可能需要结合 href 的模式
                if len(link_text) < 10: # 简单判断,可根据实际情况调整
                    print(f"通过文本 '{link_text}' 找到下一页链接: {link_tag['href']}")
                    return urljoin(base_url, link_tag['href'])

    # 如果两种方法都没找到
    return None

# 爬虫的起点
base_url = "你目标网站的域名,例如: https://www.example.com/" # 请替换
current_page_url = urljoin(base_url, "/path/to/first_chapter.html") # 请替换为第一章的实际URL

headers = {'user-agent': 'Mozilla/5.0 ...'} # 保持你的headers

page_count = 1
max_pages = 100 # 设置一个最大爬取页数,防止无限循环

while current_page_url and page_count <= max_pages:
    print(f"--- 正在抓取 (第 {page_count} 页): {current_page_url} ---")

    try:
        response = requests.get(current_page_url, headers=headers, timeout=10)
        response.raise_for_status() # 如果请求失败 (4xx, 5xx) 会抛出异常

        # !!! 处理编码问题,根据你之前遇到的 gb2312 问题 !!!
        # 尝试从 headers 获取编码,如果获取不到,再考虑 meta 标签或直接指定
        if 'charset' in response.headers.get('content-type', '').lower():
            response.encoding = response.apparent_encoding # 让 requests 尝试从 headers 推断
        else:
            # 如果headers里没有,再检查meta标签,或者根据经验直接指定
            # 这里假设你知道是 gb2312/gbk
            # temp_soup_for_charset = BeautifulSoup(response.content, 'lxml', from_encoding='gbk') #尝试用gbk解析找meta
            # meta_charset = temp_soup_for_charset.select_one('meta[charset]')
            # if meta_charset and meta_charset.get('charset'):
            # response.encoding = meta_charset.get('charset')
            # else:
            response.encoding = 'gbk' # 或 'gb2312',根据实际情况

        soup = BeautifulSoup(response.text, "lxml")

        # --- 在这里提取你需要的章节内容 ---
        # 示例:提取标题和正文 (你需要根据实际网页结构修改选择器)
        # chapter_title = soup.select_one("h1.chapter-title")
        # content_div = soup.select_one("div#chapter-content")
        # if chapter_title and content_div:
        #     print("标题:", chapter_title.text.strip())
        #     print("内容:", content_div.text.strip()[:200] + "...") # 打印部分内容
        # else:
        # print("未能提取到章节内容。")
        # --- 提取内容结束 ---

        # 查找下一页的 URL
        next_page_full_url = find_next_page_url(soup, base_url)

        if next_page_full_url:
            current_page_url = next_page_full_url
            page_count += 1
            time.sleep(1) # 礼貌性延迟
        else:
            print("\n--- 未找到'Next'按钮,或已达最大页数,爬取结束。---")
            current_page_url = None # 终止循环

    except requests.exceptions.RequestException as e:
        print(f"请求页面时发生错误: {e}")
        current_page_url = None # 发生错误,终止循环
    except Exception as e:
        print(f"处理页面时发生未知错误: {e}")
        current_page_url = None # 发生错误,终止循环

关键改动和解释:

  1. find_next_page_url(soup, base_url) 函数
    • 这个函数专门负责从当前页面的 soup 对象中找到“下一页”的完整 URL。
    • 它首先尝试你可能知道的特定 class 选择器(注释掉了,你可以按需启用和修改)。
    • 然后,它会遍历页面上所有的 <a> 标签。
    • possible_next_texts 列表包含多种“下一页”的常见说法,你可以根据目标网站的实际用词进行增删。
    • link_tag.text.strip() 获取链接的文本并去除多余空格。
    • if next_text_keyword in link_text: 判断链接文本是否包含我们定义的关键词。
    • len(link_text) < 10 是一个非常简单的判断,防止匹配到包含关键词但实际是文章标题或段落中的链接。你需要根据实际情况调整或使用更复杂的逻辑(比如检查 href 是否符合章节页的模式)。
    • 如果找到匹配的链接,使用 urljoin(base_url, link_tag['href']) 构建完整的下一页 URL 并返回。
    • 如果遍历完所有链接都找不到,则返回 None
  2. 主循环 while current_page_url:
    • 只要 current_page_url 不是 None (即能找到下一页链接),循环就继续。
    • 增加了 page_countmax_pages 来防止意外的无限循环。
    • 增加了 try-except 块来捕获网络请求错误和其他潜在错误,使爬虫更健壮。
    • 编码处理:我加入了一段更健壮的编码处理逻辑,它会优先尝试从 HTTP Headers 中获取编码,如果不行,再尝试根据经验(如 gbk)指定。你需要根据你目标网站的具体情况调整这部分。
    • 在循环的末尾调用 find_next_page_url 来更新 current_page_url

你需要做的事情:

  1. 替换 base_url 和初始的 current_page_url 为你目标网站的实际地址。
  2. 调整 possible_next_texts 列表,使其包含你目标网站上“下一页”链接的确切文本。
  3. 观察导航链接的结构:如果“上一页”、“目录”、“下一页”这些链接通常被包裹在一个特定的父元素中(比如 <div class="navigation-links">),那么在 find_next_page_url 函数中,先定位到这个父元素,再从这个父元素内部查找 <a> 标签,会更精确,减少误判。
    # 示例:如果导航链接在 class="nav-menu" 的 div 中
    nav_menu = soup.select_one('div.nav-menu')
    if nav_menu:
        all_links = nav_menu.find_all('a', href=True)
    else:
        all_links = [] # 或者 soup.find_all('a', href=True) 作为备选
  4. 根据你的实际需求,修改提取章节内容部分的选择器。

这种通过文本内容来定位功能性链接的方法,在爬虫中非常常用且有效,因为它对 HTML 结构的细微变化具有更强的抵抗力。


问: 我现在实操了一下:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
}
base_url = "http://www.purepen.com/hlm/"
next_page_relative_url = "001.htm"
i=1

with open('Dream_of_the_Red_Chamber.txt', 'w', encoding='utf-8') as f:
    while next_page_relative_url:
        current_url = urljoin(base_url, next_page_relative_url)
        response = requests.get(current_url, headers=headers, verify=False)
        response.encoding = 'gb2312'
        soup = BeautifulSoup(response.text, "lxml")
        chapter_title = soup.select_one('b').text.split('', 1)[1].strip()
        content = soup.select_one('center font').text
        next_button_tag = soup.select_one('body > p:nth-child(4) > a:nth-child(3)')
        f.write(f"{chapter_title}\n")
        f.write(f"{content}\n")

        if next_button_tag:
            next_page_relative_url = next_button_tag['href']
        elif next_page_relative_url == "001.htm":
            next_page_relative_url = "002.htm"
        else:
            next_page_relative_url = None

你又进步了很多!这份代码已经能够实际运行并抓取内容了,非常棒!你正确地使用了 with openurljoin,并且处理了 gb2312 编码,这些都是很好的实践。

现在我们来一起看看代码中的一些细节,特别是如何让你头疼的“下一页”逻辑变得更健壮、更优雅。

优点分析:

  1. 文件操作规范with open('Dream_of_the_Red_Chamber.txt', 'w', encoding='utf-8') as f: 非常标准,能确保文件正确关闭。
  2. URL拼接正确current_url = urljoin(base_url, next_page_relative_url) 是处理相对路径和绝对路径转换的最佳方式。
  3. 编码处理response.encoding = 'gb2312' 直接根据已知信息设置编码,解决了乱码问题。
  4. 基本抓取流程:获取响应、创建 BeautifulSoup 对象、提取数据的流程是清晰的。

主要优化点:不可靠的“下一页”逻辑

这是当前代码中最需要改进的地方:

# 这是非常脆弱的选择器
next_button_tag = soup.select_one('body > p:nth-child(4) > a:nth-child(3)')

if next_button_tag:
    next_page_relative_url = next_button_tag['href']
# 这是针对第一页的硬编码“补丁”
elif next_page_relative_url == "001.htm":
    next_page_relative_url = "002.htm"
else:
    next_page_relative_url = None

问题所在:

  1. body > p:nth-child(4) > a:nth-child(3):正如我们之前讨论的,这种依赖于“第几个孩子”的 CSS 选择器非常脆弱。只要页面结构稍有变化(比如第一页没有“上一回”链接,导致段落或链接的顺序改变),它就会失效。
  2. elif next_page_relative_url == "001.htm"::这个 elif 条件实际上是你为了弥补上述脆弱选择器在第一页失效而打的“补丁”。一个好的翻页逻辑应该能通用地处理所有页面,而不需要为特定页面写死逻辑。

更健壮的“下一页”方案:通过链接文本查找

对于这个网站 (purepen.com) 上的《红楼梦》,我们观察到“下一回”的链接文本通常就是 “下一回”。我们可以利用这一点来定位链接。

下面是一个改进的函数,用于查找“下一回”的链接:

def find_next_chapter_link(soup, base_url_str):
    """
    在 BeautifulSoup 对象中查找包含“下一回”文本的链接。
    """
    # 获取页面上所有带 href 属性的 <a> 标签
    all_links = soup.find_all('a', href=True)

    for link_tag in all_links:
        # .text 获取链接的可见文本,.strip() 去除首尾空格
        if "下一回" in link_tag.text.strip():
            # 找到了包含“下一回”的链接
            relative_url = link_tag['href']
            # 确保返回的是完整的 URL
            return urljoin(base_url_str, relative_url)

    return None # 如果没有找到“下一回”的链接

现在,我们将这个函数集成到你的主逻辑中:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time # 建议加入 time.sleep

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
}
base_url = "http://www.purepen.com/hlm/"
current_url = urljoin(base_url, "001.htm") # 从第一页的完整 URL 开始

# 移除了未使用的变量 i

with open('Dream_of_the_Red_Chamber.txt', 'w', encoding='utf-8') as f:
    page_count = 1 # 用于记录当前是第几章,方便调试
    max_pages = 125 # 大约《红楼梦》的章回数,防止意外的无限循环

    while current_url and page_count <= max_pages : # 只要 current_url 有值就继续
        print(f"正在抓取第 {page_count} 回: {current_url}")

        try:
            response = requests.get(current_url, headers=headers, verify=False, timeout=10) # 增加了 timeout
            response.raise_for_status() # 如果请求不成功 (例如 404),会抛出异常
            response.encoding = 'gb2312'
            soup = BeautifulSoup(response.text, "lxml")

            # --- 内容提取 ---
            # 尝试让标题提取更健壮一些
            title_tag_b = soup.find('b') # 查找第一个 <b> 标签
            chapter_title = "未知标题" # 默认标题
            if title_tag_b and title_tag_b.text:
                # 例如标题是: 《红楼梦》 第一回 甄士隐梦幻识通灵 贾雨村风尘怀闺秀
                # 我们想提取 “第一回 甄士隐梦幻识通灵 贾雨村风尘怀闺秀”
                title_parts = title_tag_b.text.split('', 1) # 从第一个《》后分割
                if len(title_parts) > 1:
                    chapter_title = title_parts[1].strip()
                else: # 如果没有《》符号,就直接用<b>标签的文本
                    chapter_title = title_tag_b.text.strip()

            # 内容提取,这个网站的结构确实比较老旧
            content_tag = soup.select_one('center font') # 或者 soup.find('center').find('font')
            content = "未能提取到内容" # 默认内容
            if content_tag:
                content = content_tag.text.strip() # 使用strip()去除可能的多余空白

            f.write(f"{chapter_title}\n\n") # 标题后加个空行
            f.write(f"{content}\n\n")   # 内容后加个空行,便于阅读
            f.flush() # 实时将缓冲区内容写入文件

            # --- 查找下一回的链接 ---
            current_url = find_next_chapter_link(soup, base_url) # 更新 current_url

            page_count += 1
            if current_url:
                time.sleep(0.5) # 礼貌性等待,避免过快请求

        except requests.exceptions.RequestException as e:
            print(f"请求错误 for {current_url}: {e}")
            current_url = None # 出错则终止
        except Exception as e:
            print(f"处理页面 {current_url} 时发生未知错误: {e}")
            current_url = None # 出错则终止

    if page_count > max_pages:
        print(f"已达到最大抓取页数 {max_pages}。")
    elif not current_url:
        print("爬取完成,未找到更多“下一回”链接。")

其他小建议:

  1. verify=False
    • 你代码中的 verify=False 是为了忽略 SSL 证书验证错误。http://www.purepen.com 这个网站是 HTTP 而非 HTTPS,所以理论上不需要 verify=False。如果它后续跳转到了某个 HTTPS 页面且证书有问题,这个参数才会起作用。
    • 一般来说,如果不是非常信任的网站,不建议禁用 SSL 验证,因为它有一定的安全风险。但对于个人爬虫项目,如果确实遇到证书问题,这是一个临时的解决办法。
  2. 内容提取的健壮性
    • chapter_title = soup.select_one('b').text.split('》', 1)[1].strip()
    • content = soup.select_one('center font').text
    • 这种依赖于特定标签(如第一个 <b>,或 <center> 下的 <font>)的选择器,在非常老旧且结构单一的网站上可能有效。但如果网站结构稍有不同(比如不止一个 <b> 标签,或者内容不在 <center><font>里),也可能会出错。
    • 在上面的优化代码中,我对标题提取做了一点点增强,增加了默认值和对 split 结果的检查。对于内容,如果能找到更独特的父容器或 class/id,会更好。
  3. 错误处理和日志
    • 加入了 try...except 块来捕获网络请求错误和解析错误,这样即使某一页出问题,整个爬虫也不会轻易崩溃。
    • 增加了 page_countmax_pages,以及一些 print 语句,帮助你了解爬虫的进度和状态。
    • f.flush() 可以确保每次写入后内容都从缓冲区刷到磁盘文件,如果你希望实时看到文件更新,这个有用。
  4. 变量 i:你在原代码中定义了 i=1 但并未使用,我已经移除了。

通过使用基于文本内容的链接查找方式,你的爬虫会变得更加强大和稳定!



Suggest Changes

Previous Post
爬虫学习笔记:静态爬取技术巩固 (以Hacker News为例)
Next Post
爬虫学习笔记:自动翻页与数据存储