用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,而选 Playwright?
2.1 Medium 的前端架构决定了传统爬虫必然失败
很多人看到“爬取”二字,第一反应就是requests+BeautifulSoup组合拳:发个 GET 请求,解析 HTML,正则匹配内容。但在 Medium 上,这套方法在2023年之后基本失效,原因很具体:Medium 的首页、个人故事页、文章详情页,全部采用客户端渲染(CSR)。你用requests.get("https://medium.com/@yourname/stories")拿到的响应体里,几乎全是<div id="root"></div>和一堆混淆的 JavaScript 脚本,真正的文章列表数据藏在某个异步加载的 JSON 接口里,而这个接口的 URL 是动态生成的,带有时效性 token。我实测过:直接请求https://medium.com/@yourname/stories返回的 HTML 中,<script>标签内嵌的初始状态对象里,stories字段为空数组;只有等页面加载完,执行 JS 后,才通过fetch()调用类似https://medium.com/_/api/users/123456789/stories?limit=10&offset=0这样的接口,把数据注入 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-testid='storyPreview']")获取文章卡片,拿到的就是真实可见的、带完整链接的节点集合;第三,选择器鲁棒性强。Medium 的 class 名经常变化(比如今天叫js-articlesList,下周可能变成js-articlesGrid),但>old_count = 0 while True: cards = page.query_selector_all("article[data-testid='storyPreview']") if len(cards) == old_count: # 检查是否到底 if page.query_selector("div[data-testid='noMoreStories']"): break else: # 可能是加载慢,再等等 page.wait_for_timeout(3000) continue old_count = len(cards) page.evaluate("window.scrollTo(0, document.body.scrollHeight)") page.wait_for_function(f"document.querySelectorAll('article[data-testid=\"storyPreview\"]').length > {old_count}")
这段代码看起来比for i in range(1, 10)复杂,但它能应对 Medium 任意的加载策略变化——哪怕明天他们改成“点击按钮加载”,我只需把scrollTo换成click,核心逻辑不变。
3.3 正文提取:为什么只抓article[data-testid='postArticle'],而不是全文?
Medium 文章页面的 HTML 结构非常“臃肿”:顶部有导航栏、作者信息、推荐栏;侧边有分享按钮、订阅提示;底部有评论区、相关文章、版权说明。如果直接page.content()拿整个 HTML,文件体积会暴涨(一篇3000字文章,完整 HTML 超过2MB),且混杂大量无关代码,后续转换 Markdown 时容易出错。Playwright 的精准定位能力在这里发挥关键作用:page.query_selector("article[data-testid='postArticle']")返回的是一个ElementHandle对象,代表正文区域的 DOM 节点。我用element.inner_html()获取其内部 HTML,得到的就是干净的、不含广告和导航的纯内容。但这里有个陷阱:Medium 的正文是分段渲染的,<p>标签里可能嵌套<em>、<strong>、<a>,还有<figure>包裹的图片和<pre><code>代码块。直接inner_html()会保留所有标签,但我们需要的是可读性高的 Markdown。所以,我额外加了一层处理:用lxml库解析这个 HTML 片段,遍历每个节点,按规则转换——<p>→ 段落,<strong>→**加粗**,<em>→*斜体*,<a href="...">→[文本](链接),<figure><img 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-testid="readingTime"]里,格式是“3 min read”;推荐数在button[data-testid="recommendButton"]的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-testid='readingTime']") read_time = read_time_elem.inner_text().strip() if read_time_elem else "" recommend_elem = page.query_selector("button[data-testid='recommendButton']") 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后,必须执行初始化命令,下载对应浏览器(Chromium):
playwright install chromium这一步会下载约180MB 的浏览器二进制文件,耐心等待。完成后,你可以快速验证环境是否正常:新建一个test.py文件,写入以下代码:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) # 有头模式,方便看 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 = f"image_{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, depth=0): 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_ok=True) (output_dir / "html").mkdir(exist_ok=True) (output_dir / "md").mkdir(exist_ok=True) (output_dir / "images").mkdir(exist_ok=True) with sync_playwright() as p: # 启动浏览器(有头模式,方便首次登录) browser = p.chromium.launch(headless=False, slow_mo=500) # 慢动作,便于观察 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 = f"https://medium.com/{username}/stories" page.goto(stories_url) # 3. 抓取所有文章链接 print("正在加载文章列表...") all_links = [] old_count = 0 while True: # 获取当前所有卡片 cards = page.query_selector_all("article[data-testid='storyPreview']") if not cards: print("未找到文章卡片,请检查用户名是否正确,或是否已登录") break current_count = len(cards) if current_count == old_count: # 检查是否到底 if page.query_selector("div[data-testid='noMoreStories']"): 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(f"document.querySelectorAll('article[data-testid=\"storyPreview\"]').length > {old_count}") # 去重并排序(按链接倒序,新文章在前) all_links = list(set(all_links)) all_links.sort(reverse=True) # 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-testid='postArticle']") 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 f"Untitled_{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', encoding='utf-8') as f: f.write(f"<html><body>{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', encoding='utf-8') as f: json.dump(meta, f, indent=2, ensure_ascii=False) 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 首次运行与调试技巧:如何快速定位失败点?
首次运行时,强烈建议全程使用headless=False(即有头模式),这样你能亲眼看到浏览器每一步操作:是否成功跳转到登录页?手动输入后,是否正确进入了故事页?滚动时,新卡片是否真的加载出来?当某篇文章卡住时,Playwright 会自动暂停,你可以在浏览器控制台(F12)里直接document.querySelector(...)测试选择器是否有效。我总结了三个最常用的调试命令,记在便签上贴在显示器边:
- 检查当前页面 URL:
page.url—— 确认是否停留在预期页面; - 查看所有文章卡片数量:
len(page.query_selector_all("article[data-testid='storyPreview']"))—— 如果为0,说明选择器失效或未登录; - 打印正文区域 HTML 片段:
print(page.query_selector("article[data-testid='postArticle']").inner_html()[:200])—— 确认是否抓到了内容,还是空 div。
如果脚本在某篇文章中断,不要急着重跑。Playwright 会保留浏览器实例,你可以在终端按Ctrl+C中断,然后在 Python 交互式环境中重新连接(需提前启用--remote-debugging-port=9222),或者直接关闭浏览器,修改脚本后重试。经验告诉我: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, timeout=60000) # 60秒超时 page.wait_for_load_state("networkidle", timeout=60000)我建议统一设为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。另一个常见错误是:下载后的图片路径写死了,比如
