プログラミング

noteの非公式APIとエディタ仕様を調べた話|Markdown自動投稿ツールを作るためにわかったこと

note.comには公開された公式APIがありません。そこでMarkdownの自動投稿ツールを作るために、ブラウザの通信を観察してnoteの内部APIを一つずつ確かめました。下書きの作成・保存・公開に使うエンドポイント、画像アップロードのpresigned URL、公開PUTが全置換で設定を吹き飛ばす罠、noteのエディタが受け付けるHTMLの形(uuid属性・インラインコード・数式の記法・URLの埋め込みカード・目次)、そして連続アクセスでIPごとブロックされた実話まで、実際に叩いたパスとコードの抜粋つきで記録します。

目次

はじめに

noteに書いた記事を、プログラムから投稿できたらいいのに、と思ったことはありませんか。

私はObsidianで記事を書いているので、最後にnoteのエディタを開いて貼り直す作業がずっと面倒でした。
それを自動化しようとして最初にぶつかったのが、note.comには公開された公式のAPIが無いという壁です。
仕方がないので、自分のブラウザがnoteとどんな通信をしているのかを1つずつ観察して、そこから投稿の経路を組み立てました。

この記事は、そのときに分かったことの記録です。
どのエンドポイントに何を送ると何が返るのか、noteのエディタがどんなHTMLを期待しているのか、どれくらいの間隔で叩くと怒られるのか。
実際に手を動かして確かめたことだけを書きます。

先に立場をはっきりさせておくと、これは自分のアカウントで自分の記事を投稿するために、自分の環境から調べた相互運用の記録です。
他人のデータを集めたり、大量のアクセスでnoteに負荷をかけたりする使い方は想定していませんし、そのための情報も書きません。

コードの抜粋がたくさん出てきますが、抜粋の直後には必ず日本語で「つまり何をしているか」を書きます。
コードは雰囲気だけ眺めて読み飛ばしても話が繋がるようにしてありますので、気になるところだけ拾ってもらって大丈夫です。

この調査を実際に動くツールへ落としたものは、ObsidianのMarkdownをnoteの下書きとして投稿するツールを作りました(note_uploader)のほうで配布しています。
「仕組みはいいから動くものが欲しい」という方は、先にそちらを見ていただくのが早いかと思います。

調べ方:ブラウザの中で fetch を実行する

やったことは単純で、noteのエディタを普通に操作しながら、ChromeのDevTools(F12で開く開発者ツール)の「ネットワーク」タブを開きっぱなしにしておいただけです。
下書きを作る、本文を保存する、画像を貼る、公開する。
その1つ1つで、どのURLにどんなJSONが飛んでいるかを見てメモを取りました。

ここで1つ、地味ですが後からずっと効いた判断があります。
観察したリクエストをPythonから直接投げるのではなく、ログイン済みのブラウザの中で fetch を実行することにしました。

Playwrightというブラウザ自動操作のライブラリで、保存しておいたログインセッションつきのChromiumを開き、そのページの上で page.evaluate() を使ってJavaScriptを走らせます。
一番短い例がこれです。

def fetch_note_status(page: Page, note_key: str) -> str | None:
    result = page.evaluate(
        """async (key) => {
            const r = await fetch('/api/v3/notes/' + key, {
                credentials: 'include',
                headers: {'X-Requested-With': 'XMLHttpRequest',
                          'Accept': 'application/json'}
            });
            if (r.status !== 200) return { status: r.status };
            const body = await r.json();
            return { status: 200, note_status: body.data && body.data.status };
        }""",
        note_key,
    )

日本語にすると「いま開いているnoteのページの中で /api/v3/notes/{記事キー} を叩き、返ってきたJSONの data.status だけをPython側に持ち帰る」です。
Pythonの関数の中にJavaScriptの文字列が丸ごと入っているので最初は面食らうと思いますが、やっていることはブラウザのコンソールに手で打つのと同じことです。

この方式の一番の利点は、認証を自分で組み立てなくていいことです。
Pythonの requests などで直接叩こうとすると、ログインのCookieやCSRFトークンを自分で集めて、正しい組み合わせでヘッダーに載せる必要があります。
これはnote側の認証まわりが変わるたびに壊れる、いちばん面倒な部分です。

ブラウザの中から credentials: 'include' を付けて呼べば、ブラウザが普段どおりCookieを付けてくれるので、その面倒がまるごと消えます。
私はここに時間を使いたくなかったので、最初からこの方式にしました。
結果的にこれで良かったと思います。

ヘッダーは、実際にnoteのエディタが送っていたものに合わせて X-Requested-With: XMLHttpRequest と Accept: application/json を付けています。
JSONを送るときは Content-Type: application/json も足します。
このあたりは真似をしただけで、深い意味を検証したわけではありません。

覚えておく取っ手としては、「ログイン済みブラウザを踏み台にして、その中からAPIを叩く」の1点です。
以降の話は全部この上に乗っています。

API編:実際に使っているエンドポイント

まず全体像です。
下書きを作ってから公開するまでに使うものと、そのあとの操作で使うものを並べると、こうなります。

メソッドとパス 用途
POST /api/v1/text_notes 空の下書き(記事の器)を作る
POST /api/v1/text_notes/draft_save?id=&is_temp_saved=true タイトルと本文を作業コピーに保存する
PUT /api/v1/text_notes/{id} 下書きを公開する/公開済み記事を更新する
GET /api/v3/notes/{key} 記事の実ステータスを取る
GET /api/v3/notes/{key}?draft=true 編集用スナップショット(作業コピー)を取る
POST /api/v3/images/upload/presigned_post 画像アップロード用の署名付きURLを取る
POST /api/v1/image_upload/note_eyecatch アイキャッチを設定する
POST /api/v2/notes/{key}/change_status 公開⇄下書きを切り替える
DELETE /api/v1/text_notes/draft_delete?id= 記事を削除する(note側ではsoft delete)
GET /api/v1/my/magazines?page= 自分のマガジン一覧を取る
GET /api/v2/embed_by_external_api/check_type?url= URLの埋め込みタイプを判定する
GET /api/v2/embed_by_external_api?url=&service=&embeddable_key=&embeddable_type=Note 埋め込み(リンクカード)を作る
GET /api/v1/stats/pv?filter=&page=&sort= 記事別のPV・スキ・コメント数を取る

v1 と v2 と v3 が混ざっているのが気持ち悪いのですが、これはnoteが時期ごとに新しいAPIを足してきた結果をそのまま使っているだけです。
統一する立場に私はいないので、動いているものを動いている形で呼んでいます。

下書きの作成と保存:draft_save は「作業コピー」

投稿は2段階です。
まず POST /api/v1/text_notes に { "name": タイトル } だけを送って、空の記事を作ります。
成功すると 201 が返り、レスポンスの data に id(数値のノートID)と key(nXXXXXXXXXXXX の形の記事キー)が入っています。
以降の操作は、エンドポイントによって id を使うものと key を使うものが分かれているので、両方を持っておく必要があります。

本文の保存はこちらです。

save_payload = {
    "body": body_html,
    "body_length": body_length,
    "name": title,
    "index": True,
    "is_lead_form": False,
}
# POST /api/v1/text_notes/draft_save?id={note_id}&is_temp_saved=true

body が本文のHTML、name がタイトルです。
body_length は本文の文字数なのですが、HTMLタグを除いた文字数を自分で数えて渡す必要がありました。
私は re.sub(r"<[^>]+>", "", html) の長さをそのまま使っています。

この draft_save で一番大事なのは、クエリの is_temp_saved=true が意味するところです。
これは公開済みの記事に対して呼んでも、読者に見えている公開版は一切変わりません。
更新されるのは編集用の作業コピーだけで、その作業コピーは GET /api/v3/notes/{key}?draft=true で読めます。

noteのエディタで公開済み記事を開いて、書きかけのまま放置しても公開面が壊れないのは、この作業コピーがあるからだと思います。
プログラムから触るときも同じで、draft_save はいくら呼んでも安全、公開面に出すには次の公開PUTを呼ぶ、という二段構えになっています。

この性質のおかげで、公開済みの記事を下書きに戻さずに本文だけ直せます。
記事のURLも公開日時も変わらないまま、誤字の修正ができるということです。
ここは調べていて一番ありがたかったところでした。

画像アップロード:署名付きURLをもらって直接送る

画像は本文HTMLに直接埋め込むのではなく、先にnoteのストレージへ上げてURLをもらいます。
実際のコードがこれです。

const presignRes = await fetch('/api/v3/images/upload/presigned_post', {
    method: 'POST',
    credentials: 'include',
    headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
        'Accept': 'application/json'
    },
    body: JSON.stringify({ filename: filename, type: 'image/png' })
});
if (presignRes.status === 429) return { error: 'rate_limited', status: 429 };
if (!presignRes.ok) return { error: 'presign_failed', status: presignRes.status };
const presignBody = await presignRes.json();
const { action, post, url } = presignBody.data;

日本語にすると「ファイル名と種類を伝えて、アップロード先の許可証をnoteに発行してもらう」です。
返ってくる data の中身は3つで、action が送り先のURL、post が一緒に送る署名パラメータ一式、url がアップロード後に本文から参照するCDNのURLです。

presigned URL(署名付きURL)というのは、「このファイルをここに置いていい」という許可証が埋め込まれた一時的なURLのことです。
画像の実体はnoteのアプリケーションサーバーを経由せず、ストレージへ直接飛びます。
続きの処理は、post の中身を全部FormDataに詰めて、最後に file としてPNGのBlobを足し、action へPOSTするだけです。
成功の判定は 204 または 200 で見ています。

ここで最初に踏んだのが 429 です。
429 Too Many Requests は「送りすぎ」の合図なので、返ってきたら30秒待ってから同じ画像を投げ直すようにしました。
それとは別に、通信の失敗そのものにも備えて最大3回までリトライし、リトライの待ち時間は 2秒 × 試行回数 で伸ばしています。
画像と画像の間は1.5秒空けています。

数字に厳密な根拠があるかというと、正直ありません。
「1枚ずつ、少し余裕を持って」くらいの感覚で決めた値です。
ただ、後で書くWAFの件があるので、ここを詰めようとは思わないほうがいいというのが実感です。

アイキャッチだけは公開面に即反映される(2026-08-02実測)

これは実際に事故りかけて分かった罠なので、少し詳しく書きます。

アイキャッチ(記事のヘッダー画像)の設定は、本文とは別のエンドポイントです。

const form = new FormData();
form.append('note_id', String(noteId));
form.append('file', file);
form.append('width', '1920');
form.append('height', '1005');
const res = await fetch('https://note.com/api/v1/image_upload/note_eyecatch', {
    method: 'POST',
    credentials: 'include',
    headers: { 'X-Requested-With': 'XMLHttpRequest' },
    body: form
});

JSONではなくFormDataで、note_id と画像ファイル、それに width と height を送ります。
サイズは実際のエディタが送っていた 1920 × 1005 をそのまま使っています。

問題は、このリクエストには本文の is_temp_saved にあたる「作業コピーにだけ保存」の概念が無いことです。
note_id に画像を直接紐づけるので、公開済みの記事に対して呼ぶと、読者に見えている画像がその瞬間に差し替わります。

2026年8月2日に、捨て記事を1本作って実測しました。
手順は「公開する → 本文Bを draft_save する → アイキャッチBを設定する」で、結果は次のとおりです。

  • 公開面の本文は、旧版のまま変わらなかった(draft_save は作業コピー止まり)
  • 公開面のアイキャッチだけが、Bに変わった

本文の更新のつもりで画像も一緒に渡すと、本文は反映されないのに画像だけ黙って入れ替わる、ということです。
これは気づきにくいので、実装側では公開状態を見て既定で止めるようにしました。

def eyecatch_allowed(note_status: str | None, *, force: bool = False) -> bool:
    if force:
        return True
    return note_status == "draft"

たった2行ですが、note_status が None(サーバーに問い合わせたが取得できなかった)のときも False になるのがポイントです。
判別できないときは安全側に倒して止める、という考え方にしています。
差し替えを承知でやりたいときだけ、明示的にフラグを立てて通します。

公開のPUTは「全置換」なので、省略した設定が消える

下書きを公開するのも、公開済み記事を更新するのも、同じ PUT /api/v1/text_notes/{id} です。
送るペイロードはこうなっています。

payload = {
    "status": "published",
    "name": title,
    "free_body": body_html,
    "pay_body": "",
    "body_length": body_length,
    "hashtags": normalized_hashtags,
    "index": index,
    "price": price,
    "separator": separator,
    "disable_comment": disable_comment,
    "exclude_from_creator_top": exclude_from_creator_top,
    "exclude_ai_learning_reward": exclude_ai_learning_reward,
    "is_refund": is_refund,
    "limited": limited,
    "send_notifications_flag": send_notifications_flag,
    "image_keys": [],
    "magazine_ids": list(magazine_ids or []),
    "magazine_keys": list(magazine_keys or []),
    "author_ids": [],
    "circle_permissions": [],
    "discount_campaigns": [],
    "pro_coupon_keys": [],
}

本文が body ではなく free_body に入っているのに注意が必要です。
有料記事の無料部分と有料部分が free_body / pay_body に分かれる作りなので、無料記事では pay_body を空文字にします。

そして、この記事で一番伝えたい罠がこれです。
このPUTは全置換で、ペイロードから省いたフィールドはnote側で既定値にリセットされます。

つまり、公開済みの有料記事の誤字を直そうとして status と name と free_body だけを送ると、price が省略されたことになって値段が0に戻ります。
同じように、コメントを閉じていた設定も、所属していたマガジンも、付けていたハッシュタグも消えます。

なので、更新のときは必ず先に現在値を取りに行きます。

return {
    "id": d.get("id"),
    "key": d.get("key") or note_key,
    "status": d.get("status"),
    "name": d.get("name") or "",
    "body": d.get("body") or "",
    "hashtags": note_hashtag_names(d),
    "price": d.get("price") or 0,
    "magazine_keys": list(d.get("belonging_magazine_keys") or []),
    "eyecatch": d.get("eyecatch"),
    "publish_at": d.get("publish_at"),
    "note_url": d.get("note_url"),
    # 再公開 PUT で素直に保持するフラグ群(欠落時は初回公開と同じ既定)
    "disable_comment": bool(d.get("disable_comment")),
    "separator": d.get("separator"),
    "limited": bool(d.get("is_limited") or d.get("limited")),
    "exclude_from_creator_top": bool(d.get("exclude_from_creator_top")),
    "exclude_ai_learning_reward": bool(d.get("exclude_ai_learning_reward")),
    "is_refund": bool(d.get("is_refund")),
}

GET /api/v3/notes/{key}?draft=true の返り値から、次のPUTで送り返すべきものだけを抜き出して整えている関数です。
本文とタイトルは差し替えたいけれど、それ以外は取ってきた値をそのまま送り返して現状維持にする、という引き回しをしています。

小さな発見が2つありました。
1つは、マガジンの所属が belonging_magazine_keys という名前で返ってくることです。
送るときは magazine_keys なので、取るときと送るときで名前が違います。

もう1つは、limited(メンバーシップ限定などの制限)が、返り値では is_limited のこともあれば limited のこともあったことです。
どちらか片方だけを見ていると取りこぼすので、d.get("is_limited") or d.get("limited") で両方を見ています。
こういう「時期によって形が違う」箇所は、非公式APIを触っているとちょくちょく出てきます。

最後に send_notifications_flag です。
これを true にすると、公開のたびにフォロワーへ通知が飛びます。
初回の公開ならそれでいいのですが、誤字を直すたびに通知が飛ぶのは申し訳ないので、更新のときは既定で false にしました。

マガジンの追加APIは廃止されていた(404)

記事をマガジンに入れる操作は、当然「記事とマガジンを結びつけるAPIがあるだろう」と思って探しました。
候補は2つ見つかったのですが、2026年8月2日時点でどちらも動きませんでした。

  • POST /api/v2/magazines/{key}/notes → {"status":404,"error":"Not Found"}
  • POST /api/v1/magazines/{id}/notes → {"error":"Magazine Not Found"}

IDとキーの組み合わせをひととおり変えて試しましたが、結果は同じでした。
一方で GET /api/v1/magazines/{key} は 200 を返すので、マガジン自体はちゃんと存在しています。
存在するマガジンに対して「Not Found」が返るので、記事単位で追加するAPIのほうが廃止されたのだと考えています。

現行で記事をマガジンに入れる手段は、公開PUTの magazine_keys だけでした。
これは実用上けっこう厳しい制約で、下書きのままマガジンに入れる方法が無いということになります。
私のツールでは、下書き保存の段階では「保留」と表示して、公開のタイミングでまとめて渡す形に作り直しました。

そして前述の全置換の話がここで効いてきます。
マガジンを1つ「追加」したいだけでも、magazine_keys には既存の所属を全部含めて送らないと、他のマガジンから外れてしまいます。

def merge_magazine_keys(existing: list[str], added: list[str]) -> list[str]:
    return list(dict.fromkeys([*existing, *added]))

既存のリストと追加分をつないでから重複を落とすだけの1行です。
dict.fromkeys() を使っているのは、Pythonの辞書がキーの挿入順を覚えているので、重複を消しつつ元の並び順を保てるからです。
set() を使うと順番がぐちゃぐちゃになるので、ここだけは辞書を使っています。

エンドポイントは時期で差し替わるので、複数試す

調べていて一番痛感したのが、これです。
公式のAPIではないので、いつ変わってもおかしくありません。
実際、マガジン一覧の取得では、過去に見つけたパスが順に使えなくなっていました。

そこで、候補を並べて上から試す形にしています。

const bases = [
    '/api/v1/my/magazines?page=',                                  // data.magazines
    urlname ? ('/api/v2/creators/' + urlname + '/contents?kind=magazine&page=') : null,  // data.contents
    '/api/v1/user_magazines?page=',                               // 旧(404化済)
    '/api/v1/user/magazines?page=',
    '/api/v2/magazines?kind=mine&page=',
];

現行のものを先頭に置き、下にいくほど古い候補になります。
返ってくるJSONの中身の場所もバラバラなので、data.magazines か data.contents か data.notes か、あるいは配列そのものか、を順に見て拾っています。

ここで1つだけ工夫をしました。
200 が返ってきても中身が0件なら、成功とみなさず次の候補に進むようにしています。
廃止されたエンドポイントが素直に404を返してくれるとは限らず、空の配列を返してくることもあるからです。
「エラーではないから成功」と判断すると、マガジンが1つも無いアカウントのように見えてしまいます。

削除も同じ考え方で、3つのパスを順に試します。

const tries = [
    '/api/v1/text_notes/draft_delete?id=' + noteId,
    '/api/v1/notes/' + noteId,
    '/api/v1/text_notes/' + noteId,
];

半年後の自分への申し送りのつもりで、コメントに「旧(404化済)」のような注記を残すようにしています。
どれが現行でどれが化石なのか、時間が経つと自分でも分からなくなるからです。

「叩いたから成功したはず」を信用しない

公式のAPIなら、200 が返ってくれば成功したと考えていいと思います。
非公式のものを相手にすると、そうもいきませんでした。

はっきり分かった例が削除です。
公開済みの記事に対して DELETE /api/v1/text_notes/draft_delete?id= を呼ぶと、200 が返ってきます。
それなのに、公開されている記事はそのまま残ります。
消えているのは作業コピー(下書き)のほうだけ、というのが2026年8月2日に実測した結果でした。

レスポンスだけを見ていると「削除に成功しました」と表示してしまうところです。
これは嘘の報告になるので、書き込み系の操作は全部、実行したあとにもう一度サーバーへ実状態を問い合わせてから成否を判定するようにしました。

  • 公開したあと → GET /api/v3/notes/{key} の status が published になったか
  • 下書きに戻したあと → 同じく draft になったか
  • 削除したあと → 参照できなくなったか、status が deleted になったか

公開済みの記事を確実に消したいときは、先に POST /api/v2/notes/{key}/change_status で下書きへ戻してから削除する、という順番にしています。

ちなみに、この change_status で下書きに戻せない記事があります。
有料記事、販売実績のある記事、メンバーシップや有料マガジンに所属している記事です。
これらは 403 が返ります。
note側の仕様として妥当だと思うので、こちらは素直に「戻せません」というエラーを出すだけにしました。

PVの取得は読み取り専用で気楽

ダッシュボードの「アクセス状況」の数字も、同じ方式で取れます。

GET /api/v1/stats/pv?filter={all|weekly|monthly|yearly|daily}&page={n}&sort={pv|like|comment}

filter で期間、sort で並び順を指定して、page を1つずつ増やしながら全件を集めます。
返ってくるのは記事ごとの read_count(PV)・like_count・comment_count などです。

1つ困ったのが、この統計APIは記事の公開日を返さないことでした。
新しい記事は週や月の数字を、古い記事は全期間の数字を見たいので、公開日が無いと判断ができません。
仕方がないので /api/v2/creators/{urlname}/contents?kind=note を別に叩いて、記事キーをキーに公開日を突き合わせています。

このあたりは読み取り専用なので、投稿に影響しないぶん気楽です。
書き込み間隔の制御も掛けていません。

エディタ・描画仕様編:noteが受け付けるHTMLの形

APIのパスが分かっても、それだけでは記事は綺麗に出ません。
body に入れるHTMLがnoteのエディタの想定どおりの形になっていないと、表示が崩れます。
ここは正直、APIを探すより時間がかかりました。

すべての要素に name と id のuuidが要る

noteのエディタが吐くHTMLを見ると、段落や見出しに毎回ランダムな文字列が付いています。

def _add_attrs(m):
    tag = m.group(1)
    rest = m.group(2)
    uid = _uuid()
    return f'<{tag} name="{uid}" id="{uid}"{rest}'

return re.sub(r"<(p|h[23]|blockquote)(\s*/?>|>)", _add_attrs, html)

Markdownから作ったHTMLに対して、p・h2・h3・blockquote の開始タグを見つけて、同じuuidを name と id の両方に入れています。
uuidというのは、重複しないように作られた識別用のランダムな文字列のことです。

エディタが要素を1つずつ区別するために振っているものだと思いますが、無いと表示がおかしくなる箇所があったので、素直に真似しています。
uuidを振っているのがこの4種類だけなのは、実際にnoteの本文で使う要素がこれで足りているからです。
見出しは h2 と h3 しか使わないことにして、Markdown側で #### 以降が出てきたら ### に丸めています。

インラインコードはブロック化するので太字にする

これは見つけたときに「そうくるか」と思った仕様です。
noteには、文中に小さくコードを差し込むインラインコードの要素がありません。

<code> タグを本文に入れると、一律でブロック扱い(display:block)として描画されます。
文の途中に pip install . のようなコードを1つ入れただけで、そこで段落が分断されて、コードだけが独立した箱になってしまいます。
Markdownの記事にはバッククォート1つのコードが山ほど出てくるので、これは致命的でした。

解決策は身も蓋もなくて、インラインコードは太字(<strong>)に置き換えることにしました。

tmp = _PRE_BLOCK_P.sub(_protect, html)
tmp = _INLINE_CODE_P.sub(r"<strong>\1</strong>", tmp)
for i, block in enumerate(stash):
    tmp = tmp.replace(f"\x00PRE{i}\x00", block)

日本語にすると「先に <pre>...</pre> のブロックを目印に退避しておき、残った <code> だけを <strong> に変える。終わったら退避したブロックを戻す」です。

退避が必要なのは、コードブロックの中身も <pre><code> という形で <code> を含んでいるからです。
何も考えずに全部の <code> を置換すると、本物のコードブロックまで太字の文章になってしまいます。
外側の <pre> ごと先に隔離してから作業する、という順番でこれを避けています。

インラインコードが太字になるのは正直うれしくないのですが、noteの見た目としては太字のほうが自然に読めるので、今は割り切っています。

数式:ディスプレイ数式は <br> 区切り、リスト内は <p> で包む

noteの数式はKaTeXベースで、書き方は $${x}$$ です。
Markdownの標準的な $x$ とはずれているので変換が要るのですが、変換したうえでさらに2つ、描画側の癖がありました。

1つ目は、複数行のディスプレイ数式です。
noteは $$ を段落の先頭行と末尾行に置いた形を期待するのですが、HTMLでは生の改行が空白に潰れるので、そのままでは $$ が独立した行だと認識されず、数式が描画されません。

def _fix(m: re.Match) -> str:
    open_tag, inner = m.group(1), m.group(2)
    return f"{open_tag}$$<br>{inner.replace(chr(10), '<br>')}<br>$$</p>"

$$ で始まって $$ で終わる段落に限って、中の改行を全部 <br> に置き換えています。
インラインの $${...}$$ は段落の途中に出てくるのでこのパターンには当たらず、影響を受けません。

2つ目は、リストの中の数式です。
noteはインライン数式を <p> 単位でしか描画しません。
<li> の直下に直接テキストとして書かれた数式は素通りして、$${x}$$ という文字列が生のまま表示されてしまいます。

def _fix(m: re.Match) -> str:
    open_tag, content, boundary = m.group(1), m.group(2), m.group(3)
    c = content.strip()
    if not c or re.match(r"<(?:p|ul|ol|div|blockquote|h\d|pre)\b", c):
        return m.group(0)
    return f"{open_tag}<p>{c}</p>{boundary}"

「<li> のすぐ下にある中身が、まだブロック要素で包まれていないただのテキストなら、<p> で包む」という処理です。
すでに <p> や入れ子のリストで始まっているものは触りません。
note自身が作るリストは <li><p>...</p></li> という構造なので、それに合わせただけです。

noteで数式がうまく出ないという話は時々見かけるのですが、少なくとも私が踏んだ範囲では、この2つが原因でした。
記法そのものについてはnoteで数式を書く方法に別途まとめています。

URLだけの段落は <figure> に、埋め込みキーは記事作成後に発行される

noteのエディタでURLを単独の行に貼ると、タイトルとサムネイル付きのリンクカードになります。
このカードの正体は <figure> 要素で、次のような形をしています。

uid = _uuid()
return (
    f'<figure name="{uid}" id="{uid}" data-src="{url}" '
    f'embedded-service="" embedded-content-key=""></figure>'
)

data-src に元のURL、embedded-service にサービス種別、embedded-content-key に埋め込みの実体を指すキーが入ります。
ここでは後ろ2つを空にしたプレースホルダーだけを作っています。

なぜ空にしておくかというと、埋め込みの実体は記事に紐づけて発行されるからです。
作成のAPIはこうなっています。

GET /api/v2/embed_by_external_api/check_type?url={URL}
GET /api/v2/embed_by_external_api?url={URL}&service={種別}&embeddable_key={記事キー}&embeddable_type=Note

1つ目でサービス種別を判定します(YouTubeやTwitterなどが返り、一般のURLやAmazonは null になります)。
2つ目でカードを作り、返ってきた data.key が embedded-content-key に入る値です。

注目してほしいのは2つ目の embeddable_key で、ここに記事キーを渡す必要があります。
つまり、記事がまだ存在しない状態では埋め込みを作れません。

そのため、投稿の順番はこうなります。

  1. Markdownを変換して、カードの空プレースホルダー入りのHTMLを作る
  2. POST /api/v1/text_notes で空の記事を作り、記事キーをもらう
  3. もらった記事キーで埋め込みを作成し、プレースホルダーの空欄を埋める
  4. draft_save で本文を保存する

「本文を組み立ててから記事を作る」のではなく、先に記事の器だけ作ってキーをもらう必要がある、というのがここでの発見でした。
埋め込みの作成に失敗したときは、その <figure> を普通のテキストリンクの段落に置き換えて、本文が壊れないようにしています。

目次は index フラグでは出ない

これは完全に勘違いをしていた部分です。

draft_save のペイロードに index: true というフィールドがあるので、最初はこれが目次の表示フラグだと思っていました。
実際に投稿してみると、index を立てても読者には目次が出ません。

正解は、目次は本文HTMLの中に置く1つの要素でした。

uid = _uuid()
return f'<table-of-contents name="{uid}" id="{uid}"><br></table-of-contents>'

<table-of-contents> という要素を本文の先頭に置いておくと、note側が h2 / h3 を拾って中身を描いてくれます。
中身は自分で書く必要がなく、空の <br> を1つ入れておくだけです。

index のほうが何なのかは、見出し一覧のメタデータらしいというところまでしか分かっていません。
これは未確認のまま、true を送り続けています。

レートリミット・WAF編:IPごとブロックされた話

ここが、この調査で一番痛い目を見たところです。

2026年8月2日、公開済み記事の一括更新を、間隔を空けずに20連発しました。
その直後から、note.comがまったく開かなくなりました。

症状はこうです。

  • ブラウザで note.com を開いても 403
  • curl で叩いても 403
  • 同じスマホでも、Wi-Fiを切ってモバイル回線にすると普通に見られる

つまり、アカウントが止められたのではなく、回線(IP)単位でCloudFront/WAFの一時ブロックに入ったということです。
ログインしていない状態のトップページすら見られないので、アカウントの制限ではないと判断しました。
しばらく時間を置いたら元に戻りました。

これは自分でも「やりすぎた」としか言いようがなく、素直に反省しています。
同時に、非公式のAPIを触るときに一番気をつけるべきなのは認証でも仕様変更でもなくアクセスの頻度なのだ、というのがよく分かりました。

対策として、書き込みを伴う操作を1記事扱うごとに、前回の書き込みから最低60秒空くまで待つようにしました。
60秒という数字に理論的な根拠はありません。
「20連発で怒られたのだから、記事1本ぶんの作業に1分かければ、人間が手で操作するのと変わらない頻度になるだろう」という考え方で決めた値です。
私は急いでいなかったので、余裕を持って倒しました。

この経緯は、忘れないようにコードのdocstringにそのまま書いてあります。

"""書き込み系操作の間隔制御(プロファイル単位・プロセス跨ぎ).

note.com は書き込みリクエストを間隔なしで連発すると、アカウントではなく
**回線(IP)ごと CloudFront/WAF の一時ブロック**に入ることがある
(2026-08-02、公開済み記事の一括更新を無間隔で20連発して実測。note.com
自体がブラウザ・curl とも 403 になり、モバイル回線からは表示できた)。
"""

実装で1つだけ気をつけたのは、前回の書き込み時刻をプロセス内の変数ではなくファイルに書くことです。
外部のスクリプトが記事ごとに別プロセスでコマンドを呼ぶループを組むと、プロセス内の変数はすぐに消えるので間隔制御が効きません。
プロファイルのフォルダに last_write.json として置いておけば、プロセスをまたいでも守られます。

あと、時計が巻き戻ったとき(前回時刻からの経過時間が負になったとき)は、計算結果を信じずに全区間を待つようにしました。
待ちすぎて損をするだけで済むほうを選んだ形です。

読み取り専用の操作は待ちません。
統計の取得やエクスポートまで60秒待たされたら、さすがに使いものにならないからです。

注意点と免責

ここまで書いてきたことは、全部非公式です。
最後に、前提として理解しておいてほしいことを並べておきます。

  1. note.comが公開しているAPIではありません。 noteに公認されたものでもありません。
  2. 予告なく変わります。 この記事の中だけでも、マガジンの追加APIが廃止されて404を返すようになり、マガジン一覧の取得は候補を5つ並べて上から試す形になっています。同じことはいつでも起こります。
  3. 書いてある内容は2026年9月1日時点のもので、実測日を明記したものはその日に確かめた結果です。この記事を読んでいる時点で同じとは限りません。
  4. 利用は自己責任でお願いします。 アカウントの制限などのリスクがあり得ます。
  5. 書き込みの間隔は必ず空けてください。 前のセクションのとおり、これは私が実際にブロックされてから入れた対策です。相手のサーバーに負荷をかけない範囲で使う、というのは最低限の礼儀かと思います。

そのうえで、この調査は自分の記事を自分で投稿するためのものであって、他人のデータを集めたり、大量にアクセスしたりする用途は想定していません。
そういう使い方に必要な情報は、この記事には書いていません。

まとめ

いかがでしたか。
今回は、公式APIの無いnote.comに対して、プログラムから記事を投稿するために調べたことをまとめました。

振り返ると、分かったことは大きく3つです。

  1. APIは「ログイン済みブラウザの中から fetch する」形で叩ける。 下書きの作成(POST /api/v1/text_notes)と保存(draft_save)と公開(PUT /api/v1/text_notes/{id})の3段構えで、draft_save は公開面に出ない作業コピーへの保存になっている
  2. エディタが受け付けるHTMLには具体的な形がある。 各要素にuuid、インラインコードは太字へ、ディスプレイ数式は <br> 区切り、リスト内の数式は <p> で包む、URLカードは記事キーをもらってから埋め込みを発行、目次は <table-of-contents> 要素
  3. 一番危ないのは仕様変更ではなくアクセス頻度。 間隔なしの20連発でIPごとブロックされたので、書き込みは60秒間隔にした

そして、公開PUTが全置換であること、削除の 200 が公開記事では当てにならないこと、アイキャッチだけは公開面に即反映されること。
この3つは、レスポンスを信じずに実状態を確認し直す、という方針に落ち着きました。

この調査をそのまま実装に落としたものが、Markdownの記事をnoteの下書きとして投稿するツール note_uploader です。
コード一式はObsidianのMarkdownをnoteの下書きとして投稿するツールを作りました(note_uploader)でMITライセンス・無料で配布していますので、ここで書いた仕組みを実際のコードで確かめたい方はどうぞ。

公式のAPIが用意されるのが一番いいのですが、無いものは無いので、当面はこうやって付き合っていくことになりそうです。
この記事が誰かの役に立てばうれしいです。

コメント

読み込んでいます…

この記事を書いた人

リットン

都内金融機関で経営企画とデータアナリストをしているアラサー。
文系の非エンジニアですが、独学で始めた Python と生成AIにすっかりはまりました。
プログラミング・サッカー・ミステリ紹介など、好きなことを好きに書いています。

プロフィールを見る →