1. 项目缘起与核心价值你有没有遇到过这种情况在CSDN上看到一个质量非常高的专栏作者写得深入浅出你恨不得一口气读完甚至想把它打印出来慢慢研究。但问题是专栏文章一篇一篇地翻不仅效率低而且一旦网络不好或者专栏被作者删除、平台调整这些宝贵的资料可能就再也找不到了。更别提想离线阅读、做笔记或者整合到自己的知识库里的需求了。我之前就遇到过一个讲Python异步编程的专栏看了几篇觉得醍醐灌顶结果过了一周想接着看发现作者把专栏设置成了付费之前的免费入口也没了那种感觉真是抓心挠肝。所以今天我们来聊聊一个非常实用的技能如何将CSDN的专栏文章完整地“搬”到你的本地电脑上。我们的目标不仅仅是把文字抓下来而是要尽可能原汁原味地保存包括排版、代码高亮、图片并且要能灵活地转换成多种格式。具体来说我们要实现三个输出HTML用于在浏览器中完美复现网页效果、PDF用于打印、归档和跨平台阅读以及Markdown用于编辑、整合到笔记软件或发布到其他平台。这听起来像是一个简单的爬虫任务但实际操作起来你会发现从登录反爬、动态内容加载、样式还原到格式转换每一步都有不少坑。网上很多教程只讲怎么用requests和BeautifulSoup抓取静态页面对于CSDN这种有复杂前端渲染和反爬机制的站点往往束手无策。本文将基于我多次实战的经验手把手带你走通全流程并分享那些官方文档里不会写的细节和避坑指南。2. 技术选型与工具链搭建为什么是它们在开始写代码之前花点时间在工具选型上是绝对值得的。一个错误的工具选择可能会让你在后期调试上花费数倍的时间。我们的核心需求是模拟真人浏览器行为获取完整页面内容并进行高质量、保真度的格式转换。2.1 爬虫框架放弃RequestsBS4拥抱Playwright很多新手会首选requestsBeautifulSoup的组合因为它简单。但对于现代网站尤其是CSDN这几乎是行不通的。CSDN大量使用了JavaScript动态加载内容比如文章主体、评论、推荐阅读简单的HTTP GET请求拿到的HTML只是一个空壳真正的文章内容你抓不到。此外CSDN有基本的反爬措施如验证码、请求头校验、Cookie验证等。因此我们需要一个能控制真实浏览器的工具。常见的有Selenium、PuppeteerNode.js和Playwright。我强烈推荐Playwright。理由如下自动等待机制Playwright内置了智能等待可以等待元素加载、网络请求完成大大减少了需要手动添加time.sleep的情况代码更健壮。速度快相比SeleniumPlaywright启动浏览器和执行操作的速度通常更快。强大的API提供截图、模拟移动设备、拦截网络请求等高级功能对于复杂场景处理更得心应手。多浏览器支持一套代码可以跑Chromium、Firefox和WebKit兼容性好。所以我们的爬虫核心将使用playwright的Python版本。2.2 格式转换核心Pandoc WeasyPrint / wkhtmltopdf获取到完整的HTML后我们需要把它变成PDF和Markdown。HTML - Markdown这个转换的保真度是关键。简单的正则表达式替换会搞得一团糟。这里的神器是Pandoc。它是一个“文档转换的瑞士军刀”支持在数十种格式间互转对Markdown的渲染逻辑非常成熟能较好地处理代码块、列表、表格等复杂元素。我们将用Python调用Pandoc的命令行来完成转换。HTML - PDF将网页保存为PDF也有多种选择。浏览器打印Playwright可以直接调用浏览器的“打印为PDF”功能这是最方便、还原度最高的方式之一因为它直接使用了浏览器自身的渲染引擎。WeasyPrint一个纯Python的HTML/CSS渲染引擎可以直接将HTMLCSS转换为PDF。它的优点是不需要浏览器纯代码操作适合自动化流水线。但缺点是对一些现代CSS3特性支持可能不如浏览器全面。wkhtmltopdf另一个经典的命令行HTML转PDF工具基于Qt WebKit。它很强大但安装和配置相对麻烦在某些系统上可能有字体问题。为了平衡便捷性和可靠性我们的方案是优先使用Playwright的浏览器打印功能生成PDF同时将完整的HTML包含内联样式保存下来作为备用和给WeasyPrint的输入源。这样我们就有双重保障。2.3 项目环境搭建首先确保你安装了Python建议3.8以上版本。然后我们通过pip安装必要的库。# 安装Playwright及其浏览器内核 pip install playwright playwright install chromium # 安装Chromium浏览器足够用了 # 安装用于解析和操作HTML的库 pip install beautifulsoup4 # 虽然爬虫不用它但后续处理HTML可能用到 # 安装用于PDF生成的备用方案可选但推荐 pip install weasyprint # 安装用于调用Pandoc和文件操作的辅助库 pip install markdown # 用于一些简单的MD处理 # Pandoc需要单独从官网下载安装https://github.com/jgm/pandoc/releases安装Pandoc请根据你的操作系统Windows/macOS/Linux从Pandoc的GitHub发布页面下载安装包并安装。安装后确保在命令行输入pandoc --version能显示版本信息这意味着它已加入系统PATH。至此我们的工具链就准备好了Playwright负责“抓”Pandoc和浏览器负责“转”。3. 爬虫实战从专栏列表到单篇文章详情接下来我们进入核心的爬取环节。我们的目标是输入一个CSDN专栏的首页URL自动获取该专栏下所有文章的链接然后逐一访问这些链接获取完整的文章内容。3.1 专栏文章列表的获取与解析CSDN专栏的列表页通常是分页的URL模式类似https://blog.csdn.net/username/category_xxx.html或https://blog.csdn.net/username/column/info/xxxxx。我们需要从中提取出所有文章的链接。这里有一个关键点不要试图去解析HTML来获取链接。因为列表页的HTML结构可能变化而且可能涉及动态加载。更可靠的方法是直接找到专栏页面加载文章列表时调用的数据接口。打开浏览器开发者工具F12切换到“网络”(Network)选项卡然后刷新专栏页面。在众多的请求中寻找一个返回JSON格式数据的请求其响应内容里包含了文章列表、标题、链接等信息。CSDN常用的接口模式可能包含/article/list/这样的路径。通过分析我们可能会找到一个返回如下JSON数据的API{ code: 200, message: success, data: { list: [ { articleId: 123456789, title: Python爬虫入门实战, url: https://blog.csdn.net/author/article/details/123456789 }, // ... 更多文章 ], total: 50, page: 1 } }我们的策略就是模拟这个API请求。这样效率更高且不依赖于页面HTML结构。我们需要从专栏页面的HTML中或者通过固定URL模式构造出这个API的请求URL和所需参数如columnId,page,size。下面是一个模拟获取专栏文章列表的示例函数import asyncio from playwright.async_api import async_playwright import json async def get_column_article_links(column_url): 通过模拟浏览器访问专栏页并拦截其数据接口获取所有文章链接。 article_links [] async with async_playwright() as p: # 使用无头模式headlessTrue更快调试时可设为False看浏览器操作 browser await p.chromium.launch(headlessTrue) context await browser.new_context( viewport{width: 1920, height: 1080}, # 设置一个常见的User-Agent降低被识别为爬虫的风险 user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ) page await context.new_page() # 监听网络响应当找到目标API时提取数据 def handle_response(response): if /api/v1/column/article/list in response.url: # 这个路径需要根据实际情况调整 try: data response.json() if data.get(code) 200: for item in data[data].get(list, []): # 构造完整的文章URL full_url fhttps://blog.csdn.net{item[url]} if not item[url].startswith(http) else item[url] article_links.append({ title: item[title], url: full_url, id: item.get(articleId) }) except Exception as e: print(f解析接口响应出错: {e}) page.on(response, handle_response) # 访问专栏首页触发列表加载 await page.goto(column_url, wait_untilnetworkidle) # 等待网络空闲 # 可能需要滚动页面以触发分页加载如果列表是懒加载的 await page.evaluate(window.scrollTo(0, document.body.scrollHeight)) await asyncio.sleep(2) # 等待可能的动态加载 await browser.close() return article_links # 测试代码 async def main(): links await get_column_article_links(https://blog.csdn.net/your_username/column/info/12345) print(f共获取到 {len(links)} 篇文章) for link in links[:5]: # 打印前5篇 print(link[title], link[url]) # 运行 # asyncio.run(main())注意上面的接口路径/api/v1/column/article/list是一个示例你需要用开发者工具找到真实的接口。此外CSDN的接口可能有token或签名验证如果直接请求失败那么通过浏览器上下文携带了完整Cookie去访问页面并拦截响应是最稳妥的方式。3.2 单篇文章内容的完整抓取与保存获取到文章链接后下一步就是访问每一篇文章并保存其完整内容。这里的目标是保存“所见即所得”的HTML包括样式。一个天真的做法是直接page.content()获取HTML然后保存。但这存在一个问题页面引用的外部CSS、JS、图片等资源在离线后都会失效。保存的HTML在本地打开会失去所有样式图片显示为裂图。因此我们的策略是将页面完整截图保存为视觉备份同时将HTML、CSS内联并下载图片到本地生成一个完全自包含的HTML文件。这个过程可以分为几步访问文章页面并等待完全加载。处理图片找到所有img标签下载图片到本地文件夹并将HTML中的src属性修改为指向本地文件。内联CSS样式找到页面所有link relstylesheet和style标签将其中的样式规则提取出来以内联style属性的方式写入到对应的HTML元素上或者集中放在一个style标签里。这一步是保证PDF转换和离线阅读样式的关键。我们可以使用工具premailer来实现这个功能。清理无关元素移除广告、侧边栏、评论框、页头页脚等我们不需要的内容只保留文章主体。这需要通过CSS选择器精准定位。保存处理后的HTML。我们先安装一个辅助库premailer用于将外部CSS内联。pip install premailer下面是单篇文章抓取与处理的示例代码import os from urllib.parse import urljoin import aiohttp import aiofiles from premailer import Premailer async def download_file(session, url, filepath): 异步下载文件如图片到指定路径 try: async with session.get(url, timeoutaiohttp.ClientTimeout(total30)) as resp: if resp.status 200: async with aiofiles.open(filepath, wb) as f: await f.write(await resp.read()) return True except Exception as e: print(f下载文件失败 {url}: {e}) return False async def fetch_and_save_article(page, article_info, output_diroutput): 抓取单篇文章保存为自包含的HTML并同时生成PDF和MD。 title article_info[title] url article_info[url] article_id article_info.get(id, unknown) # 创建文章专属目录 safe_title .join(c for c in title if c.isalnum() or c in ( , -, _)).rstrip() article_dir os.path.join(output_dir, safe_title[:50]) # 防止文件名过长 os.makedirs(article_dir, exist_okTrue) img_dir os.path.join(article_dir, images) os.makedirs(img_dir, exist_okTrue) print(f正在处理: {title}) # 1. 访问页面 await page.goto(url, wait_untilnetworkidle) # 确保文章主体加载完成可以等待特定元素出现 await page.wait_for_selector(#content_views, timeout10000) # CSDN文章内容通常在这个id里 # 2. 移除不需要的元素广告、侧边栏等 # 这里的选择器需要根据CSDN的实际页面结构调整 selectors_to_remove [ .blog_container_aside, # 侧边栏 .tool-box, # 工具条 .recommend-box, # 推荐阅读 .comment-box, # 评论框可选 header.csdn-toolbar, # 顶部导航栏 footer.csdn-footer, # 底部 footer .pulllog-box, # 登录框 .meau-gotop-box, # 回到顶部 ] for selector in selectors_to_remove: elements await page.query_selector_all(selector) for element in elements: await element.evaluate(element element.remove()) # 3. 下载图片并替换链接 async with aiohttp.ClientSession() as session: images await page.query_selector_all(#content_views img) for idx, img in enumerate(images): src await img.get_attribute(src) if not src or src.startswith(data:): # 跳过base64图片 continue # 处理可能的相对路径 full_src urljoin(url, src) # 生成本地文件名 ext os.path.splitext(full_src)[1] or .jpg # 简单处理避免非法字符 img_filename fimg_{idx}{ext} local_img_path os.path.join(images, img_filename) # HTML中使用的相对路径 absolute_img_path os.path.join(img_dir, img_filename) # 下载 success await download_file(session, full_src, absolute_img_path) if success: # 修改HTML中的src属性 await img.evaluate(f(element, newSrc) element.src newSrc, local_img_path) else: # 如果下载失败可以选择保留原链接或删除该图片元素 print(f图片下载失败保留原链接: {full_src}) # 4. 获取处理后的HTML内容 content_html await page.content() # 5. 使用premailer内联CSS样式 # 注意premailer需要能访问到CSS文件但我们已经将页面隔离这里主要处理style标签和内联。 # 更稳妥的做法是在page.goto之后先提取所有CSS文本然后传递给premailer。 # 这里我们采用一个简化方案直接对当前页面的HTML字符串进行处理。 # 由于我们可能已经移除了外部链接premailer可能无法获取外部CSS所以这步可能效果有限。 # 替代方案在page.goto后通过page.evaluate提取所有计算后的样式并内联到元素上。但这非常复杂。 # 一个更实用的方案我们主要依赖后续的“浏览器打印PDF”功能来保证样式HTML保存主要用于存档和MD转换。 # 因此这里我们跳过复杂的CSS内联仅做简单清理。 # 6. 保存HTML文件 html_file_path os.path.join(article_dir, f{safe_title}.html) # 在HTML头部添加base href原文章URL有助于解决一些相对路径问题谨慎使用可能影响本地图片 # 更安全的做法是确保所有资源路径都是相对的或已下载到本地。 async with aiofiles.open(html_file_path, w, encodingutf-8) as f: await f.write(content_html) print(f - HTML已保存: {html_file_path}) # 7. 使用浏览器打印功能生成PDF这是最保真的方式 pdf_file_path os.path.join(article_dir, f{safe_title}.pdf) # Playwright的page.pdf()方法需要Chromium以非无头模式运行才能生成PDF。 # 但我们之前以无头模式启动。有两个选择 # A. 重新用非无头模式打开这个页面并打印麻烦。 # B. 使用我们保存的HTML文件用一个新的非无头浏览器打开并打印。 # 这里演示方案B因为它更清晰。 await save_as_pdf_via_browser(url, pdf_file_path) # 这个函数后面实现 # 8. 使用Pandoc将HTML转换为Markdown md_file_path os.path.join(article_dir, f{safe_title}.md) await convert_html_to_markdown(html_file_path, md_file_path) # 这个函数后面实现 return { title: title, html: html_file_path, pdf: pdf_file_path, md: md_file_path }上面的代码是一个框架其中save_as_pdf_via_browser和convert_html_to_markdown函数我们将在下一节实现。这里重点展示了爬取、清理、下载图片的核心流程。关键经验直接修改DOMelement.remove(),element.src ...比获取HTML字符串后用BeautifulSoup再解析、修改、写回要高效和可靠得多因为它直接操作的是浏览器内存中的实时DOM树。4. 格式转换PDF与Markdown的生成艺术现在我们有了一个清理过的、图片本地化的HTML文件。接下来就是把它变成PDF和Markdown。4.1 使用浏览器打印生成高保真PDF正如之前所说用浏览器自身的打印功能是生成PDF最可靠的方式之一。我们将写一个函数专门用于打开本地HTML文件并将其打印为PDF。import asyncio from playwright.async_api import async_playwright async def save_as_pdf_via_browser(html_file_path, pdf_output_path): 使用Playwright控制浏览器打开HTML文件并打印为PDF async with async_playwright() as p: # 注意生成PDF需要非无头模式 browser await p.chromium.launch(headlessFalse) # 设置为False context await browser.new_context() page await context.new_page() # 使用 file:// 协议打开本地HTML文件 file_url ffile://{os.path.abspath(html_file_path)} await page.goto(file_url, wait_untilnetworkidle) # 等待页面内可能通过JS加载的内容如果有的话 await page.wait_for_timeout(1000) # 生成PDF # 可以配置PDF选项如页边距、纸张大小等 await page.pdf( pathpdf_output_path, formatA4, print_backgroundTrue, # 打印背景对代码高亮等样式很重要 margin{top: 0.5in, right: 0.5in, bottom: 0.5in, left: 0.5in} ) print(f - PDF已生成: {pdf_output_path}) await browser.close()为什么这里用headlessFalse因为Chromium的无头模式对page.pdf()的支持可能不完整或有问题。虽然新版本Playwright声称支持无头模式打印但在实践中非无头模式更加稳定可靠。你会看到一个浏览器窗口一闪而过这是正常的。4.2 使用Pandoc将HTML转换为结构化Markdown将HTML转为Markdown我们依赖Pandoc。Pandoc的命令行功能非常强大我们将通过Python的subprocess模块来调用它。import subprocess import asyncio async def convert_html_to_markdown(html_file_path, md_output_path): 调用Pandoc将HTML文件转换为Markdown # 构建Pandoc命令 # --wrapnone 防止Pandoc自动换行保持代码块等原样 # --standalone 不是必须的我们只想要body内容 # -f html -t markdown 指定从html转换到markdown command [ pandoc, --wrapnone, -f, html, -t, markdown, --extract-media., # 将HTML中的图片提取到当前目录需要时 html_file_path, -o, md_output_path ] try: # 运行命令 process await asyncio.create_subprocess_exec( *command, stdoutasyncio.subprocess.PIPE, stderrasyncio.subprocess.PIPE ) stdout, stderr await process.communicate() if process.returncode 0: print(f - Markdown已生成: {md_output_path}) # 可选的对生成的MD文件进行后处理比如修正图片路径 await post_process_markdown(md_output_path) else: print(f - Pandoc转换失败错误信息: {stderr.decode()}) except FileNotFoundError: print( - 错误: 未找到pandoc命令。请确保已安装Pandoc并已添加到系统PATH。) except Exception as e: print(f - 转换过程中发生未知错误: {e}) async def post_process_markdown(md_file_path): 对Pandoc生成的Markdown进行后处理例如修正本地图片路径 # 因为我们的图片已经下载到 文章目录/images/ 下而Pandoc提取的图片可能在当前目录。 # 我们需要根据实际情况调整MD文件中的图片引用路径。 # 这里是一个简单的示例如果图片引用是 ./media/image1.png而我们希望它是 images/img_0.jpg # 实际情况更复杂可能需要根据之前下载图片时的映射关系来替换。 # 一种更简单粗暴但有效的方法在调用Pandoc时使用--extract-mediaimages将图片提取到images文件夹 # 并确保HTML中的图片src已经是相对路径如images/img_0.jpg这样Pandoc转换后会保持这个路径。 # 因此这个后处理函数可能不需要前提是我们在爬取阶段就做好了图片路径的规范化。 pass关于图片路径的坑这是HTML转Markdown最容易出问题的地方。Pandoc的--extract-media选项会尝试从HTML中提取图片并保存到指定目录同时更新Markdown中的引用。但是如果我们的HTML中图片src已经是本地相对路径如images/img_0.jpg并且该图片确实存在Pandoc可能会直接使用这个路径而不会重复提取。为了确保一致性最好在爬虫阶段就将图片下载到images/子目录并将HTML中的src修改为相对路径。这样生成的HTML和由它转换来的Markdown其图片引用都是一致的images/xxx格式。4.3 备用方案使用WeasyPrint生成PDF虽然浏览器打印很完美但在全自动化的流水线中启动一个图形界面的浏览器可能不是最优雅的方式尤其是在服务器环境。这时WeasyPrint可以作为备选。from weasyprint import HTML, CSS import os def save_as_pdf_via_weasyprint(html_file_path, pdf_output_path): 使用WeasyPrint将HTML文件转换为PDF try: # 读取HTML文件 html HTML(filenamehtml_file_path) # 可以添加额外的CSS来优化打印样式 # css CSS(stringpage { size: A4; margin: 1cm; }) # 生成PDF html.write_pdf(pdf_output_path) #, stylesheets[css]) print(f - PDF (WeasyPrint) 已生成: {pdf_output_path}) except Exception as e: print(f - 使用WeasyPrint生成PDF失败: {e}) # 可以在这里回退到浏览器打印方案WeasyPrint的优点是纯Python、无头运行。但缺点是对某些CSS3特性如Flexbox、Grid的部分属性支持不如现代浏览器可能导致排版细微差异。建议将WeasyPrint作为备选方案在主方案浏览器打印失败时启用。5. 工程化整合与实战避坑指南现在我们把所有模块组合起来形成一个完整的、健壮的爬取脚本。同时分享一些我踩过的坑和对应的解决方案。5.1 主流程串联与错误处理一个完整的脚本需要管理异步任务、处理错误、提供进度提示并且要友好可配置。import asyncio import aiohttp import os import sys from typing import List, Dict import logging # 配置日志方便调试 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) async def main(column_url: str, output_base_dir: str csdn_column_backup, max_articles: int None): 主函数爬取指定专栏的所有文章并保存为HTML, PDF, MD。 :param column_url: CSDN专栏首页URL :param output_base_dir: 输出根目录 :param max_articles: 最大爬取文章数用于测试 os.makedirs(output_base_dir, exist_okTrue) # 1. 获取文章列表 logger.info(f开始获取专栏列表: {column_url}) try: article_list await get_column_article_links(column_url) except Exception as e: logger.error(f获取文章列表失败: {e}) return if not article_list: logger.warning(未获取到任何文章链接请检查专栏URL或网络。) return if max_articles: article_list article_list[:max_articles] logger.info(f共发现 {len(article_list)} 篇文章待处理。) # 2. 初始化一个浏览器实例供所有文章爬取复用提高效率 async with async_playwright() as p: # 注意为了PDF打印我们使用非无头模式。但爬取列表和内容时可以用无头。 # 我们可以创建两个context一个无头用于爬取一个非无头用于打印PDF。 # 为了简化这里我们全部使用非无头以便随时调用PDF打印。 browser await p.chromium.launch(headlessFalse) # 全部非无头方便PDF context await browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ) # 3. 遍历文章列表逐一处理 results [] for idx, article in enumerate(article_list, 1): logger.info(f[{idx}/{len(article_list)}] 处理: {article[title]}) page await context.new_page() # 为每篇文章开一个新页面/tab try: result await fetch_and_save_article(page, article, output_base_dir) results.append(result) except Exception as e: logger.error(f处理文章《{article[title]}》时出错: {e}, exc_infoTrue) # 记录失败信息继续处理下一篇 results.append({title: article[title], error: str(e)}) finally: await page.close() # 关闭页面释放资源 # 礼貌性延迟避免请求过快 await asyncio.sleep(2) await browser.close() # 4. 生成摘要报告 logger.info(*50) logger.info(爬取任务完成) success_count sum(1 for r in results if error not in r) logger.info(f成功处理: {success_count}/{len(article_list)} 篇) if success_count len(article_list): logger.warning(失败的文章:) for r in results: if error in r: logger.warning(f - {r[title]}: {r[error]}) # 运行 if __name__ __main__: # 示例 python script.py https://blog.csdn.net/username/column/info/12345 if len(sys.argv) 1: column_url sys.argv[1] else: column_url input(请输入CSDN专栏首页URL: ).strip() asyncio.run(main(column_url))5.2 实战中遇到的坑与解决方案反爬与封禁现象请求被拒绝返回403或跳转到验证码页面。对策设置合理的请求头User-Agent、Referer、Accept-Language等都要模拟真实浏览器。使用浏览器上下文Playwright的browser.new_context()会管理独立的Cookie、本地存储等模拟一个真实的用户会话。不要为每个请求都新建浏览器实例。添加延迟在请求间加入随机延迟asyncio.sleep(random.uniform(1, 3))模拟人类操作。处理验证码如果遇到验证码可能需要引入OCR识别如ddddocr、tesseract或手动干预。对于大规模爬取建议寻找无需登录的公开专栏或考虑使用官方API如果存在。动态加载内容抓取不全现象文章列表或文章内容只有一部分需要滚动才能加载更多。对策等待网络空闲page.goto(url, wait_untilnetworkidle)或wait_untildomcontentloaded。等待特定元素page.wait_for_selector(#content_views)确保文章主体加载完成。模拟滚动对于懒加载列表使用page.evaluate(window.scrollTo(0, document.body.scrollHeight))并配合page.wait_for_timeout()或等待新元素出现。图片下载失败或路径混乱现象本地HTML中图片显示为裂图或Markdown中图片路径错误。对策统一路径管理在每篇文章目录下创建images文件夹所有图片下载到此。处理相对路径和绝对路径使用urllib.parse.urljoin(base_url, src)将图片src转换为绝对URL再下载。处理Base64图片检查src是否以data:image开头如果是可以直接将其嵌入HTML无需下载。重试机制为下载图片的函数添加重试逻辑如aiohttp的重试客户端。样式丢失或PDF排版错乱现象生成的PDF样式与网页差异大或布局崩坏。对策优先使用浏览器打印这是最保真的方法。打印背景page.pdf(print_backgroundTrue)。调整PDF边距和纸张根据内容调整margin和format参数。对于WeasyPrint可能需要为打印提供专门的CSS使用page规则控制分页。Pandoc转换Markdown格式不理想现象代码块语言标识丢失、表格转换错乱、复杂列表层级错误。对策使用Pandoc过滤器Pandoc支持用Lua或Python写过滤器在转换过程中自定义AST抽象语法树的处理逻辑可以精细控制输出。后处理脚本针对Pandoc输出的Markdown用正则表达式或解析库进行二次修正。例如如果发现代码块没有语言可以尝试根据内容或class名添加。接受不完美对于极其复杂的页面布局HTML到Markdown的转换本身就是有损的。我们的目标应是保留核心内容文字、代码、图片而非100%的排版。异步编程的坑现象asyncio运行时错误如事件循环已关闭、任务未完成等。对策使用asyncio.run()作为主入口它负责创建和管理事件循环。妥善管理资源对于aiohttp.ClientSession、Playwright的browser和page确保使用async with上下文管理器或在finally块中正确关闭。控制并发度不要一次性创建成百上千个并发任务去下载文章或图片这可能导致被目标站封IP或本地资源耗尽。可以使用asyncio.Semaphore来限制最大并发数。5.3 进阶优化思路增量爬取记录已爬取的文章ID或URL下次运行时只爬取新文章。元数据保存除了内容还可以保存文章的发布时间、作者、标签、阅读数等元数据到一个JSON或SQLite数据库中。断点续传如果爬取过程中断可以从上次失败的文章继续而不是从头开始。配置化将需要调整的参数如输出目录、并发数、等待时间、需要移除的CSS选择器放到配置文件中。Docker化将整个环境打包成Docker镜像方便在不同机器上部署运行尤其是解决Playwright浏览器依赖的问题。这个项目从简单的“抓取网页”需求出发深入到了模拟浏览器、DOM操作、资源下载、格式转换、异步编程和错误处理等多个层面。它不仅仅是一个脚本更是一个小型的数据归档工具。希望这份详细的指南和代码框架能帮助你顺利地将有价值的CSDN专栏知识安全、完整地迁移到本地构建属于你自己的离线知识库。
Python爬虫实战:用Playwright+Pandoc完整抓取CSDN专栏并转PDF/Markdown
1. 项目缘起与核心价值你有没有遇到过这种情况在CSDN上看到一个质量非常高的专栏作者写得深入浅出你恨不得一口气读完甚至想把它打印出来慢慢研究。但问题是专栏文章一篇一篇地翻不仅效率低而且一旦网络不好或者专栏被作者删除、平台调整这些宝贵的资料可能就再也找不到了。更别提想离线阅读、做笔记或者整合到自己的知识库里的需求了。我之前就遇到过一个讲Python异步编程的专栏看了几篇觉得醍醐灌顶结果过了一周想接着看发现作者把专栏设置成了付费之前的免费入口也没了那种感觉真是抓心挠肝。所以今天我们来聊聊一个非常实用的技能如何将CSDN的专栏文章完整地“搬”到你的本地电脑上。我们的目标不仅仅是把文字抓下来而是要尽可能原汁原味地保存包括排版、代码高亮、图片并且要能灵活地转换成多种格式。具体来说我们要实现三个输出HTML用于在浏览器中完美复现网页效果、PDF用于打印、归档和跨平台阅读以及Markdown用于编辑、整合到笔记软件或发布到其他平台。这听起来像是一个简单的爬虫任务但实际操作起来你会发现从登录反爬、动态内容加载、样式还原到格式转换每一步都有不少坑。网上很多教程只讲怎么用requests和BeautifulSoup抓取静态页面对于CSDN这种有复杂前端渲染和反爬机制的站点往往束手无策。本文将基于我多次实战的经验手把手带你走通全流程并分享那些官方文档里不会写的细节和避坑指南。2. 技术选型与工具链搭建为什么是它们在开始写代码之前花点时间在工具选型上是绝对值得的。一个错误的工具选择可能会让你在后期调试上花费数倍的时间。我们的核心需求是模拟真人浏览器行为获取完整页面内容并进行高质量、保真度的格式转换。2.1 爬虫框架放弃RequestsBS4拥抱Playwright很多新手会首选requestsBeautifulSoup的组合因为它简单。但对于现代网站尤其是CSDN这几乎是行不通的。CSDN大量使用了JavaScript动态加载内容比如文章主体、评论、推荐阅读简单的HTTP GET请求拿到的HTML只是一个空壳真正的文章内容你抓不到。此外CSDN有基本的反爬措施如验证码、请求头校验、Cookie验证等。因此我们需要一个能控制真实浏览器的工具。常见的有Selenium、PuppeteerNode.js和Playwright。我强烈推荐Playwright。理由如下自动等待机制Playwright内置了智能等待可以等待元素加载、网络请求完成大大减少了需要手动添加time.sleep的情况代码更健壮。速度快相比SeleniumPlaywright启动浏览器和执行操作的速度通常更快。强大的API提供截图、模拟移动设备、拦截网络请求等高级功能对于复杂场景处理更得心应手。多浏览器支持一套代码可以跑Chromium、Firefox和WebKit兼容性好。所以我们的爬虫核心将使用playwright的Python版本。2.2 格式转换核心Pandoc WeasyPrint / wkhtmltopdf获取到完整的HTML后我们需要把它变成PDF和Markdown。HTML - Markdown这个转换的保真度是关键。简单的正则表达式替换会搞得一团糟。这里的神器是Pandoc。它是一个“文档转换的瑞士军刀”支持在数十种格式间互转对Markdown的渲染逻辑非常成熟能较好地处理代码块、列表、表格等复杂元素。我们将用Python调用Pandoc的命令行来完成转换。HTML - PDF将网页保存为PDF也有多种选择。浏览器打印Playwright可以直接调用浏览器的“打印为PDF”功能这是最方便、还原度最高的方式之一因为它直接使用了浏览器自身的渲染引擎。WeasyPrint一个纯Python的HTML/CSS渲染引擎可以直接将HTMLCSS转换为PDF。它的优点是不需要浏览器纯代码操作适合自动化流水线。但缺点是对一些现代CSS3特性支持可能不如浏览器全面。wkhtmltopdf另一个经典的命令行HTML转PDF工具基于Qt WebKit。它很强大但安装和配置相对麻烦在某些系统上可能有字体问题。为了平衡便捷性和可靠性我们的方案是优先使用Playwright的浏览器打印功能生成PDF同时将完整的HTML包含内联样式保存下来作为备用和给WeasyPrint的输入源。这样我们就有双重保障。2.3 项目环境搭建首先确保你安装了Python建议3.8以上版本。然后我们通过pip安装必要的库。# 安装Playwright及其浏览器内核 pip install playwright playwright install chromium # 安装Chromium浏览器足够用了 # 安装用于解析和操作HTML的库 pip install beautifulsoup4 # 虽然爬虫不用它但后续处理HTML可能用到 # 安装用于PDF生成的备用方案可选但推荐 pip install weasyprint # 安装用于调用Pandoc和文件操作的辅助库 pip install markdown # 用于一些简单的MD处理 # Pandoc需要单独从官网下载安装https://github.com/jgm/pandoc/releases安装Pandoc请根据你的操作系统Windows/macOS/Linux从Pandoc的GitHub发布页面下载安装包并安装。安装后确保在命令行输入pandoc --version能显示版本信息这意味着它已加入系统PATH。至此我们的工具链就准备好了Playwright负责“抓”Pandoc和浏览器负责“转”。3. 爬虫实战从专栏列表到单篇文章详情接下来我们进入核心的爬取环节。我们的目标是输入一个CSDN专栏的首页URL自动获取该专栏下所有文章的链接然后逐一访问这些链接获取完整的文章内容。3.1 专栏文章列表的获取与解析CSDN专栏的列表页通常是分页的URL模式类似https://blog.csdn.net/username/category_xxx.html或https://blog.csdn.net/username/column/info/xxxxx。我们需要从中提取出所有文章的链接。这里有一个关键点不要试图去解析HTML来获取链接。因为列表页的HTML结构可能变化而且可能涉及动态加载。更可靠的方法是直接找到专栏页面加载文章列表时调用的数据接口。打开浏览器开发者工具F12切换到“网络”(Network)选项卡然后刷新专栏页面。在众多的请求中寻找一个返回JSON格式数据的请求其响应内容里包含了文章列表、标题、链接等信息。CSDN常用的接口模式可能包含/article/list/这样的路径。通过分析我们可能会找到一个返回如下JSON数据的API{ code: 200, message: success, data: { list: [ { articleId: 123456789, title: Python爬虫入门实战, url: https://blog.csdn.net/author/article/details/123456789 }, // ... 更多文章 ], total: 50, page: 1 } }我们的策略就是模拟这个API请求。这样效率更高且不依赖于页面HTML结构。我们需要从专栏页面的HTML中或者通过固定URL模式构造出这个API的请求URL和所需参数如columnId,page,size。下面是一个模拟获取专栏文章列表的示例函数import asyncio from playwright.async_api import async_playwright import json async def get_column_article_links(column_url): 通过模拟浏览器访问专栏页并拦截其数据接口获取所有文章链接。 article_links [] async with async_playwright() as p: # 使用无头模式headlessTrue更快调试时可设为False看浏览器操作 browser await p.chromium.launch(headlessTrue) context await browser.new_context( viewport{width: 1920, height: 1080}, # 设置一个常见的User-Agent降低被识别为爬虫的风险 user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ) page await context.new_page() # 监听网络响应当找到目标API时提取数据 def handle_response(response): if /api/v1/column/article/list in response.url: # 这个路径需要根据实际情况调整 try: data response.json() if data.get(code) 200: for item in data[data].get(list, []): # 构造完整的文章URL full_url fhttps://blog.csdn.net{item[url]} if not item[url].startswith(http) else item[url] article_links.append({ title: item[title], url: full_url, id: item.get(articleId) }) except Exception as e: print(f解析接口响应出错: {e}) page.on(response, handle_response) # 访问专栏首页触发列表加载 await page.goto(column_url, wait_untilnetworkidle) # 等待网络空闲 # 可能需要滚动页面以触发分页加载如果列表是懒加载的 await page.evaluate(window.scrollTo(0, document.body.scrollHeight)) await asyncio.sleep(2) # 等待可能的动态加载 await browser.close() return article_links # 测试代码 async def main(): links await get_column_article_links(https://blog.csdn.net/your_username/column/info/12345) print(f共获取到 {len(links)} 篇文章) for link in links[:5]: # 打印前5篇 print(link[title], link[url]) # 运行 # asyncio.run(main())注意上面的接口路径/api/v1/column/article/list是一个示例你需要用开发者工具找到真实的接口。此外CSDN的接口可能有token或签名验证如果直接请求失败那么通过浏览器上下文携带了完整Cookie去访问页面并拦截响应是最稳妥的方式。3.2 单篇文章内容的完整抓取与保存获取到文章链接后下一步就是访问每一篇文章并保存其完整内容。这里的目标是保存“所见即所得”的HTML包括样式。一个天真的做法是直接page.content()获取HTML然后保存。但这存在一个问题页面引用的外部CSS、JS、图片等资源在离线后都会失效。保存的HTML在本地打开会失去所有样式图片显示为裂图。因此我们的策略是将页面完整截图保存为视觉备份同时将HTML、CSS内联并下载图片到本地生成一个完全自包含的HTML文件。这个过程可以分为几步访问文章页面并等待完全加载。处理图片找到所有img标签下载图片到本地文件夹并将HTML中的src属性修改为指向本地文件。内联CSS样式找到页面所有link relstylesheet和style标签将其中的样式规则提取出来以内联style属性的方式写入到对应的HTML元素上或者集中放在一个style标签里。这一步是保证PDF转换和离线阅读样式的关键。我们可以使用工具premailer来实现这个功能。清理无关元素移除广告、侧边栏、评论框、页头页脚等我们不需要的内容只保留文章主体。这需要通过CSS选择器精准定位。保存处理后的HTML。我们先安装一个辅助库premailer用于将外部CSS内联。pip install premailer下面是单篇文章抓取与处理的示例代码import os from urllib.parse import urljoin import aiohttp import aiofiles from premailer import Premailer async def download_file(session, url, filepath): 异步下载文件如图片到指定路径 try: async with session.get(url, timeoutaiohttp.ClientTimeout(total30)) as resp: if resp.status 200: async with aiofiles.open(filepath, wb) as f: await f.write(await resp.read()) return True except Exception as e: print(f下载文件失败 {url}: {e}) return False async def fetch_and_save_article(page, article_info, output_diroutput): 抓取单篇文章保存为自包含的HTML并同时生成PDF和MD。 title article_info[title] url article_info[url] article_id article_info.get(id, unknown) # 创建文章专属目录 safe_title .join(c for c in title if c.isalnum() or c in ( , -, _)).rstrip() article_dir os.path.join(output_dir, safe_title[:50]) # 防止文件名过长 os.makedirs(article_dir, exist_okTrue) img_dir os.path.join(article_dir, images) os.makedirs(img_dir, exist_okTrue) print(f正在处理: {title}) # 1. 访问页面 await page.goto(url, wait_untilnetworkidle) # 确保文章主体加载完成可以等待特定元素出现 await page.wait_for_selector(#content_views, timeout10000) # CSDN文章内容通常在这个id里 # 2. 移除不需要的元素广告、侧边栏等 # 这里的选择器需要根据CSDN的实际页面结构调整 selectors_to_remove [ .blog_container_aside, # 侧边栏 .tool-box, # 工具条 .recommend-box, # 推荐阅读 .comment-box, # 评论框可选 header.csdn-toolbar, # 顶部导航栏 footer.csdn-footer, # 底部 footer .pulllog-box, # 登录框 .meau-gotop-box, # 回到顶部 ] for selector in selectors_to_remove: elements await page.query_selector_all(selector) for element in elements: await element.evaluate(element element.remove()) # 3. 下载图片并替换链接 async with aiohttp.ClientSession() as session: images await page.query_selector_all(#content_views img) for idx, img in enumerate(images): src await img.get_attribute(src) if not src or src.startswith(data:): # 跳过base64图片 continue # 处理可能的相对路径 full_src urljoin(url, src) # 生成本地文件名 ext os.path.splitext(full_src)[1] or .jpg # 简单处理避免非法字符 img_filename fimg_{idx}{ext} local_img_path os.path.join(images, img_filename) # HTML中使用的相对路径 absolute_img_path os.path.join(img_dir, img_filename) # 下载 success await download_file(session, full_src, absolute_img_path) if success: # 修改HTML中的src属性 await img.evaluate(f(element, newSrc) element.src newSrc, local_img_path) else: # 如果下载失败可以选择保留原链接或删除该图片元素 print(f图片下载失败保留原链接: {full_src}) # 4. 获取处理后的HTML内容 content_html await page.content() # 5. 使用premailer内联CSS样式 # 注意premailer需要能访问到CSS文件但我们已经将页面隔离这里主要处理style标签和内联。 # 更稳妥的做法是在page.goto之后先提取所有CSS文本然后传递给premailer。 # 这里我们采用一个简化方案直接对当前页面的HTML字符串进行处理。 # 由于我们可能已经移除了外部链接premailer可能无法获取外部CSS所以这步可能效果有限。 # 替代方案在page.goto后通过page.evaluate提取所有计算后的样式并内联到元素上。但这非常复杂。 # 一个更实用的方案我们主要依赖后续的“浏览器打印PDF”功能来保证样式HTML保存主要用于存档和MD转换。 # 因此这里我们跳过复杂的CSS内联仅做简单清理。 # 6. 保存HTML文件 html_file_path os.path.join(article_dir, f{safe_title}.html) # 在HTML头部添加base href原文章URL有助于解决一些相对路径问题谨慎使用可能影响本地图片 # 更安全的做法是确保所有资源路径都是相对的或已下载到本地。 async with aiofiles.open(html_file_path, w, encodingutf-8) as f: await f.write(content_html) print(f - HTML已保存: {html_file_path}) # 7. 使用浏览器打印功能生成PDF这是最保真的方式 pdf_file_path os.path.join(article_dir, f{safe_title}.pdf) # Playwright的page.pdf()方法需要Chromium以非无头模式运行才能生成PDF。 # 但我们之前以无头模式启动。有两个选择 # A. 重新用非无头模式打开这个页面并打印麻烦。 # B. 使用我们保存的HTML文件用一个新的非无头浏览器打开并打印。 # 这里演示方案B因为它更清晰。 await save_as_pdf_via_browser(url, pdf_file_path) # 这个函数后面实现 # 8. 使用Pandoc将HTML转换为Markdown md_file_path os.path.join(article_dir, f{safe_title}.md) await convert_html_to_markdown(html_file_path, md_file_path) # 这个函数后面实现 return { title: title, html: html_file_path, pdf: pdf_file_path, md: md_file_path }上面的代码是一个框架其中save_as_pdf_via_browser和convert_html_to_markdown函数我们将在下一节实现。这里重点展示了爬取、清理、下载图片的核心流程。关键经验直接修改DOMelement.remove(),element.src ...比获取HTML字符串后用BeautifulSoup再解析、修改、写回要高效和可靠得多因为它直接操作的是浏览器内存中的实时DOM树。4. 格式转换PDF与Markdown的生成艺术现在我们有了一个清理过的、图片本地化的HTML文件。接下来就是把它变成PDF和Markdown。4.1 使用浏览器打印生成高保真PDF正如之前所说用浏览器自身的打印功能是生成PDF最可靠的方式之一。我们将写一个函数专门用于打开本地HTML文件并将其打印为PDF。import asyncio from playwright.async_api import async_playwright async def save_as_pdf_via_browser(html_file_path, pdf_output_path): 使用Playwright控制浏览器打开HTML文件并打印为PDF async with async_playwright() as p: # 注意生成PDF需要非无头模式 browser await p.chromium.launch(headlessFalse) # 设置为False context await browser.new_context() page await context.new_page() # 使用 file:// 协议打开本地HTML文件 file_url ffile://{os.path.abspath(html_file_path)} await page.goto(file_url, wait_untilnetworkidle) # 等待页面内可能通过JS加载的内容如果有的话 await page.wait_for_timeout(1000) # 生成PDF # 可以配置PDF选项如页边距、纸张大小等 await page.pdf( pathpdf_output_path, formatA4, print_backgroundTrue, # 打印背景对代码高亮等样式很重要 margin{top: 0.5in, right: 0.5in, bottom: 0.5in, left: 0.5in} ) print(f - PDF已生成: {pdf_output_path}) await browser.close()为什么这里用headlessFalse因为Chromium的无头模式对page.pdf()的支持可能不完整或有问题。虽然新版本Playwright声称支持无头模式打印但在实践中非无头模式更加稳定可靠。你会看到一个浏览器窗口一闪而过这是正常的。4.2 使用Pandoc将HTML转换为结构化Markdown将HTML转为Markdown我们依赖Pandoc。Pandoc的命令行功能非常强大我们将通过Python的subprocess模块来调用它。import subprocess import asyncio async def convert_html_to_markdown(html_file_path, md_output_path): 调用Pandoc将HTML文件转换为Markdown # 构建Pandoc命令 # --wrapnone 防止Pandoc自动换行保持代码块等原样 # --standalone 不是必须的我们只想要body内容 # -f html -t markdown 指定从html转换到markdown command [ pandoc, --wrapnone, -f, html, -t, markdown, --extract-media., # 将HTML中的图片提取到当前目录需要时 html_file_path, -o, md_output_path ] try: # 运行命令 process await asyncio.create_subprocess_exec( *command, stdoutasyncio.subprocess.PIPE, stderrasyncio.subprocess.PIPE ) stdout, stderr await process.communicate() if process.returncode 0: print(f - Markdown已生成: {md_output_path}) # 可选的对生成的MD文件进行后处理比如修正图片路径 await post_process_markdown(md_output_path) else: print(f - Pandoc转换失败错误信息: {stderr.decode()}) except FileNotFoundError: print( - 错误: 未找到pandoc命令。请确保已安装Pandoc并已添加到系统PATH。) except Exception as e: print(f - 转换过程中发生未知错误: {e}) async def post_process_markdown(md_file_path): 对Pandoc生成的Markdown进行后处理例如修正本地图片路径 # 因为我们的图片已经下载到 文章目录/images/ 下而Pandoc提取的图片可能在当前目录。 # 我们需要根据实际情况调整MD文件中的图片引用路径。 # 这里是一个简单的示例如果图片引用是 ./media/image1.png而我们希望它是 images/img_0.jpg # 实际情况更复杂可能需要根据之前下载图片时的映射关系来替换。 # 一种更简单粗暴但有效的方法在调用Pandoc时使用--extract-mediaimages将图片提取到images文件夹 # 并确保HTML中的图片src已经是相对路径如images/img_0.jpg这样Pandoc转换后会保持这个路径。 # 因此这个后处理函数可能不需要前提是我们在爬取阶段就做好了图片路径的规范化。 pass关于图片路径的坑这是HTML转Markdown最容易出问题的地方。Pandoc的--extract-media选项会尝试从HTML中提取图片并保存到指定目录同时更新Markdown中的引用。但是如果我们的HTML中图片src已经是本地相对路径如images/img_0.jpg并且该图片确实存在Pandoc可能会直接使用这个路径而不会重复提取。为了确保一致性最好在爬虫阶段就将图片下载到images/子目录并将HTML中的src修改为相对路径。这样生成的HTML和由它转换来的Markdown其图片引用都是一致的images/xxx格式。4.3 备用方案使用WeasyPrint生成PDF虽然浏览器打印很完美但在全自动化的流水线中启动一个图形界面的浏览器可能不是最优雅的方式尤其是在服务器环境。这时WeasyPrint可以作为备选。from weasyprint import HTML, CSS import os def save_as_pdf_via_weasyprint(html_file_path, pdf_output_path): 使用WeasyPrint将HTML文件转换为PDF try: # 读取HTML文件 html HTML(filenamehtml_file_path) # 可以添加额外的CSS来优化打印样式 # css CSS(stringpage { size: A4; margin: 1cm; }) # 生成PDF html.write_pdf(pdf_output_path) #, stylesheets[css]) print(f - PDF (WeasyPrint) 已生成: {pdf_output_path}) except Exception as e: print(f - 使用WeasyPrint生成PDF失败: {e}) # 可以在这里回退到浏览器打印方案WeasyPrint的优点是纯Python、无头运行。但缺点是对某些CSS3特性如Flexbox、Grid的部分属性支持不如现代浏览器可能导致排版细微差异。建议将WeasyPrint作为备选方案在主方案浏览器打印失败时启用。5. 工程化整合与实战避坑指南现在我们把所有模块组合起来形成一个完整的、健壮的爬取脚本。同时分享一些我踩过的坑和对应的解决方案。5.1 主流程串联与错误处理一个完整的脚本需要管理异步任务、处理错误、提供进度提示并且要友好可配置。import asyncio import aiohttp import os import sys from typing import List, Dict import logging # 配置日志方便调试 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) async def main(column_url: str, output_base_dir: str csdn_column_backup, max_articles: int None): 主函数爬取指定专栏的所有文章并保存为HTML, PDF, MD。 :param column_url: CSDN专栏首页URL :param output_base_dir: 输出根目录 :param max_articles: 最大爬取文章数用于测试 os.makedirs(output_base_dir, exist_okTrue) # 1. 获取文章列表 logger.info(f开始获取专栏列表: {column_url}) try: article_list await get_column_article_links(column_url) except Exception as e: logger.error(f获取文章列表失败: {e}) return if not article_list: logger.warning(未获取到任何文章链接请检查专栏URL或网络。) return if max_articles: article_list article_list[:max_articles] logger.info(f共发现 {len(article_list)} 篇文章待处理。) # 2. 初始化一个浏览器实例供所有文章爬取复用提高效率 async with async_playwright() as p: # 注意为了PDF打印我们使用非无头模式。但爬取列表和内容时可以用无头。 # 我们可以创建两个context一个无头用于爬取一个非无头用于打印PDF。 # 为了简化这里我们全部使用非无头以便随时调用PDF打印。 browser await p.chromium.launch(headlessFalse) # 全部非无头方便PDF context await browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ) # 3. 遍历文章列表逐一处理 results [] for idx, article in enumerate(article_list, 1): logger.info(f[{idx}/{len(article_list)}] 处理: {article[title]}) page await context.new_page() # 为每篇文章开一个新页面/tab try: result await fetch_and_save_article(page, article, output_base_dir) results.append(result) except Exception as e: logger.error(f处理文章《{article[title]}》时出错: {e}, exc_infoTrue) # 记录失败信息继续处理下一篇 results.append({title: article[title], error: str(e)}) finally: await page.close() # 关闭页面释放资源 # 礼貌性延迟避免请求过快 await asyncio.sleep(2) await browser.close() # 4. 生成摘要报告 logger.info(*50) logger.info(爬取任务完成) success_count sum(1 for r in results if error not in r) logger.info(f成功处理: {success_count}/{len(article_list)} 篇) if success_count len(article_list): logger.warning(失败的文章:) for r in results: if error in r: logger.warning(f - {r[title]}: {r[error]}) # 运行 if __name__ __main__: # 示例 python script.py https://blog.csdn.net/username/column/info/12345 if len(sys.argv) 1: column_url sys.argv[1] else: column_url input(请输入CSDN专栏首页URL: ).strip() asyncio.run(main(column_url))5.2 实战中遇到的坑与解决方案反爬与封禁现象请求被拒绝返回403或跳转到验证码页面。对策设置合理的请求头User-Agent、Referer、Accept-Language等都要模拟真实浏览器。使用浏览器上下文Playwright的browser.new_context()会管理独立的Cookie、本地存储等模拟一个真实的用户会话。不要为每个请求都新建浏览器实例。添加延迟在请求间加入随机延迟asyncio.sleep(random.uniform(1, 3))模拟人类操作。处理验证码如果遇到验证码可能需要引入OCR识别如ddddocr、tesseract或手动干预。对于大规模爬取建议寻找无需登录的公开专栏或考虑使用官方API如果存在。动态加载内容抓取不全现象文章列表或文章内容只有一部分需要滚动才能加载更多。对策等待网络空闲page.goto(url, wait_untilnetworkidle)或wait_untildomcontentloaded。等待特定元素page.wait_for_selector(#content_views)确保文章主体加载完成。模拟滚动对于懒加载列表使用page.evaluate(window.scrollTo(0, document.body.scrollHeight))并配合page.wait_for_timeout()或等待新元素出现。图片下载失败或路径混乱现象本地HTML中图片显示为裂图或Markdown中图片路径错误。对策统一路径管理在每篇文章目录下创建images文件夹所有图片下载到此。处理相对路径和绝对路径使用urllib.parse.urljoin(base_url, src)将图片src转换为绝对URL再下载。处理Base64图片检查src是否以data:image开头如果是可以直接将其嵌入HTML无需下载。重试机制为下载图片的函数添加重试逻辑如aiohttp的重试客户端。样式丢失或PDF排版错乱现象生成的PDF样式与网页差异大或布局崩坏。对策优先使用浏览器打印这是最保真的方法。打印背景page.pdf(print_backgroundTrue)。调整PDF边距和纸张根据内容调整margin和format参数。对于WeasyPrint可能需要为打印提供专门的CSS使用page规则控制分页。Pandoc转换Markdown格式不理想现象代码块语言标识丢失、表格转换错乱、复杂列表层级错误。对策使用Pandoc过滤器Pandoc支持用Lua或Python写过滤器在转换过程中自定义AST抽象语法树的处理逻辑可以精细控制输出。后处理脚本针对Pandoc输出的Markdown用正则表达式或解析库进行二次修正。例如如果发现代码块没有语言可以尝试根据内容或class名添加。接受不完美对于极其复杂的页面布局HTML到Markdown的转换本身就是有损的。我们的目标应是保留核心内容文字、代码、图片而非100%的排版。异步编程的坑现象asyncio运行时错误如事件循环已关闭、任务未完成等。对策使用asyncio.run()作为主入口它负责创建和管理事件循环。妥善管理资源对于aiohttp.ClientSession、Playwright的browser和page确保使用async with上下文管理器或在finally块中正确关闭。控制并发度不要一次性创建成百上千个并发任务去下载文章或图片这可能导致被目标站封IP或本地资源耗尽。可以使用asyncio.Semaphore来限制最大并发数。5.3 进阶优化思路增量爬取记录已爬取的文章ID或URL下次运行时只爬取新文章。元数据保存除了内容还可以保存文章的发布时间、作者、标签、阅读数等元数据到一个JSON或SQLite数据库中。断点续传如果爬取过程中断可以从上次失败的文章继续而不是从头开始。配置化将需要调整的参数如输出目录、并发数、等待时间、需要移除的CSS选择器放到配置文件中。Docker化将整个环境打包成Docker镜像方便在不同机器上部署运行尤其是解决Playwright浏览器依赖的问题。这个项目从简单的“抓取网页”需求出发深入到了模拟浏览器、DOM操作、资源下载、格式转换、异步编程和错误处理等多个层面。它不仅仅是一个脚本更是一个小型的数据归档工具。希望这份详细的指南和代码框架能帮助你顺利地将有价值的CSDN专栏知识安全、完整地迁移到本地构建属于你自己的离线知识库。