用 Playwright 自动发帖到知乎和小红书:一个程序员的偷懒指南

Posted on 六 21 3月 2026 in Tech

用 Playwright 自动发帖到知乎和小红书:一个程序员的偷懒指南

一、程序员的内容分发之痛

写技术博客的人大概都有这个烦恼:文章写完了,发布才是噩梦的开始。

先在自己的博客站发一遍,然后复制到知乎专栏,格式全乱了,得重新调。再发小红书,标题要改短,正文要加 emoji,图片要重新上传。如果还想发公众号,那又是另一套排版规则。

一篇文章,发三个平台,光复制粘贴加排版就得半小时。写文章本身可能也就一小时。

咱们是程序员啊,重复劳动这种事,不应该让机器来干吗?

有人会说,用各平台的 API 不就行了?问题是——知乎没有公开的发文 API,小红书的开放平台主要面向商家,个人创作者基本拿不到权限。就算有 API,每个平台的接口规范、鉴权方式都不一样,维护成本也不低。

所以咱们换个思路:既然手动操作浏览器能发,那就让程序来操作浏览器

这就是 Playwright 的用武之地。

二、为什么选 Playwright

浏览器自动化工具不少,Selenium 是老前辈,Puppeteer 是 Node.js 生态的宠儿。我选 Playwright 有几个理由:

对比项 Selenium Puppeteer Playwright
语言支持 多语言 JS/TS Python/JS/Java/C#
浏览器 需要 WebDriver 仅 Chromium Chromium + Firefox + WebKit
自动等待 手动写 wait 部分自动 内置智能等待
录制工具 codegen 一键录制
Cookie 管理 手动 手动 storage_state 一行搞定

最打动我的是两个功能:codegen 录制storage_state 登录状态管理。前者让你不用猜 DOM 选择器,后者让你不用每次都扫码登录。

三、基本套路:录制 → 改写 → 封装

整个流程就三步:

1. codegen 录制操作,顺便保存登录状态
2. 把录制的代码改写成可复用的函数
3. 加上错误处理和参数化,封装成 CLI

就像练功,先照着师傅的动作比划(录制),再理解每一招的用意(改写),最后融会贯通变成自己的(封装)。

3.1 安装

pip install playwright
playwright install chromium

两行命令,连浏览器都帮你下好了。

3.2 录制登录操作

这是最关键的一步。codegen 会打开一个浏览器窗口,你在里面正常操作,它在旁边实时生成代码。

# 录制知乎登录,保存 cookie
playwright codegen https://www.zhihu.com/signin --save-storage zhihu-auth.json

# 录制小红书登录,保存 cookie
playwright codegen https://www.xiaohongshu.com --save-storage xhs-auth.json

操作流程: 1. 浏览器打开后,正常登录(扫码或密码都行) 2. 登录成功后,关闭浏览器 3. Cookie 自动保存到 json 文件

以后的脚本直接加载这个 json,就跳过登录了。省心。

四、知乎自动发文章

知乎专栏的发文页面是 https://zhuanlan.zhihu.com/write,用的是 Draft.js 富文本编辑器。

#!/usr/bin/env python3
"""zhihu_poster.py — 自动发布知乎文章"""
import asyncio
from playwright.async_api import async_playwright


async def post_to_zhihu(
    title: str,
    content: str,
    topics: list[str] | None = None,
    headless: bool = False,
    dry_run: bool = False,
) -> str | None:
    """发布文章到知乎专栏,返回文章 URL。"""
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=headless)
        context = await browser.new_context(storage_state="zhihu-auth.json")
        page = await context.new_page()

        try:
            # 1. 打开写文章页面
            await page.goto("https://zhuanlan.zhihu.com/write")
            await page.wait_for_load_state("networkidle")

            # 2. 填写标题
            title_input = page.locator('textarea[placeholder*="请输入标题"]')
            await title_input.click()
            await title_input.fill(title)

            # 3. 填写正文
            # 知乎用 Draft.js,不能直接 fill,得模拟键盘输入
            editor = page.locator(".public-DraftEditor-content")
            await editor.click()

            paragraphs = content.split("\n\n")
            for i, para in enumerate(paragraphs):
                await page.keyboard.type(para, delay=20)
                if i < len(paragraphs) - 1:
                    await page.keyboard.press("Enter")
                    await page.keyboard.press("Enter")

            # 4. 添加话题
            if topics:
                topic_btn = page.locator('button:has-text("添加话题")')
                if await topic_btn.is_visible():
                    await topic_btn.click()
                    for topic in topics:
                        topic_input = page.locator(
                            'input[placeholder*="搜索话题"]'
                        )
                        await topic_input.fill(topic)
                        await page.wait_for_timeout(1000)
                        first_result = page.locator(
                            ".TopicSelector-item"
                        ).first
                        if await first_result.is_visible():
                            await first_result.click()

            # 5. 发布(或 dry run 截图)
            if dry_run:
                await page.screenshot(path="zhihu-preview.png")
                print("🔍 Dry run 完成,截图已保存: zhihu-preview.png")
                return None

            publish_btn = page.locator('button:has-text("发布")')
            await publish_btn.click()

            await page.wait_for_url("**/p/**", timeout=15000)
            article_url = page.url
            print(f"✅ 知乎文章已发布: {article_url}")

            # 保存更新后的 cookie
            await context.storage_state(path="zhihu-auth.json")
            return article_url

        except Exception as e:
            # 出错时截图,方便排查
            await page.screenshot(path="zhihu-error.png")
            print(f"❌ 发布失败: {e},截图已保存: zhihu-error.png")
            raise
        finally:
            await browser.close()

几个要注意的点:

  • Draft.js 编辑器不能用 fill(),得用 keyboard.type() 模拟逐字输入,否则内容不会被编辑器识别
  • delay=20 是每个字符之间的间隔(毫秒),太快可能丢字,太慢浪费时间,20ms 是个不错的平衡
  • dry run 模式很重要——先截图看看效果,确认没问题再真发

五、小红书自动发笔记

小红书的创作者后台在 https://creator.xiaohongshu.com/publish/publish,和知乎最大的区别是:必须有图片

#!/usr/bin/env python3
"""xhs_poster.py — 自动发布小红书笔记"""
import asyncio
from playwright.async_api import async_playwright


async def post_to_xiaohongshu(
    title: str,
    content: str,
    images: list[str] | None = None,
    topics: list[str] | None = None,
    headless: bool = False,
    dry_run: bool = False,
):
    """发布笔记到小红书。images 为图片路径列表,至少一张。"""
    if not images:
        print("⚠️  小红书必须上传至少一张图片")
        return

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=headless)
        context = await browser.new_context(storage_state="xhs-auth.json")
        page = await context.new_page()

        try:
            # 1. 打开发布页
            await page.goto(
                "https://creator.xiaohongshu.com/publish/publish"
            )
            await page.wait_for_load_state("networkidle")

            # 2. 上传图片
            upload_input = page.locator('input[type="file"]')
            for img_path in images:
                await upload_input.set_input_files(img_path)
                await page.wait_for_timeout(2000)  # 等上传完成

            # 3. 填写标题(小红书标题限 20 字)
            if len(title) > 20:
                print(f"⚠️  标题超过 20 字,已截断: {title[:20]}...")
                title = title[:20]

            title_input = page.locator(
                '#title-textarea, [placeholder*="标题"]'
            )
            await title_input.click()
            await title_input.fill(title)

            # 4. 填写正文,话题标签直接写在正文末尾
            full_content = content
            if topics:
                tags = " ".join(f"#{t}" for t in topics)
                full_content = f"{content}\n\n{tags}"

            content_editor = page.locator(
                '#post-textarea, [placeholder*="正文"]'
            )
            await content_editor.click()
            await page.keyboard.type(full_content, delay=15)

            # 5. 发布
            if dry_run:
                await page.screenshot(path="xhs-preview.png")
                print("🔍 Dry run 完成,截图已保存: xhs-preview.png")
                return

            publish_btn = page.locator('button:has-text("发布")')
            await publish_btn.click()
            await page.wait_for_timeout(5000)
            print("✅ 小红书笔记已发布")

            await context.storage_state(path="xhs-auth.json")

        except Exception as e:
            await page.screenshot(path="xhs-error.png")
            print(f"❌ 发布失败: {e},截图已保存: xhs-error.png")
            raise
        finally:
            await browser.close()

小红书的坑比知乎多一些:

  • 标题限 20 字,超了会被截断,脚本里做了自动处理
  • 图片是必须的,纯文字发不了
  • 话题标签不像知乎有专门的选择器,小红书的做法是直接在正文里写 #话题,平台会自动识别

六、封装成统一 CLI

两个平台的脚本有了,再包一层 CLI,一行命令搞定多平台分发:

#!/usr/bin/env python3
"""auto_poster.py — 多平台自动发帖 CLI"""
import argparse
import asyncio
from pathlib import Path


async def main():
    parser = argparse.ArgumentParser(
        description="自动发帖到知乎/小红书",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 发知乎文章
  python auto_poster.py zhihu --title "AI编程" --content ./blog.md

  # 发小红书笔记
  python auto_poster.py xhs --title "周末coding" \\
      --content "今天写了个工具..." --images cover.jpg

  # 同时发两个平台(先预览)
  python auto_poster.py all --title "新文章" \\
      --content ./article.md --images cover.jpg --dry-run
        """,
    )
    parser.add_argument(
        "platform", choices=["zhihu", "xhs", "all"],
        help="目标平台"
    )
    parser.add_argument("--title", required=True, help="文章标题")
    parser.add_argument(
        "--content", required=True,
        help="正文内容,或 .md/.txt 文件路径"
    )
    parser.add_argument("--images", nargs="*", help="图片路径列表")
    parser.add_argument("--topics", nargs="*", help="话题标签")
    parser.add_argument("--headless", action="store_true", help="无头模式")
    parser.add_argument(
        "--dry-run", action="store_true", help="只预览截图,不真正发布"
    )

    args = parser.parse_args()

    # 支持从文件读取正文
    content_path = Path(args.content)
    if content_path.exists() and content_path.suffix in (".md", ".txt"):
        content = content_path.read_text(encoding="utf-8")
        # 简单处理 Markdown front matter
        if content.startswith("---"):
            # 跳过 YAML front matter
            end = content.find("---", 3)
            if end != -1:
                content = content[end + 3 :].strip()
    else:
        content = args.content

    results = {}

    if args.platform in ("zhihu", "all"):
        url = await post_to_zhihu(
            title=args.title,
            content=content,
            topics=args.topics,
            headless=args.headless,
            dry_run=args.dry_run,
        )
        results["zhihu"] = url

    if args.platform in ("xhs", "all"):
        await post_to_xiaohongshu(
            title=args.title,
            content=content,
            images=args.images,
            topics=args.topics,
            headless=args.headless,
            dry_run=args.dry_run,
        )
        results["xhs"] = "posted"

    # 打印汇总
    print("\n📋 发布汇总:")
    for platform, result in results.items():
        status = result if result else "dry-run"
        print(f"  {platform}: {status}")


if __name__ == "__main__":
    asyncio.run(main())

用法很直观:

# 发知乎
python auto_poster.py zhihu --title "AI编程时代的思考" --content ./blog.md --topics "AI" "编程"

# 发小红书
python auto_poster.py xhs --title "周末coding💻" --content "今天试了个新工具..." --images cover.jpg

# 两个平台都发,先 dry run 看看
python auto_poster.py all --title "新文章" --content ./article.md --images cover.jpg --dry-run

七、踩坑备忘录

用了一段时间,总结几个实战经验:

选择器会变,codegen 是你的好朋友

知乎和小红书都在频繁迭代前端,class 名隔几周就可能变。我的经验是:

  • 优先用 text=placeholder=role= 这类语义选择器,比 .css-1a2b3c 稳定得多
  • 选择器失效了?别硬猜,重新跑一遍 playwright codegen,两分钟搞定

知乎的登录态大约能撑一周,小红书更短,可能两三天就要重新登录。两个应对办法:

  1. 脚本开头检测登录状态,失效了自动提醒
  2. 写个定时任务,每隔几天手动刷新一次 storage_state
# 检测登录状态的简单方法
async def check_login(page, platform):
    if platform == "zhihu":
        # 检查是否有登录按钮(有 = 未登录)
        login_btn = page.locator('button:has-text("登录")')
        if await login_btn.is_visible():
            print("⚠️  知乎 cookie 已过期,请重新录制登录")
            return False
    return True

别太快,别太频繁

这些平台都有反爬机制。几个建议:

  • 字符输入加随机延迟delay=random.randint(15, 50) 比固定 20ms 更像真人
  • 操作之间加等待wait_for_timeout(random.randint(500, 1500))
  • 发布频率控制:一天 2-3 篇为上限,别搞得像机器人批量灌水
  • 首次调试用 headed 模式headless=False,亲眼看着跑一遍

错误处理要到位

网络抖动、页面加载慢、弹窗遮挡……线上环境什么情况都可能遇到。关键是:出错时截图

except Exception as e:
    await page.screenshot(path=f"{platform}-error-{int(time.time())}.png")
    print(f"❌ 失败: {e}")

一张截图胜过十行日志。

八、进阶方向

基础版跑通之后,还可以往几个方向扩展:

  1. Markdown → 富文本转换:目前是纯文本输入,如果文章有代码块、图片、链接,需要做格式转换。知乎支持 Markdown 导入,可以利用这个功能
  2. 图片自动处理:小红书对图片尺寸有要求(推荐 3:4),可以用 Pillow 自动裁剪
  3. 定时发布:结合 cron 或 APScheduler,设定发布时间
  4. 发布结果通知:发完之后推送到飞书/微信,附上文章链接
  5. 更多平台:公众号、掘金、CSDN……套路都一样,录制 → 改写 → 封装

九、一句话总结

整个方案的核心就一句话:

playwright codegen 录制操作 → 提取关键步骤 → 加上错误处理和延迟 → 封装成脚本。

选择器变了就重新录,比硬猜 DOM 结构靠谱得多。

说到底,程序员写博客已经够费脑子了,发布这种机械劳动,就该交给机器。省下来的时间,多写一段代码,多看一篇论文,或者——多睡一会儿,都比复制粘贴有意义。