ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python爬虫实战:基于BeautifulSoup与正则表达式抓取晋江文学城数据

2026/8/8 3:58:47 拓冰建站 浏览量
Python爬虫实战:基于BeautifulSoup与正则表达式抓取晋江文学城数据

1. 项目缘起:为什么选择晋江文学城作为数据分析的起点

作为一个在数据领域摸爬滚打了十来年的老手,我常常被问到:“数据分析从哪里开始练手最有效?”我的答案一直很明确:找一个你真正感兴趣、数据又足够丰富的领域。对我而言,这个领域就是网络文学。而晋江文学城,作为国内最大的女性向原创文学网站,其海量的作品、标签、评论和榜单数据,简直就是一个天然的数据金矿。它不像一些金融或工业数据那样冰冷和抽象,每一行数据背后都可能是一个生动的故事、一个热门的题材趋势,或者是一群读者共同的情感投射。用Python去挖掘这座金矿,不仅能练技术,过程本身也充满了探索的乐趣。

这次,我们就从最基础也是最关键的一步开始:数据采集。没有高质量、结构化的数据,后续所有的分析、可视化都无从谈起。我将带你手把手搭建一个针对晋江文学城的爬虫,核心工具是BeautifulSoup和正则表达式。选择它们组合的原因很实际:BeautifulSoup擅长解析复杂的HTML文档树,能优雅地处理嵌套标签;而正则表达式则是处理文本中不规则、模式化信息的“手术刀”,两者结合,能应对晋江页面中绝大部分的数据提取场景。这个项目不追求一蹴而就的“大而全”框架,而是聚焦于解决实际爬取过程中会遇到的具体问题,比如反爬应对、数据清洗和增量抓取策略。无论你是刚学完Python语法想找个项目练手,还是已经有一定基础想深入网络数据抓取,我相信这个实战过程都能给你带来实实在在的收获。

2. 环境准备与核心工具选型逻辑

在开始写代码之前,合理的工具选择和清晰的项目结构是成功的一半。很多人一上来就埋头写请求和解析代码,最后往往陷入混乱的字符串处理和异常报错中。我们先花点时间把地基打好。

2.1 Python环境与必备库安装

首先,确保你有一个干净的Python 3.7及以上版本的环境。我强烈建议使用venvconda创建独立的虚拟环境,避免不同项目间的库版本冲突。

# 创建并激活虚拟环境(以venv为例) python -m venv jj_env # Windows jj_env\Scripts\activate # Linux/Mac source jj_env/bin/activate

接下来安装核心库。我们通过pip一次性安装:

pip install requests beautifulsoup4 lxml pandas
  • requests: 这是HTTP请求的绝对主力。相比Python内置的urllib,它的API设计更加人性化,会话管理、代理设置、超时控制都非常方便。在爬虫中,稳定可靠的网络请求是基石。
  • BeautifulSoup4(bs4): HTML/XML解析器。我们选择它而不是pyqueryparsel,是因为它的学习曲线平缓,且与Python的思维方式(遍历、搜索)结合得很好。它本身只是一个解析器,需要搭配一个“后端”解析引擎。
  • lxml: 这就是我们为BeautifulSoup选择的解析引擎。为什么不直接用Python标准库的html.parser?因为lxml的解析速度更快,对混乱HTML的容错能力更强。在爬虫这种需要快速处理大量页面的场景下,性能优势很明显。
  • pandas: 虽然这一篇主要讲爬虫,但数据最终要落地。pandasDataFrame是内存中处理表格数据的利器,可以方便地进行初步清洗、去重,并保存为CSV或Excel文件,为下一篇数据分析做准备。

注意lxml的安装有时会因为缺少系统级的C库(如libxml2, libxslt)而失败。在Windows上,如果遇到困难,可以尝试从 这里 下载对应Python版本和系统位数的.whl文件进行离线安装。Linux/Mac用户通常可以通过包管理器(如apt-get install libxml2-dev libxslt-dev)先安装这些依赖。

2.2 项目目录结构规划

一个清晰的项目结构能让代码维护性大增。在开始前,先创建如下目录和文件:

jinjiang_data_analysis/ ├── spiders/ # 存放爬虫核心脚本 │ ├── __init__.py │ └── novel_spider.py # 本篇主要的爬虫脚本 ├── data/ # 存放爬取到的原始数据 │ └── raw/ # 原始HTML或JSON备份(可选) ├── utils/ # 存放工具函数 │ ├── __init__.py │ └── request_utils.py # 请求头、代理、异常处理等 ├── config.py # 配置文件,存放URL模板、常量等 └── requirements.txt # 项目依赖库列表

现在,在config.py中,我们先定义一些基础配置,避免将URL等硬编码在爬虫脚本里:

# config.py BASE_URL = "https://www.jjwxc.net" # 榜单页URL模板,以“现代言情”分类的月榜为例 RANK_URL_TEMPLATE = "https://www.jjwxc.net/topten.php?orderstr=4&t=0&page={page}" # 小说详情页URL模板,{novelid}为小说ID DETAIL_URL_TEMPLATE = "https://www.jjwxc.net/onebook.php?novelid={novelid}" # 通用请求头,模拟浏览器访问 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', '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', }

3. 爬虫核心:请求、解析与数据提取的实战拆解

一切就绪,我们开始编写核心爬虫脚本novel_spider.py。爬虫的逻辑可以抽象为三个核心步骤:获取页面、解析内容、提取数据。我们一步步来,并深入每个环节的细节。

3.1 稳健的页面请求与反爬初步应对

网络请求是爬虫最可能出错的环节。一个健壮的请求函数必须处理超时、重试、状态码异常等问题。

# utils/request_utils.py import requests import time from requests.exceptions import RequestException def get_page(url, headers=None, retries=3, delay=2, timeout=10): """ 带重试和延迟的页面请求函数 :param url: 目标URL :param headers: 请求头,默认为config.HEADERS :param retries: 重试次数 :param delay: 重试延迟(秒) :param timeout: 请求超时时间(秒) :return: 响应文本(HTML)或 None """ if headers is None: from config import HEADERS headers = HEADERS for attempt in range(retries): try: response = requests.get(url, headers=headers, timeout=timeout) response.raise_for_status() # 如果状态码不是200,抛出HTTPError异常 # 检查编码,晋江通常使用gb2312或utf-8,这里根据响应头自动判断 response.encoding = response.apparent_encoding return response.text except RequestException as e: print(f"第{attempt+1}次请求失败,URL: {url}, 错误: {e}") if attempt < retries - 1: time.sleep(delay * (attempt + 1)) # 延迟时间递增,避免被屏蔽 else: print(f"请求{url}失败,已重试{retries}次。") return None

这个函数的关键点在于:

  1. 异常捕获:使用requests.exceptions.RequestException捕获所有网络相关异常(连接错误、超时、HTTP错误等)。
  2. 重试机制:网络不稳定或目标网站临时限制时,简单的重试往往能解决问题。递增的延迟时间(delay * (attempt + 1))是一种礼貌的爬取策略,也能规避一些简单的频率限制。
  3. 编码处理:中文网站常见的坑就是乱码。response.apparent_encodingrequests库基于内容推测的编码,通常比response.encoding(来自HTTP头)更准确。对于晋江,明确指定response.encoding = 'gb2312'有时也是可行的,但用apparent_encoding更通用。

3.2 解析榜单页:BeautifulSoup的精准定位

我们以爬取“现代言情”月榜为例。首先,观察榜单页(https://www.jjwxc.net/topten.php?orderstr=4&t=0)的HTML结构。使用浏览器的开发者工具(F12)查看,发现榜单小说列表通常在一个<table>里,或者由一系列具有特定class<div><tr>组成。

假设我们分析发现,每本小说的信息在一个class="tr1"class="tr2"<tr>标签内。那么解析代码如下:

# spiders/novel_spider.py from bs4 import BeautifulSoup from utils.request_utils import get_page from config import RANK_URL_TEMPLATE import pandas as pd def parse_rank_page(page_num=1): """ 解析榜单页面,提取小说基本信息(ID、书名、作者、链接等) :param page_num: 榜单页码 :return: 小说信息字典列表 """ url = RANK_URL_TEMPLATE.format(page=page_num) html = get_page(url) if not html: print(f"获取榜单第{page_num}页失败") return [] soup = BeautifulSoup(html, 'lxml') novel_list = [] # 关键步骤:找到包含小说信息的容器。这里需要你实际查看页面结构来确定选择器。 # 假设小说条目在 <tr class="tr1"> 或 <tr class="tr2"> 里 novel_rows = soup.find_all('tr', class_=lambda x: x in ['tr1', 'tr2']) for row in novel_rows: novel_info = {} # 通常,小说链接和书名在<a>标签里 title_tag = row.find('a', href=True) if title_tag and 'onebook.php' in title_tag['href']: novel_info['title'] = title_tag.get_text(strip=True) # 从链接中提取小说ID,例如 onebook.php?novelid=123456 href = title_tag['href'] # 这里就用到了第一个正则表达式:提取novelid参数的值 import re match = re.search(r'novelid=(\d+)', href) if match: novel_info['novel_id'] = match.group(1) novel_info['detail_url'] = f"https://www.jjwxc.net/onebook.php?novelid={novel_info['novel_id']}" # 查找作者信息,可能在另一个<a>标签或<td>里 author_tag = row.find('a', href=lambda x: x and 'authorid=' in x) or row.find_all('td')[2] # 需要根据实际结构调整索引 if author_tag: novel_info['author'] = author_tag.get_text(strip=True) # 其他信息:字数、积分、收藏数等,查找方式类似 # ... if novel_info: # 确保提取到有效信息 novel_list.append(novel_info) print(f"第{page_num}页解析完成,共找到{len(novel_list)}本小说。") return novel_list

为什么这么写?

  • BeautifulSoup(html, 'lxml'):指定使用lxml解析器,速度更快。
  • soup.find_all('tr', class_=lambda x: x in ['tr1', 'tr2']):这是一个灵活的查找方式。class_参数接受一个函数,该函数返回TrueFalse。这里我们查找class属性是tr1tr2<tr>标签。这比写两个find_all然后合并结果更简洁。
  • title_tag.get_text(strip=True):获取标签内的文本,并去除首尾空白字符。strip=True参数非常实用。
  • 正则表达式初现re.search(r'novelid=(\d+)', href)。当我们需要从一串URL中提取出模式固定的数字ID时,正则表达式是最直接的工具。\d+匹配一个或多个数字,括号()表示捕获组,match.group(1)就能取出捕获到的ID。

3.3 解析详情页:正则表达式的深度应用

榜单页只提供了基础信息。要获取更丰富的元数据,如文案、标签、章节数、主角名等,必须进入小说详情页。详情页的信息往往分散在多个地方,有些信息直接以文本形式存在,没有清晰的标签包裹,这时正则表达式就大显身手了。

假设我们要从详情页提取“文章类型”(如“原创-言情-近代现代-爱情”)和“作品标签”。

def parse_novel_detail(novel_id): """ 解析小说详情页,提取更丰富的信息 :param novel_id: 小说ID :return: 包含详情信息的字典 """ from config import DETAIL_URL_TEMPLATE url = DETAIL_URL_TEMPLATE.format(novelid=novel_id) html = get_page(url) if not html: print(f"获取小说{novel_id}详情页失败") return {} soup = BeautifulSoup(html, 'lxml') detail_info = {'novel_id': novel_id} # 1. 使用BeautifulSoup提取有明确标签的信息 # 例如,小说标题可能在<h1>或<h2>标签里 title_tag = soup.find('h1') or soup.find('h2') if title_tag: detail_info['full_title'] = title_tag.get_text(strip=True) # 2. 对付“文章类型”这类格式固定的文本块,正则表达式是利器 # 假设“文章类型”出现在类似“文章类型:原创-言情-近代现代-爱情”的文本中 # 我们先找到包含这个文本的某个大的容器,比如整个简介区域的div intro_div = soup.find('div', id='novelintro') or soup.find('div', class_='novelintro') if intro_div: intro_text = intro_div.get_text() # 使用正则表达式匹配“文章类型:”后面的内容 import re pattern_article_type = r'文章类型[::]\s*([^\n]+)' match = re.search(pattern_article_type, intro_text) if match: detail_info['article_type'] = match.group(1).strip() # 3. 提取“作品标签”,可能以“作品标签:”开头,后面跟着用空格或标点分隔的多个标签 pattern_tags = r'作品标签[::]\s*([^\n]+)' match = re.search(pattern_tags, intro_text) if match: tags_raw = match.group(1).strip() # 清洗标签:按逗号、空格等分割,并去除空项 # 这里用了一个更复杂的正则来分割,它匹配中文逗号、英文逗号、空格(多个)等 tags_list = re.split(r'[,,\s]+', tags_raw) detail_info['tags'] = [tag for tag in tags_list if tag] # 4. 提取主角名,可能格式为“主角:XXX,YYY ┃ 配角:ZZZ” pattern_lead = r'主角[::]\s*([^┃\n]+)' match = re.search(pattern_lead, intro_text) if 'intro_text' in locals() else None if match: leads_raw = match.group(1).strip() # 主角可能有多人,用顿号、逗号或空格分隔 leads_list = re.split(r'[、,,\s]+', leads_raw) detail_info['leading_roles'] = [lead for lead in leads_list if lead] # 5. 提取字数,可能格式为“字数:123456” pattern_word_count = r'字数[::]\s*(\d+)' match = re.search(pattern_word_count, intro_text) if 'intro_text' in locals() else None if match: detail_info['word_count'] = int(match.group(1)) return detail_info

正则表达式详解与避坑指南:

  • r'文章类型[::]\s*([^\n]+)'
    • r前缀表示原始字符串,避免反斜杠\被转义。
    • [::]匹配中文冒号或英文冒号,提高容错性。
    • \s*匹配0个或多个空白字符(空格、制表符等)。
    • ([^\n]+)是核心。[^\n]是一个否定字符集,表示匹配除了换行符之外的任何字符。+表示匹配一次或多次。括号()将其捕获。这样就能捕获从“文章类型:”之后到行尾的所有内容。
  • re.split(r'[,,\s]+', tags_raw):使用正则表达式进行分割。[,,\s]匹配中文逗号、英文逗号或任何空白字符。+表示一个或多个。这样无论标签是用什么分隔的,都能正确分割。
  • 关键经验:在编写正则表达式时,务必先打印出你准备匹配的原始文本。网页源码中的空格、换行、不可见字符可能比你想象的多。使用print(repr(intro_text[:500]))可以显示原始字符串表示,看到\n\t等,帮助你调整正则模式。

3.4 数据清洗、存储与增量爬取策略

爬取到的数据往往是粗糙的,需要清洗。同时,我们需要考虑如何高效、可持续地运行爬虫。

def clean_and_save_data(novel_list, detail_info_list, filename='novel_data.csv'): """ 清洗数据并保存到CSV文件 """ import pandas as pd # 将列表转换为DataFrame df_novels = pd.DataFrame(novel_list) df_details = pd.DataFrame(detail_info_list) # 合并数据(以novel_id为键) if not df_novels.empty and not df_details.empty: # 确保novel_id列类型一致,方便合并 df_novels['novel_id'] = df_novels['novel_id'].astype(str) df_details['novel_id'] = df_details['novel_id'].astype(str) df_merged = pd.merge(df_novels, df_details, on='novel_id', how='left') else: df_merged = df_novels if not df_novels.empty else df_details # 数据清洗示例 # 1. 去除完全空白的行 df_merged.dropna(how='all', inplace=True) # 2. 对标签列,如果是列表,可以转换为用分号分隔的字符串,方便CSV存储 if 'tags' in df_merged.columns: df_merged['tags'] = df_merged['tags'].apply(lambda x: ';'.join(x) if isinstance(x, list) else x) if 'leading_roles' in df_merged.columns: df_merged['leading_roles'] = df_merged['leading_roles'].apply(lambda x: ';'.join(x) if isinstance(x, list) else x) # 3. 填充缺失值 df_merged.fillna({'word_count': 0}, inplace=True) # 保存到CSV df_merged.to_csv(f'data/{filename}', index=False, encoding='utf-8-sig') # utf-8-sig解决Excel打开中文乱码 print(f"数据已保存至 data/{filename}, 共{len(df_merged)}条记录。") return df_merged def main(): """ 主函数,协调整个爬取流程 """ all_novel_basic_info = [] all_novel_detail_info = [] # 爬取前3页榜单作为示例 for page in range(1, 4): print(f"正在爬取榜单第{page}页...") novels_on_page = parse_rank_page(page) all_novel_basic_info.extend(novels_on_page) # 对当前页的每本小说,爬取详情(这里可以加入延迟,避免请求过快) for novel in novels_on_page: novel_id = novel.get('novel_id') if novel_id: print(f" 正在爬取小说 {novel_id} 的详情...") detail = parse_novel_detail(novel_id) if detail: all_novel_detail_info.append(detail) time.sleep(1) # 礼貌性延迟,非常重要! # 清洗并保存数据 clean_and_save_data(all_novel_basic_info, all_novel_detail_info) if __name__ == '__main__': main()

增量爬取策略思考:在实际项目中,我们不可能每次都全量爬取。一个简单的增量策略是:

  1. 记录已爬取的ID:每次爬取后,将成功爬取的novel_id保存到一个文件(如crawled_ids.txt)或数据库。
  2. 爬取前过滤:在parse_rank_page获取到新的榜单列表后,先与已爬取的ID列表对比,只爬取那些不在列表中的新小说。
  3. 定时与更新:可以设置定时任务(如每天一次)运行爬虫,只爬取榜单上新出现的小说。对于详情页,如果小说信息可能更新(如字数增长),也可以根据时间戳决定是否重新爬取。

4. 高级话题:反爬虫应对与工程化考量

如果只是运行上面的基础代码,很可能很快就会被网站限制访问。一个成熟的爬虫必须考虑反爬策略。

4.1 常见的反爬机制与应对方案

  1. User-Agent检测:我们已经做了,使用常见的浏览器UA。
  2. 请求频率限制:这是最普遍的。我们的代码中在详情页爬取时加入了time.sleep(1)。更优的做法是使用随机延迟,并控制总体并发数。
    import random time.sleep(random.uniform(1, 3)) # 随机延迟1-3秒
  3. IP封锁:单个IP短时间内请求过多会被封。解决方案包括:
    • 使用代理IP池:从免费的或付费的代理服务获取IP,并在请求中轮换使用。requests库使用代理很简单:requests.get(url, proxies={'http': 'http://ip:port', 'https': 'https://ip:port'})
    • 降低请求速度:这是最经济的方法,配合随机延迟。
  4. Cookie/Session验证:有些页面需要登录或携带特定Cookie。我们可以使用requests.Session()对象来保持会话,并手动设置必要的Cookie。
    session = requests.Session() # 可以预先访问一次首页,获取初始Cookie session.get('https://www.jjwxc.net', headers=HEADERS) # 后续请求都用session response = session.get(target_url, headers=HEADERS)
  5. JavaScript渲染:如果目标数据是通过JavaScript动态加载的(晋江大部分核心数据不是,但一些交互内容可能是),那么requests+BeautifulSoup就无能为力了。这时需要用到SeleniumPlaywright这类自动化测试工具来模拟浏览器行为,或者更高效地,找到数据背后的真实API接口(通过浏览器开发者工具的“网络”选项卡抓包分析)。

4.2 错误处理与日志记录

一个健壮的程序必须有完善的错误处理和日志。

import logging # 配置日志 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('spider.log'), logging.StreamHandler() ]) logger = logging.getLogger(__name__) def safe_parse_detail(novel_id): try: detail = parse_novel_detail(novel_id) if detail: logger.info(f"成功爬取小说 {novel_id} 详情。") return detail else: logger.warning(f"爬取小说 {novel_id} 详情但未提取到有效数据。") return None except Exception as e: logger.error(f"爬取小说 {novel_id} 详情时发生异常: {e}", exc_info=True) return None

将核心函数用try...except包裹,并使用logging模块记录不同级别(INFO, WARNING, ERROR)的日志,可以帮助你在爬虫长时间运行时快速定位问题。

4.3 使用数据库进行持久化存储

当数据量变大后,CSV文件会变得难以管理。使用SQLite或MySQL等数据库是更好的选择。

import sqlite3 import pandas as pd def save_to_sqlite(df, db_name='novels.db', table_name='novels'): """ 将DataFrame保存到SQLite数据库 """ conn = sqlite3.connect(f'data/{db_name}') # if_exists='replace'表示如果表存在则替换,'append'表示追加 df.to_sql(table_name, conn, if_exists='append', index=False) conn.close() logger.info(f"数据已存入数据库 {db_name}.{table_name}")

数据库便于查询、去重和增量更新。你可以设计更复杂的表结构,比如将标签单独存一张表,与小说表建立多对多关系,这样数据分析时会更灵活。

5. 实战中的“坑”与经验总结

最后,分享几个我在爬取晋江以及其他网站时踩过的坑和总结的经验,这些是文档里不会写的。

  1. HTML结构变动:这是最大的风险。网站改版后,你的选择器可能全部失效。对策:不要将选择器(如div.class1 > span)写死。尽量使用更稳定的特征,比如id属性(如果它有的话),或者通过多个标签层级和文本内容来定位。将关键的选择器字符串作为配置项放在config.py里,方便统一修改。定期(比如每周)用爬虫跑一下核心页面,检查解析是否正常。

  2. 编码问题的顽固性:即使使用了apparent_encoding,偶尔还是会遇到乱码。终极方案:如果知道网站固定编码(如晋江有时是gb2312),可以尝试response.content.decode('gb2312', errors='ignore')errors='ignore'会忽略无法解码的字符,虽然可能丢失极少信息,但保证了程序不崩溃,拿到大部分正确数据。

  3. 正则表达式的贪婪与懒惰:这是正则里最容易出错的地方。默认的*+是“贪婪”的,会匹配尽可能多的字符。例如,用r'主角:(.*)配角'去匹配“主角:张三,李四 配角:王五”,由于.匹配任何字符(除了换行),贪婪模式下的(.*)会一直匹配到最后一个“配角”,结果可能不是你想要的。这时需要使用懒惰模式,在量词后加?,如r'主角:(.*?)配角',它会匹配到第一个“配角”就停止。

  4. 网络不稳定与超时设置timeout参数至关重要。设置一个合理的总超时和连接超时。对于requests,可以传入一个元组(connect_timeout, read_timeout)。对于慢速或不稳定的网站,适当调大read_timeout

  5. 尊重robots.txt:在爬取前,访问一下https://www.jjwxc.net/robots.txt,看看网站是否允许爬虫访问你目标目录。虽然这不是法律强制,但这是良好的网络公民礼仪。对于明确禁止的目录,应避免爬取。

  6. 数据清洗的复杂性:从网页抓取的文本经常包含多余的空格、换行、不可见的Unicode字符(如\u3000全角空格)。在存储前,进行彻底的清洗:使用str.strip()str.replace(),或者更强大的re.sub(r'\s+', ' ', text)来将多个空白字符替换为一个空格。

写完爬虫代码只是第一步,让它稳定、高效、可持续地运行,并在网站结构变化时能快速调整,才是真正的挑战。下一篇,我们将利用这篇爬取到的数据,开始有趣的数据分析部分,看看晋江文学城里藏着哪些有趣的趋势和洞见。