1. 项目缘起从手动复制到自动化归档的转变做技术学习或者知识整理的时候我们经常会遇到一个场景某个网站上有成体系的、质量不错的试题或文章你想把它们保存下来方便离线查阅或者导入到自己的笔记系统里。手动一页页复制粘贴效率低下不说格式还容易乱。我之前在整理一些技术面试题和竞赛题目时就深受其苦直到用Scrapy框架把整个过程自动化才算是真正解放了双手。这个项目就是教你如何用Scrapy这个强大的Python爬虫框架精准地抓取目标网站上的试题内容并规整地保存成本地Markdown文件。这不仅仅是“爬下来”更是“爬得好”——保持原有结构、清晰易读、便于后续处理。为什么是Scrapy因为它不是简单的requests加BeautifulSoup。Scrapy是一个为持续、大规模数据抓取而设计的异步框架它内置了健壮的请求调度、去重、管道处理机制。对于需要翻页、需要保持会话、或者网站结构稍复杂的试题库Scrapy的稳定性和扩展性远胜于写脚本。而输出为Markdown则是因为它几乎是技术文档的通用语纯文本、轻量、兼容性强可以直接用Typora、VS Code等编辑器查看也能轻松导入Notion、Obsidian等知识管理工具甚至转换为PDF或Word。整个流程的核心思路可以概括为定义抓取目标试题列表页和详情页 - 解析页面结构提取数据题目、选项、答案、解析 - 通过Scrapy的Item和Pipeline对数据进行清洗和格式化 - 最后写入到本地的.md文件中。听起来步骤清晰但每个环节都有不少细节需要打磨比如如何应对网站的反爬策略、如何处理不规则的多级标题、如何设计Markdown的存储结构才能既保持原貌又方便检索。接下来我就结合一个模拟的“运维知识题库”网站为避免侵权我们以虚构结构为例原理通用拆解每一步的具体实现和避坑要点。2. 环境搭建与Scrapy项目初始化工欲善其事必先利其器。首先确保你的工作环境是准备好的。我强烈建议使用Python的虚拟环境来管理项目依赖这样可以避免不同项目间的包版本冲突。2.1 创建虚拟环境与安装Scrapy打开你的终端Windows用CMD或PowerShellmacOS/Linux用Terminal执行以下命令来创建并激活一个虚拟环境。# 创建名为 scrapy_tutorial 的虚拟环境 python -m venv scrapy_tutorial_env # 激活虚拟环境 # Windows: scrapy_tutorial_env\Scripts\activate # macOS/Linux: source scrapy_tutorial_env/bin/activate激活后命令行提示符前通常会显示环境名(scrapy_tutorial_env)。接下来安装Scrapy它会自动安装一系列依赖如Twisted异步网络引擎、lxml解析库等。pip install scrapy安装完成后可以输入scrapy version来验证是否成功。我个人的经验是如果网络环境导致Twisted安装缓慢或失败可以尝试先使用国内镜像源安装Twisted再安装Scrapypip install Twisted -i https://pypi.tuna.tsinghua.edu.cn/simple。2.2 创建Scrapy项目与第一个爬虫Scrapy使用项目Project来组织代码。我们在合适的目录下创建一个名为question_spider的项目。scrapy startproject question_spider cd question_spider这个命令会生成一个标准的Scrapy项目结构主要目录和文件如下question_spider/ ├── scrapy.cfg # 项目部署配置文件 └── question_spider/ # 项目Python模块 ├── __init__.py ├── items.py # 定义要抓取的数据结构 ├── middlewares.py # 自定义中间件如代理、User-Agent轮换 ├── pipelines.py # 数据管道用于处理清洗和存储 ├── settings.py # 项目设置文件非常重要 └── spiders/ # 爬虫代码存放目录 └── __init__.py我们的核心爬虫代码就放在spiders目录下。假设我们要爬取的网站是https://example-questions.com/ops列表页分页URL格式为https://example-questions.com/ops/page/1每个试题详情页的链接在列表页中。我们创建一个名为ops_question_spider.py的爬虫文件。scrapy genspider ops_question example-questions.com这个命令会在spiders目录下生成一个模板文件。但通常我更习惯手动创建以便有更清晰的掌控。手动创建spiders/ops_spider.py并写入以下基础骨架import scrapy class OpsQuestionSpider(scrapy.Spider): name ops_question # 爬虫的唯一标识运行爬虫时使用 allowed_domains [example-questions.com] # 限制爬取的域名 start_urls [https://example-questions.com/ops/page/1] # 起始URL def parse(self, response): # 这个方法用于处理列表页提取详情页链接并跟进 pass至此项目骨架就搭好了。但在真正开始写解析逻辑前我们必须先做好两件事一是在settings.py中配置好爬虫的“行为准则”二是在items.py中定义好我们要抓取的“试题”到底长什么样。3. 核心配置与数据结构定义3.1 调整Scrapy设置settings.pysettings.py文件是Scrapy爬虫的大脑控制着并发、延迟、缓存、管道等所有行为。对于试题爬虫我通常会做如下调整以平衡效率与友好度并规避一些常见的反爬机制。# question_spider/settings.py BOT_NAME question_spider # 遵守robots协议对于正规网站建议设为True但有些网站会通过robots.txt屏蔽爬虫可根据实际情况调整。 ROBOTSTXT_OBEY False # 配置并发请求数默认16。对于小型网站或避免给对方服务器造成压力可以调低比如5。 CONCURRENT_REQUESTS 5 # 下载延迟单位秒。两次请求间的最小等待时间体现爬虫的礼貌。设为1秒是比较保守友好的选择。 DOWNLOAD_DELAY 1 # 启用并配置Cookies中间件对于需要登录或保持会话的网站很重要。 COOKIES_ENABLED True # 默认的请求头模拟浏览器访问是绕过基础反爬的关键。 DEFAULT_REQUEST_HEADERS { Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, 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, } # 启用并配置Item Pipeline。数字代表优先级值越小优先级越高。 ITEM_PIPELINES { question_spider.pipelines.QuestionSpiderPipeline: 300, } # 启用AutoThrottle扩展它能根据服务器负载自动调整请求延迟更智能。 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1 AUTOTHROTTLE_MAX_DELAY 10 AUTOTHROTTLE_TARGET_CONCURRENCY 2.0 # 设置日志级别开发调试时设为DEBUG生产运行时设为INFO或WARNING。 LOG_LEVEL INFO注意USER_AGENT最好定期更新或者使用fake_useragent库来随机生成。将DOWNLOAD_DELAY和CONCURRENT_REQUESTS结合使用是控制爬取速度、避免IP被封的最基本手段。3.2 定义试题数据结构items.py在Scrapy中Item对象用于定义要抓取的数据结构它像一个容器规范了我们从网页中提取哪些字段。对于一道试题我们通常关心以下信息# question_spider/items.py import scrapy class QuestionItem(scrapy.Item): # 定义试题的数据字段 # 试题ID或唯一标识可从URL或页面元素中提取 question_id scrapy.Field() # 试题分类或标签 category scrapy.Field() # 试题标题/题干 title scrapy.Field() # 试题选项可能是一个列表如 [A. xxx, B. xxx] options scrapy.Field() # 正确答案 correct_answer scrapy.Field() # 试题解析或知识点说明 analysis scrapy.Field() # 试题来源URL便于追溯 source_url scrapy.Field() # 爬取时间戳 crawl_time scrapy.Field()为什么要把options定义为一个Field()而不是提前定死结构因为不同网站的试题形式差异很大单选题、多选题、判断题的选项呈现方式不同。我们可以先把它爬取下来可能是一段HTML或纯文本然后在Pipeline中再做统一的清洗和格式化。定义好Item后在爬虫的解析函数中我们就可以实例化QuestionItem对象并像字典一样为其赋值。4. 爬虫逻辑编写解析列表与详情页这是整个爬虫的“肌肉”部分负责从网页HTML中提取我们需要的数据。我们使用XPath或CSS选择器来定位元素。我个人更偏好XPath因为它功能强大且表达式灵活。这里以XPath为例。4.1 解析列表页获取详情页链接首先我们需要修改ops_spider.py中的parse方法让它处理起始的列表页。假设列表页中每个试题的链接在一个article classquestion-item标签内的a标签的href属性中。# spiders/ops_spider.py import scrapy from question_spider.items import QuestionItem from urllib.parse import urljoin # 用于拼接相对URL为绝对URL class OpsQuestionSpider(scrapy.Spider): name ops_question allowed_domains [example-questions.com] start_urls [https://example-questions.com/ops/page/1] def parse(self, response): # 提取当前列表页所有试题详情页的链接 question_links response.xpath(//article[classquestion-item]/a/href).getall() for link in question_links: # 将相对URL转换为绝对URL absolute_url urljoin(response.url, link) # 发起对详情页的请求并指定回调函数为 parse_question yield scrapy.Request(absolute_url, callbackself.parse_question) # 处理分页查找并跟进“下一页”链接 next_page response.xpath(//a[classnext-page]/href).get() if next_page: next_page_url urljoin(response.url, next_page) yield scrapy.Request(next_page_url, callbackself.parse)这里的关键点是yield scrapy.Request。Scrapy是异步框架yield一个Request对象会将这个请求加入调度队列而callback参数指定了当这个请求下载完成后由哪个方法来处理其响应。这样我们就实现了“爬取列表页 - 提取所有详情页链接 - 分别爬取每个详情页”的流程。4.2 解析详情页提取试题数据接下来我们需要实现parse_question方法它负责从试题详情页中提取具体内容并填充到QuestionItem中。def parse_question(self, response): # 实例化一个Item对象 item QuestionItem() # 填充字段 item[source_url] response.url # 假设ID可以从URL中截取例如 URL 为 .../question/123/ item[question_id] response.url.split(/)[-2] if response.url.endswith(/) else response.url.split(/)[-1] # 提取分类假设在 span classcategoryLinux基础/span item[category] response.xpath(//span[classcategory]/text()).get(default未分类).strip() # 提取题干假设在 h1 classquestion-title 标签内 item[title] response.xpath(//h1[classquestion-title]/text()).get(default).strip() # 提取选项。选项可能在一个 div classoptions 下的多个 li 中。 # 使用 getall() 获取所有选项文本的列表 options_list response.xpath(//div[classoptions]/li/text()).getall() # 清理每个选项的空白字符 item[options] [opt.strip() for opt in options_list if opt.strip()] # 提取正确答案可能在 div classanswer 标签内并有特殊标记如 strong correct_answer response.xpath(//div[classanswer]//text()).getall() # 将列表合并成字符串并清理空白 item[correct_answer] .join([text.strip() for text in correct_answer if text.strip()]) # 提取解析可能在 div classanalysis 标签内 analysis_text response.xpath(//div[classanalysis]//text()).getall() item[analysis] \n.join([text.strip() for text in analysis_text if text.strip()]) # 添加爬取时间戳 from datetime import datetime item[crawl_time] datetime.now().strftime(%Y-%m-%d %H:%M:%S) # 将填充好的Item返回交给Pipeline处理 yield item实操心得网页结构千变万化这里的XPath路径只是示例。在实际操作中你必须使用浏览器的开发者工具F12来仔细审查目标网页的HTML结构。在Elements面板中右键点击目标元素选择“Copy - Copy XPath”可以快速获取一个XPath但这生成的路径往往很长且脆弱依赖于完整层级。更好的做法是观察元素的id、class等属性编写更简洁、健壮的相对路径。例如//div[contains(class, content)]//p就比一长串的绝对路径要好。5. 数据处理与Markdown文件生成管道爬虫解析出数据Item后并不会直接保存。它们会被发送到ITEM_PIPELINES中定义的管道Pipeline进行处理。这是进行数据清洗、验证和持久化的最佳位置。我们将在这里实现将试题数据写入Markdown文件的逻辑。5.1 设计Markdown文件存储结构在保存之前我们需要决定文件的组织方式。一种简单的方式是按分类Category建立文件夹每个试题一个文件。但试题文件太多会难以管理。我更推荐的方式是按分类创建文件夹每个文件夹下存放一个以日期或批次命名的Markdown文件该文件内按顺序存放该分类下的所有试题。这样既保持了分类清晰又避免了文件爆炸。例如爬取后的目录结构可能如下output/ ├── Linux基础/ │ └── questions_20231027.md ├── 网络协议/ │ └── questions_20231027.md └── 系统安全/ └── questions_20231027.md每个Markdown文件内部每道试题可以用二级标题##开始后面跟着题干、选项用列表表示、答案和解析。5.2 实现自定义Pipeline现在我们来编写pipelines.py。这个Pipeline需要做以下几件事在爬虫启动时创建按分类组织的输出目录。对每个到来的Item根据其分类追加写入到对应的Markdown文件中。对Item中的数据进行最后的清洗和格式化例如确保选项的字母编号正确。# question_spider/pipelines.py import os from datetime import datetime class QuestionSpiderPipeline: def open_spider(self, spider): 当爬虫启动时调用。初始化输出目录。 # 基础输出路径 self.base_output_dir ./output # 用于跟踪每个分类文件当前写入到的文件句柄 self.category_files {} # 确保基础目录存在 if not os.path.exists(self.base_output_dir): os.makedirs(self.base_output_dir) # 生成一个本次爬取的批次标识例如日期 self.batch_id datetime.now().strftime(%Y%m%d_%H%M%S) spider.logger.info(f爬虫启动输出批次ID: {self.batch_id}) def process_item(self, item, spider): 对每个Item进行处理。 # 1. 数据清洗与补全 category item.get(category, 未分类) # 清理分类名中的非法文件名字符 safe_category .join([c for c in category if c.isalnum() or c in ( , -, _)]).rstrip() safe_category safe_category if safe_category else 未分类 # 确保选项是一个列表并格式化为Markdown无序列表 options item.get(options, []) if isinstance(options, list): formatted_options \n.join([f- {opt} for opt in options]) else: # 如果options不是列表比如是字符串尝试按行分割 formatted_options - str(options).replace(\n, \n- ) # 2. 确定该分类对应的输出文件路径 category_dir os.path.join(self.base_output_dir, safe_category) if not os.path.exists(category_dir): os.makedirs(category_dir) filename fquestions_{self.batch_id}.md filepath os.path.join(category_dir, filename) # 3. 获取或创建该分类的文件句柄以追加模式打开 # 如果是第一次打开这个文件可以在文件开头写一些元信息比如分类名 if filepath not in self.category_files: f open(filepath, a, encodingutf-8) self.category_files[filepath] f # 写入文件头 f.write(f# {safe_category} 试题集\n) f.write(f 爬取批次: {self.batch_id}\n 来源网站: {spider.allowed_domains[0]}\n\n) else: f self.category_files[filepath] # 4. 将Item格式化为Markdown并写入文件 markdown_content f## {item.get(title, 无标题)} **试题ID:** {item.get(question_id, N/A)} **分类:** {category} **来源URL:** [{item.get(source_url, N/A)}]({item.get(source_url, N/A)}) ### 选项 {formatted_options} ### 正确答案 {item.get(correct_answer, 暂无)} ### 试题解析 {item.get(analysis, 暂无解析)} --- f.write(markdown_content) f.flush() # 确保内容及时写入磁盘 spider.logger.debug(f已写入试题到: {filepath}) return item def close_spider(self, spider): 当爬虫关闭时调用。关闭所有打开的文件。 for filepath, f in self.category_files.items(): f.close() spider.logger.info(f文件已关闭: {filepath}) spider.logger.info(所有数据已保存完毕。)这个Pipeline的核心是process_item方法。它接收爬虫yield出来的每一个item然后进行以下操作数据清洗确保分类名是安全的不包含/、\等非法字符确保选项被格式化为Markdown列表。文件管理根据分类动态创建目录和文件。使用self.category_files字典来管理不同分类文件的打开状态避免反复打开关闭文件带来的性能损耗。Markdown格式化按照预设的模板将item的各个字段组合成一段结构清晰的Markdown文本。这里使用了##作为试题标题###作为“选项”、“答案”、“解析”的小节标题并用---分隔每道题视觉上很清晰。追加写入以追加模式a打开文件这样同一分类下的所有试题都会按爬取顺序写入同一个文件。重要提示文件操作务必使用encodingutf-8以正确处理中文。f.flush()不是必须的但可以确保在爬虫长时间运行或意外中断时数据能及时落盘。close_spider方法中的关闭文件操作至关重要否则可能导致最后一部分数据丢失。6. 运行爬虫与结果验证代码编写完成后就可以运行爬虫了。在项目根目录question_spider/下执行以下命令scrapy crawl ops_questionScrapy会开始运行并在控制台输出日志信息。你可以看到它发送请求、接收响应、解析Item的过程。如果一切顺利爬虫结束后你会在项目目录下看到一个output文件夹里面就是按分类整理好的Markdown文件。打开一个生成的.md文件内容应该类似下面这样# Linux基础 试题集 爬取批次: 20231027_143022 来源网站: example-questions.com ## 如何查看Linux系统当前的内存使用情况 **试题ID:** 101 **分类:** Linux基础 **来源URL:** [https://example-questions.com/ops/question/101/](https://example-questions.com/ops/question/101/) ### 选项 - A. df -h - B. free -h - C. top - D. ps aux ### 正确答案 B ### 试题解析 free -h命令可以以人类可读的格式如G、M显示内存使用情况包括总内存、已用内存、空闲内存等。df -h用于查看磁盘空间top和ps aux主要用于查看进程信息。 --- ## 在Linux中哪个命令用于永久修改环境变量 ...这样的Markdown文件结构清晰可以直接阅读也可以轻松导入到各种支持Markdown的笔记软件中。7. 高级技巧与常见问题排查一个基础的爬虫跑起来后我们总会遇到各种现实问题。下面分享几个进阶技巧和排坑经验。7.1 应对动态加载内容与反爬策略很多现代网站使用JavaScript动态加载内容Scrapy默认的下载器不执行JS。如果你发现用浏览器能看到试题但Scrapy抓取到的response.body里没有那很可能内容是通过AJAX请求加载的。解决方案分析网络请求在浏览器的开发者工具中打开“Network”面板刷新页面过滤XHR/Fetch请求。寻找包含试题数据的请求通常返回JSON格式。然后在Scrapy中直接模拟这个AJAX请求。# 在爬虫中可以添加一个专门处理AJAX请求的方法 def start_requests(self): # 起始请求可能是一个API接口而不是HTML页面 api_url https://example-questions.com/api/questions?page1 yield scrapy.Request(api_url, callbackself.parse_api) def parse_api(self, response): # 解析返回的JSON数据 data response.json() for q in data[questions]: item QuestionItem() item[title] q[title] # ... 其他字段赋值 yield item # 处理分页 if data[has_next]: next_page_api fhttps://example-questions.com/api/questions?page{data[next_page]} yield scrapy.Request(next_page_api, callbackself.parse_api)使用Splash或Selenium对于JS渲染极其复杂的网站可以考虑集成Splash一个带HTTP API的轻量级浏览器或直接使用Selenium。但这会显著增加复杂性和运行开销。Scrapy有与Splash集成的中间件scrapy-splash。反爬策略应对User-Agent轮换在settings.py的DOWNLOADER_MIDDLEWARES中启用并配置UserAgentMiddleware或者使用scrapy-fake-useragent库。IP代理池对于有严格频率限制的网站需要使用代理IP。可以配置scrapy-rotating-proxies等中间件。设置合理的DOWNLOAD_DELAY和CONCURRENT_REQUESTS这是最基本的礼貌也能有效降低被封风险。处理Cookie和Session确保COOKIES_ENABLED TrueScrapy会自动维护会话。7.2 数据清洗与格式化的精细化处理在Pipeline的process_item中我们做的清洗可能还不够。例如题干中可能包含多余的HTML标签可以使用w3lib.html的remove_tags函数来清理。from w3lib.html import remove_tags raw_title item[title] clean_title remove_tags(raw_title) # 移除HTML标签只留文本选项格式不统一有些网站选项是A.内容有些是(A) 内容。需要编写正则表达式或字符串处理逻辑来统一格式。import re def format_option(opt): # 将 (A) xxx 或 A) xxx 统一为 A. xxx match re.match(r\(?([A-D])\)?\s*[\.、]?\s*(.), opt) if match: return f{match.group(1)}. {match.group(2)} return opt处理图片如果试题中包含图片你需要额外处理。Scrapy的ImagesPipeline可以帮你自动下载图片。你需要将图片URL放在Item的一个特定字段如image_urls并在Pipeline中配置存储路径。在生成Markdown时将图片的本地路径或在线链接格式化为![描述](图片路径)。7.3 错误处理与日志记录爬虫在运行中难免会遇到404错误、解析失败、字段缺失等问题。良好的错误处理能让爬虫更健壮。使用try...except包裹解析逻辑在parse_question方法中对关键字段的提取进行异常捕获。try: item[title] response.xpath(//h1[classquestion-title]/text()).get().strip() if not item[title]: raise ValueError(标题解析为空) except Exception as e: self.logger.warning(f解析标题失败于 {response.url}: {e}) item[title] 解析失败利用Scrapy的日志系统使用self.logger来记录不同级别的信息debug,info,warning,error。在settings.py中设置LOG_LEVEL来控制输出粒度。忽略失败请求在settings.py中设置HTTPERROR_ALLOWED_CODES [404, 500]可以让Scrapy不将这些状态码视为错误而停止爬虫但通常我们更希望记录它们。更好的做法是在Request中设置errback回调函数来处理错误。7.4 性能优化与增量爬取当试题库很大时性能就很重要了。调整并发和延迟在settings.py中精细调整CONCURRENT_REQUESTS、DOWNLOAD_DELAY和AUTOTHROTTLE相关参数找到速度和稳定性的平衡点。启用缓存在开发调试阶段可以启用HTTP缓存避免重复请求相同页面。设置HTTPCACHE_ENABLED True。实现增量爬取避免每次全量爬取。思路是记录已爬取试题的ID例如存入一个文本文件或小型数据库如SQLite。在爬虫开始时加载这些ID在解析详情页时如果发现ID已存在则跳过该Item的后续处理yield。这需要在Pipeline或爬虫中增加去重逻辑。8. 项目总结与扩展思路通过这个项目我们完成了一个从网站抓取试题并保存为结构化Markdown文件的完整爬虫。核心在于理解Scrapy的“Spider解析 - Item封装 - Pipeline处理”的工作流以及如何将非结构化的网页数据转化为规整的、易于使用的文本格式。这个基础框架具有很强的扩展性多网站适配你可以为不同的试题网站编写不同的Spider类它们可以共享同一个Item和Pipeline只需重写解析逻辑parse,parse_question方法。输出格式多样化除了Markdown你可以在Pipeline中轻松地将数据存入数据库如MySQL、MongoDB或者导出为JSON、CSV、Excel文件只需添加相应的处理代码。集成到工作流你可以设置定时任务例如使用cron或APScheduler让爬虫定期运行实现试题库的自动更新。结合Git你甚至可以搭建一个自动同步的、版本化的个人知识库。最后也是最关键的一点务必遵守法律法规和网站的robots.txt协议尊重版权将爬虫用于个人学习和研究切勿用于商业用途或给目标网站服务器造成过大压力。技术是中立的但使用技术的人需要负责任。在实际操作中建议先小规模测试确认无误后再进行全量爬取并始终将请求频率控制在合理范围内。