“本文案例小说《神话版三国》作者:坟土荒草,首发于阅文集团旗下平台”
引言:一次失败的请求
今天上午,我想下载一本网络小说《神话版三国》离线阅读。作为一名 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)
天真的我按照看的第一篇教程,里面说<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 异步请求加载。
第一步:打开开发者工具,找到真实接口
操作步骤:
- 打开浏览器(Chrome 或 Edge),按
F12打开开发者工具(其实我按F12没有成功,是右键检查),切换到 Network(网络)标签。 - 刷新页面,会看到很多网络请求。为了过滤出数据请求,可以点击 XHR 或 Fetch 按钮,或者直接在筛选框中输入常见的接口关键词(如
api、chapter、content)。 - 观察请求,找到一个返回内容包含小说章节信息的请求。点击它,查看 Preview 或 Response 标签,确认是否是需要的数据。
- 复制请求 URL 和必要的请求头(如
User-Agent、Referer,可能还有Cookie),然后用requests模拟请求。
在开发者工具里找到网络,然后刷新页面,重新获取网页内容在 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 失败,到最终拿到干净的全本小说,这个过程让我对爬虫的理解上了一个台阶。希望这篇记录也能帮你避开我曾踩过的坑,写出更健壮的爬虫。如果你有更好的思路或问题,欢迎留言讨论!