1. 项目概述与核心思路上次我们聊了怎么从笔趣阁这类小说网站抓取单本小说的基本流程用requests和BeautifulSoup算是开了个头。但很多朋友看完后反馈光搞定一本不过瘾想的是“一锅端”——把整个网站的小说目录都扒下来。这个想法很自然也是爬虫项目从“玩具”走向“实用”的关键一步。今天要聊的就是如何系统性地爬取一个小说网站上的所有作品这不仅仅是写个循环那么简单它涉及到对网站结构的深度分析、反爬策略的应对、以及海量数据的高效管理与存储。当你面对一个存有成千上万本小说的网站时手动一本本去查目录页URL是不现实的。核心思路在于找到网站存放所有小说列表的“入口”通常是一个带有分页的列表页比如“全部小说”、“小说库”这样的链接。我们的任务就是先爬取这个列表页解析出每一本小说的链接和基本信息如书名、作者、分类然后再循着这些链接去执行我们上一期已经熟悉的单本小说爬取流程。听起来像是“循环套循环”但其中每一步的稳定性、效率和异常处理才是真正考验功力的地方。2. 网站结构与入口分析在动手写代码之前花时间“看”网站比什么都重要。你不能假设所有笔趣阁风格的网站结构都一样。你需要亲自打开目标网站用浏览器的开发者工具F12仔细侦查。2.1 定位小说列表入口首先在网站首页寻找类似“小说库”、“全本小说”、“作品列表”的导航链接。点击进去后注意观察URL的变化。一个典型的列表页URL可能长这样https://www.biquge.com.cn/book/或者https://www.xxbiquge.com/modules/article/articlelist.php?page1。关键点在于查询参数比如page1这明确告诉我们这是一个分页列表。你需要手动多翻几页比如第1页第2页最后一页观察URL的规律。是page后面跟数字还是p有些网站可能用index_2.html这样的文件名形式。把这个规律记下来这是我们自动遍历所有页面的依据。2.2 解析列表页的HTML结构在列表页右键“检查”元素找到小说条目所在的位置。通常每个小说条目会包裹在一个tr、li或者div标签里。你需要找到这个包裹容器的共同CSS选择器或标签特征。例如你可能会发现所有小说行都在一个idnr的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(levellogging.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, headersHEADERS, timeout10) 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: # 假设结构trtda href“/book/123”书名/a/tdtd作者/td.../tr link_tag item.find(a, hrefTrue) 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(stripTrue) # 尝试获取作者可能需要根据实际HTML结构调整 author_tag item.find(td, class_author) # 假设作者在class‘author’的td里 author author_tag.get_text(stripTrue) 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_page1, max_pagesNone): 爬取所有列表页收集小说信息 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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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( stopstop_after_attempt(3), # 最多重试3次 waitwait_exponential(multiplier1, min2, max10), # 指数退避等待 retryretry_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, timeout15) 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_infoTrue) 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(stripTrue) or a_tag.get(‘title’, ‘’) # 找作者可能在不同标签或位置 author_span item_element.find(‘span’, class_‘author’) if not author_span: author_span item_element.find(‘td’, textre.compile(‘作者’)) if author_span: info[‘author’] author_span.get_text(stripTrue).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_workers5): 从数据库取出待处理的任务进行调度下载 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_workersmax_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更多的是关于资源管理、异常处理、遵守规则和设计可持续的系统。保持耐心持续迭代你的爬虫会越来越强大。