1. 项目概述与核心思路
上次我们聊了怎么从笔趣阁这类小说网站抓取单本小说的基本流程,用requests和BeautifulSoup算是开了个头。但很多朋友看完后反馈,光搞定一本不过瘾,想的是“一锅端”——把整个网站的小说目录都扒下来。这个想法很自然,也是爬虫项目从“玩具”走向“实用”的关键一步。今天要聊的,就是如何系统性地爬取一个小说网站上的所有作品,这不仅仅是写个循环那么简单,它涉及到对网站结构的深度分析、反爬策略的应对、以及海量数据的高效管理与存储。
当你面对一个存有成千上万本小说的网站时,手动一本本去查目录页URL是不现实的。核心思路在于找到网站存放所有小说列表的“入口”,通常是一个带有分页的列表页,比如“全部小说”、“小说库”这样的链接。我们的任务就是先爬取这个列表页,解析出每一本小说的链接和基本信息(如书名、作者、分类),然后再循着这些链接,去执行我们上一期已经熟悉的单本小说爬取流程。听起来像是“循环套循环”,但其中每一步的稳定性、效率和异常处理,才是真正考验功力的地方。
2. 网站结构与入口分析
在动手写代码之前,花时间“看”网站比什么都重要。你不能假设所有笔趣阁风格的网站结构都一样。你需要亲自打开目标网站,用浏览器的开发者工具(F12)仔细侦查。
2.1 定位小说列表入口
首先,在网站首页寻找类似“小说库”、“全本小说”、“作品列表”的导航链接。点击进去后,注意观察URL的变化。一个典型的列表页URL可能长这样:https://www.biquge.com.cn/book/或者https://www.xxbiquge.com/modules/article/articlelist.php?page=1。关键点在于查询参数,比如page=1,这明确告诉我们这是一个分页列表。
你需要手动多翻几页(比如第1页,第2页,最后一页),观察URL的规律。是page=后面跟数字,还是p=?有些网站可能用index_2.html这样的文件名形式。把这个规律记下来,这是我们自动遍历所有页面的依据。
2.2 解析列表页的HTML结构
在列表页,右键“检查”元素,找到小说条目所在的位置。通常,每个小说条目会包裹在一个<tr>、<li>或者<div>标签里。你需要找到这个包裹容器的共同CSS选择器或标签特征。
例如,你可能会发现所有小说行都在一个id="nr"的<div>里,每个条目是一个<li>。或者,更常见的是在一个<table>的<tbody>下的多个<tr>里。使用开发者工具的“选择元素”功能,点击一个小说名,查看它对应的HTML代码。你的目标是找到一个能唯一、稳定地定位到小说链接(<a>标签)的选择器。
这个选择器不能太宽泛(如div a,这会选中页面所有链接),也不能太脆弱(依赖于某个易变的class名)。理想的选择器是基于稳定的父容器ID和标签路径,例如:#list > div > ul > li > a。如果网站结构规整,直接通过<table>的<tr>和<td>来定位也是可行的。
注意:不要依赖文本内容(如“书名:”)来定位,因为不同网站翻译或排版可能不同。要依赖HTML标签的结构和属性。
3. 核心爬虫逻辑设计与实现
有了清晰的入口和结构认知,我们就可以开始设计代码的主干逻辑了。整个程序可以划分为几个清晰的模块。
3.1 模块化设计
一个健壮的多小说爬虫至少应包含以下模块:
- 配置模块:存放基础URL、请求头、数据库配置等常量。
- 网页请求模块:封装
requests请求,集成代理、重试、异常处理。 - 列表页解析模块:专门负责从列表页HTML中提取小说元信息(链接、书名、作者等)。
- 详情页(章节页)解析模块:复用上一期的代码,负责解析单本小说的章节和内容。
- 数据存储模块:负责将爬取到的小说信息和内容保存到文件或数据库。
- 主控调度模块:协调以上模块,控制爬取流程(先爬列表,再依次爬详情)。
3.2 列表页爬取与解析代码实现
假设我们分析出列表页URL格式为base_url + /booklist?page={page_num},每页的小说条目都在selector = “table.list tbody tr”下。以下是核心代码片段:
import requests from bs4 import BeautifulSoup import time import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s') BASE_URL = 'https://www.examplebiquge.com' # 替换成目标网站 LIST_URL_TEMPLATE = BASE_URL + '/booklist?page={}' LIST_ITEM_SELECTOR = 'table.list tbody tr' # 根据实际情况修改 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' } def fetch_list_page(page_num): """获取指定页码的列表页HTML""" url = LIST_URL_TEMPLATE.format(page_num) try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 如果状态码不是200,抛出HTTPError异常 # 可以在这里检查页面内容是否有效,比如是否包含“没有找到书籍”等提示 if “空空如也” in resp.text: return None return resp.text except requests.exceptions.RequestException as e: logging.error(f“获取列表页 {page_num} 失败: {e}”) return None def parse_list_page(html): """从列表页HTML中解析出小说信息列表""" if not html: return [] soup = BeautifulSoup(html, 'html.parser') book_items = soup.select(LIST_ITEM_SELECTOR) book_list = [] for item in book_items: # 假设结构:<tr><td><a href=“/book/123”>书名</a></td><td>作者</td>...</tr> link_tag = item.find('a', href=True) if not link_tag: continue book_url = link_tag['href'] # 处理相对链接 if book_url.startswith('/'): book_url = BASE_URL + book_url book_name = link_tag.get_text(strip=True) # 尝试获取作者,可能需要根据实际HTML结构调整 author_tag = item.find('td', class_='author') # 假设作者在class=‘author’的td里 author = author_tag.get_text(strip=True) if author_tag else ‘未知’ book_list.append({ 'name': book_name, 'url': book_url, 'author': author }) return book_list这段代码定义了两个核心函数。fetch_list_page负责抓取网页,加入了基本的异常处理和内容校验。parse_list_page是解析核心,它使用我们事先分析好的CSS选择器找到所有条目,然后遍历每个条目,提取出小说的名称和详情页链接。这里特别要注意链接可能是相对路径,需要拼接上基础URL。
3.3 主控循环与分页处理
接下来,我们需要一个循环来遍历所有列表页,直到没有内容为止。
def crawl_all_books(start_page=1, max_pages=None): """爬取所有列表页,收集小说信息""" all_books = [] current_page = start_page while True: logging.info(f“正在爬取第 {current_page} 页列表...”) html = fetch_list_page(current_page) if html is None: logging.warning(f“第 {current_page} 页获取失败或为空,可能已到末页。”) break books_on_page = parse_list_page(html) if not books_on_page: logging.info(f“第 {current_page} 页未解析到书籍,列表爬取结束。”) break all_books.extend(books_on_page) logging.info(f“第 {current_page} 页找到 {len(books_on_page)} 本书,已累计 {len(all_books)} 本。”) # 翻页 current_page += 1 time.sleep(1) # 礼貌性延迟,避免请求过快 # 可选:限制最大爬取页数 if max_pages and current_page > start_page + max_pages - 1: logging.info(f“已达到最大爬取页数限制 {max_pages}。”) break return all_books这个crawl_all_books函数是调度中心。它从起始页开始,不断请求、解析、收集,直到某一页解析不到小说或获取失败(通常意味着到了最后一页)。这里加入了time.sleep(1)进行延迟,这是对目标网站最基本的尊重,也能有效降低被封IP的风险。max_pages参数用于测试时控制范围。
4. 深入细节:反爬策略与健壮性提升
直接按上面的基础代码跑,很可能中途就挂了。真实的网站环境复杂得多,我们必须把程序打造得更加强壮。
4.1 请求头与会话管理
我们之前只用了简单的User-Agent。对于有些检查严格的网站,可能需要模拟更完整的浏览器行为。
HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate', # 注意:requests自动处理解码,这里写上与浏览器一致即可 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', } # 使用Session对象,可以自动保持Cookies,更接近真实浏览器会话 session = requests.Session() session.headers.update(HEADERS)使用requests.Session()是个好习惯,它会在多次请求间保持一些会话信息,比如Cookies,使得爬虫行为更像一个真实的用户在浏览。
4.2 代理IP与请求重试
单一IP高频请求是爬虫大忌。引入代理IP池和重试机制至关重要。我们可以使用tenacity库来实现优雅的重试。
import random from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type PROXY_POOL = [‘http://proxy1:port’, ‘http://proxy2:port’] # 替换为你的代理IP列表 @retry( stop=stop_after_attempt(3), # 最多重试3次 wait=wait_exponential(multiplier=1, min=2, max=10), # 指数退避等待 retry=retry_if_exception_type(requests.exceptions.RequestException) ) def fetch_with_retry(url, session): proxy = random.choice(PROXY_POOL) if PROXY_POOL else None try: resp = session.get(url, proxies={“http”: proxy, “https”: proxy} if proxy else None, timeout=15) resp.raise_for_status() return resp.text except requests.exceptions.ProxyError: logging.warning(f“代理 {proxy} 失效,尝试移除或重试...”) # 可以从PROXY_POOL中移除失效代理 raise # 再次抛出异常以触发重试这个fetch_with_retry函数装饰了重试逻辑。当发生网络异常或代理失败时,它会等待一段时间后重试,最多3次。指数退避等待(wait_exponential)让每次重试的间隔逐渐变长,既给了服务器喘息时间,也增加了成功率。
4.3 解析容错与数据清洗
网页HTML可能不规范,或者个别条目结构有差异。我们的解析代码必须有足够的容错能力。
def parse_list_page_robust(html): book_list = [] try: soup = BeautifulSoup(html, 'html.parser') # 尝试多种可能的选择器,增加容错 possible_selectors = [‘table.list tbody tr’, ‘ul.book-list li’, ‘div.book-item’] for selector in possible_selectors: items = soup.select(selector) if items: logging.debug(f“使用选择器 ‘{selector}’ 找到 {len(items)} 个条目”) for item in items: book_info = extract_book_info(item) if book_info and book_info[‘url’] and book_info[‘name’]: book_list.append(book_info) break # 使用第一个成功的选择器 else: logging.warning(“未能使用任何预设选择器找到书籍条目,HTML结构可能已变化。”) # 可以在这里保存当前HTML,供后续分析 except Exception as e: logging.error(f“解析列表页时发生未知错误: {e}”, exc_info=True) return book_list def extract_book_info(item_element): """从一个条目元素中提取书籍信息,增加健壮性""" info = {‘name’: ‘’, ‘url’: ‘’, ‘author’: ‘’, ‘category’: ‘’} try: # 找链接和书名 a_tag = item_element.find(‘a’) if a_tag and a_tag.has_attr(‘href’): info[‘url’] = a_tag[‘href’] info[‘name’] = a_tag.get_text(strip=True) or a_tag.get(‘title’, ‘’) # 找作者,可能在不同标签或位置 author_span = item_element.find(‘span’, class_=‘author’) if not author_span: author_span = item_element.find(‘td’, text=re.compile(‘作者’)) if author_span: info[‘author’] = author_span.get_text(strip=True).replace(‘作者:’, ‘’) # 类似方法找分类... except AttributeError as e: logging.debug(f“提取书籍信息时遇到属性错误: {e},条目HTML: {item_element}”) return infoparse_list_page_robust函数尝试了多种可能的选择器,直到找到一个能用的。extract_book_info函数内部使用try...except捕获属性错误,即使某个标签缺失,也不会导致整个解析崩溃,而是记录日志并跳过。这种“宽进严出”的策略保证了流程的持续进行。
5. 数据存储与任务调度
爬取到的海量小说信息需要妥善保存。同时,如何高效、可控地调度成千上万个详情页(即单本小说)的爬取任务,是另一个挑战。
5.1 存储方案选择:SQLite vs 文件
对于中等规模的数据(几万本书),SQLite数据库是一个轻量且理想的选择。它无需安装单独的数据库服务,一个文件搞定,支持SQL查询,方便去重和管理。
import sqlite3 import os DB_NAME = ‘novels.db’ def init_database(): conn = sqlite3.connect(DB_NAME) cursor = conn.cursor() # 创建书籍列表表 cursor.execute(‘‘‘ CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT UNIQUE NOT NULL, -- URL唯一,用于去重 author TEXT, category TEXT, status TEXT DEFAULT ‘pending’, -- pending, downloading, completed, failed created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ‘‘’) # 创建章节内容表(可根据需要设计,这里简化) cursor.execute(‘‘‘ CREATE TABLE IF NOT EXISTS chapters ( book_id INTEGER, chapter_title TEXT, content TEXT, FOREIGN KEY (book_id) REFERENCES books (id) ) ‘‘’) conn.commit() conn.close() def save_book_list_to_db(book_list): conn = sqlite3.connect(DB_NAME) cursor = conn.cursor() new_count = 0 for book in book_list: try: cursor.execute( “INSERT OR IGNORE INTO books (name, url, author, category) VALUES (?, ?, ?, ?)”, (book[‘name’], book[‘url’], book.get(‘author’), book.get(‘category’)) ) if cursor.rowcount > 0: new_count += 1 except sqlite3.Error as e: logging.error(f“插入书籍 {book[‘name’]} 失败: {e}”) conn.commit() conn.close() logging.info(f“成功存入 {new_count} 本新书,跳过 {len(book_list)-new_count} 本重复书籍。”)使用INSERT OR IGNORE语句和UNIQUE约束,可以自动避免重复插入相同的书籍URL。status字段用来标记每本书的爬取状态,这对于管理大规模爬取任务非常有用。
如果数据量极大,或者你更喜欢文件形式,也可以按分类或首字母将小说信息保存为JSON或CSV文件。但数据库在查询、更新状态和去重方面有天然优势。
5.2 任务队列与并发控制
当我们有几万本书需要爬取时,顺序执行是不可接受的。我们需要一个任务队列系统。Python的queue模块和threading或multiprocessing可以构建简单的多线程/多进程爬虫。但对于生产环境,更推荐使用像Celery这样的分布式任务队列,或者asyncio进行异步IO操作。
这里展示一个使用concurrent.futures的线程池示例,它相对简单易用:
from concurrent.futures import ThreadPoolExecutor, as_completed def download_single_book(book_info): """下载单本书的详细章节,这里调用第一期写的函数""" # 假设这是第一期实现的函数 # book_chapters = crawl_book_chapters(book_info[‘url’]) # save_chapters_to_db(book_info[‘id’], book_chapters) # update_book_status(book_info[‘id’], ‘completed’) time.sleep(random.uniform(1, 3)) # 随机延迟,模拟人工 return book_info[‘name’] def schedule_downloads(max_workers=5): """从数据库取出待处理的任务进行调度下载""" conn = sqlite3.connect(DB_NAME) cursor = conn.cursor() # 获取所有状态为pending的书籍 cursor.execute(“SELECT id, name, url FROM books WHERE status = ‘pending’ LIMIT 50”) # 限制一批次数量 pending_books = cursor.fetchall() conn.close() if not pending_books: logging.info(“没有待下载的书籍。”) return books_to_download = [{‘id’: row[0], ‘name’: row[1], ‘url’: row[2]} for row in pending_books] logging.info(f“开始批量下载 {len(books_to_download)} 本书籍...”) # 使用线程池 with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_book = {executor.submit(download_single_book, book): book for book in books_to_download} for future in as_completed(future_to_book): book = future_to_book[future] try: result = future.result() logging.info(f“书籍《{result}》下载完成。”) # 更新数据库状态为 completed update_book_status_in_db(book[‘id’], ‘completed’) except Exception as e: logging.error(f“下载书籍《{book[‘name’]}》时出错: {e}”) update_book_status_in_db(book[‘id’], ‘failed’)ThreadPoolExecutor创建了一个包含5个工作线程的池子。我们将50本待下载的书(LIMIT 50)提交给线程池,它们会并发执行download_single_book函数。as_completed会返回完成的任务,我们可以及时处理结果和异常。务必注意:max_workers不要设置过大,通常5-10个线程对于爬虫来说已经足够,设置过高会变成DDoS攻击,导致IP被封。同时,在下载函数内部一定要有足够的延迟(time.sleep)。
6. 实战中的常见问题与排查技巧
即使做了万全准备,在实际运行中还是会遇到各种问题。下面是一些典型场景和解决思路。
6.1 页面结构突然变化
这是最常见的问题。今天还能跑的程序,明天可能就解析不到数据了。
- 症状:列表页或详情页返回的HTML代码中,原有的CSS选择器找不到元素。
- 排查:
- 立即手动访问目标URL,用开发者工具查看当前HTML结构。
- 检查是否有重定向到了错误页、验证码页或反爬提示页。
- 对比新旧HTML,找出标签、
class或id的变化。
- 应对:
- 临时:立即更新代码中的选择器或解析逻辑。
- 长期:编写更健壮的解析函数(如我们上面做的
parse_list_page_robust),采用多种选择器后备,或者使用更宽松的查找方式(如通过文本内容的部分匹配)。可以考虑将解析规则(如选择器)配置化,存到配置文件或数据库,这样无需修改代码即可更新。
6.2 访问频率过高被封锁
- 症状:请求开始返回
403 Forbidden、429 Too Many Requests,或者返回的HTML是验证码页面、空白页、包含“访问过于频繁”等提示文本。 - 排查:
- 检查响应状态码和响应体内容。
- 降低请求频率,观察是否恢复。
- 应对:
- 增加延迟:在请求间加入随机延迟,
time.sleep(random.uniform(2, 5))比固定延迟更有效。 - 使用代理池:这是应对IP封锁最有效的方法。可以从付费服务购买,或自建代理池。
- 模拟更真实的行为:随机切换
User-Agent,在会话中模拟点击、滚动(通过执行JavaScript,需要Selenium等工具)。 - 设置请求超时和重试:使用我们上面实现的
fetch_with_retry函数。
- 增加延迟:在请求间加入随机延迟,
6.3 数据不完整或乱码
- 症状:爬取到的小说内容有缺失、章节顺序错乱,或者出现乱码。
- 排查:
- 乱码:检查网页编码。
requests会自动推测编码,但有时会出错。可以手动指定:resp.encoding = ‘utf-8’或resp.encoding = ‘gbk’(笔趣阁类网站常用GBK编码)。查看网页HTML中<meta charset=“...”>标签。 - 内容缺失:可能是动态加载。打开浏览器开发者工具的“网络”(Network)选项卡,过滤XHR或Fetch请求,查看章节内容是否通过AJAX接口加载。如果是,则需要直接请求那个接口URL。
- 顺序错乱:检查解析章节列表的逻辑,是否严格按照页面上的顺序提取。有些网站目录页的章节链接可能是乱序的,需要根据章节编号或发布时间排序。
- 乱码:检查网页编码。
- 应对:
- 对于动态加载,需要分析JavaScript请求,找到真正的数据API。
- 在保存章节内容时,同时保存章节序号或URL中的顺序标识,用于后续排序。
6.4 数据库操作异常
- 症状:程序在插入或更新数据库时崩溃,报错如
sqlite3.OperationalError: database is locked。 - 排查:多线程或多进程同时写同一个SQLite数据库文件时容易发生锁冲突。
- 应对:
- 为每个线程创建独立的数据库连接:不要在线程间共享同一个连接对象。
- 使用连接池:或者使用支持并发更好的数据库,如 PostgreSQL。
- 将写操作队列化:使用一个专门的线程来处理所有数据库写入请求,其他线程通过队列传递数据。
7. 项目优化与扩展方向
一个基础的全站爬虫完成后,可以考虑以下方向进行优化和扩展,让它更强大、更智能。
7.1 增量爬取与更新
我们不想每次都从头爬取整个网站。理想状态是只爬取新增或更新过的小说。
- 思路:在数据库表中增加
last_updated_time字段。定期运行爬虫时:- 爬取最新的列表页。
- 将新抓取到的书籍URL与数据库中已有的对比。
- 只插入数据库中不存在的URL。
- 对于已存在的书籍,可以对比一个“最新章节”的标识(如最新章节标题或链接),如果不同,则触发对该书籍的重新爬取(或只爬取新章节)。
- 实现:这需要设计更精细的数据表结构和爬取状态机。
7.2 分布式爬虫架构
当目标网站数据量极其庞大,或者需要7x24小时稳定运行时,单机爬虫显得力不从心。
- 组件:可以考虑引入消息队列(如 RabbitMQ, Redis)、任务调度器(如 Scrapy 的 Scrapyd, 或自制调度中心)和多个爬虫节点。
- 流程:
- 一个“调度器”负责发现新的列表页URL,并将其作为任务发布到消息队列。
- 多个“爬虫节点”从队列中领取列表页任务,解析出书籍URL,再将书籍URL作为新任务发布到另一个队列。
- 另一组“详情页爬虫节点”领取书籍URL任务,下载小说内容并存储。
- 优势:水平扩展能力强,一个节点挂了不影响整体,可以方便地管理任务优先级和去重。
7.3 引入更强大的框架:Scrapy
如果你对这个项目非常认真,强烈建议学习并使用 Scrapy 框架。它原生支持了我们上面讨论的很多复杂特性:
- 内置的请求调度、去重、并发控制。
- 强大的中间件系统,可以方便地集成代理、自定义请求头、处理Cookies等。
- Item Pipeline用于数据清洗和存储,结构清晰。
- Feed Exports支持多种格式输出。
- 扩展性强,有丰富的社区插件。
用Scrapy重写这个项目,代码会更简洁、更健壮,也更容易维护和扩展。它相当于为你搭建好了爬虫的“骨架”,你只需要填充“解析肌肉”和“存储血液”即可。
从爬取单本书到爬取全站,你迈出的这一步,是从编写脚本到设计系统的跨越。过程中遇到的每一个错误和解决它的过程,都是宝贵的经验。记住,爬虫工程不仅仅是解析HTML,更多的是关于资源管理、异常处理、遵守规则和设计可持续的系统。保持耐心,持续迭代,你的爬虫会越来越强大。