【自動化第5弾】Mediumへの自動投稿——公式APIが死んでいたので、Playwrightで殴り込んだ話
前回(第4弾)では、note.comのいいね&コメント自動化を実装した。投稿先はQiita・note・Zennの3つ。ここにMediumを加えることにした。英語記事もこのパイプラインで処理したい。
そして案の定、一筋縄ではいかなかった。
1. 公式API v1——最初から選択肢になかった
Mediumには公式のAPIがある。https://api.medium.com/v1だ。ドキュメントも整っている。しかし調べるとすぐわかる。
新規のIntegration Tokenは発行できない。
GitHubのmediuim-api-docsリポジトリを見ると、最後のコミットは数年前。Settings > Security から申請するIntegration Tokenは「既存ユーザーには有効だが、新規発行はサポート外」という扱いになっている。自分のアカウントではトークンを取得できなかった。
公式APIのPOSTは最初から使えない前提で進んだ。
2. 内部GraphQL API——読めるが書けない
Mediumはブラウザで操作するとき、内部のGraphQL APIと通信している。エンドポイントはhttps://medium.com/_/graphql。Firefoxのdevtoolsで捕捉できる。
createPostというmutationが存在することはエラーメッセージを手繰って確認できた。
mutation {
createPost(input: {}) {
id
}
}これを実行すると——空の下書きが作られる。IDが返ってくる。「いける」と思った。
しかし、そこから先がまったく動かなかった。
Mediumはリッチテキストエディタの状態をOperational Transform(OT)という仕組みで管理している。記事の本文を更新するにはPOST /p/{postId}/deltasへデルタ差分を送る必要がある。
POST /p/89d2b4f23121/deltas
{"revision": -1, "deltas": [...]}何を送ってもBad revision undefinedが返ってくる。
JSONのフィールド名を総当たりした
クエリパラメータとして渡した
Protobufバイナリで送った
全滅。Mediumのエディタに使われているOTライブラリの内部フォーマットがわからない限り、正しいリビジョン番号で正しいデルタを構築できない。
3. tutu/imports——有望だったが403
次に見つけたのがPOST /_/api/tutu/importsというエンドポイントだ。これはMarkdownやHTMLから記事をインポートする機能のためのAPIらしい。内部のJS束からフィールド名を掘り出した。
content, title, canonicalUrl, collectionId, visibility, license, tagsしかし実行すると即座に403。
レスポンスのHTMLをよく読むと、MediumのGLOBALS設定に"hasApiAccess":falseという値が埋め込まれていた。このエンドポイントは特別なAPIアクセス許可を持つアカウントでないと使えない。一般ユーザーは不可だ。
4. 転換——他の人間はどう解決したか
3つのAPIアプローチがすべて失敗したところで、発想を変えた。
ブラウザ経由で投稿する。
Mediumのエディタはhttps://medium.com/new-storyから開ける。リッチテキストエディタはクリップボードからのHTMLペーストを受け付ける。つまり——MarkdownをHTMLに変換してクリップボードに積み、エディタ上でCtrl+Vすれば、Mediumが自動的にフォーマットを解釈してくれる。
Playwrightがあれば、この操作を自動化できる。
5. Firefoxのセッションクッキーを注入する
Playwrightは新しいブラウザ環境を起動する。Medium.comにはログインしていない。
ここで使うのが、すでにnote.comで実装したFirefoxのSQLiteからクッキーを抽出する仕組みだ。
shutil.copy2(sqlite_path, tmp) # ロック回避のためコピー
con = sqlite3.connect(tmp)
rows = con.execute(
"SELECT name, value FROM moz_cookies WHERE host LIKE '%.medium.com%'"
).fetchall()sid, uid, xsrf, cf_clearance——必要なクッキーが全部取れる。これをPlaywrightのコンテキストに注入してからmedium.com/new-storyへナビゲートすると、ログイン済みの状態でエディタが開く。
ctx.add_cookies(pw_cookies)
page.goto("https://medium.com/new-story", ...)6. クリップボードHTMLで本文を流し込む
Markdownの変換にはPythonのmarkdownライブラリを使う。ストライクスルー(text)はPyMdownXが必要なため正規表現で前処理した。ネストリストはtab_length=2で対応。
md_text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", md_text)
html = markdown.markdown(md_text, extensions=[...], tab_length=2)変換したHTMLをJS経由でクリップボードに書き込む。
page.evaluate(f"""
async () => {{
const blob = new Blob([html], {{type: 'text/html'}});
await navigator.clipboard.write([
new ClipboardItem({{'text/html': blob}})
]);
}}
""")そしてエディタのタイトルフィールドにタイトルを入力し、Enterで本文フィールドへ移動、Ctrl+Vでペーストする。
title_field.click()
page.keyboard.type(title)
page.keyboard.press("Enter")
set_clipboard_html(page, html_body)
page.keyboard.press("Control+v")7. Publishモーダルの罠
ペーストは成功した。次はPublishだ。
ツールバーのPublishボタンをクリックすると、「Story preview」モーダルが開く。ここでタイトル・サブタイトルのプレビューを確認し、トピックを追加し、最終的にPublishボタンを押す。
しかし——page.locator('button:has-text("Publish")').click()が毎回タイムアウトする。
原因はオートコンプリートのドロップダウンだ。トピック入力欄に文字を入れると候補リストが表示される。この候補リストのdivがポインターイベントを横取りして、Publishボタンへのクリックを阻んでいた。
Playwrightの通常クリックは「要素が安定して操作可能な状態になるまで待つ」という仕様がある。ドロップダウンが開いている間は「別の要素がポインターを奪っている」と判定され、永遠に待ち続ける。
解決策はJavaScriptで直接クリックすることだ。
page.keyboard.press("Escape") # ドロップダウンを閉じる
page.evaluate("""
() => {
const btns = Array.from(document.querySelectorAll('button'));
const pubs = btns.filter(b =>
b.textContent.trim() === 'Publish' && b.offsetParent !== null
);
pubs[pubs.length - 1].click();
}
""")offsetParent !== nullで非表示ボタンを除外し、最後のPublishボタン(モーダルの確認ボタン)をJSで直接叩く。これでPublish成功。URLが/p/{id}?postPublishedType=initialに変わったことで投稿完了を確認した。
8. 動作確認
実際にフォーマットテスト記事を投稿して確認した結果:
https://gist.github.com/LoveKapibarasan/424d73a7eb8e4507d02fa87a2e894501
テーブルと外部画像はMediumの仕様上の制限だ。テーブルはMediumのエディタにネイティブサポートがなく、貼り付けてもプレーンテキストに変換される。
振り返り
今回の教訓:
- 公式APIが死んでいてもブラウザ操作で突破できる — Playwright + Firefoxクッキー注入は強力な組み合わせだ。note.comの実装経験がそのまま活きた
- Playwright標準クリックは「安定状態」を要求する — ドロップダウンやオーバーレイが干渉するときはJSクリックに切り替える
- GraphQLのintrospectionが無効でもエラーメッセージを手掛かりにできる — 意図的に間違ったフィールドを送ってエラーレスポンスからスキーマを逆算する
スクリプトはmedium_post.pyとして実装した。python medium_post.py <mdファイル> --publishで投稿まで完結する。
次は——投稿後のURLを自動でフロントマターに書き戻す部分を整備したい。
質問・感想はコメント欄へどうぞ。
