九成自动化批量备份知乎专栏文章

很多年前我在雅虎博客上写了一些诗,后来雅虎离开中国,博客关闭,虽然发过要我备份的邮件,但是我没注意,后来雅虎走了,那些诗就丢失了。现在我在知乎上写了个笑庵诗草专栏,前天知乎崩溃上不去,一下子让我紧张了,赶紧把专栏备份。专栏上的诗也不多,文言白话总共也就五十来首,可惜逼乎不够忠厚,官方没有提供导出专栏文章的功能。但是作为会写程序的文科生,要一篇篇打开专栏文章并复制备份,那比为了赚取每天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格式的文件。

python 复制代码
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 = f"https://www.zhihu.com/api/v4/columns/{column_id}/items"
    params = {"limit": 1, "offset": 0}  # 仅请求1篇文章,减少数据传输

    try:
        response = requests.get(url, headers=headers, params=params)
        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(headless=False)  # 设置 headless=False 可以看到浏览器界面
        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 = f"https://zhuanlan.zhihu.com/{column_id}"
        page.goto(url, timeout=60000)
        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 = f"https://www.zhihu.com/api/v4/articles/{article_id}"
    params = {"include": "content,title,created,author.name"}  # 包含所需字段
    try:
        response = requests.get(url, headers=article_headers, params=params)
        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, headers=article_headers, stream=True, timeout=10)
        response.raise_for_status()
        with open(img_path, "wb") as f:
            for chunk in response.iter_content(chunk_size=8192):
                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(strip=True) 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"![图片]({local_img_path})\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", encoding="utf-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", encoding="utf-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", encoding="utf-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": f"https://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_ok=True)
    image_dir = os.path.join(SAVE_DIR, "images")
    os.makedirs(image_dir, exist_ok=True)
    if COOKIE == "你的知乎Cookie字符串":
        print("错误:请先获取并填写知乎Cookie(参考Firefox Cookie查看方法)")
        exit(1)
    batch_backup_articles(COLUMN_ID, SAVE_DIR, zhihu_headers, headers)

上面的程序要成功运行,需要先安装playwright库及其支持的浏览器运行时(当然还有bs4,requests等相关的库),可以执行以下命令:

pip install playwright bs4 requests

playwright install

需要说明的是上面的程序并没有成功下载文章中的图片,不过目前我对图片不感兴趣,所以也许等到以后无聊时再来改进图片下载问题。如果第一次备份部分文章没有成功下载,重新运行程序即可继续备份,这也算是某种断点续传吧。我看了下,有个什么叫知乎回答专栏文章收集助手的软件似乎也能完成知乎专栏备份,但它的永久会员好像要收299块,阅读这篇文章的兄弟们,你们可是省下了299😀。虽然程序中只备份了专栏文章,但是其方法也完全可以应用到备份知乎回答、收藏上。

相关推荐
好好好起个名真难18 小时前
爬虫 beautifulSoup 方法
爬虫·beautifulsoup
深兰科技1 天前
深兰科技法务大模型亮相,推动律所文书处理智能化
人工智能·scrapy·beautifulsoup·scikit-learn·pyqt·fastapi·深兰科技
万粉变现经纪人5 天前
如何解决 pip install -r requirements.txt 子目录可编辑安装缺少 pyproject.toml 问题
开发语言·python·scrapy·beautifulsoup·scikit-learn·matplotlib·pip
万粉变现经纪人5 天前
如何解决 pip install -r requirements.txt 私有索引未设为 trusted-host 导致拒绝 问题
开发语言·python·scrapy·flask·beautifulsoup·pandas·pip
万粉变现经纪人6 天前
如何解决 pip install -r requirements.txt 私有仓库认证失败 401 Unauthorized 问题
开发语言·python·scrapy·flask·beautifulsoup·pandas·pip
西欧伯爵9 天前
Playwright自动化实战一
自动化测试·自动化·playwright
虎头金猫9 天前
我的远程开发革命:从环境配置噩梦到一键共享的蜕变
网络·python·网络协议·tcp/ip·beautifulsoup·负载均衡·pandas
weixin-a1530030831610 天前
[数据抓取-1]beautifulsoup
开发语言·python·beautifulsoup
一晌小贪欢10 天前
Python爬虫第3课:BeautifulSoup解析HTML与数据提取
爬虫·python·网络爬虫·beautifulsoup·python爬虫·python3·requests