用Playwright归档Medium个人文章:创作者数字资产自救指南
1. 项目概述这不是爬虫是给自己建一座数字档案馆“Scraping Your Medium Stories”——光看标题很多人第一反应是“又一个绕过付费墙的工具”或者“批量下载别人文章的黑产脚本”。但如果你真在 Medium 上写了三年以上、发过四十多篇长文、被平台算法反复推荐又悄悄限流过你就会明白这根本不是技术问题而是生存问题。Medium 不提供个人数据导出功能不支持 Markdown 原文备份不保留编辑历史甚至不让你一键下载自己发布的全部内容。你辛辛苦苦写的思考、改了七版的结构、插入的三张自制图表、加了注释的代码块……全锁在它的数据库里随时可能因账号异常、域名迁移或产品策略调整而不可逆丢失。我去年就经历过一次因邮箱验证超时未处理账号被临时冻结48小时期间所有草稿页显示“404”虽然后来恢复但其中一篇写到一半的深度分析稿编辑器里只剩空标题栏——Medium 的“自动保存”根本没生效。所以“Scraping Your Medium Stories”本质是一场自救行动用可控的技术手段把属于你的创作资产从平台的黑盒中稳稳地、可验证地、带元数据地搬回本地。它不针对他人内容不绕过任何权限机制只读取你本人登录态下合法可见的页面它不追求速度而追求完整性——每张图是否下载成功、每个引用链接是否可追溯、每段加粗/斜体/引用块的格式是否准确还原。关键词“Medium”“Scraping”“Stories”背后其实是创作者对数字主权最朴素的坚持我的文字我的时间我的上下文理应由我自己长期持有。这个项目适合三类人第一类是 Medium 中高频创作者月更2篇以上需要定期归档避免意外丢失第二类是内容复用者比如把 Medium 文章同步到个人博客、Notion 知识库或出版电子书需要原始 HTML 或 Markdown 作为中间格式第三类是技术型写作者想分析自己的写作习惯——比如统计每篇文章平均段落数、代码块占比、外链密度这些都必须基于本地结构化数据才能做。它不需要你懂分布式爬虫或反爬对抗但要求你理解 HTTP 请求的基本逻辑、浏览器开发者工具的实用价值以及如何用最小侵入性方式与网页交互。整个过程不依赖第三方 SaaS 服务不上传任何内容到云端所有代码运行在你自己的机器上输出结果完全由你控制。我试过用官方 RSS 订阅导出结果发现 RSS 只包含摘要和前200字图片全部丢失且无法获取已发布但未公开的文章也试过浏览器插件一键保存但遇到长文时经常卡死且无法批量处理带分页的系列文章。最终落地的方案是用 Python Playwright 构建一个“模拟真人操作”的轻量级归档器——它会真实打开你的 Medium 主页点击“Your stories”逐篇进入编辑界面再通过 DOM 解析精准提取正文区域连同图片、标题、发布时间、阅读数、推荐数等元数据一并存为 JSON 和 HTML。整个流程像你在深夜安静地手动复制粘贴只是它不会手抖、不会漏行、不会忘记保存图片。2. 核心思路拆解为什么不用 Requests BeautifulSoup而选 Playwright2.1 Medium 的前端架构决定了传统爬虫必然失败很多人看到“爬取”二字第一反应就是requestsBeautifulSoup组合拳发个 GET 请求解析 HTML正则匹配内容。但在 Medium 上这套方法在2023年之后基本失效原因很具体Medium 的首页、个人故事页、文章详情页全部采用客户端渲染CSR。你用requests.get(https://medium.com/yourname/stories)拿到的响应体里几乎全是div idroot/div和一堆混淆的 JavaScript 脚本真正的文章列表数据藏在某个异步加载的 JSON 接口里而这个接口的 URL 是动态生成的带有时效性 token。我实测过直接请求https://medium.com/yourname/stories返回的 HTML 中script标签内嵌的初始状态对象里stories字段为空数组只有等页面加载完执行 JS 后才通过fetch()调用类似https://medium.com/_/api/users/123456789/stories?limit10offset0这样的接口把数据注入 DOM。这意味着纯静态解析连文章列表都看不到更别说单篇文章内容了。你可能会说“那我直接调这个 API 不就行了”——不行。这个 API 需要有效的Authorizationheader而 token 是存在浏览器localStorage里的medium-auth-token每次登录后由前端 JS 动态写入且有效期通常只有几小时。requests无法自动管理 cookie 和 localStorage更无法执行 JS 渲染强行模拟只会触发 401 错误。2.2 Playwright 的核心优势真实环境 可控交互 稳定选择器Playwright 是微软开源的端到端测试框架但它在数据采集场景的价值远超测试。它启动的是一个真实的 Chromium 或 Firefox 浏览器实例能完整执行 JavaScript自动管理 cookie、localStorage、sessionStorage还能模拟鼠标滚动、键盘输入、等待元素出现等人类操作。对于 Medium 归档这带来三个不可替代的优势第一登录态天然继承。你只需在 Playwright 启动的浏览器中手动登录一次 Medium 账号后续所有页面请求都会自动携带有效 session无需手动提取 token 或构造 header第二DOM 状态实时可信。当 Playwright 执行page.goto(https://medium.com/yourname/stories)后它会等待页面“网络空闲”networkidle即所有资源加载完成、JS 执行完毕、列表数据已渲染进 DOM此时再用page.query_selector_all(article[data-testidstoryPreview])获取文章卡片拿到的就是真实可见的、带完整链接的节点集合第三选择器鲁棒性强。Medium 的 class 名经常变化比如今天叫js-articlesList下周可能变成js-articlesGrid但>old_count 0 while True: cards page.query_selector_all(article[data-testidstoryPreview]) if len(cards) old_count: # 检查是否到底 if page.query_selector(div[data-testidnoMoreStories]): break else: # 可能是加载慢再等等 page.wait_for_timeout(3000) continue old_count len(cards) page.evaluate(window.scrollTo(0, document.body.scrollHeight)) page.wait_for_function(fdocument.querySelectorAll(article[data-testid\storyPreview\]).length {old_count})这段代码看起来比for i in range(1, 10)复杂但它能应对 Medium 任意的加载策略变化——哪怕明天他们改成“点击按钮加载”我只需把scrollTo换成click核心逻辑不变。3.3 正文提取为什么只抓article[data-testidpostArticle]而不是全文Medium 文章页面的 HTML 结构非常“臃肿”顶部有导航栏、作者信息、推荐栏侧边有分享按钮、订阅提示底部有评论区、相关文章、版权说明。如果直接page.content()拿整个 HTML文件体积会暴涨一篇3000字文章完整 HTML 超过2MB且混杂大量无关代码后续转换 Markdown 时容易出错。Playwright 的精准定位能力在这里发挥关键作用page.query_selector(article[data-testidpostArticle])返回的是一个ElementHandle对象代表正文区域的 DOM 节点。我用element.inner_html()获取其内部 HTML得到的就是干净的、不含广告和导航的纯内容。但这里有个陷阱Medium 的正文是分段渲染的p标签里可能嵌套em、strong、a还有figure包裹的图片和precode代码块。直接inner_html()会保留所有标签但我们需要的是可读性高的 Markdown。所以我额外加了一层处理用lxml库解析这个 HTML 片段遍历每个节点按规则转换——p→ 段落strong→**加粗**em→*斜体*a href...→[文本](链接)figureimg src...→。特别注意图片Medium 的图片 URL 是 CDN 地址如https://miro.medium.com/v2/...但这个地址有时效性几天后可能失效。所以我的脚本会下载每张图片到本地images/目录并将 HTML 中的src替换为相对路径./images/xxx.jpg确保离线可读。这个细节很多教程都忽略了导致导出的 HTML 在断网时图片全挂。3.4 元数据采集标题、时间、阅读数这些数字怎么来的除了正文一篇 Medium 文章的元数据同样重要标题决定文件名发布时间影响归档顺序阅读数和推荐数是内容效果的量化指标。这些数据分散在页面不同位置标题在h1标签里但 Medium 有时会用h2发布时间在time标签的datetime属性里阅读数在span[data-testidreadingTime]里格式是“3 min read”推荐数在button[data-testidrecommendButton]的aria-label里如“Recommend (24)”。Playwright 的query_selector可以精准定位每个元素但要注意容错。比如有些文章没有推荐数显示“Recommend”无括号数字这时aria-label可能是“Recommend”需要判空。我的处理逻辑是title_elem page.query_selector(h1, h2) # 兼容两种标题标签 title title_elem.inner_text().strip() if title_elem else Untitled time_elem page.query_selector(time) publish_time time_elem.get_attribute(datetime) if time_elem else read_time_elem page.query_selector(span[data-testidreadingTime]) read_time read_time_elem.inner_text().strip() if read_time_elem else recommend_elem page.query_selector(button[data-testidrecommendButton]) recommend_text recommend_elem.get_attribute(aria-label) if recommend_elem else recommend_count 0 if recommend_text and ( in recommend_text: try: recommend_count int(recommend_text.split(()[1].split())[0]) except: pass这段代码看起来琐碎但它保证了即使 Medium 某天把readingTime的>python3 -m venv medium-scraper-env source medium-scraper-env/bin/activate # macOS/Linux # medium-scraper-env\Scripts\activate # Windows接着安装核心依赖。这里只装两个包playwright和lxml。playwright是主力框架lxml用于后续 HTML 转 Markdown 的精细解析比BeautifulSoup快3倍内存占用低pip install playwright lxml安装完playwright后必须执行初始化命令下载对应浏览器Chromiumplaywright install chromium这一步会下载约180MB 的浏览器二进制文件耐心等待。完成后你可以快速验证环境是否正常新建一个test.py文件写入以下代码from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) # 有头模式方便看 page browser.new_page() page.goto(https://example.com) print(page.title()) browser.close()运行python test.py如果弹出浏览器窗口并打印出 “Example Domain”说明环境配置成功。注意不要用pip install playwright后直接import playwright这是常见错误——Playwright 的 Python binding 必须通过from playwright.sync_api import sync_playwright导入否则会报ModuleNotFoundError。4.2 编写主脚本scrape_medium.py核心代码逐行注释下面是你需要完整复制粘贴的主脚本。我把它控制在180行以内每行都有明确目的没有一行是“为了凑数”#!/usr/bin/env python3 # -*- coding: utf-8 -*- Medium Stories Scraper v1.0 Usage: python scrape_medium.py import os import json import time import re from pathlib import Path from urllib.parse import urlparse, unquote from playwright.sync_api import sync_playwright from lxml import html, etree def sanitize_filename(s): 将标题转为安全文件名去除非法字符 s re.sub(r[:/\\|?*], _, s) # Windows非法字符 s re.sub(r\s, _, s) # 多个空格变下划线 return s.strip(_)[:100] # 截断过长文件名 def download_image(page, img_url, save_dir): 下载单张图片到本地目录 if not img_url or not img_url.startswith(http): return None try: # 从URL提取文件名保留扩展名 parsed urlparse(img_url) filename os.path.basename(unquote(parsed.path)) if not filename or . not in filename: filename fimage_{int(time.time())}.jpg save_path save_dir / filename # Playwright内置下载功能 response page.request.get(img_url) if response.status 200: with open(save_path, wb) as f: f.write(response.body()) return f./images/{filename} except Exception as e: print(f下载图片失败 {img_url}: {e}) return None def html_to_markdown(html_content): 将HTML片段转为简化Markdown不依赖外部库 if not html_content: return root html.fromstring(html_content) lines [] def traverse(node, depth0): if node.tag p: text node.text_content().strip() if text: lines.append(text \n) elif node.tag h1: text node.text_content().strip() if text: lines.append(f# {text}\n\n) elif node.tag h2: text node.text_content().strip() if text: lines.append(f## {text}\n\n) elif node.tag strong: text node.text_content().strip() if text: lines.append(f**{text}**) elif node.tag em: text node.text_content().strip() if text: lines.append(f*{text}*) elif node.tag a: href node.get(href, ) text node.text_content().strip() if href and text: lines.append(f[{text}]({href})) elif node.tag figure: img node.find(.//img) if img is not None: src img.get(src, ) alt img.get(alt, ) if src: local_path download_image(None, src, Path(images)) # 这里简化实际需传page if local_path: lines.append(f\n) elif node.tag pre: code node.find(code) if code is not None: lang code.get(class, ).replace(language-, ) if code.get(class) else lines.append(f{lang}\n{code.text_content()}\n\n) elif node.tag ul or node.tag ol: for li in node.iterchildren(li): prefix - if node.tag ul else 1. text li.text_content().strip() if text: lines.append(f{prefix}{text}\n) # 递归处理子节点 for child in node: traverse(child, depth 1) traverse(root) return .join(lines) def main(): # 创建输出目录 output_dir Path(medium_archive) output_dir.mkdir(exist_okTrue) (output_dir / html).mkdir(exist_okTrue) (output_dir / md).mkdir(exist_okTrue) (output_dir / images).mkdir(exist_okTrue) with sync_playwright() as p: # 启动浏览器有头模式方便首次登录 browser p.chromium.launch(headlessFalse, slow_mo500) # 慢动作便于观察 page browser.new_page() # 1. 手动登录 print(请在打开的浏览器中登录你的 Medium 账号...) page.goto(https://medium.com/m/signin) input(登录完成后按回车键继续...) # 2. 跳转到个人故事页 username input(请输入你的 Medium 用户名如yourname: ).strip() if not username.startswith(): username username stories_url fhttps://medium.com/{username}/stories page.goto(stories_url) # 3. 抓取所有文章链接 print(正在加载文章列表...) all_links [] old_count 0 while True: # 获取当前所有卡片 cards page.query_selector_all(article[data-testidstoryPreview]) if not cards: print(未找到文章卡片请检查用户名是否正确或是否已登录) break current_count len(cards) if current_count old_count: # 检查是否到底 if page.query_selector(div[data-testidnoMoreStories]): print(已加载完所有文章) break else: print(等待新文章加载...) page.wait_for_timeout(3000) continue # 提取链接 for card in cards: link_elem card.query_selector(a) if link_elem: href link_elem.get_attribute(href) if href and href.startswith(https://medium.com): all_links.append(href) old_count current_count print(f已发现 {current_count} 篇文章正在滚动加载...) page.evaluate(window.scrollTo(0, document.body.scrollHeight)) page.wait_for_function(fdocument.querySelectorAll(article[data-testid\storyPreview\]).length {old_count}) # 去重并排序按链接倒序新文章在前 all_links list(set(all_links)) all_links.sort(reverseTrue) # Medium链接含时间戳倒序即新到旧 # 4. 逐篇抓取正文 print(f\n开始归档 {len(all_links)} 篇文章...) for idx, url in enumerate(all_links, 1): print(f正在处理 ({idx}/{len(all_links)}): {url}) try: # 新页面打开避免缓存干扰 new_page browser.new_page() new_page.goto(url) # 等待正文加载 new_page.wait_for_load_state(networkidle) article new_page.query_selector(article[data-testidpostArticle]) if not article: print(f警告未找到正文区域跳过 {url}) new_page.close() continue # 提取元数据 title_elem new_page.query_selector(h1, h2) title title_elem.inner_text().strip() if title_elem else fUntitled_{idx} time_elem new_page.query_selector(time) publish_time time_elem.get_attribute(datetime) if time_elem else # 下载图片并替换src html_content article.inner_html() # 此处省略图片下载逻辑实际需遍历img标签调用download_image # 保存HTML safe_title sanitize_filename(title) html_path output_dir / html / f{safe_title}.html with open(html_path, w, encodingutf-8) as f: f.write(fhtmlbody{html_content}/body/html) # 保存JSON元数据 meta { title: title, url: url, publish_time: publish_time, scraped_at: time.strftime(%Y-%m-%d %H:%M:%S), html_path: str(html_path.relative_to(output_dir)) } json_path output_dir / html / f{safe_title}.json with open(json_path, w, encodingutf-8) as f: json.dump(meta, f, indent2, ensure_asciiFalse) new_page.close() time.sleep(1.5) # 礼貌性延时避免触发限流 except Exception as e: print(f处理 {url} 时出错: {e}) continue print(\n归档完成文件保存在 ./medium_archive/ 目录下。) if __name__ __main__: main()提示这段代码是可运行的完整版本但为了简洁我删减了部分图片下载的详细实现实际使用时需补全。核心逻辑已全部呈现环境初始化、手动登录、无限滚动抓链接、逐页提取、HTML 保存、元数据 JSON 化。你只需复制保存为scrape_medium.py然后在终端运行python scrape_medium.py即可。4.3 首次运行与调试技巧如何快速定位失败点首次运行时强烈建议全程使用headlessFalse即有头模式这样你能亲眼看到浏览器每一步操作是否成功跳转到登录页手动输入后是否正确进入了故事页滚动时新卡片是否真的加载出来当某篇文章卡住时Playwright 会自动暂停你可以在浏览器控制台F12里直接document.querySelector(...)测试选择器是否有效。我总结了三个最常用的调试命令记在便签上贴在显示器边检查当前页面 URLpage.url—— 确认是否停留在预期页面查看所有文章卡片数量len(page.query_selector_all(article[data-testidstoryPreview]))—— 如果为0说明选择器失效或未登录打印正文区域 HTML 片段print(page.query_selector(article[data-testidpostArticle]).inner_html()[:200])—— 确认是否抓到了内容还是空 div。如果脚本在某篇文章中断不要急着重跑。Playwright 会保留浏览器实例你可以在终端按CtrlC中断然后在 Python 交互式环境中重新连接需提前启用--remote-debugging-port9222或者直接关闭浏览器修改脚本后重试。经验告诉我90% 的失败源于选择器变更或网络超时而非逻辑错误。所以与其花两小时 debug不如花五分钟打开 Medium 页面右键“检查”搜索>import json from pathlib import Path from datetime import datetime meta_files Path(medium_archive/html).glob(*.json) posts [] for f in meta_files: with open(f) as j: data json.load(j) if data.get(publish_time): dt datetime.fromisoformat(data[publish_time].replace(Z, 00:00)) posts.append({title: data[title], month: dt.strftime(%Y-%m), url: data[url]}) # 按月份分组计数 from collections import Counter monthly_count Counter(p[month] for p in posts) print(monthly_count) # 输出Counter({2023-10: 4, 2023-11: 3, ...})这就是归档的价值它把散落在平台上的内容变成了你本地可编程、可查询、可分析的数据资产。你甚至可以把html/目录拖进 Obsidian用插件自动索引构建自己的知识图谱。而这一切都始于那个看似简单的标题——“Scraping Your Medium Stories”。5. 常见问题与独家避坑指南那些没人告诉你的细节5.1 “为什么脚本运行到一半就卡住浏览器没反应”这是新手最常遇到的问题90% 的原因是网络超时未处理。Playwright 默认等待超时是30秒但 Medium 在弱网环境下加载一篇文章可能需要45秒尤其是带高清图的长文。脚本会一直卡在page.wait_for_load_state(networkidle)直到超时抛出异常。解决方案很简单在page.goto()和wait_for_load_state()之间显式设置更长的超时page.goto(url, timeout60000) # 60秒超时 page.wait_for_load_state(networkidle, timeout60000)我建议统一设为60秒既给了足够缓冲又不会无限等待。另外如果公司网络有代理Playwright 默认不走系统代理需要显式配置browser p.chromium.launch(proxy{server: http://your-proxy:8080})但大多数家庭网络无需此配置乱加反而导致连接失败。5.2 “导出的 HTML 里图片显示为红叉怎么办”这几乎100%是因为图片下载逻辑未实现或路径错误。上面的主脚本里我故意省略了图片下载的完整代码因为它需要遍历 HTML 中所有img标签逐个提取src再调用page.request.get()下载。很多教程直接用urllib下载但这样会丢失cookie导致 Medium CDN 返回 403 Forbidden。正确做法必须用page.request.get()因为它自动携带当前页面的 session cookie。另一个常见错误是下载后的图片路径写死了比如