Python爬虫实战:基于BeautifulSoup与正则表达式抓取晋江文学城数据 1. 项目缘起为什么选择晋江文学城作为数据分析的起点作为一个在数据领域摸爬滚打了十来年的老手我常常被问到“数据分析从哪里开始练手最有效”我的答案一直很明确找一个你真正感兴趣、数据又足够丰富的领域。对我而言这个领域就是网络文学。而晋江文学城作为国内最大的女性向原创文学网站其海量的作品、标签、评论和榜单数据简直就是一个天然的数据金矿。它不像一些金融或工业数据那样冰冷和抽象每一行数据背后都可能是一个生动的故事、一个热门的题材趋势或者是一群读者共同的情感投射。用Python去挖掘这座金矿不仅能练技术过程本身也充满了探索的乐趣。这次我们就从最基础也是最关键的一步开始数据采集。没有高质量、结构化的数据后续所有的分析、可视化都无从谈起。我将带你手把手搭建一个针对晋江文学城的爬虫核心工具是BeautifulSoup和正则表达式。选择它们组合的原因很实际BeautifulSoup擅长解析复杂的HTML文档树能优雅地处理嵌套标签而正则表达式则是处理文本中不规则、模式化信息的“手术刀”两者结合能应对晋江页面中绝大部分的数据提取场景。这个项目不追求一蹴而就的“大而全”框架而是聚焦于解决实际爬取过程中会遇到的具体问题比如反爬应对、数据清洗和增量抓取策略。无论你是刚学完Python语法想找个项目练手还是已经有一定基础想深入网络数据抓取我相信这个实战过程都能给你带来实实在在的收获。2. 环境准备与核心工具选型逻辑在开始写代码之前合理的工具选择和清晰的项目结构是成功的一半。很多人一上来就埋头写请求和解析代码最后往往陷入混乱的字符串处理和异常报错中。我们先花点时间把地基打好。2.1 Python环境与必备库安装首先确保你有一个干净的Python 3.7及以上版本的环境。我强烈建议使用venv或conda创建独立的虚拟环境避免不同项目间的库版本冲突。# 创建并激活虚拟环境以venv为例 python -m venv jj_env # Windows jj_env\Scripts\activate # Linux/Mac source jj_env/bin/activate接下来安装核心库。我们通过pip一次性安装pip install requests beautifulsoup4 lxml pandasrequests: 这是HTTP请求的绝对主力。相比Python内置的urllib它的API设计更加人性化会话管理、代理设置、超时控制都非常方便。在爬虫中稳定可靠的网络请求是基石。BeautifulSoup4(bs4): HTML/XML解析器。我们选择它而不是pyquery或parsel是因为它的学习曲线平缓且与Python的思维方式遍历、搜索结合得很好。它本身只是一个解析器需要搭配一个“后端”解析引擎。lxml: 这就是我们为BeautifulSoup选择的解析引擎。为什么不直接用Python标准库的html.parser因为lxml的解析速度更快对混乱HTML的容错能力更强。在爬虫这种需要快速处理大量页面的场景下性能优势很明显。pandas: 虽然这一篇主要讲爬虫但数据最终要落地。pandas的DataFrame是内存中处理表格数据的利器可以方便地进行初步清洗、去重并保存为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?orderstr4t0page{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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }3. 爬虫核心请求、解析与数据提取的实战拆解一切就绪我们开始编写核心爬虫脚本novel_spider.py。爬虫的逻辑可以抽象为三个核心步骤获取页面、解析内容、提取数据。我们一步步来并深入每个环节的细节。3.1 稳健的页面请求与反爬初步应对网络请求是爬虫最可能出错的环节。一个健壮的请求函数必须处理超时、重试、状态码异常等问题。# utils/request_utils.py import requests import time from requests.exceptions import RequestException def get_page(url, headersNone, retries3, delay2, timeout10): 带重试和延迟的页面请求函数 :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, headersheaders, timeouttimeout) response.raise_for_status() # 如果状态码不是200抛出HTTPError异常 # 检查编码晋江通常使用gb2312或utf-8这里根据响应头自动判断 response.encoding response.apparent_encoding return response.text except RequestException as e: print(f第{attempt1}次请求失败URL: {url}, 错误: {e}) if attempt retries - 1: time.sleep(delay * (attempt 1)) # 延迟时间递增避免被屏蔽 else: print(f请求{url}失败已重试{retries}次。) return None这个函数的关键点在于异常捕获使用requests.exceptions.RequestException捕获所有网络相关异常连接错误、超时、HTTP错误等。重试机制网络不稳定或目标网站临时限制时简单的重试往往能解决问题。递增的延迟时间delay * (attempt 1)是一种礼貌的爬取策略也能规避一些简单的频率限制。编码处理中文网站常见的坑就是乱码。response.apparent_encoding是requests库基于内容推测的编码通常比response.encoding来自HTTP头更准确。对于晋江明确指定response.encoding gb2312有时也是可行的但用apparent_encoding更通用。3.2 解析榜单页BeautifulSoup的精准定位我们以爬取“现代言情”月榜为例。首先观察榜单页https://www.jjwxc.net/topten.php?orderstr4t0的HTML结构。使用浏览器的开发者工具F12查看发现榜单小说列表通常在一个table里或者由一系列具有特定class的div或tr组成。假设我们分析发现每本小说的信息在一个classtr1或classtr2的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_num1): 解析榜单页面提取小说基本信息ID、书名、作者、链接等 :param page_num: 榜单页码 :return: 小说信息字典列表 url RANK_URL_TEMPLATE.format(pagepage_num) html get_page(url) if not html: print(f获取榜单第{page_num}页失败) return [] soup BeautifulSoup(html, lxml) novel_list [] # 关键步骤找到包含小说信息的容器。这里需要你实际查看页面结构来确定选择器。 # 假设小说条目在 tr classtr1 或 tr classtr2 里 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, hrefTrue) if title_tag and onebook.php in title_tag[href]: novel_info[title] title_tag.get_text(stripTrue) # 从链接中提取小说ID例如 onebook.php?novelid123456 href title_tag[href] # 这里就用到了第一个正则表达式提取novelid参数的值 import re match re.search(rnovelid(\d), href) if match: novel_info[novel_id] match.group(1) novel_info[detail_url] fhttps://www.jjwxc.net/onebook.php?novelid{novel_info[novel_id]} # 查找作者信息可能在另一个a标签或td里 author_tag row.find(a, hreflambda x: x and authorid in x) or row.find_all(td)[2] # 需要根据实际结构调整索引 if author_tag: novel_info[author] author_tag.get_text(stripTrue) # 其他信息字数、积分、收藏数等查找方式类似 # ... 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_参数接受一个函数该函数返回True或False。这里我们查找class属性是tr1或tr2的tr标签。这比写两个find_all然后合并结果更简洁。title_tag.get_text(stripTrue)获取标签内的文本并去除首尾空白字符。stripTrue参数非常实用。正则表达式初现re.search(rnovelid(\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(novelidnovel_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(stripTrue) # 2. 对付“文章类型”这类格式固定的文本块正则表达式是利器 # 假设“文章类型”出现在类似“文章类型原创-言情-近代现代-爱情”的文本中 # 我们先找到包含这个文本的某个大的容器比如整个简介区域的div intro_div soup.find(div, idnovelintro) 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. 提取主角名可能格式为“主角XXXYYY ┃ 配角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, filenamenovel_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, onnovel_id, howleft) else: df_merged df_novels if not df_novels.empty else df_details # 数据清洗示例 # 1. 去除完全空白的行 df_merged.dropna(howall, inplaceTrue) # 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}, inplaceTrue) # 保存到CSV df_merged.to_csv(fdata/{filename}, indexFalse, encodingutf-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()增量爬取策略思考在实际项目中我们不可能每次都全量爬取。一个简单的增量策略是记录已爬取的ID每次爬取后将成功爬取的novel_id保存到一个文件如crawled_ids.txt或数据库。爬取前过滤在parse_rank_page获取到新的榜单列表后先与已爬取的ID列表对比只爬取那些不在列表中的新小说。定时与更新可以设置定时任务如每天一次运行爬虫只爬取榜单上新出现的小说。对于详情页如果小说信息可能更新如字数增长也可以根据时间戳决定是否重新爬取。4. 高级话题反爬虫应对与工程化考量如果只是运行上面的基础代码很可能很快就会被网站限制访问。一个成熟的爬虫必须考虑反爬策略。4.1 常见的反爬机制与应对方案User-Agent检测我们已经做了使用常见的浏览器UA。请求频率限制这是最普遍的。我们的代码中在详情页爬取时加入了time.sleep(1)。更优的做法是使用随机延迟并控制总体并发数。import random time.sleep(random.uniform(1, 3)) # 随机延迟1-3秒IP封锁单个IP短时间内请求过多会被封。解决方案包括使用代理IP池从免费的或付费的代理服务获取IP并在请求中轮换使用。requests库使用代理很简单requests.get(url, proxies{http: http://ip:port, https: https://ip:port})。降低请求速度这是最经济的方法配合随机延迟。Cookie/Session验证有些页面需要登录或携带特定Cookie。我们可以使用requests.Session()对象来保持会话并手动设置必要的Cookie。session requests.Session() # 可以预先访问一次首页获取初始Cookie session.get(https://www.jjwxc.net, headersHEADERS) # 后续请求都用session response session.get(target_url, headersHEADERS)JavaScript渲染如果目标数据是通过JavaScript动态加载的晋江大部分核心数据不是但一些交互内容可能是那么requestsBeautifulSoup就无能为力了。这时需要用到Selenium或Playwright这类自动化测试工具来模拟浏览器行为或者更高效地找到数据背后的真实API接口通过浏览器开发者工具的“网络”选项卡抓包分析。4.2 错误处理与日志记录一个健壮的程序必须有完善的错误处理和日志。import logging # 配置日志 logging.basicConfig(levellogging.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_infoTrue) 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_namenovels.db, table_namenovels): 将DataFrame保存到SQLite数据库 conn sqlite3.connect(fdata/{db_name}) # if_existsreplace表示如果表存在则替换append表示追加 df.to_sql(table_name, conn, if_existsappend, indexFalse) conn.close() logger.info(f数据已存入数据库 {db_name}.{table_name})数据库便于查询、去重和增量更新。你可以设计更复杂的表结构比如将标签单独存一张表与小说表建立多对多关系这样数据分析时会更灵活。5. 实战中的“坑”与经验总结最后分享几个我在爬取晋江以及其他网站时踩过的坑和总结的经验这些是文档里不会写的。HTML结构变动这是最大的风险。网站改版后你的选择器可能全部失效。对策不要将选择器如div.class1 span写死。尽量使用更稳定的特征比如id属性如果它有的话或者通过多个标签层级和文本内容来定位。将关键的选择器字符串作为配置项放在config.py里方便统一修改。定期比如每周用爬虫跑一下核心页面检查解析是否正常。编码问题的顽固性即使使用了apparent_encoding偶尔还是会遇到乱码。终极方案如果知道网站固定编码如晋江有时是gb2312可以尝试response.content.decode(gb2312, errorsignore)。errorsignore会忽略无法解码的字符虽然可能丢失极少信息但保证了程序不崩溃拿到大部分正确数据。正则表达式的贪婪与懒惰这是正则里最容易出错的地方。默认的*和是“贪婪”的会匹配尽可能多的字符。例如用r主角(.*)配角去匹配“主角张三李四 配角王五”由于.匹配任何字符除了换行贪婪模式下的(.*)会一直匹配到最后一个“配角”结果可能不是你想要的。这时需要使用懒惰模式在量词后加?如r主角(.*?)配角它会匹配到第一个“配角”就停止。网络不稳定与超时设置timeout参数至关重要。设置一个合理的总超时和连接超时。对于requests可以传入一个元组(connect_timeout, read_timeout)。对于慢速或不稳定的网站适当调大read_timeout。尊重robots.txt在爬取前访问一下https://www.jjwxc.net/robots.txt看看网站是否允许爬虫访问你目标目录。虽然这不是法律强制但这是良好的网络公民礼仪。对于明确禁止的目录应避免爬取。数据清洗的复杂性从网页抓取的文本经常包含多余的空格、换行、不可见的Unicode字符如\u3000全角空格。在存储前进行彻底的清洗使用str.strip()、str.replace()或者更强大的re.sub(r\s, , text)来将多个空白字符替换为一个空格。写完爬虫代码只是第一步让它稳定、高效、可持续地运行并在网站结构变化时能快速调整才是真正的挑战。下一篇我们将利用这篇爬取到的数据开始有趣的数据分析部分看看晋江文学城里藏着哪些有趣的趋势和洞见。