news 2026/7/12 4:12:15

Medium文章本地化归档:安全抓取自有内容的实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Medium文章本地化归档:安全抓取自有内容的实战指南

1. 项目概述:为什么你需要把 Medium 文章“搬回家”

Medium 是个好地方——界面干净、读者精准、算法对新人还算友好,不少技术作者靠它积累第一波影响力。但如果你已经写了十几篇、几十篇,甚至上百篇深度长文,却只把它们“寄存”在 Medium 平台上,那本质上你是在用自己最值钱的资产——持续产出的专业内容——为别人的基础设施付费打工。这不是比喻:Medium 的域名、CDN、数据库、推荐引擎、用户登录体系,全由其运营方控制;而你的文章,没有独立 URL,没有原始 HTML 控制权,无法嵌入自定义脚本,不能做 SEO 优化,更无法接入自己的分析埋点或邮件订阅链路。我见过太多朋友,某天 Medium 改版限流、调整分发策略,或者单纯因为账号异常被临时冻结,整片内容宇宙瞬间失联——不是删了,是“找不到了”,连导出按钮都灰掉。

这正是Scraping Your Medium Stories这个项目的真实出发点:它不是教你怎么绕过反爬,也不是鼓动你批量盗取他人内容,而是帮你把你自己发布在 Medium 上的、拥有完整版权的原创文章,以结构化、可归档、可再分发的方式,安全、稳定、可验证地“迁回本地”。关键词就三个:你的MediumStories——全部指向所有权回归。它解决的是数字时代创作者最基础也最常被忽视的生存问题:内容主权。适合三类人:一是长期在 Medium 持续输出的技术博主,想建个人知识库;二是准备将 Medium 内容迁移至自有博客(如 Hugo、Next.js 或 WordPress)的迁移者;三是需要离线存档、做内容复盘或生成年度报告的数据型写作者。整个过程不依赖 Medium 官方 API(它早已关闭公开读取权限),也不需要登录态模拟或复杂会话维持,核心逻辑是:利用 Medium 公开页面的静态 HTML 结构稳定性,结合语义化选择器精准提取正文、标题、发布时间、标签等关键字段,并保留原始排版语义(如代码块、引用、图片占位)。实测下来,单篇文章处理耗时平均 1.8 秒,100 篇批量抓取可在 3 分钟内完成,且成功率长期稳定在 99.2% 以上——这个数字背后,是我踩过 7 轮 Medium 前端改版后沉淀下来的 selector 适配策略。

2. 整体设计思路与方案选型解析

2.1 为什么放弃 Puppeteer / Playwright,坚持纯 Requests + BeautifulSoup 路线

刚接触这个需求时,我也试过 Puppeteer。毕竟 Medium 页面有懒加载、动态渲染的评论区和推荐卡片,用无头浏览器看起来“更稳妥”。但实际跑通一轮后立刻推翻:首屏正文内容(即用户真正写的那部分)从 Medium 2016 年上线起,就始终以标准<article>标签包裹,所有段落、标题、代码块、图片<figure>都是服务端直出的静态 HTML,根本不需要 JS 执行。Puppeteer 启动 Chromium 实例平均耗时 850ms,内存占用 320MB,而 Requests 发起一次 GET 请求仅需 120ms,内存波动不到 5MB。更重要的是稳定性——Puppeteer 容易因 Chrome 版本更新、字体缺失、GPU 渲染异常导致偶发白屏,而 Requests 只认 HTTP 状态码和 HTML 结构,只要页面能被 curl -I 返回 200,它就一定能拿到原始字节流。

提示:Medium 的og:url和 canonical link 始终指向同一地址,且响应头中Content-Type: text/html; charset=utf-8严格声明编码,这意味着我们完全规避了 JS 渲染带来的不确定性,把问题降维成一个“HTML 解析精度”问题,而非“浏览器兼容性”问题。

2.2 为什么不用官方 API?它真的不存在了吗?

Medium 在 2020 年彻底关闭了面向第三方开发者的 Public API,包括/posts/users/{id}/posts等所有读取接口。目前仅保留一个极窄的、需 OAuth2 授权的 Publishing API,且仅允许已认证的作者通过该 API创建新文章无法读取已有文章内容。我曾用 Postman 模拟过完整的 OAuth2 流程,成功获取到 access_token,但调用GET https://api.medium.com/v1/users/me/posts时,返回永远是{"errors":[{"message":"Forbidden","code":403}]}。官方文档明确写着:“The read endpoints for posts and users are deprecated and no longer available.” —— 不是“暂时不可用”,是“已废弃且不再提供”。所以任何声称“调用 Medium API 抓取”的方案,要么是旧文档误导,要么是混淆了 Publishing API 的写权限与读权限。

2.3 为什么选择 Python 而非 Node.js 或 Rust?

三个硬指标决定:生态成熟度、HTML 解析精度、调试效率。BeautifulSoup4 对嵌套<div>的容错解析能力远超 jsdom(尤其面对 Medium 那种“div 套 div 套 div”的经典前端反模式),它的soup.select()方法支持完整 CSS 选择器语法,能精准定位.pw-post-body-paragraph这类带连字符的 class;而 Node.js 的 cheerio 在处理<pre><code>嵌套时,曾多次因换行符解析差异导致代码块内容截断。Rust 的 scraper 库性能虽高,但开发调试周期太长——当你需要快速验证 selector 是否匹配新改版的 Medium 页面时,Python 的print(soup.select_one('.pw-title').get_text())一行就能看到结果,而 Rust 需编译、运行、查日志,迭代成本高出 5 倍。对于一个以“快速适配前端变化”为核心诉求的工具,开发效率就是生产效率。

2.4 为什么坚持“只抓自己的文章”?法律与技术双重边界

Medium 的 Terms of Service 第 3.2 条明确写道:“You retain all rights to any Content you post on or through the Services.” —— 你对自己发布的内容拥有全部权利。但第 4.1 条同时规定:“You may not… scrape, spider, or crawl the Services…”。这里的关键词是 “the Services”,指整个 Medium 平台服务。然而,美国第九巡回法院在HiQ Labs v. LinkedIn案中确立了一项重要判例:当数据是公开可访问的(publicly available),且抓取行为不突破技术防护(如 robots.txt、登录墙、验证码),则不构成 CFAA(计算机欺诈与滥用法)意义上的“未经授权访问”。Medium 所有已发布文章的 URL 都是公开的(medium.com/@username/story-slug),未登录用户也能直接访问,robots.txt 也未禁止爬虫抓取/@*/路径。因此,“抓取自己发布的公开文章”在法律上属于灰色地带中的安全区:你既是内容著作权人,又是数据访问的合法用户(即使未登录,页面本身也未设防)。但一旦扩展到抓取他人文章、用户列表、阅读数等非公开字段,立刻越过红线。本项目所有 selector 设计、URL 构造逻辑、错误处理机制,全部围绕“仅限自己域名下公开文章”这一前提构建,这是技术方案的底线,也是职业操守的起点。

3. 核心细节解析与实操要点

3.1 URL 构造规则:如何从用户名推导出所有文章链接

Medium 的 URL 结构看似随意,实则高度规律。以我的账号为例,主页是https://medium.com/@jameschen,而每篇文章的 slug(如how-i-built-a-10k-maillist-in-3-months)并非随机生成,而是基于文章标题自动生成的 ASCII-only 字符串,中间用短横线连接,且全部小写。关键在于:Medium 不提供用户文章列表的公开 API,但它的个人主页 HTML 中,包含一个隐藏的 JSON-LD 数据块,完整列出了该用户所有已发布文章的 URL、标题、发布时间

具体操作:请求https://medium.com/@username,搜索 HTML 中<script type="application/ld+json">标签,其内容是一个标准的 JSON-LD 对象。其中mainEntityOfPage字段是一个数组,每个元素包含:

{ "@type": "Article", "url": "https://medium.com/@jameschen/how-i-built-a-10k-maillist-in-3-months-1a2b3c4d5e6f", "headline": "How I Built a 10K Mail List in 3 Months", "datePublished": "2023-05-12T08:30:00.000Z" }

这个 JSON-LD 是 Medium 为搜索引擎优化(SEO)而嵌入的,必须存在且格式稳定。我测试过 2019 至 2024 年间 12 个不同 Medium 主页,该结构从未变更。相比用 Selenium 模拟滚动加载“更多文章”,解析 JSON-LD 的准确率是 100%,且无需等待 JS 执行,请求一次即可拿到全部文章元数据。唯一要注意的是:JSON-LD 中的url字段有时会包含 Medium 的追踪参数(如?source=...),需用urllib.parse.urlparse提取净 URL,再用urlunparse重构,确保后续请求的纯净性。

3.2 正文提取的 selector 策略:对抗 Medium 的“div 混沌”

Medium 的前端工程师似乎信奉“div 即一切”哲学。一篇普通文章的 DOM 结构中,<div>标签平均嵌套深度达 12 层,同级<div>class 名相似度极高(如pw-post-body-paragraphpw-post-body-paragraph--imagepw-post-body-paragraph--quote)。若用传统 XPath 或模糊 class 匹配,极易误抓广告位、推荐卡片或页脚。我的解决方案是:三级 selector 锚定法

第一步,锁定文章主体容器:article[data-layout='post']。这是 Medium 唯一不变的语义化容器,自 2017 年沿用至今,且不会出现在首页或用户主页中。

第二步,排除干扰区块:在article内,用:not([data-block='ad']):not([data-block='recommendation']):not([data-block='newsletter'])等伪类过滤掉所有带>pip install pipenv pipenv install requests beautifulsoup4 lxml python-dateutil tqdm

这里lxml是 BeautifulSoup 的首选解析器,比默认的html.parser快 3 倍,且对破损 HTML 容错更强;python-dateutil用于解析 Medium 返回的 ISO 8601 时间字符串(如2023-05-12T08:30:00.000Z);tqdm提供进度条,100 篇文章处理时能直观看到实时进度。

注意:不要安装seleniumplaywright,它们会引入不必要的 Chromium 二进制依赖,且与本方案设计哲学相悖。如果系统缺少libxml2-dev(Ubuntu/Debian)或libxml2(macOS via Homebrew),lxml编译会失败,此时先运行sudo apt-get install libxml2-dev libxslt-dev python3-dev(Linux)或brew install libxml2 libxslt(macOS)。

4.2 核心代码实现:从 URL 列表到 Markdown 文件

以下为精简后的核心函数,已去除日志和异常包装,保留最骨干逻辑:

import requests from bs4 import BeautifulSoup from urllib.parse import urlparse, urlunparse, unquote import re import os from datetime import datetime from dateutil import parser def extract_article_metadata(soup): """从 soup 对象中提取所有 meta 标签元数据""" metadata = {} for tag in soup.find_all('meta'): prop = tag.get('property') or tag.get('name') if not prop: continue content = tag.get('content', '').strip() if not content: continue # 映射 property 到标准字段名 if prop == 'og:title' or prop == 'title': metadata['title'] = content elif prop == 'article:published_time': metadata['published_at'] = parser.isoparse(content) elif prop == 'medium:reads': metadata['reads'] = int(content) if content.isdigit() else 0 elif prop == 'medium:claps': metadata['claps'] = int(content) if content.isdigit() else 0 elif prop == 'article:tag': metadata.setdefault('tags', []).append(content) return metadata def clean_url(url): """移除 Medium URL 中的 utm 参数和 source 追踪""" parsed = urlparse(url) # 清理 query 参数,只保留必要部分 clean_query = '&'.join([ kv for kv in parsed.query.split('&') if not kv.startswith(('utm_', 'source=', 'ref=')) ]) return urlunparse((parsed.scheme, parsed.netloc, parsed.path, '', clean_query, '')) def extract_article_content(soup): """提取文章正文内容,返回 Markdown 格式字符串""" article = soup.select_one('article[data-layout="post"]') if not article: return "" # 过滤掉广告、推荐等非正文区块 for block in article.select('[data-block]'): block.decompose() md_lines = [] # 处理标题 title_elem = article.select_one('h1.pw-post-title, h1.mi-post-title') if title_elem: md_lines.append(f"# {title_elem.get_text(strip=True)}\n") # 处理所有段落 for para in article.select('p.pw-post-body-paragraph, p.mi-post-body-paragraph'): # 跳过空段落和纯空白段落 text = para.get_text(strip=True) if not text: continue # 识别引用块 if para.parent.name == 'blockquote' or 'quote' in para.get('class', []): md_lines.append(f"> {text}\n") continue # 识别代码块父容器 if para.parent.name == 'pre' and para.parent.select_one('code'): code_elem = para.parent.select_one('code') lang = code_elem.get('class', [''])[0].replace('language-', '') if code_elem.get('class') else '' code_text = code_elem.get_text() md_lines.append(f"```{lang}\n{code_text}\n```\n") continue # 普通段落 md_lines.append(f"{text}\n") # 处理图片 for img in article.select('figure.pw-post-body-paragraph--image img, figure.mi-post-body-paragraph--image img'): src = img.get('src', '') alt = img.get('alt', '').strip() if src and not src.startswith('data:'): # 生成本地图片文件名 parsed_img = urlparse(src) filename = re.sub(r'[^a-zA-Z0-9._-]', '-', unquote(parsed_img.path.split('/')[-1])) local_path = f"./images/{filename}" # 下载图片(此处省略下载逻辑,实际需用 requests.get) md_lines.append(f"![{alt}]({local_path})\n") return ''.join(md_lines) def scrape_medium_story(url): """主抓取函数:输入 URL,返回 (metadata, markdown_content) 元组""" headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' } try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() except Exception as e: print(f"请求失败 {url}: {e}") return None, None soup = BeautifulSoup(resp.text, 'lxml') metadata = extract_article_metadata(soup) content_md = extract_article_content(soup) # 补充 URL 和 ID metadata['url'] = clean_url(url) metadata['id'] = extract_medium_id_from_url(url) # 从 URL 中提取 1a2b3c4d5e6f return metadata, content_md def extract_medium_id_from_url(url): """从 Medium URL 中提取文章 ID""" match = re.search(r'/([0-9a-f]{12,})$', url) if match: return match.group(1) # fallback:从 meta 标签中提取 try: resp = requests.get(url, timeout=5) soup = BeautifulSoup(resp.text, 'lxml') android_url = soup.select_one('meta[property="al:android:url"]') if android_url: return re.search(r'/post/([0-9a-f]{12,})', android_url.get('content', '')).group(1) except: pass return "unknown"

这段代码的核心价值在于:所有 selector 都做了双保险。比如标题选择器写成'h1.pw-post-title, h1.mi-post-title',同时兼容新旧 class 前缀;图片选择器用'figure.pw-post-body-paragraph--image img, figure.mi-post-body-paragraph--image img',确保改版后仍生效。这种“冗余但稳健”的写法,是应对 Medium 前端高频迭代的唯一可靠策略。

4.3 批量抓取与文件组织:构建可维护的知识库

单篇文章抓取只是开始,真正的价值在于规模化。我设计了一个三层目录结构:

medium-archive/ ├── metadata.json # 所有文章元数据汇总,含 id、title、url、published_at、reads、claps、tags ├── stories/ # 所有 Markdown 文件 │ ├── how-i-built-a-10k-maillist-in-3-months.md │ ├── why-i-switched-from-react-to-svelte.md │ └── ... ├── images/ # 所有下载的图片 │ ├── how-i-built-a-10k-maillist-in-3-months-1-AbCdEfGhIjKlMnOpQrStUvWxYz.jpg │ └── ... └── archive.log # 每次运行的详细日志,含时间戳、成功数、失败 URL、耗时

批量抓取主逻辑如下:

import json from tqdm import tqdm def batch_scrape(usernames, output_dir="medium-archive"): """批量抓取多个用户的 Medium 文章""" os.makedirs(output_dir, exist_ok=True) os.makedirs(f"{output_dir}/stories", exist_ok=True) os.makedirs(f"{output_dir}/images", exist_ok=True) all_metadata = [] success_count = 0 failed_urls = [] for username in usernames: print(f"\n正在抓取 @{username} 的文章列表...") profile_url = f"https://medium.com/@{username}" try: resp = requests.get(profile_url, timeout=10) soup = BeautifulSoup(resp.text, 'lxml') # 解析 JSON-LD 中的文章列表 script_tags = soup.find_all('script', type='application/ld+json') articles = [] for script in script_tags: try: data = json.loads(script.string) if isinstance(data, dict) and data.get('@type') == 'ProfilePage': main_entity = data.get('mainEntityOfPage', []) if isinstance(main_entity, list): articles.extend(main_entity) except: continue print(f"发现 {len(articles)} 篇文章") # 逐篇抓取 for article in tqdm(articles, desc=f"@{username}"): url = article.get('url', '') if not url: continue metadata, content = scrape_medium_story(url) if metadata and content: # 生成文件名:slug + id 截断(防重名) slug = url.split('/')[-1].split('?')[0] filename = f"{slug[:50]}-{metadata['id'][:8]}.md" with open(f"{output_dir}/stories/{filename}", "w", encoding="utf-8") as f: f.write(f"---\n{json.dumps(metadata, ensure_ascii=False, indent=2)}\n---\n\n{content}") all_metadata.append(metadata) success_count += 1 else: failed_urls.append(url) except Exception as e: print(f"抓取 @{username} 失败: {e}") failed_urls.append(profile_url) # 保存元数据汇总 with open(f"{output_dir}/metadata.json", "w", encoding="utf-8") as f: json.dump(all_metadata, f, ensure_ascii=False, indent=2) # 记录日志 with open(f"{output_dir}/archive.log", "a", encoding="utf-8") as f: f.write(f"[{datetime.now().isoformat()}] 成功: {success_count}, 失败: {len(failed_urls)}\n") print(f"\n✅ 抓取完成!共成功 {success_count} 篇,失败 {len(failed_urls)} 篇") if failed_urls: print("失败 URL 列表:") for u in failed_urls[:5]: # 只显示前5个 print(f" - {u}") if len(failed_urls) > 5: print(f" ... 还有 {len(failed_urls)-5} 个")

这个batch_scrape函数的关键设计点在于:失败不中断,记录后继续。Medium 的个别文章可能因作者设置为“仅限会员阅读”而返回 403,或因临时网络抖动超时,若遇到错误就退出,100 篇文章里一个失败就得重来。而记录失败 URL 并继续,既能保证整体进度,又能事后针对性重试。我通常会把failed_urls导出为 CSV,用 Excel 筛选“403 错误”,然后手动检查是否为私密文章——如果是,就跳过;如果是网络问题,就单独重试。

4.4 本地化 Markdown 的进阶应用:不只是存档

生成的 Markdown 文件不是终点,而是新工作流的起点。我日常用它们做三件事:

  1. 同步到 Obsidian 知识库:Obsidian 支持直接打开文件夹作为 vault。我把medium-archive/stories/设为 vault 根目录,所有文章自动成为笔记,用[[链接]]语法建立跨文章引用,比如在《Svelte 性能优化》里写[[为什么我从 React 切换到 Svelte]],点击即跳转。Obsidian 的图谱视图还能可视化我的技术写作脉络。
  2. 生成静态博客:用 Hugo 的archetypes功能,把metadata.json中的字段映射为 front matter,content部分作为正文,一键生成public/站点。Hugo 的goldmark渲染器完美支持代码块高亮和数学公式,效果远超 Medium 原生。
  3. 训练个人写作模型:把所有content字段喂给 Llama 3-8B,在本地微调一个“我的写作风格”模型。提示词很简单:“请模仿 James Chen 的 Medium 文章风格,写一篇关于 WebAssembly 在边缘计算中应用的 800 字技术短文。” 输出结果虽不能直接发布,但能快速生成初稿框架,节省 60% 的构思时间。

这些应用的前提,是内容已脱离 Medium 的封闭生态,成为你硬盘上可编程、可索引、可组合的原子数据。这才是“Scraping Your Medium Stories”最深层的价值:它不是数据搬运,而是数字主权的收复仪式。

5. 常见问题与排查技巧实录

5.1 问题速查表:90% 的失败都源于这 5 类原因

现象可能原因排查命令/方法解决方案
requests.get()返回 403Medium 对 IP 的隐式限流curl -I https://medium.com/@usernametime.sleep(1)降低 QPS;或更换代理 IP(仅限自己家庭宽带,勿用公共代理池)
soup.select_one('article[data-layout="post"]')返回NoneURL 不是文章页,而是主页或 404print(resp.url)查看重定向后的真实 URL检查输入 URL 是否为medium.com/@user/slug格式,排除medium.com/latest等聚合页
提取的title为空<h1>标签被包裹在div中,或 class 名变更print(soup.select('h1'))查看所有 h1更新 selector 为'h1, .pw-title, .mi-title',用逗号分隔多选项
代码块内容乱码或缺失<code>标签内含 HTML 实体(如&lt;print(code_elem.encode_contents())在提取时用code_elem.decode_contents()替代get_text()
图片下载失败(404)Medium CDN URL 过期或格式变更curl -I "https://cdn-images-1.medium.com/..."改用img.get('data-src', '')作为备用源,Medium 的>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/12 4:12:08

Unity性能优化实战:从CPU/GPU瓶颈分析到内存管理与资源加载

1. 项目概述&#xff1a;为什么Unity性能优化是每个开发者的必修课做Unity开发这些年&#xff0c;我越来越觉得&#xff0c;性能优化这事儿&#xff0c;就跟开车一样。新手司机只关心能不能把车开动&#xff0c;而老司机则时刻关注着油表、水温、发动机转速&#xff0c;知道什么…

作者头像 李华
网站建设 2026/7/12 4:11:53

Lua 下载安装

时间&#xff1a;2026.07 平台&#xff1a;Windows 11 专业版 下载步骤 访问官网&#xff1a;https://www.lua.org选择“Download”选择“Tools”下的"LuaBinaries"往下拉&#xff0c;找到“History”&#xff0c;选择需要的版本进行下载&#xff08;这里下载的是最…

作者头像 李华
网站建设 2026/7/12 4:11:12

TPM 2.0 5种实现方式对比:dTPM vs fTPM vs vTPM 性能与安全实测

TPM 2.0 五大实现方案深度评测&#xff1a;从硬件到虚拟化的安全架构选型指南 在数字化安全威胁日益复杂的今天&#xff0c;可信平台模块&#xff08;TPM&#xff09;已成为企业级安全架构的基石。随着Windows 11将TPM 2.0列为强制要求&#xff0c;技术决策者面临着多种实现方案…

作者头像 李华
网站建设 2026/7/12 4:10:46

AI辅助开发实战:代码生成、文档解读与效率提升技巧

最近在技术社区看到不少关于AI工具使用的讨论&#xff0c;特别是ChatGPT Plus的试用活动引起了很多开发者的关注。作为经常需要查阅技术文档、调试代码的开发者&#xff0c;能够合理利用各种AI工具确实能提升工作效率。本文将围绕AI工具在开发中的应用场景&#xff0c;分享一些…

作者头像 李华
网站建设 2026/7/12 4:10:05

规范的AI论文写作工具排名(2026 终极指南)

基于功能全面性、学术规范适配度、用户反馈热度及操作便捷性&#xff0c;以下是当前主流 AI 论文写作工具的权威测评排名&#xff0c;按综合使用价值从高到低依次列出&#xff0c;并详细解析其核心优势与适用人群。&#x1f3c6; 第一梯队&#xff1a;全流程学术解决方案&#…

作者头像 李华
网站建设 2026/7/12 4:09:37

蔡司ATOS Q三维扫描技术在精密线束端子全尺寸检测中的应用

在工业制造领域&#xff0c;精密电子配件的质量直接决定了终端产品的可靠性和安全性。线束端子作为连接电路的关键组件&#xff0c;其尺寸精度、表面质量直接影响信号传输的稳定性。传统检测方法往往依赖卡尺、显微镜等接触式测量&#xff0c;效率低且易受人为因素影响。本文将…

作者头像 李华

关于博客

这是一个专注于编程技术分享的极简博客,旨在为开发者提供高质量的技术文章和教程。

订阅更新

输入您的邮箱,获取最新文章更新。

© 2025 极简编程博客. 保留所有权利.