見出し画像

【自動化第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を自動でフロントマターに書き戻す部分を整備したい。


質問・感想はコメント欄へどうぞ。

いいなと思ったら応援しよう!