九成自动化批量备份知乎专栏文章
注原来的方法已经被知乎反爬干掉了请参阅末尾的新方法。很多年前我在雅虎博客上写了一些诗后来雅虎离开中国博客关闭虽然发过要我备份的邮件但是我没注意后来雅虎走了那些诗就丢失了。现在我在知乎上写了个笑庵诗草专栏前天知乎崩溃上不去一下子让我紧张了赶紧把专栏备份。专栏上的诗也不多文言白话总共也就五十来首可惜逼乎不够忠厚官方没有提供导出专栏文章的功能。但是作为会写程序的文科生要一篇篇打开专栏文章并复制备份那比为了赚取每天25去上班还要难受完全是不可能的事。不过作为懒惰的文科生在自己写程序前还是先找AIs要个脚本。可惜知乎的API修改了各个AI给的脚本都只能自动爬取前十首。再上网找找脚本最多也就AI的水平或者还不如AI。没办法只能自己动动脑子了。打开知乎专栏的时候它只会显示一部分专栏中的文章但是滚动鼠标的话页面会刷新专栏中的文章会逐步列出来直至全部完成。如果知道了所有这些文章的ID那么利用知乎的API就能很容易获取文章的信息再借助BeautifulSoup就可以很容易分析内容并保存了。所以这里最关键的是要能够打开知乎专栏并自动化模拟手工滚动鼠标从而取得所有专栏文章的ID。正好有至少两个库Playwright和Selenium可以实现用浏览器打开知乎专栏并模拟手工滚动鼠标的效果。另外使用requests库发送查询获取专栏文章总数以及获取文章ID后利用知乎的API读取文章信息都必须传入知乎的cookie信息以作为登录用户进行操作。以我使用的Firefox浏览器为例打开知乎按F12键调出开发者工具如下图所示可以找到自己的知乎cookie在z_c0那行的值那一列双击其内容就是我们需要的cookie将其复制粘贴在下面的程序中的COOKIE常量赋值处。下面的程序利用Playwright运行Firefox浏览器手工打开知乎网站并登录登录后在程序运行窗口按下回车键这就是为什么这个程序只有九成自动化因为手工登录这一点如果避免就没法取到专栏全部文章的ID然后自动模拟滚动鼠标不停加载文章直至加载的文章总数达到专栏文章的总数。之后就可以遍历ID列表利用知乎API读取文章信息在使用BeautifulSoup解析文章内容拼接后保存为md格式的文件。import os import random import re import time from datetime import datetime from urllib.parse import urljoin import requests from bs4 import BeautifulSoup from playwright.sync_api import sync_playwright def get_zhihu_column_article_count(column_id: str, headers: dict) - int: 获取知乎专栏文章总数 Args: column_id: 专栏ID从专栏URL提取如c_123456 headers: 浏览器请求头包含知乎登录Cookie需包含z_c0字段 Returns: 文章总数失败时返回-1 url fhttps://www.zhihu.com/api/v4/columns/{column_id}/items params {limit: 1, offset: 0} # 仅请求1篇文章减少数据传输 try: response requests.get(url, headersheaders, paramsparams) response.raise_for_status() # 抛出HTTP错误如403/404 data response.json() return data.get(paging, {}).get(totals, 0) # 从分页信息提取总数 except Exception as e: print(f获取{column_id}专栏文章总数失败{str(e)}) return -1 def get_zhihu_column_article_ids(column_id, count_headers): with sync_playwright() as p: # 启动浏览器可以选择 Chromium、Firefox 或 WebKit browser p.firefox.launch(headlessFalse) # 设置 headlessFalse 可以看到浏览器界面 page browser.new_page() # 设置 User-Agent 和其他请求头 page.set_extra_http_headers({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 }) # 手动登录知乎或加载保存的 Cookie page.goto(https://www.zhihu.com/signin) print(请在打开的浏览器中手动登录知乎登录后按 Enter 键继续...) input() # 等待用户手动登录 # 打开专栏页面 url fhttps://zhuanlan.zhihu.com/{column_id} page.goto(url, timeout60000) print(f已打开专栏页面{url}) # 等待页面初始加载 time.sleep(3) # 用于存储文章 ID 的集合 article_ids set() # 取得专栏文章总数 total_ids get_zhihu_column_article_count(column_id, count_headers) while True: # 模拟鼠标滚动加载更多文章 page.mouse.wheel(0, 10000) # 每次滚动 10000 像素 time.sleep(random.uniform(2, 4)) # 随机等待 2-4 秒 current_ids page.evaluate( () { const links Array.from(document.querySelectorAll(a[href*/p/])); return links.map(link { const href link.getAttribute(href); const match href.match(/\/p\/(\d)/); return match ? match[1] : null; }).filter(id id ! null); } ) article_ids.update(current_ids) print(f当前已获取文章 ID 数量{len(article_ids)}) # 已获取全部文章 ID 则退出循环否则继续模拟滚动鼠标加载剩余文章 if len(article_ids) total_ids: print(f已获取全部id共{len(article_ids)}个。) break # 关闭浏览器 browser.close() return list(article_ids) def get_article_detail(article_id, article_headers): 获取单篇文章详情标题、正文、发布时间等 Args: article_id: 文章ID article_headers: 浏览器请求头包含知乎登录Cookie需包含z_c0字段 Returns: 文章详情JSON失败时返回None if not article_id: return None # 利用知乎API获取文章详情 url fhttps://www.zhihu.com/api/v4/articles/{article_id} params {include: content,title,created,author.name} # 包含所需字段 try: response requests.get(url, headersarticle_headers, paramsparams) response.raise_for_status() # 抛出HTTP错误如403、404 return response.json() except Exception as e: print(f获取文章详情失败ID{article_id}{str(e)}) return None def download_image(img_url, article_title, article_headers): 下载图片到本地并返回相对路径复用原逻辑 if not img_url: return img_url urljoin(https://zhihu.com, img_url) img_ext img_url.split(.)[-1].split(?)[0].lower() if img_ext not in [jpg, jpeg, png, gif]: img_ext jpg # 先生成安全的文件名再用于 f-string避免在 f-string 表达式中使用反斜杠或需转义的引号 safe_title re.sub(r[\\/*?:|], _, article_title) img_filename f{safe_title}_{int(time.time())}.{img_ext} img_path os.path.join(image_dir, img_filename) try: response requests.get(img_url, headersarticle_headers, streamTrue, timeout10) response.raise_for_status() with open(img_path, wb) as f: for chunk in response.iter_content(chunk_size8192): f.write(chunk) return os.path.relpath(img_path, SAVE_DIR) except Exception as e: print(f图片下载失败{img_url}错误{str(e)}) return img_url def parse_article_content(html_content, article_title): 解析HTML正文为Markdown优化诗词排版处理 soup BeautifulSoup(html_content, html.parser) md_content [] # 处理段落和换行增强对诗词格式的支持 for block in soup.find_all([p, div]): # 跳过空块 if not block.get_text(stripTrue) and not block.find_all(img): continue # 处理图片 img_tags block.find_all(img) for img in img_tags: img_url img.get(data-original) or img.get(src) local_img_path download_image(img_url, article_title) md_content.append(f\n) img.extract() # 处理文本保留空行适合诗词分行 text block.get_text().strip() if text: # 对包含中文标点的段落保留原始换行适合诗词 if re.search(r[。], text): md_content.append(text \n) else: md_content.append(text \n\n) # 普通文本增加空行分隔 return \n.join(md_content).rstrip(\n) # 移除末尾多余空行 def save_article_as_markdown(article_data): 保存文章为Markdown文件增加作者信息 if not article_data: return False title article_data.get(title, 未命名文章) safe_title re.sub(r[\\/*?:|], _, title) created_time datetime.fromtimestamp(article_data.get(created, 0)).strftime(%Y-%m-%d) author article_data.get(author, {}).get(name, 未知作者) html_content article_data.get(content, ) md_content parse_article_content(html_content, safe_title) # Markdown头部包含作者信息 md_header f# {title}\n\n**作者**{author} | **发布时间**{created_time}\n\n---\n\n full_md md_header md_content file_path os.path.join(SAVE_DIR, f{created_time}_{safe_title}.md) with open(file_path, w, encodingutf-8) as f: f.write(full_md) print(f✅ 已保存{os.path.basename(file_path)}) return True # ---------------------- 提供重新下载前次备份失败的文章的功能 -------------------------- def load_backuped_ids(save_dir: str): 从文件加载已备份的文章ID id_file os.path.join(save_dir, backuped_ids.txt) if os.path.exists(id_file): with open(id_file, r, encodingutf-8) as f: return set(f.read().splitlines()) return set() def save_backuped_id(article_id, save_dir: str): 保存已备份的文章ID到文件 id_file os.path.join(save_dir, backuped_ids.txt) with open(id_file, a, encodingutf-8) as f: f.write(f{article_id}\n) def batch_backup_articles(column_id, save_dir, article_headers, count_headers): 知乎专栏文章批量备份支持备份失败后再次运行继续备份未完成的文章 # 读取已备份的文章ID backuped_ids load_backuped_ids(save_dir) success_count 0 fail_count 0 ids get_zhihu_column_article_ids(column_id, count_headers) print(f开始备份 {len(ids)} 篇文章...\n) for article_id in ids: print(f\n----- 处理 ID{article_id} -----) # 跳过已备份的文章 if article_id in backuped_ids: print(f已跳过已备份{article_id}) continue article_data get_article_detail(article_id, article_headers) save_backuped_id(article_id, save_dir) if save_article_as_markdown(article_data): success_count 1 else: fail_count 1 time.sleep(random.uniform(3, 5)) # 随机等待 3-5 秒 控制请求间隔避免触发反爬 print(f\n 备份完成 ) print(f成功{success_count} 篇 | 失败{fail_count} 篇) print(f保存路径{os.path.abspath(save_dir)}) if __name__ __main__: # 1. 替换为目标专栏ID打开知乎专栏浏览器地址栏中c_开头加上一串数字的字符串就是专栏ID如https://zhihu.com/column/c_123456 → c_123456 COLUMN_ID c_1745169660587147264 # 笑庵诗草专栏ID # 2. 替换为你的知乎Cookie需赋值为z_c0字段 COOKIE 你的知乎Cookie字符串 # 从浏览器开发者工具获取存储 → Cookie SAVE_DIR ./备份 # 本地保存路径 USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0 # 用于获取文章总数的请求头 headers { User-Agent: USER_AGENT, Cookie: COOKIE, Referer: fhttps://zhuanlan.zhihu.com/{COLUMN_ID}, Accept: application/json } # 用于获取文章详情的请求头 zhihu_headers { User-Agent: USER_AGENT, Cookie: COOKIE, Accept: application/json, text/plain, */*, Referer: https://zhuanlan.zhihu.com/ } # 创建保存文件夹 os.makedirs(SAVE_DIR, exist_okTrue) image_dir os.path.join(SAVE_DIR, images) os.makedirs(image_dir, exist_okTrue) if COOKIE 你的知乎Cookie字符串: print(错误请先获取并填写知乎Cookie参考Firefox Cookie查看方法) exit(1) batch_backup_articles(COLUMN_ID, SAVE_DIR, zhihu_headers, headers)上面的程序要成功运行需要先安装playwright库及其支持的浏览器运行时当然还有bs4requests等相关的库可以执行以下命令pip install playwright bs4 requestsplaywright install需要说明的是上面的程序并没有成功下载文章中的图片不过目前我对图片不感兴趣所以也许等到以后无聊时再来改进图片下载问题。如果第一次备份部分文章没有成功下载重新运行程序即可继续备份这也算是某种断点续传吧。我看了下有个什么叫知乎回答专栏文章收集助手的软件似乎也能完成知乎专栏备份但它的永久会员好像要收299块阅读这篇文章的兄弟们你们可是省下了299。虽然程序中只备份了专栏文章但是其方法也完全可以应用到备份知乎回答、收藏上。PlayWright方法被逼乎干掉以后2025年底giuhub上又出来了一个浏览器模拟库CloakBrowser它直接在 Chromium 源代码的 C 底层打补丁source-level patches修改了指纹相关特性如 Canvas、WebGL、渲染行为等而不是只在 JS 层或自动化库层面做伪装。它提供 Playwright 的 wrapperPython 和 JavaScript 都支持可以几乎 drop-in 替换使用并且由于修改了浏览器引擎底层自动化脚本更难被反爬/反 bot 系统检测绕过 reCAPTCHA 等。使用这个库需要先安装CloakBrowserpip install cloakbrowser首次执行会自动下载 stealth Chromium但是大概率会因为下载 stealth Chromium 二进制文件时遇到的 SSL 证书验证问题而失败可以通过下面的命令来修复这一问题pip install --upgrade certifi pip-system-certs或者使用 CloakBrowser 内置 CLI 手动下载注意python解释器使用安装CloakBrowser的虚拟环境中的解释器python -m cloakbrowser install如果仍然下载不成功那么可以考虑手动上GitHub下载项目地址https://github.com/CloakHQ/CloakBrowser。下载后的文件解压到CloakBrowser的缓存目录缓存目录的具体地址可以通过下面的命令查看python -m cloakbrowser info命令执行的结果大致如下Version: 146.0.7680.177.5Platform: windows-x64Binary: C:\Users\登录用户名\.cloakbrowser\chromium-146.0.7680.177.5\chrome.exeInstalled: TrueCache: C:\Users\登录用户名\.cloakbrowser\chromium-146.0.7680.177.5那么你需要在C:\Users\登录用户名\.cloakbrowser\目录下创建chromium-146.0.7680.177.5目录然后将下载的cloakbrowser-windows-x64.zip中的所有文件解压到这个目录下。然后执行下面的脚本第一次需要手动登录登录成功后只要没有删除缓存目录后须在运行脚本就不需要登录了import json import re import time from pathlib import Path from urllib.parse import urljoin import requests # 使用同步导入 from cloakbrowser import launch_persistent_context def human_like_scroll(page, max_scrolls150): print(开始人性化滚动加载专栏所有文章...) last_height 0 for i in range(max_scrolls): page.evaluate( () window.scrollBy(0, Math.random() * 400 500) ) time.sleep(1.1 (i % 3) * 0.4) new_height page.evaluate(document.body.scrollHeight) if new_height last_height and i 25: break last_height new_height if i % 15 0: print(f已滚动 {i} 次...) page.evaluate(window.scrollTo(0, document.body.scrollHeight)) time.sleep(3) def download_images_and_convert_md(page, html: str, image_dir: Path, article_idx: int): img_pattern re.compile(rimg[^]src[\](.*?)[\], re.IGNORECASE) img_urls img_pattern.findall(html) md_images {} for idx, src in enumerate(img_urls, 1): if not src or src.startswith(data:): continue if src.startswith(//): src https: src elif not src.startswith(http): src urljoin(page.url, src) try: filename fimg_{article_idx:03d}_{idx:03d}_{Path(src).name.split(?)[0] or image.jpg} filename re.sub(r[^\w.-], _, filename)[:100] save_path image_dir / filename response page.request.get(src, timeout15000) if response.ok: save_path.write_bytes(response.body()) md_images[src] f./images/{filename} print(f 下载图片: {filename}) except Exception as e: print(f 图片下载失败: {str(e)[:60]}) # 替换并清理成 Markdown markdown html for original, local in md_images.items(): markdown markdown.replace(original, local) markdown re.sub(rh1[^]*(.*?)/h1, r# \1, markdown, flagsre.IGNORECASE | re.DOTALL) markdown re.sub(rh2[^]*(.*?)/h2, r## \1, markdown, flagsre.IGNORECASE | re.DOTALL) markdown re.sub(rp[^]*(.*?)/p, r\1\n\n, markdown, flagsre.IGNORECASE | re.DOTALL) markdown re.sub(r[^], , markdown) return markdown def main(): # 配置区 COLUMN_URL https://zhuanlan.zhihu.com/c_1745169660587147264 # ← 修改这里 PROFILE_DIR Path(zhihu_profile) OUTPUT_DIR Path(zhihu_articles) IMAGE_DIR OUTPUT_DIR / images OUTPUT_DIR.mkdir(exist_okTrue) IMAGE_DIR.mkdir(exist_okTrue) # print(启动 CloakBrowser同步模式 持久化登录...) context launch_persistent_context( user_data_dirstr(PROFILE_DIR), headlessFalse, humanizeTrue, viewport{width: 1440, height: 900}, localezh-CN, ) page context.new_page() print(f打开专栏: {COLUMN_URL}) page.goto(COLUMN_URL, wait_untildomcontentloaded, timeout60000) # 首次手动登录检查 cookies context.cookies() if not any(c[name] in [z_c0, _xsrf, SESSIONID] for c in cookies): print(\n 请在浏览器窗口中手动登录 ) input(登录完成后按 Enter 继续...) # 导出 Headers 和 Cookies print(\n正在导出 Headers 和 Cookies...) cookies context.cookies() (OUTPUT_DIR / cookies.json).write_text( json.dumps(cookies, ensure_asciiFalse, indent2), encodingutf-8 ) headers page.evaluate(() ({ User-Agent: navigator.userAgent, Referer: window.location.href, Cookie: document.cookie, x-zse-93: 3.0 })) (OUTPUT_DIR / headers.json).write_text( json.dumps(headers, ensure_asciiFalse, indent2), encodingutf-8 ) print(✅ Headers 和 Cookies 已保存) # 滚动 爬取 human_like_scroll(page) # 尝试多种策略提取文章链接优先匹配 href 中包含 /p/ 的 a其次尝试从页面内的初始化 JSON 中提取 articles [] for attempt in range(6): # 最多尝试六次通过在页面上执行 JavaScript 来提取文章链接等待页面渲染和可能的懒加载 articles page.evaluate(() { try { // 策略 1: 查找 href 属性中带有 /p/ 的A标签 const anchors Array.from(document.querySelectorAll(a[href*/p/])); if (anchors.length) { const seen new Set(); return anchors.map(a ({ title: (a.querySelector(h2)?.innerText || a.innerText || ).trim(), url: a.href })).filter(item { if (!item.url || seen.has(item.url)) return false; seen.add(item.url); return true; }); } // 策略 2: 尝试解析数据初始化脚本 const ids [js-initialData, js-clientConfig]; for (const id of ids) { const el document.getElementById(id); if (!el) continue; try { const data JSON.parse(el.textContent || el.innerText || {}); const results []; // 遍历 JSON 中的对象以找到文章信息 const walk obj { if (!obj || typeof obj ! object) return; if (Array.isArray(obj)) { obj.forEach(walk); return; } if (obj.articles typeof obj.articles object) { for (const k in obj.articles) { const a obj.articles[k]; if (a (a.title || a.id)) { const url a.url a.url.indexOf(http) 0 ? a.url : (a.id ? (https://zhuanlan.zhihu.com/p/ a.id) : null); if (url) results.push({ title: a.title || , url }); } } } for (const k in obj) walk(obj[k]); }; walk(data); if (results.length) return results; } catch (e) { // ignore JSON parse errors } } } catch (e) {} return []; }) if articles and len(articles) 0: break # 如果未找到稍等并重试等待页面渲染/懒加载 print(f尝试 {attempt 1}/6: 未找到文章等待 1.5s 后重试...) time.sleep(1.5) print(f\n发现 {len(articles)} 篇文章开始爬取...\n) # 如果在页面中没有找到文章尝试使用专栏的 zhuanlan API需要已导出的 headers/cookies作为后备方案 if not articles: try: print(页面中未找到文章尝试使用 zhuanlan API 后备抓取...) # 从 COLUMN_URL 中提取专栏 id形如 c_123456 m re.search(rc_(\d), COLUMN_URL) if m: col_id m.group(1) api_url fhttps://zhuanlan.zhihu.com/api/columns/{col_id}/items headers_path OUTPUT_DIR / headers.json if headers_path.exists(): headers json.loads(headers_path.read_text(encodingutf-8)) else: headers { User-Agent: Mozilla/5.0, Referer: COLUMN_URL } resp requests.get(api_url, headersheaders, params{limit: 200, offset: 0}, timeout20) if resp.ok: data resp.json() items data.get(data) or data.get(items) or data.get(paging) or [] api_articles [] # items 有时为 dict 包含 data 键 if isinstance(items, dict) and items.get(data): items items.get(data) for it in items: if not isinstance(it, dict): continue title it.get(title) or it.get(excerpt) or url it.get(url) or ( (https://zhuanlan.zhihu.com/p/ str(it.get(id))) if it.get(id) else None) if url: api_articles.append({title: title, url: url}) if api_articles: articles api_articles print(f通过 API 发现 {len(articles)} 篇文章) except Exception as e: print(使用 zhuanlan API 抓取失败, str(e)[:200]) for idx, article in enumerate(articles, 1): print(f[{idx}/{len(articles)}] 正在处理: {article[title][:70]}...) try: page.goto(article[url], wait_untildomcontentloaded, timeout45000) time.sleep(2.5) result page.evaluate(() { const title document.querySelector(h1)?.innerText.trim() || 无标题; const content document.querySelector(.RichText) || document.querySelector(article) || document.querySelector(.Post-RichTextContainer); return { title, html: content ? content.outerHTML : document.body.innerHTML }; }) md_body download_images_and_convert_md(page, result[html], IMAGE_DIR, idx) md_content f# {result[title]}\n\n原文链接{article[url]}\n\n{md_body} safe_title re.sub(r[^\w\u4e00-\u9fa5。-], _, result[title])[:80] md_path OUTPUT_DIR / f{idx:03d}_{safe_title}.md md_path.write_text(md_content, encodingutf-8) print(f ✓ 保存完成: {md_path.name}) except Exception as e: print(f ✗ 失败: {str(e)[:100]}) time.sleep(2) print(\n 全部完成输出目录:, OUTPUT_DIR.resolve()) context.close() if __name__ __main__: main()看看这个方法能坚持多久。