资讯详情 Python多线程爬虫实战:章节抓取与顺序写入TXT全流程
📅 2026/10/16 5:10:06
朋友上周问了我一个挺典型的需求想把一个连载文本站点上整本小说按章节顺序抓下来存成一个干净的TXT文件拿去做离线阅读。他第一句话就是“听说用1000个线程爬速度起飞”。我听完先没急着写代码而是帮他把需求拆了拆。这个项目看着就是“Python 多线程 爬虫 文件落盘”四件事但真正决定成败的反而不是“1000个线程”这个数字而是站点结构、章节顺序、并发控制、文本清洗这几个细节。这篇文章把我实际操盘这个项目的全过程写出来包括怎么规划线程数量、怎么解析章节列表、怎么解决并发抓取后的乱序问题、怎么写TXT不至于内存爆炸以及一堆不会写进教科书里的坑。不管你是刚接触爬虫的初学者还是想优化现有采集脚本的老手这套流程都能直接抄作业。前提是目标站点必须是公开可索引、无版权争议或已获授权的文本资源严禁拿这套逻辑去碰盗版内容。合规是底线别把技术用偏了。1. 项目拆解核心取决于三个环节1.1 需求本质是I/O密集型任务不是计算密集型很多人一听“1000个线程”就热血沸腾觉得线程越多越快。这里先纠正一个底层认知爬虫任务属于典型的I/O密集型瓶颈在网络请求的等待时间不在CPU计算。你发出一个HTTP请求从建立TCP连接到服务器返回响应通常需要100毫秒到数秒不等其中绝大部分时间都在等数据从远端传回来。如果单线程顺序抓一个章节0.5秒1000个章节就要8分多钟体验确实不行。多线程的核心价值就在这里当一个线程在等网络响应时CPU几乎是空闲的正好让其他线程去发起新的请求。所以抓取速度近似等于“并发数 ÷ 单请求耗时”而不是“线程数 ÷ 单请求耗时”。理论上把1000个线程撒出去同时等1000个请求回来吞吐量确实能拉满。但你马上会碰到另一个问题线程本身的开销、目标站的承受能力、本机文件句柄上限都会限制你的“百线程千线程”计划。1.2 正确打开方式先列功能清单再定技术方案接到需求先别写代码把要做的功能一条条列出来拿到整个书籍的章节索引即章节名和对应URL的完整列表抓取每个章节的正文内容去掉导航、广告、版权声明这类噪声将抓取结果按照章节序号排列确保TXT里章节顺序不乱写入单个TXT文件编码统一、格式清晰这四条缺一不可。尤其是第三条很多人在多线程场景下翻车就翻在“顺序”上。并发请求的完成时间天然是乱序的第1章可能最后才返回第500章可能最先写完。如果不做排序你得到的TXT就是一部被随机洗牌的“盲盒小说”。所以这个项目的真正难点是“并发抓取 顺序落盘”而不是单纯的“开多少线程”。2. 线程规划从“1000线程”到“1000任务”的正确翻译2.1 直接创建1000个Thread会有哪些连锁问题先正面回答标题里“创建1000个线程”这个问题。用threading.Thread直接裸开1000个线程程序确实能跑但代价不小每个线程默认分配约8MB的虚拟内存栈1000个线程光栈空间就是8GB级别。虽然虚拟内存不是物理内存但疯狂创建重叠线程会导致频繁的上下文切换CPU时间片全耗在切换上了。线程没有上限控制如果目标站响应变慢连接越积越多触发本机TCP端口耗尽的风险表现为OSError: [Errno 24] Too many open files。站点方很快会发现异常流量轻则限速重则封IP。Python的GIL决定了一个进程同一时刻只能执行一个字节码CPU密集型任务开1000线程毫无意义I/O密集任务里线程的真正价值是并行等待而不是并行计算。所以在实践里我以前也试过直接撸1000个线程最终效果反而不如开64个线程池稳定。线程数量不是越大越好它是“够用就好”。目标站能承受的并发连接和你的网速带宽才是天花板。2.2 用线程池把“任务数量”和“并发数量”分开管理真正合理的做法是任务数量是1000个甚至更多但并发执行的任务保持在可控区间。concurrent.futures.ThreadPoolExecutor就是干这个的。你把所有章节URL作为一个一个任务提交进线程池线程池内部用固定数量的工作线程循环处理谁做完任务就去取下一个任务线程总数始终可控。这里直接给我实测稳定的一套配置import threading import requests from bs4 import BeautifulSoup from concurrent.futures import ThreadPoolExecutor, as_completed HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, } session requests.Session() session.headers.update(HEADERS) def fetch_content(url: str, timeout: int 15) - str: resp session.get(url, timeouttimeout) resp.raise_for_status() return resp.text def process_chapter(chapter): chapter: dict - {seq: 1, title: 第一章 xxx, raw_url: http://...} try: html fetch_content(chapter[raw_url]) content parse_content(html) return { seq: chapter[seq], title: chapter[title], content: content, status: ok, } except Exception as exc: return { seq: chapter[seq], title: chapter[title], content: , status: ferror: {exc}, }提交任务时用一个变量控制并发线程数MAX_WORKERS 32 # 我这边实测32~64最稳 with ThreadPoolExecutor(max_workersMAX_WORKERS) as pool: future_map {} for ch in chapter_list: future pool.submit(process_chapter, ch) future_map[future] ch[seq] results [] for future in as_completed(future_map): result future.result() results.append(result)as_completed会一边完成一边返回结果你不用等所有请求都结束才动手。收集完results之后做一次排序再落盘这个大方向是对的。关于排序的具体写法放到第4节细说。2.3 并发数到底怎么定从测试数据看我自己的经验是对于普通中型动态站点MAX_WORKERS16到32是最舒服的区间对于静态资源站可以大胆一点开到64。再往上比如128、256提升不明显且排查问题的难度成倍增加。如果你非要在项目里保留“1000”这个字眼那就把它定义成“1000个章节任务”而不是“1000个线程”。一个1000章节的书用32线程并发抓单章平均算0.3秒的话理论上1000个任务全跑完大概10~15秒这速度足够用了。你需要的不是“同时发起1000个请求”而是“尽快处理完1000个任务”。3. 正文抓取链路章节索引与文章详情分离3.1 阶段一解析目录索引页多数小说站的结构是两层一个包含全书目录的页面以及每个章节独立的详情页。稳妥的爬法不是直接猜URL规则而是先从目录页提取所有章节链接拿到完整URL列表再去并发抓详情页。目录页的HTML结构千差万别以最常见的“书籍详情页里挂一个章节列表区块”为例解析手段如下from urllib.parse import urljoin def parse_chapter_list(index_url: str, html: str) - list: soup BeautifulSoup(html, html.parser) chapter_items [] # 假设章节链接都放在 id 为 chapter-list 的容器内 container soup.find(div, idchapter-list) if not container: return chapter_items for a in container.find_all(a, hrefTrue): title a.get_text(stripTrue) href urljoin(index_url, a[href]) if title and href: chapter_items.append({ seq: len(chapter_items) 1, title: title, raw_url: href, }) return chapter_items写解析器时需要注意有些站点会把最新章节列表和全部章节列表放在同一页面前者只是后者的子集直接用len(chapter_items) 1作为序号有缺陷。稳妥的做法是先确认容器范围比如select(div.chapter-list a)里到底有多少个链接或者直接利用页面自带的“显示全部章节”入口。我在实操中踩过一次某站点“最新章节”列表只有20个链接主列表有800个按错误范围解析导致后面全部乱序。所以解析前先print(len(chapter_items))和页面a标签总数做一个交叉验证。3.2 阶段二提取正文内容并清洗噪声拿到章节HTML后需要从正文容器里提取文字。常见正文容器特征有classcontent、idchapter-content、classread-content等等。提取正文只是第一步清洗才是大头。页面里通常还夹杂着“上一章”“下一章”“加入书签”“最新章节列表”“广告位”等噪声文本统统要处理掉。下面是一个比较实用的正文提取函数def parse_content(html: str) - str: soup BeautifulSoup(html, html.parser) content_div soup.find(div, class_content) if not content_div: content_div soup.find(div, idchapter-content) if not content_div: return # 去掉脚本和样式块 for tag in content_div.find_all([script, style]): tag.decompose() # 去掉直接挂在正文下的错误标签 for tag in content_div.find_all(a): tag.decompose() # 提取文本保留段落结构 lines [] for p in content_div.find_all(p): text p.get_text(stripTrue) if text: lines.append(text) if not lines: # 如果正文不是p标签包裹直接取所有文本 raw content_div.get_text(\n, stripTrue) lines [ln.strip() for ln in raw.splitlines() if ln.strip()] return \n.join(lines)清洗逻辑里有几个细节值得展开正文区块里经常混入“本章未完请点击下一页继续阅读”之类的东西这些通常出现在a标签或单独的span里把a标签直接decompose()能去掉大部分干扰。还有一些正文段落不用p包裹而是直接换行分隔的纯文本所以get_text(\n)后按行拆分、过滤空行是必要的兜底逻辑。清洗时别过度过滤。有些正文里正常包含一些特殊字符、对话引号这些要原样保留。如果清洗规则太激进容易把章节内容删得缺斤少两。宁可多保留再人工检查也不要一刀切删错。3.3 会话复用与控制抓取频率抓取阶段要用requests.Session()而不是每次requests.get()。Session会复用底层TCP连接频繁访问同一站点时可以明显减少握手开销。同一线程内连续请求同一站点速度能快一截。同时加一点温和的限速避免把目标站打爆import time import random def polite_delay(): time.sleep(random.uniform(0.1, 0.3))每个线程每次请求前调用一下polite_delay()。1000个任务、32个线程这个延迟会让总耗时从5秒变成20秒左右但换来的稳定性是值回票价的。真碰到反爬严格点的站这个延迟可能还得放大到1~2秒。还有一个关键点timeout一定要设。不设timeout时如果某个连接挂起线程会一直阻塞在recv整个线程池可能被几个坏连接拖死。设置15秒超时然后配合重试机制是实测下来比较稳的组合。4. 章节顺序并发抓完怎样拼出一部完整TXT4.1 并发必然带来乱序不能直接边抓边写简单验证一下1000个章节任务丢进线程池线程A拿到第1章线程B拿到第2章但线程B的网络更快它的结果先返回。如果你在回调里直接打开文件追加写入那么TXT里的顺序就变成了2、1、3……这就是标准的乱序写入问题。解决方案有两个主流一是“先抓后排”把所有任务的抓取结果收集到内存全部完成后统一按seq排序再写入文件二是“边抓边占位”提前用章节序号在TXT里占好位置每个线程写完直接覆写自己的位置。两者各有优劣1000章节这种量级我推荐方案一。4.2 方案一抓取结果收集后统一排序前面代码里的results列表就是干这个的。所有任务结束后按seq排序即可results.sort(keylambda item: item[seq])然后一次性写入TXT。注意1000章的完整文本通常只有几MB到十几MB最多几十MB现代电脑内存毫无压力。一次性持有结果再写入代码逻辑是最清晰、最不易出错的。真正的超大书库几十万章节那种场景才需要考虑分批落盘普通小说完全没必要。写入TXT时我习惯用utf-8编码加一个可读性高的章节分隔格式def write_to_txt(results, output_pathbook.txt): with open(output_path, w, encodingutf-8) as f: for item in results: if item[status] ! ok: f.write(f\n\n 章节抓取失败待补抓 \n\n) continue f.write(item[title]) f.write(\n\n) f.write(item[content]) f.write(\n\n) # 每次写入后flush防止程序中途崩溃丢缓冲 f.flush()flush()这个细节是我自己血泪换来的。爬虫脚本跑得久万一中途断网或手动终止缓冲在内存里的数据可能全部丢失。频繁flush()会稍微降低写入性能但换来的是随时可以安全终止任务。4.3 方案二占位符覆写机制如果你抓的是超大文本库担心内存占用可以用占位符方案。提前用w模式把文件按照“章节序号 固定分隔符”铺好每个线程完成抓取后回到主进程统一排序写入即可——其实方案二我真正用的场景是“边爬边看进度”比如要预览前几章是否正常。做法是先在文件里写入占位行然后抓完一章就覆写一行但这样打印进度和断点续爬都会更复杂。除非书籍体量极大否则不建议优先考虑。4.4 写入格式与文本清洗边界TXT文件里最好体现三个信息书名与作者信息如果页面有且允许保留、章节标题、正文段落。尽量不要把页面底部的“本章完”“防采集标识”等写进TXT。有些站点为避免爬虫会在正文中插入类似“content”的随机字符串这种属于站点标识符通常清洗规则里直接把这类字符替换为空白def clean_placeholder(content: str) - str: # 清洗常见的防采集占位串具体需要根据站点情况调整 placeholder_list [content, 本章未完请点击下一页继续阅读, 请记住本站网址] for ph in placeholder_list: content content.replace(ph, ) return content.strip()这里想提醒一点清洗规则一定要在完整跑完一遍后检查产出文件。我第一次跑完第35章到第60章的结尾悄悄混入了几条阅读链接就是因为我没检查a标签decompose()的覆盖范围漏了挂在容器外的链接节点。跑完一把grep一下产出文件里的URL特征能发现绝大部分漏网之鱼。5. 常见问题与避坑实录5.1 线程一多就被限流403大面积报错这是多线程爬虫最常见的事故。症状是前几十个请求正常突然连续403或429然后线程池里的process_chapter全部报错。原因很简单目标站的WAF检测到同一IP在极短时间内发起大量请求触发限流策略。我的标准处理策略分三层控制MAX_WORKERS不超过32每次请求之间加随机延时失败重试指数退避import time def fetch_with_retry(url, max_retry3, timeout15): for attempt in range(max_retry): try: resp session.get(url, timeouttimeout) if resp.status_code in (403, 429): raise RuntimeError(fstatus_code: {resp.status_code}) resp.raise_for_status() return resp.text except Exception: if attempt max_retry - 1: raise wait_time 2 ** attempt random.uniform(0, 1) time.sleep(wait_time)如果你遇到过严格的反爬页面它返回的403页面里可能带着“验证码”或“跳转确认”之类的字样。这种就不是调延时能解决的属于需要评估合法授权路径的场景。我的建议是直接放弃偷鸡方案联系站方获取公开API或授权爬取接口走正道。5.2 部分章节抓回空内容现象整体抓取成功但某几章正文是空字符串。排查方向有三个章节详情页是否属于独立域名Session携带的Cookie是否在跳转时丢失以及页面正文是不是由JavaScript动态渲染的。如果是动态渲染requests直接抓到的HTML里根本没有正文数据你必须改用浏览器渲染方案比如无头浏览器或者找到站点背后的JSON接口。用无头浏览器会牺牲掉并发效率所以我通常会优先抓包看接口。用浏览器的开发者工具刷新章节页过滤XHR请求往往能找到一个返回纯文本或JSON的正文接口直接用requests访问要轻量得多。5.3 乱码网页说charsetutf-8实际写入却全是乱码这里有个经典问题页面声明编码和实际编码不一致。有些老站点的页面meta charsetgb2312但文本内容实际是GBK或GB18030。如果直接用requests的默认解码拿到的字符串就是乱码。解决方式是优先用resp.apparent_encoding或resp.encoding做判断def safe_decode(resp): # 优先用响应头声明的编码没有就尝试自动推断 if resp.encoding and resp.encoding.lower() not in (iso-8859-1,): return resp.text resp.encoding resp.apparent_encoding return resp.text判断核心逻辑是先用requests根据响应头推断编码如果推断结果看起来可疑比如落到iso-8859-1基本等于没识别出来再用apparent_encoding做一次猜测。把解码这一步在入口处解决后面BeautifulSoup解析和写入TXT才会顺。5.4 半路中断后如何续跑爬1000章的时候跑到一半断网或程序崩溃太常见了。没有断点续传机制的话重新跑全量非常浪费时间。我在脚本里加了一个简单的“进度记录”import json import os PROGRESS_FILE progress.json def save_progress(finished_seq: list): with open(PROGRESS_FILE, w, encodingutf-8) as f: json.dump({finished: finished_seq}, f) def load_finished(): if not os.path.exists(PROGRESS_FILE): return set() with open(PROGRESS_FILE, r, encodingutf-8) as f: data json.load(f) return set(data.get(finished, []))在process_chapter返回成功后把seq写进进度文件里任务开始前先过滤掉已经完成的章节。这属于“笨办法但有效”的典型。很多现成的爬虫框架自带断点续爬能力但手写脚本时加上这几行代码成本极低能省掉大量返工时间。5.5 常见故障速查表现象原因解决方案大面积403/429并发太高触发反爬降低线程数、加延时、指数退避重试抓回来内容为空页面由JS动态渲染找JSON接口或改用无头浏览器乱码声明编码与实际不符apparent_encoding推断后用正确编码解码进度中断后重跑无断点标记维护progress.json记录完成序号标题与正文重复解析容器范围过大精确选择正文容器过滤非正文节点文件太大写入慢每章都open/write/close一次性流程结束后统一写入并适当flush6. 还能怎么优化协程与更稳的工程化方案6.1 线程池换成协程会怎样同样是并发等待I/O协程比线程更轻量。asyncioaiohttp可以在单线程事件循环里管理上万个并发请求。1000个协程的开销远小于1000个线程这在追求“高并发”的场景里更合适。我后来把同样的书库抓取任务用协程重写了一遍1000个章节的任务开500个并发请求全程稳定内存占用比32线程还要低。核心结构大概是import asyncio import aiohttp async def fetch_one(session, url): async with session.get(url) as resp: return await resp.text() async def main(chapter_list): async with aiohttp.ClientSession() as session: tasks [] for ch in chapter_list: tasks.append(fetch_one(session, ch[raw_url])) pages await asyncio.gather(*tasks, return_exceptionsTrue)协程虽然门槛比ThreadPoolExecutor高一点但优势明显没有线程切换开销、没有GIL干扰、并发数可以轻松开到几百甚至上千。如果你的目标是真正“压榨”并发性能值得专门把这套方案做深一点。6.2 断点续爬与增量更新组合拳前面的progress.json只是最基础的断点恢复。更进一步你可以在章节列表里记录每个章节的正文哈希值比如md5(content)存入一个本地索引文件。下次运行时只抓取哈希值不一致或缺失的章节这样日常追更就只抓新增章节不用全量重抓。这种设计用在“每周更新的连载文本源”上非常实用。import hashlib def content_hash(content: str) - str: return hashlib.md5(content.encode(utf-8)).hexdigest() def need_update(ch: dict, local_index: dict) - bool: old_hash local_index.get(ch[raw_url]) return old_hash not in (None, ch.get(content_hash))6.3 我踩过几次坑之后的个人建议这个项目做下来最大的体会是任何规模的任务方案设计都优先于代码量。开多少个线程、用什么解析库、要不要断点这些决定都应该在写第一行代码之前想清楚。如果你只是临时抓几百章单线程 顺序写入都够用如果你要抓几千章并持续维护那才需要把并发和断点机制加进来。这两者的工程复杂度差了一个数量级。另外一个容易被忽略的点最终产出TXT的“可读性”往往比抓取速度更重要。一份排版混乱、夹杂着广告文本、顺序错乱的TXT即便抓得再快也等于废品。我习惯在写完脚本后随机抽取开头、中间、结尾各三章人工校对确认标题格式、空行分隔、正文完整度都正常再批量使用。这个项目后续可以扩展的方向也很多比如加上自动重试报警、通过正文哈希做增量更新、把文件输出改成EPUB或Markdown格式。核心思路不变先拿到可靠的数据流再把数据以约定格式稳定落盘。你手头如果正好有类似“按章节抓取并排序保存”的需求直接照这个流程搭一套就够用了。先把合规边界划清楚再去折腾线程数路才能走得稳。