Zennで公開していた900件以上の記事を自前のHugoブログ(tech.ldas.jp)に移行しました。移行後、Zenn側に元の本文が残っていると重複コンテンツとしてSEO上の問題が生じるため、Zenn側の記事を移転通知に置き換える必要がありました。

Zennの記事編集手段

Zennでは、GitHub連携を使っていない場合、記事のCRUD APIは公開されていないようです。そのため、Playwrightでブラウザ操作を自動化する方針にしました。

ただし、ZennのログインにはGoogleアカウントを使っており、Googleログインは自動化ブラウザ(Playwright等)からのアクセスをブロックします。そこで、通常のブラウザで手動ログインし、DevTools > Application > Cookies から _zenn_sessionremember_user_token の2つのCookieを取得しました。

import json

cookies = [
    {"name": "_zenn_session", "value": "取得した値", "domain": "zenn.dev", "path": "/"},
    {"name": "remember_user_token", "value": "取得した値", "domain": "zenn.dev", "path": "/"},
]

with open("zenn_cookies.json", "w") as f:
    json.dump(cookies, f)

編集ページの構造と内部API

Zennの記事編集ページのURLは https://zenn.dev/articles/{slug}/edit です(ユーザー名は含まれません)。エディタにはCodeMirror(div.cm-content)が使われています。

当初はPlaywrightのキーボード入力で本文を書き換えようとしましたが、Zennの自動保存が発火しませんでした。DevToolsのNetworkタブを確認したところ、内部API PUT /api/articles/{slug} でリクエストボディ { "article": { "body_markdown": "..." } } を送信することで本文を直接更新できることがわかりました。

Playwrightのブラウザコンテキスト内から page.evaluate() を使ってこのAPIを呼び出す形にしました。Cookie認証がブラウザコンテキストに紐づいているため、fetch をページ内で実行する必要があります。

async def update_article(page, slug, new_body):
    result = await page.evaluate("""
        async ({slug, body}) => {
            const res = await fetch(`/api/articles/${slug}`, {
                method: 'PUT',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({article: {body_markdown: body}})
            });
            return {status: res.status, ok: res.ok};
        }
    """, {"slug": slug, "body": new_body})
    return result

一括処理の実行

Zennの公開API GET /api/articles?username=nakamura196&count=100&order=latest で記事一覧を取得できます。ページネーションで全件取得し、いいね数(liked_count)が0件の508記事を対象にしました。

置き換え後の本文は以下の形式です。

:::message
この記事は移転しました。
https://tech.ldas.jp/ja/posts/{slug}/
:::

バッチ処理のコアは以下のようになります。

import asyncio
from playwright.async_api import async_playwright

async def batch_update(articles):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context()

        # Cookie認証の設定
        with open("zenn_cookies.json") as f:
            cookies = json.load(f)
        await context.add_cookies(cookies)

        page = await context.new_page()
        await page.goto("https://zenn.dev")

        for i, article in enumerate(articles):
            slug = article["slug"]
            new_body = f":::message\nこの記事は移転しました。\nhttps://tech.ldas.jp/ja/posts/{slug}/\n:::"
            result = await update_article(page, slug, new_body)
            print(f"[{i+1}/{len(articles)}] {slug}: {result['status']}")
            await asyncio.sleep(0.3)

        await browser.close()

0.3秒間隔で508件を処理し、全件ステータス200で完了しました。

重複コンテンツへの対応

当初は移転通知に加えて元の本文も残す方式を試しましたが、Googleに重複コンテンツと判定されました。Zennではcanonical URLをカスタム設定できないため、本文を削除して移転通知のみにすることで、Googleがtech.ldas.jp側をオリジナルとして認識するようになりました。

利用規約との関係

2026年3月時点で確認した限り、Zennの利用規約には自動化やAPI利用を明示的に禁止する条項は見当たりませんでした。ただし、「同一内容の文章を繰り返し投稿する行為」が禁止事項に含まれており、508記事に同じ移転通知テキストを設定した点は、広く解釈すれば該当する可能性があります。

また、/api/articles/{slug} は公式に文書化されたAPIではなく、内部的に使用されているエンドポイントです。仕様変更や利用制限が予告なく行われる可能性があります。

同様の方法を利用する場合は、利用規約の最新版を確認のうえ、自己責任で判断してください。サーバーへの負荷を考慮し、リクエスト間隔は十分に空けることが望ましいです。