Zennで公開していた900件以上の記事を自前のHugoブログ(tech.ldas.jp)に移行しました。移行後、Zenn側に元の本文が残っていると重複コンテンツとしてSEO上の問題が生じるため、Zenn側の記事を移転通知に置き換える必要がありました。
Zennの記事編集手段
Zennでは、GitHub連携を使っていない場合、記事のCRUD APIは公開されていないようです。そのため、Playwrightでブラウザ操作を自動化する方針にしました。
ただし、ZennのログインにはGoogleアカウントを使っており、Googleログインは自動化ブラウザ(Playwright等)からのアクセスをブロックします。そこで、通常のブラウザで手動ログインし、DevTools > Application > Cookies から _zenn_session と remember_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ではなく、内部的に使用されているエンドポイントです。仕様変更や利用制限が予告なく行われる可能性があります。
同様の方法を利用する場合は、利用規約の最新版を確認のうえ、自己責任で判断してください。サーバーへの負荷を考慮し、リクエスト間隔は十分に空けることが望ましいです。