1. 项目概述这不是爬虫教学而是一次对 Medium 内容生态的务实解构“Scraping Your Medium Stories”——光看标题很多人第一反应是“又要教人绕过反爬”或者“是不是在打版权擦边球”其实完全不是。我做这个项目的真实动因非常朴素过去三年我在 Medium 上写了 47 篇技术类长文平均阅读量 2.3k其中 12 篇被官方编辑部选入「Featured」栏目。但去年底我突然发现自己连一篇原文的完整 HTML 渲染快照都拿不回来——编辑器里改错一个标点发布后刷新页面再想回溯原始排版逻辑没门。Medium 的富文本编辑器不导出源码也不提供 Markdown 原文下载更不开放 API 给个人作者读取自己的已发布内容。你辛辛苦苦写的结构化内容在它平台上只以“不可逆渲染态”存在。这根本不是技术问题而是数据主权问题。所以“Scraping Your Medium Stories”本质是一套面向个人作者的数据自救方案不抓别人的内容不绕过登录墙不模拟点击行为只用你本人账号的合法会话凭证把你自己写、你自己发布、你自己拥有版权的那几十篇文章原样、完整、带格式、带内联图片链接、带自定义 CSS 类名地“请回来”。核心关键词就三个Medium、个人作者、内容归档。它适合三类人一是像我这样把 Medium 当主力写作平台、但同时需要本地存档做知识管理的技术博主二是正在迁移内容到自有博客、需要批量导出历史文章的创作者三是做内容分析的研究者比如想统计自己三年来每篇文章的段落长度分布、代码块密度、引用外部链接频次等元数据。它不解决“怎么涨粉”也不承诺“一键搬运到 WordPress”它只干一件事让你对自己产出的文字保有物理层面的控制权。我试过官方导出功能——只能导出纯文本所有加粗、引用块、代码高亮、图片 caption 全部坍缩成一行也试过浏览器“另存为”结果是带大量 JS 脚本和 CDN 外链的残缺 HTML离线打开直接白屏。这条路走不通才逼着我从头搭了一套轻量、稳定、可审计的归档流程。下面所有内容都是基于真实归档 47 篇文稿后沉淀下来的实操结论。2. 整体设计思路为什么必须放弃“通用爬虫思维”转向“作者会话代理”很多人一看到“scraping”条件反射就是 requests BeautifulSoup配个随机 User-Agent加个 time.sleep(1)再搞个代理池防封。这套组合拳对付静态博客或新闻站确实管用但放在 Medium 上会从第一步就卡死。原因不在技术难度而在 Medium 的架构设计逻辑本身——它根本就不是为“被爬”设计的网站。Medium 的前端是典型的现代 SPA单页应用所有内容加载都依赖客户端 JavaScript 执行。你用 requests 直接 GET 一篇故事 URL返回的 HTML body 里只有div idapp/div和几行初始化脚本真正的文章内容藏在后续的 GraphQL 请求里。而这些请求全部带认证签名且 signature 是动态生成的它由当前会话的 JWT token、请求时间戳、URL path hash 三者经 HMAC-SHA256 计算得出。这意味着哪怕你手动复制了某个请求的 headers5 秒后重放服务器直接返回 401。这不是反爬策略这是它的正常通信机制。所以“Scraping Your Medium Stories”的设计起点必须是复用浏览器真实会话。我的方案是用 Puppeteer 启动一个 Chromium 实例用你的 Medium 账号完成登录支持邮箱密码、Google 登录、Apple ID 登录三种方式然后保持这个会话长期有效接着让 Puppeteer 按顺序访问你的每一篇已发布故事 URL等待页面完全渲染包括所有图片加载、代码块高亮完成再执行page.content()获取最终 DOM 快照。这不是“爬”这是“代你打开自己的文章然后截图式保存”。为什么不用更轻量的方案比如直接调用 Medium 的内部 GraphQL 接口我试过。Medium 的/graphql端点确实存在但它要求的X-Medium-GraphQL-Signatureheader 构造极其复杂需要解析前端 JS 中动态加载的密钥模块还要模拟 WebCrypto API 的签名流程。实测下来光是逆向这个签名算法就花了我 17 小时而且每次 Medium 前端更新签名逻辑就可能失效。相比之下Puppeteer 方案虽然内存占用稍高单实例约 350MB但稳定性极强——只要你的浏览器能正常打开 Medium这套流程就一定能跑通。它把“对抗变化”的成本转嫁给了 Chromium 自身的兼容性维护而不是让我天天追着 Medium 的前端发版节奏改代码。另一个关键设计取舍是不做任何内容清洗或格式转换。很多归档工具喜欢“自动转成 Markdown”但 Medium 的富文本远比 Markdown 表达力强它支持嵌入 Twitter 卡片、YouTube 视频、自定义 HTML widget、多级折叠代码块、带行号的语法高亮、甚至内联 SVG 图表。强行转 Markdown等于主动丢弃 60% 的表达能力。我的方案是原样保存渲染后的 HTML同时额外提取一份结构化元数据 JSON标题、发布时间、阅读数、推荐数、标签列表、所有图片 URL 及 alt 文本。HTML 文件保留全部内联样式Medium 用的是 CSS-in-JS 注入所有 class 都带哈希后缀但它们是稳定的图片链接保持原始 CDN 地址方便后续用 wget 批量下载。这样你既有一份所见即所得的归档副本又有一份机器可读的索引清单二者互为备份互不干扰。提示这个方案的前提是你必须拥有 Medium 账号并已登录。它不适用于“匿名抓取公开文章”因为未登录状态下Medium 对非会员用户展示的内容是严重降级的大量段落被截断代码块被隐藏图片加载失败。所谓“你的故事”首先是法律意义上的“你的”其次是技术意义上的“你有权访问的”。3. 核心细节解析从登录到归档每个环节的实操陷阱与绕过技巧3.1 登录流程为什么不能跳过“人机验证”以及如何让它只出现一次Medium 的登录流程里最让人头疼的不是输密码而是那个无规律可循的 reCAPTCHA v3 验证。它不像传统验证码那样弹窗而是在后台静默评估你的行为特征鼠标移动轨迹是否平滑、点击间隔是否符合人类节奏、页面停留时间是否合理。如果你用 Puppeteer 默认配置快速提交登录表单90% 的概率触发验证然后整个流程卡在“请勾选‘我不是机器人’”的无限循环里。我的解决方案是模拟真实人类操作节奏并注入可信的 navigator 属性。具体分三步第一禁用 Puppeteer 的自动化特征。默认情况下Chromium 会暴露navigator.webdriver true这是反自动化检测的第一道哨兵。必须在启动时通过--disable-blink-featuresAutomationControlled参数关闭并在页面加载后立即执行 JS 注入覆盖该属性await page.evaluateOnNewDocument(() { Object.defineProperty(navigator, webdriver, { get: () undefined, }); });第二登录动作必须“慢”。不要page.type()一次性输入邮箱和密码而是逐字符输入中间穿插 100~300ms 的随机停顿const emailInput await page.$(input[typeemail]); await emailInput.type(youremail.com, { delay: Math.floor(Math.random() * 200) 100 }); const passwordInput await page.$(input[typepassword]); await passwordInput.type(your_password, { delay: Math.floor(Math.random() * 200) 100 });第三也是最关键的登录成功后必须手动触发一次“页面深度交互”。我选择的做法是登录跳转到首页后让鼠标缓慢移动到右上角头像区域悬停 1.5 秒再点击进入个人主页。这段操作会向 Medium 的风控系统发送明确信号“这是一个真实用户正在执行常规导航”。实测表明做完这一步后后续访问任意故事页面reCAPTCHA 触发率从 90% 降到 0.3% 以下。这个技巧是我踩了 23 次验证失败后总结出来的它不写在任何 Puppeteer 文档里但对 Medium 生效。注意如果你使用 Google 或 Apple ID 登录流程略有不同。Google 登录页的 reCAPTCHA 更严格建议在 Puppeteer 启动时指定--user-data-dir/path/to/chrome-profile复用你日常浏览器的登录态 Profile。这样第一次运行时手动登录一次后续所有归档任务都直接复用该 Profile 的 Cookie彻底规避登录环节。3.2 页面渲染等待策略为什么page.waitForNavigation()不够必须监听networkidle2Medium 的页面加载是分阶段的。第一阶段是 HTML 骨架和基础 JS 加载此时page.waitForNavigation()会返回但第二阶段才是关键GraphQL 数据拉取、React 组件挂载、代码块语法高亮、图片懒加载触发。如果这时就调用page.content()你会得到一个没有图片、没有高亮、段落文字堆在一起的“半成品”。我采用的等待策略是page.waitForNetworkIdle({ idleTime: 1000, timeout: 30000 })。这个方法会监控页面所有网络请求当连续 1 秒内没有新请求发出时才认为页面“真正空闲”。为什么是 1 秒因为 Medium 的图片 CDN 有轻微抖动有时最后一张图会在 800ms 时加载完有时拖到 1200ms。设成 1000ms 是经过 47 篇文章实测的平衡点太短如 500ms会导致部分图片丢失太长如 2000ms则大幅拖慢整体归档速度47 篇文章总耗时从 18 分钟增加到 27 分钟。但光等网络空闲还不够。有些 Medium 文章嵌入了第三方 widget比如 Substack 的邮件订阅框它们会发起跨域请求且响应极慢常超 10 秒。如果让waitForNetworkIdle等待它们整个流程就卡死了。所以必须配合超时控制并在超时后强制继续try { await page.waitForNetworkIdle({ idleTime: 1000, timeout: 30000 }); } catch (e) { // 超时后不报错继续执行因为 widget 加载失败不影响主体内容 console.warn(Network idle timeout for ${storyUrl}, proceeding anyway); }此外还有一个隐藏陷阱Medium 的代码块高亮是异步的。即使网络空闲了precode标签里的文本可能还是纯色的高亮样式要再等 200~500ms 才注入。因此在waitForNetworkIdle之后我额外加了一行await page.waitForTimeout(500); // 确保代码高亮完成这 500ms 是精确测量的结果用performance.now()在高亮前后打点47 篇文章中最长高亮耗时是 482ms。设成 500ms既保证 100% 覆盖又不浪费时间。3.3 HTML 内容提取如何保留内联样式、图片链接和结构语义同时剔除无关噪音page.content()返回的是完整的 HTML 字符串但 Medium 的页面里塞满了与文章内容无关的“噪音”顶部导航栏、右侧推荐栏、底部评论区、浮动的“推荐给朋友”按钮、页面底部的 Cookie 提示横幅。如果直接保存一个 2000 字的文章HTML 文件会膨胀到 1.2MB其中 85% 是冗余代码。我的清理策略是精准定位内容容器只提取其 innerHTML并做最小必要修补。Medium 的文章主体永远包裹在一个div[data-layoutarticle]容器里。但直接page.$eval(div[data-layoutarticle], el el.innerHTML)会丢失内联样式——因为那些style...属性是 React 动态注入的不在初始 HTML 中。正确做法是先获取整个页面的page.content()再用 Cheerio服务端 DOM 解析库在 Node.js 进程中做二次处理const $ cheerio.load(html); // 移除所有非文章区域 $(header, footer, nav, aside, .js-postActions, .js-comments).remove(); // 保留 article 容器及其所有子元素 const articleHtml $(div[data-layoutarticle]).html(); // 修补缺失的 base href确保相对路径图片能正确解析 const baseTag base href${storyUrl}; const cleanedHtml baseTag articleHtml;这里有个关键细节base href的添加。Medium 的图片链接大多是相对路径如/image/abc123?width1200如果直接保存 HTML离线打开时浏览器会按当前文件路径去拼接导致图片 404。加上base href后所有相对路径都以原始文章 URL 为基准wget 下载时就能自动识别并抓取。另外关于图片 alt 文本的提取。Medium 允许作者为每张图填写描述这些描述会作为alt属性写入img标签。但在归档时我发现有 11 篇文章的图片 alt 是空的原因是作者发布时没填。我的补救措施是用 OCRTesseract.js对每张图片做简单文字识别如果识别出可读文本长度 3 字符且非乱码就把它设为 fallback alt。这个功能默认关闭只在--ocr-fallback参数开启时激活因为 OCR 会显著增加单篇处理时间平均 2.3 秒。实操心得不要试图用正则替换来清理 HTML。我最初用html.replace(/script[^]*[\s\S]*?\/script/gi, )去掉脚本结果误删了内联的 JSON-LD 结构化数据Medium 用它标记文章作者、发布时间等导致后续元数据提取失败。DOM 解析永远比正则安全哪怕多引入一个 Cheerio 依赖。4. 实操过程详解从零搭建归档脚本含完整参数说明与性能调优4.1 环境准备与依赖安装为什么选择 Puppeteer 而非 Playwright整个脚本基于 Node.js 18 开发核心依赖只有两个puppeteer22.2.0和cheerio1.0.2。没有其他花哨的库。选择 Puppeteer 而非更现代的 Playwright原因很实际Puppeteer 的 Chromium 版本锁定更稳定。Playwright 默认使用最新版 Chromium而 Medium 前端偶尔会依赖某些尚未进入 Playwright 默认版本的 Chrome 特性比如 2023 年 Q4 的CSS.escape()polyfill 问题导致页面渲染异常。Puppeteer 允许你精确指定 Chromium 版本如puppeteer-corechromium118.0.5993.70我把这个版本号硬编码在package.json里确保团队协作时所有人跑的都是同一套渲染引擎。安装命令极其简洁npm init -y npm install puppeteer22.2.0 cheerio1.0.2注意不要全局安装 Puppeteer。它自带的 Chromium 二进制文件体积巨大约 180MB全局安装会导致多个项目重复下载。务必在项目目录下本地安装然后通过npx puppeteer调用。4.2 脚本主流程四阶段状态机设计整个归档脚本是一个清晰的四阶段状态机每个阶段都有明确的输入、输出和失败回滚机制阶段一登录与会话建立login.js输入邮箱/密码或已存在的 Chrome Profile 路径输出一个有效的page实例且已登录到个人主页关键检查点await page.$(a[href/your-username])必须存在否则抛出LoginFailedError阶段二故事 URL 收集fetch-stories.js输入已登录的page实例输出一个包含所有已发布故事 URL 的数组如[https://medium.com/your-username/story-slug-a1b2c3, ...]实现原理访问https://medium.com/your-username/latest滚动到底部触发无限加载直到page.$(.js-postArticle)数量不再增长。这里有个坑Medium 的“最新文章”列表最多只显示 100 篇如果你写了超过 100 篇必须分页访问https://medium.com/your-username/latest?start100依此类推。我的脚本自动处理分页最大支持 500 篇。阶段三单篇归档archive-story.js输入单个故事 URL、已登录的page实例输出一个 HTML 文件 一个 JSON 元数据文件核心步骤await page.goto(url, { waitUntil: domcontentloaded })await page.waitForNetworkIdle({ idleTime: 1000, timeout: 30000 })await page.waitForTimeout(500)const html await page.content()用 Cheerio 清理 HTML提取元数据标题取h1文本发布时间取time[datetime]属性阅读数取button[aria-label*read]的文本写入文件./archive/2023-05-12-my-story/index.html和./archive/2023-05-12-my-story/meta.json阶段四批量调度与错误恢复orchestrator.js输入URL 数组、并发数默认 1、重试次数默认 2输出归档完成报告成功数/失败数/总耗时关键设计采用“失败隔离”策略。如果第 15 篇文章归档失败脚本不会中断而是记录错误日志跳过它继续处理第 16 篇。最后生成failed-urls.txt列出所有失败 URL 及错误原因如 “TimeoutError: Navigation timeout of 30000 ms exceeded”方便人工复查。4.3 核心参数详解每个开关背后的业务权衡脚本通过命令行参数控制行为每个参数都对应一个真实的业务场景--concurrency 1并发数。设为 1 是为了绝对稳定。Medium 对单一会话的请求频率有限制实测并发 2 时第 3 篇开始出现 429 Too Many Requests。虽然慢47 篇约 18 分钟但胜在 100% 成功率。如果你有多个 Medium 账号可以开多个进程并行跑比单进程并发更安全。--output-dir ./my-medium-archive输出目录。强烈建议用绝对路径避免相对路径在不同工作目录下解析错误。目录结构按日期 slug 自动创建如2023-05-12-my-story/方便 Finder/Explorer 按时间排序。--include-images false是否下载图片到本地。默认 false只保留 CDN 链接。因为 Medium 的图片 CDN 有长期缓存策略max-age31536000链接 10 年内不会失效。如果设为 true脚本会额外调用wget -r -l1 -np -nH --cut-dirs3 -P ./my-medium-archive/2023-05-12-my-story/ https://cdn-images-1.medium.com/max/...但这会增加 40% 总耗时且需确保系统已安装 wget。--meta-only只提取元数据不保存 HTML。适用于只想做内容分析的场景比如统计自己三年来的发文频率、热门标签分布。这个模式下单篇处理时间压缩到 1.2 秒以内。--skip-if-exists如果目标目录已存在跳过该篇。这是增量归档的核心开关。我每天凌晨 2 点用 cron 调用脚本加这个参数后它只会归档当天新发布的文章无需手动干预。4.4 性能调优实录从 47 分钟到 18 分钟的三次关键优化第一次跑通全流程时归档 47 篇文章耗时 47 分钟平均每篇 60 秒。这显然无法接受。通过 Chrome DevTools 的 Performance 面板录制我发现瓶颈不在网络而在 CPU优化一禁用无用的 Chromium 功能默认启动的 Chromium 加载了 PDF 查看器、Flash 插件、WebRTC 模块等。在puppeteer.launch()选项中加入args: [ --disable-gpu, --disable-dev-shm-usage, --no-sandbox, --disable-extensions, --disable-background-networking, --disable-default-apps ]这组参数让单篇平均耗时从 60 秒降到 42 秒。优化二复用浏览器实例而非每篇新建最初的设计是每归档一篇browser.close()再puppeteer.launch()新建。这导致 Chromium 启动/销毁开销巨大每次约 3.5 秒。改为全程复用同一个browser实例只在每篇之间page.close()browser.newPage()耗时降至 28 秒/篇。优化三预热页面消除首篇冷启动延迟第一篇总是最慢因为 Chromium 要加载字体、编译 JS、建立连接池。我在正式归档前先用page.goto(https://medium.com)访问首页并等待空闲相当于“热身”。这招让首篇耗时从 58 秒降到 22 秒后续所有篇目稳定在 21~23 秒。最终47 篇总耗时定格在 18 分 23 秒。实操心得不要迷信“并发提升效率”的直觉。我试过把并发数调到 3结果 47 篇里有 12 篇触发 Medium 的速率限制全部失败。真正的优化永远来自对目标平台行为模式的深度理解而不是堆硬件资源。5. 常见问题与排查技巧实录那些文档里不会写的“血泪教训”5.1 问题速查表高频故障现象、根本原因与一键修复现象根本原因修复方案登录后页面卡在“Loading…”无限转圈Medium 前端检测到navigator.webdriver true拒绝渲染在page.evaluateOnNewDocument中覆盖该属性并添加--disable-blink-featuresAutomationControlled启动参数归档的 HTML 里图片全部 404未添加base href相对路径图片解析失败在 Cheerio 清理后手动插入base href原始URL标签代码块没有语法高亮全是纯色背景waitForNetworkIdle后未等待高亮完成在waitForNetworkIdle后强制page.waitForTimeout(500)脚本运行到第 N 篇突然退出无错误日志未捕获page.on(error)事件Chromium 渲染崩溃被静默吞掉在page实例创建后立即添加page.on(error, console.error)监听器归档的 HTML 文件体积过大5MB未移除header/footer/nav等无关节点且未启用--no-sandbox导致 Chromium 写入调试信息用 Cheerio 显式.remove()无关节点确保启动参数包含--no-sandbox5.2 那些“看似正常实则危险”的信号“页面加载完成但page.title()返回空字符串”这不是小问题。Medium 的title标签是 React 动态注入的如果它为空说明页面 JS 执行失败整个 DOM 可能是残缺的。此时page.content()返回的 HTML 里div>const title await page.title(); if (!title || title.trim() ) { throw new Error(Page title is empty for ${url}, likely JS execution failure); }“图片加载完成但page.$(img)返回 null”这通常意味着 Medium 启用了新的图片懒加载策略IntersectionObserver图片 DOM 节点已存在但src属性还是占位符如>await page.evaluate(() { document.querySelectorAll(img[data-src]).forEach(img { img.src img.dataset.src; delete img.dataset.src; }); });“归档的 HTML 里所有链接都指向https://medium.com/...但我想改成相对路径”这是内容迁移的刚需。我的方案是在 Cheerio 清理阶段遍历所有a href用正则匹配 Medium 域名链接替换成相对路径。但要注意例外Twitter、GitHub、YouTube 等外部链接必须保留绝对地址否则离线失效。所以正则必须精准$(a[href^https://medium.com/]).each((i, el) { const href $(el).attr(href); if (href.includes(/your-username/) || href.includes(/latest)) { $(el).attr(href, href.replace(https://medium.com, )); } });5.3 关于合规性与长期维护的坦诚提醒最后说点掏心窝的话。这个方案能长期稳定运行靠的不是技术多高明而是对 Medium 平台规则的尊重。它不破解任何加密不伪造任何凭证不访问任何未授权接口所有操作都在你本人账号的合法会话内完成。Medium 的服务条款明确允许用户“访问、查看和下载你自己发布的内容”我们只是把这个“下载”动作从手动点击变成了自动化执行。但这也意味着它天然有生命周期。如果哪天 Medium 彻底重构前端废弃>