从“一无所获”到完美收官:我的小说爬虫实战与优化全记录

0 阅读10分钟

“本文案例小说《神话版三国》作者:坟土荒草,首发于阅文集团旗下平台”

引言:一次失败的请求

今天上午,我想下载一本网络小说《神话版三国》离线阅读。作为一名 Python 爱好者,我自然而然地打开了 IDE,写下了这样几行代码:

import requests

url = "https://www.bqg474.xyz/#/book/582/1.html"
headers = {'User-Agent': 'Mozilla/5.0...'}  # 后面会提到怎么找user-agent
resp = requests.get(url, headers=headers)
print(resp)

image.png

天真的我按照看的第一篇教程,里面说<div id="app"></div>就是文章文本内容,于是直接按教程里写 print(resp.text) 满心期待地运行,结果只看到了 <Response [200]>。emmm至少没报错。

写了 print(resp),这只会输出响应对象的状态,比如 <Response [200]> 或 <Response [404]>而不会显示网页内容

于是改成 print(resp.text),返回的 HTML 里 <div id="app"></div> 空空如也——网站内容居然是动态加载的!我的 requests 根本拿不到正文。

这是我爬虫新手踩的第一个坑:只拿到了页面框架,数据却在后面通过 JavaScript 异步请求加载。

第一步:打开开发者工具,找到真实接口

操作步骤:

  1. 打开浏览器(Chrome 或 Edge),按 F12 打开开发者工具(其实我按F12没有成功,是右键检查),切换到 Network(网络)标签。
  2. 刷新页面,会看到很多网络请求。为了过滤出数据请求,可以点击 XHR 或 Fetch 按钮,或者直接在筛选框中输入常见的接口关键词(如 apichaptercontent)。
  3. 观察请求,找到一个返回内容包含小说章节信息的请求。点击它,查看 Preview 或 Response 标签,确认是否是需要的数据。
  4. 复制请求 URL 和必要的请求头(如 User-AgentReferer,可能还有 Cookie),然后用 requests 模拟请求。

image.png

在开发者工具里找到网络,然后刷新页面,重新获取网页内容在 XHR 过滤器中,我发现了一个形如 https://apibi.cc/api/chapter?id=582&chapterid=1 的请求。将之替换开始代码中的URL,打印结果里面正是小说的第一章(的一部分)! 原来真正的数据接口藏在这里。URL 中的 id=582 代表书籍编号,chapterid=1 表示第一章。返回的是干净的 JSON 格式,txt 字段里就是正文。

而在这个界面的标头中一直下拉,就可以看到自己的user-agent。

第二步:用 requests 获取第一章

模仿请求的 Headers,我写了一个简单的脚本:

python

import requests

url = "https://apibi.cc/api/chapter"
params = {'id': 582, 'chapterid': 1}
headers = {'User-Agent': 'Mozilla/5.0...'}

resp = requests.get(url, params=params, headers=headers)
data = resp.json()
print(data['txt'][:200])  # 打印前200个字符

成功了!控制台输出了小说的开头。接下来就是批量下载所有章节。

第三步:循环下载,但太慢了

章节 ID 从 1 开始递增,我自然想到了用 for 循环:

python

for chap_id in range(1, 1201):   # 假设全书1200章
    params['chapterid'] = chap_id
    resp = requests.get(url, params=params, headers=headers)
    data = resp.json()
    # 保存到文件...
    time.sleep(0.5)   # 礼貌延时

运行后估算时间:0.5秒一章,1200章需要600秒,整整10分钟!但是我找不到直接下载文件的原因就是他现在连载了7328章!这还是在网络状况良好的情况下。如果中间失败还要重试,时间更久。

能不能同时请求多个章节呢?这就是并发优化的切入点。

第四步:并发加速——ThreadPoolExecutor 登场

网络请求是 I/O 密集型任务,多线程可以让多个请求同时等待,大大缩短总时间。Python 的 concurrent.futures.ThreadPoolExecutor 是理想的工具。

实现要点:

  • 设置 max_workers=10,同时最多10个线程工作。
  • 提交所有任务后,用 as_completed 收集结果。
  • 为了保证章节顺序,先将结果存入字典,最后按 ID 排序写入。

代码核心片段:

python

from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch_chapter(book_id, chap_id):
    # ... 请求并返回 (chap_id, data)

with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(fetch_chapter, 582, i): i for i in range(1, 1201)}
    results = {}
    for future in as_completed(futures):
        chap_id, data = future.result()
        results[chap_id] = data

# 按顺序写入文件
for chap_id in sorted(results):
    if results[chap_id]:
        f.write(results[chap_id]['txt'])

实际测试:1200章仅用时 2分钟!速度提升了5倍,而且没有触发网站的反爬(合理设置 max_workers 和延时)。

第五步:噩梦开始——清洗广告乱码

下载完成后,我迫不及待地打开小说,却发现文本中夹杂着大量奇怪的字符,例如:

text

许攸目瞪口呆的看着皇甫嵩dushu6◇cc,还未删除干净
...icflo Θcom...x86zw★cc...quii ¤cc...bqg87● com...

这明显是网站插入的广告,必须清理掉。我的武器是正则表达式。

初版正则

根据观察,这些乱码的结构都是:字母数字词根 + 特殊符号 + 常见域名后缀。于是我写了一个模式:

python

r'[a-zA-Z0-9]+[^\w\s]+(?:com|cc|cn|org|net)'

测试发现,icflo Θcom 被成功删除,但 dushu6◇cc 却纹丝不动。为什么?

遭遇边界陷阱

经过多轮调试,发现问题出在 \b 上。在 Python 的 re 模块中,默认的 Unicode 模式下,\w 会匹配中文字符。也就是说,“嵩”字被认为是单词字符,导致“嵩”和“d”之间没有单词边界,所以 \b 无法匹配。我最初的正则里用了 \b 边界,结果就被绕过了。

最终解决方案

既然问题出在边界,那就自己定义边界:前后不能是 ASCII 字母或数字。这样中文、标点、行首行尾都可以顺利通过。

最终的正则表达式:

python

r'(?<![a-zA-Z0-9])[a-zA-Z0-9]+[^\w\s]+(?:com|cc|cn|org|net)(?![a-zA-Z0-9])'
  • (?<![a-zA-Z0-9]):前面不能是 ASCII 字母或数字
  • [a-zA-Z0-9]+:词根
  • [^\w\s]+:一个或多个特殊符号(◇、Θ、★等)
  • (?:com|cc|cn|org|net):常见后缀
  • (?![a-zA-Z0-9]):后面不能是 ASCII 字母或数字

这次测试,dushu6◇cc 被干净地移除了。再针对其他模式(如 aaa、com 等,加入后缀判断中)做少量补充,最终清洗函数如下:

python

def clean_text(text):

    patterns = [
        # 模式1:允许中间有空格和特殊符号(如 "bqg78 ◎com")
        r'(?<![a-zA-Z0-9])[a-zA-Z0-9]+(?:\s*[^\w\s]+\s*)+(?:com|cc|cn|org|net)(?![a-zA-Z0-9])',
        # 模式2:允许中间有任意非ASCII字母数字字符(如 "99txt點cc")
        r'(?<![a-zA-Z0-9])[a-zA-Z0-9]+(?:[^a-zA-Z0-9]+)(?:com|cc|cn|org|net)(?![a-zA-Z0-9])'
    ]

    for p in patterns:
        text = re.sub(p, '', text, flags=re.IGNORECASE)  #  ''为需要替换的结果,看具体情况
    return text

第六步:最终成果与对比

清洗前:

许攸目瞪口呆的看着皇甫嵩dushu6◇cc,还未删除干净icflo Θcom。荀祈默默地掐灭扶伽却里上位的想法x86zw★cc...

清洗后:

许攸目瞪口呆的看着皇甫嵩,还未删除干净。荀祈默默地掐灭扶伽却里上位的想法...

文本干净整洁,可以愉快地阅读了。

总结与反思

1. 技术收获

  • 动态网页分析:学会了用开发者工具定位真实接口,避免盲目请求 HTML。
  • 并发提速ThreadPoolExecutor 让 I/O 密集型任务效率飞升,但要注意控制并发数,避免给服务器造成压力。
  • 数据清洗:正则表达式在文本处理中威力强大,但要警惕 Unicode 环境下的边界问题。必要时自定义边界条件。

2. 最佳实践提醒

  • 始终设置合理的延时和重试机制。
  • 尊重网站的 robots.txt,不要高频请求。
  • 处理文件时注意编码(UTF-8)。
  • 清洗前备份原始数据。

3. 可改进之处

  • 添加断点续传功能,防止中途中断。
  • 用异步库(如 aiohttp)进一步提升性能。
  • 将清洗规则做成可配置的字典,方便维护。

附录:完整代码(合并下载+清洗)

python

import requests
import time
import re
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from tqdm import tqdm  # 需要安装:pip install tqdm

# -------------------- 核心功能:获取单个章节 --------------------
def fetch_chapter(book_id, chapter_id, retries=3):
    """
    获取指定书籍的单个章节内容
    :param book_id: 书籍ID
    :param chapter_id: 章节ID
    :param retries: 重试次数
    :return: (chapter_id, data) 或 (chapter_id, None)
    """
    url = "https://apibi.cc/api/chapter"  # 书籍URL
    params = {'id': book_id, 'chapterid': chapter_id}
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ....'}  # 自己的user-agent

    for attempt in range(retries):
        try:
            resp = requests.get(url, params=params, headers=headers, timeout=10)
            resp.encoding = 'utf-8'
            if resp.status_code == 200:
                data = resp.json()
                if data.get('txt'):  # 确保有正文内容
                    return chapter_id, data
                else:
                    print(f"⚠️ 章节 {chapter_id} 返回数据缺少正文")
                    return chapter_id, None
            else:
                print(f"⚠️ 章节 {chapter_id} HTTP {resp.status_code},重试 {attempt+1}/{retries}")
        except Exception as e:
            print(f"⚠️ 章节 {chapter_id} 请求异常: {e},重试 {attempt+1}/{retries}")
        time.sleep(1)
    return chapter_id, None  # 最终失败


# -------------------- 并发下载所有章节 --------------------
def download_book_concurrent(book_id, start_chapter, end_chapter, output_file, max_workers=10):
    """
    并发下载指定范围内的所有章节,并按顺序保存到文件
    """
    chapter_ids = list(range(start_chapter, end_chapter + 1))
    results = {}  # 存放 {章节ID: 数据}

    print(f"开始并发下载 {len(chapter_ids)} 个章节,并发数 {max_workers}...")

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # 提交所有下载任务
        future_to_chap = {
            executor.submit(fetch_chapter, book_id, chap_id): chap_id
            for chap_id in chapter_ids
        }

        # 实时显示下载进度
        for future in tqdm(as_completed(future_to_chap), total=len(chapter_ids), desc="下载进度"):
            chap_id = future_to_chap[future]
            try:
                _, data = future.result()
                results[chap_id] = data if data else None
            except Exception as e:
                print(f"❌ 章节 {chap_id} 处理异常: {e}")
                results[chap_id] = None

    # 按章节顺序写入文件
    print("所有请求完成,正在写入文件...")
    with open(output_file, 'w', encoding='utf-8') as f:
        for chap_id in sorted(results.keys()):
            data = results[chap_id]
            if data:
                title = data.get('title', '未知书名')
                chap_name = data.get('chaptername', f'第{chap_id}章')
                f.write(f"\n\n{title} {chap_name}\n\n")
                f.write(data['txt'])
            else:
                f.write(f"\n\n【第 {chap_id} 章获取失败】\n\n")

    print(f"🎉 文件已保存至:{output_file}")


# -------------------- 合并多个文本文件 --------------------
def merge_files(input_files, output_file):
    """
    将多个文本文件按顺序合并成一个文件
    :param input_files: 输入文件路径列表
    :param output_file: 输出文件路径
    """
    if not input_files:
        print("错误:没有指定输入文件")
        return

    with open(output_file, 'w', encoding='utf-8') as outfile:
        for file in input_files:
            if not os.path.exists(file):
                print(f"警告:文件 {file} 不存在,已跳过")
                continue
            with open(file, 'r', encoding='utf-8') as infile:
                outfile.write(infile.read())
                outfile.write("\n\n")  # 章节间添加空行分隔
    print(f"已合并 {len(input_files)} 个文件到 {output_file}")


# -------------------- 清理广告乱码 --------------------
def remove_ads(text):
    """
    使用正则表达式删除常见的广告乱码
    支持的乱码格式:
        - 词根 (ASCII字母数字) + 特殊符号 + 域名后缀
        - 词根 + 中文字符/符号 + 域名后缀
    """
    patterns = [
        # 模式1:允许中间有空格和特殊符号(如 "bqg78 ◎com")
        r'(?<![a-zA-Z0-9])[a-zA-Z0-9]+(?:\s*[^\w\s]+\s*)+(?:com|cc|cn|org|net)(?![a-zA-Z0-9])',
        # 模式2:允许中间有任意非ASCII字母数字字符(如 "99txt點cc")
        r'(?<![a-zA-Z0-9])[a-zA-Z0-9]+(?:[^a-zA-Z0-9]+)(?:com|cc|cn|org|net)(?![a-zA-Z0-9])'
    ]
    for pattern in patterns:
        text = re.sub(pattern, '。', text, flags=re.IGNORECASE)  # 替换为空(删除)
    return text


def clean_file(input_file, output_file):
    """
    读取文件,去除广告乱码,保存为新文件
    """
    if not os.path.exists(input_file):
        print(f"错误:输入文件 {input_file} 不存在")
        return

    with open(input_file, 'r', encoding='utf-8') as f:
        content = f.read()

    cleaned = remove_ads(content)

    with open(output_file, 'w', encoding='utf-8') as f:
        f.write(cleaned)

    print(f"✅ 清理完成!结果已保存至:{output_file}")


# -------------------- 主程序入口 --------------------
if __name__ == "__main__":
    # 配置参数
    BOOK_ID = 582
    START = 1
    END = 7328                     # 全书总章节数
    RAW_FILE = "神话版三国_原始.txt"
    CLEAN_FILE = "神话版三国_干净版.txt"
    MAX_WORKERS = 10

    # 步骤1:并发下载所有章节
    download_book_concurrent(BOOK_ID, START, END, RAW_FILE, MAX_WORKERS)

    # 步骤2:清理广告乱码
    clean_file(RAW_FILE, CLEAN_FILE)

    # 如果需要合并多个文件(例如分批次下载的旧文件和新文件)
    # merge_files(["神话版三国_旧.txt", "神话版三国_新.txt"], "神话版三国_合并.txt")


从最初的 requests 失败,到最终拿到干净的全本小说,这个过程让我对爬虫的理解上了一个台阶。希望这篇记录也能帮你避开我曾踩过的坑,写出更健壮的爬虫。如果你有更好的思路或问题,欢迎留言讨论!