ObsidianのMarkdownをnoteの下書きとして投稿するツールを作りました(note_uploader)
ObsidianなどのMarkdown記事を、note.comの下書きとしてそのまま投稿できる自作のCLI/GUIツール note_uploader の紹介です。noteのエディタでは貼れないテーブルを画像に変換し、記事内の画像を自動アップロードし、数式をnoteの記法に書き換えます。複数アカウントの切り替え、投稿済み記事の更新・公開・削除、PVの取得まで、実装コードの抜粋を交えて仕組みごと説明します。コード一式はこのページでMITライセンス・無料のzipとして配布しています。
目次
はじめに
Obsidianで書いたMarkdownの記事を、noteに載せるためだけにブラウザを開いてコピペしている、ということはありませんか。
私はこれがずっと面倒でした。
Markdownで書いてMarkdownで保存しているのに、最後の最後でnoteのエディタを開いて本文を貼り、崩れた表を作り直し、画像を1枚ずつドラッグして、数式を書き換える。
1本ならまだしも、記事が何十本もあるとどうにもなりません。
そこで作ったのが note_uploader という、Markdownの記事をnoteの下書きとして投稿するPython製のツールです。
コマンドライン(CLI)とウィンドウ(GUI)の両方から使えて、Obsidianの元記事をそのまま渡せば、noteで貼れないものを勝手に変換してから投稿してくれます。
この記事は「こんなツールを作りました、機能はこれです」で終わらせずに、実装のコードを抜粋しながら内部でどう動いているかまで書いてみます。
コードの部分は雰囲気だけ眺めて読み飛ばしてもらっても話が繋がるように、抜粋の直後に必ず日本語で種明かしをしますので、「使えればいい」という方も安心して読み進めてください。
コード一式は、この記事の後半の「配布」でzipとして配布しています(MITライセンス・無料)。
まず使ってみたいという方は、そちらから先にダウンロードしてもらって大丈夫です。
なお、この記事は「ツールの紹介と使い方」に絞ってあります。
note.comには公式のAPIが無いので、投稿の経路は自分で調べて見つけたものを使っているのですが、その調査そのものの記録はnoteの非公式APIとエディタ仕様を調べた話に分けました。
実際に叩いているエンドポイントや、note側の仕様で踏んだ罠に興味がある方は、そちらもどうぞ。
このツールで何ができるか
一言でいうと、「Markdownの .md ファイルを入力に、note.comの下書きを作る」だけのツールです。
できることを並べると、こうなります。
- Markdownファイルを指定するだけで、noteに下書きが作られる(ブラウザの操作は不要)
- noteのエディタでは貼れないテーブルを、PNG画像に描き直して埋め込む
- 記事内で参照している画像を集めて、noteのサーバーへ自動でアップロードする
$x$のような数式を、noteが解釈できる$${x}$$の形に書き換える- 本文中の「URLだけの行」を、noteでURLを貼ったときと同じリンクカードにする
- 投稿済みの下書き・公開済み記事を、後から本文だけ修正できる
- 下書きの公開、公開記事を下書きに戻す、記事の削除を、本文を触らずに実行できる
- 記事ごとのPV・スキ・コメント数をCSVで取り出せる
- 複数のnoteアカウントを、プロファイル名で切り替えられる
要点だけ覚えるなら、「表は画像に、画像はnoteのサーバーに、数式はnote記法に、URLはカードに」の4点セットです。
この4つが、noteのエディタに手で貼ったときに崩れるところで、記事の後半ではこの4つをそれぞれ掘り下げます。
noteエディタにMarkdownを持ち込むときの困りごと
作る前に私が踏んでいた面倒は、だいたい次の4つでした。
1つ目はテーブルです。
noteのエディタには表を作る機能がありません。
Markdownの表をそのまま貼っても、| 列 | 列 | という文字がそのまま並ぶだけになります。
比較表を使う記事はそれなりに書くので、これが一番困りました。
2つ目は画像です。
Obsidianの記事は ![[image.png]] のようなwikilinkでvault内の画像を参照していますが、noteはローカルのファイルを表示できません。
結局、noteのエディタで1枚ずつ手でアップロードして、正しい位置に差し込み直すことになります。
3つ目は数式です。
noteの数式はKaTeXベースなのですが、インライン数式が $${x}$$ という独自の合図になっていて、Markdownの標準記法 $x$ とずれています。
このあたりはnoteで数式を書く方法に別途まとめました。
4つ目は本数です。
1本ずつなら手作業でもいいのですが、下書きを何十本もまとめて登録したいとなると、コピペでは現実的ではありません。
このうち1〜3を自動でやってしまえば、4も自然に片付きます。
それがこのツールの出発点でした。
導入と最小フロー
必要なものは Python 3.10以上 です。
まず、後半の「配布」にあるzipをダウンロードしてください。
ダウンロードしたzipを右クリックし、「すべて展開」を選びます。
zipの中から直接実行せず、展開してできた note_uploader フォルダを使えば大丈夫です。
あとは、展開したフォルダの中でこの2つを実行するだけです。
# 1) 展開したフォルダに移動してインストール
cd note_uploader
pip install .
# 2) Playwright のブラウザを入れる(初回のみ・必須)
python -m playwright install chromium
cd note_uploader の部分は、展開してできたフォルダ(pyproject.toml と src が入っているほうの階層)を指します。
Windowsのエクスプローラーでそのフォルダを開き、アドレスバーに cmd と打ってEnterを押すと、そのフォルダの場所でコマンドプロンプトが開くので楽かと思います。
Playwrightというのは、プログラムからブラウザを動かすためのライブラリです。
note.comにログインした状態を保持するために使っていて、そのためのChromiumを1回だけ入れておく必要があります。
Linuxの場合は、これに加えて python -m playwright install-deps chromium が必要なこともあります。
テーブルの画像化を使う場合は、描画にmatplotlibを使うので pip install ".[table]" を実行してください。
表を使わない記事しか投稿しないなら、無くても動きます。
使い方は「ログインして、投稿する」の2コマンドが基本です。
# プロファイル "myblog" で初回ログイン(ブラウザが開く)
note-uploader login --profile myblog
# Obsidian の元記事をそのまま下書き投稿
note-uploader publish /path/to/obsidian/article.md --profile myblog
login を実行するとブラウザのウィンドウが開くので、いつも通りnote.comにログインしてください。
ログイン処理自体はツールがやらず、人間が手でログインしてセッションだけ保存する形にしています。
IDとパスワードをツールに渡さずに済むようにしたかったからです。
一度ログインすれば、次回からは保存されたセッションが使われます。
publish に渡すのはObsidianの元記事で構いません。
テーブルの画像化などは投稿時に一時フォルダでその場変換され、変換後のファイルはディスクに残らない作りにしてあります。
複数のファイルを並べて渡すことも、フォルダを渡して配下の .md をまとめて投稿することもできます。
note-uploader publish a.md b.md c.md --profile myblog
note-uploader publish /path/to/articles-dir --profile myblog
ウィンドウから操作したい方向けに、Python標準のTkinterで作ったGUIも同梱しています。
note-uploader gui で起動して、アカウントを選び、記事とアイキャッチを選んで「下書き投稿を実行」を押す、という流れです。
追加のライブラリは要りませんが、一部のLinuxではTkinterが別パッケージなので python3-tk などの導入が必要です。
Windowsでコマンドを打たずにGUIを開きたい方向けに、zipには note-uploader起動.bat も入れてあります。
これは展開したフォルダの .venv\Scripts\note-uploader-gui.exe を探して起動するだけのバッチなので、使う場合は展開したフォルダの中で py -m venv .venv と .venv\Scripts\pip install -e . を実行して、フォルダ内に仮想環境を作っておいてください。
見つからないときはその旨を表示して止まるので、いきなりウィンドウが閉じて何が起きたか分からない、ということにはならないかと思います。
なお、記事の投稿は必ず下書きとして作られます。
公開はnoteの画面で中身を確認してから、自分の手でやってもらう前提です(--publish を付ければそのまま公開もできます)。
ここから、冒頭に書いた4点セットを1つずつ、実際のコードを抜粋しながら見ていきます。
抜粋はすべて実装からそのままで、コピペして動かす用ではなく「こう動いている」を見てもらうためのものです。
変換の詳細①:テーブルはPNGに描き直す
noteに表が貼れない以上、選択肢は「表を諦める」か「表の絵を貼る」かの二択です。
このツールは後者を選びました。
Markdownの表を検出して、matplotlibで表の絵をPNGに描き、その画像を本文に埋め込みます。
まず、どこからどこまでが表なのかを見つける部分です。
if (
_TABLE_ROW_RE.match(line)
and i + 1 < len(lines)
and _TABLE_SEP_RE.match(lines[i + 1])
):
start = i
headers = [h.strip() for h in line.strip("|").split("|")]
title = self._find_table_title(lines, start)
i += 2
rows = []
while i < len(lines) and _TABLE_ROW_RE.match(lines[i]):
cells = [c.strip() for c in lines[i].strip("|").split("|")]
rows.append(cells)
i += 1
日本語にすると「| で始まる行があって、その次の行が |---|---| のような区切り行だったら、そこが表の先頭。以降 | の行が続く限り、1行ずつセルに分解して集める」です。
読みどころは、判定に区切り行を使っているところです。
| を含む行は本文中にもいくらでも出てきますが、「| の行の直後に区切り行がある」という並びは、ほぼ表にしか現れません。
Markdown全体を構文解析するのではなく、この2行の並びだけを見る割り切りで、誤爆をほぼ潰しています。
表のタイトルは _find_table_title() が拾います。
表の直前5行をさかのぼって、太字だけの行(**...**)か見出し行(#〜####)が見つかればそれをタイトルにし、無ければタイトル無しで描きます。
ここを「直前の行を何でもタイトルにする」にしていた時期があったのですが、普通の段落を拾ってしまうと、長い文がタイトルになって画像が異様に横長になりました。
太字と見出しに限定したのはそのためです。
描画のほうは table_image.py が担当していて、既定のDPIは300です。
列数に応じて図の幅と文字サイズを変えていて、2列なら幅10インチ・12ポイント、3列なら幅12インチ・11ポイント、4列以上は列数から計算した幅(上限18インチ)・10ポイントになります。
日本語フォントは --font-path で指定できますが、指定が無ければWindowsの游ゴシック・メイリオ、それも無ければ環境にある日本語フォントを順に探します。
変換の詳細②:画像はnoteのサーバーへ自動で上げる
表を画像にすると決めた時点で、画像のアップロードは避けて通れなくなります。
Obsidianのwikilink画像も同じ経路に乗せて、記事に出てくる画像を全部まとめて上げる作りにしました。
処理は2段構えです。
まず変換の段で、記事内の表と画像参照を連番のプレースホルダー(**[画像01]** のような目印)に置き換え、実体のPNGを一時フォルダの images/ に連番ファイル名で書き出します。
そのあと投稿の段で、images/ のPNGを順にアップロードし、返ってきたURLでプレースホルダーを差し替えます。
このプレースホルダー方式にしたのは、変換の段でnote.comを一切触らずに済ませたかったからです。
表の画像化も画像の集約も、ブラウザを起動しないただの文字列処理として動くので、通信なしでテストできます。
アップロードそのものは、noteが画像をやり取りするときの経路をそのまま使っています。
noteに「ここに置いていい」という許可証つきの一時的なURL(presigned URL)を発行してもらい、画像はそこへ直接送る、という形です。
画像1枚ごとに間隔を空け、送りすぎで弾かれたら30秒待ち、失敗しても最大3回までリトライします。
1枚失敗しても止まらず、最後に「アップロード失敗: ○○.png」と警告を出して先に進みます。
この経路をどうやって見つけたのか、実際に叩いているエンドポイントとJavaScriptのコードは、noteの非公式APIとエディタ仕様を調べた話のほうにまとめました。
ここではひとまず「画像は勝手に上がる」とだけ思っていただければ大丈夫です。
enhanced_exporter.py 側の連番ファイル名には、地味な意味があります。
images/ のPNGは 01_table.png のようにファイル名の先頭が番号になっていて、アップロード側は ^(\d+)_ で番号を取り出して {画像番号: URL} の対応表を作ります。
アップロードの順番ではなくファイル名の番号を正としているので、途中の1枚が失敗して抜けても、残りの画像が1つずつずれて別の場所に入る、という壊れ方をしません。
変換の詳細③:数式はnote記法に、ただしコードと $100 は守る
noteのインライン数式は $${x}$$ という書き方なので、$x$ を機械的に $${x}$$ に置換すればよさそうに見えます。
ところが実際にやると、コードブロックの中の $ や、文中の $100 のような金額まで数式にされて記事が壊れます。
そこで、置換の前に「触ってはいけないもの」を退避しています。
text, fenced_codes = _protect(text, FENCED_CODE_PATTERN, FENCED_CODE_PLACEHOLDER)
text, inline_codes = _protect(text, INLINE_CODE_PATTERN, INLINE_CODE_PLACEHOLDER)
text = DISPLAY_MATH_PATTERN.sub(_capture_display, text)
text, currency_matches = _protect(text, CURRENCY_PATTERN, CURRENCY_PLACEHOLDER)
text = INLINE_MATH_PATTERN.sub(r'$${\1}$$', text)
text = _restore(text, currency_matches, CURRENCY_PLACEHOLDER)
text = _restore_display_math(text, display_matches)
text = _restore(text, inline_codes, INLINE_CODE_PLACEHOLDER)
text = _restore(text, fenced_codes, FENCED_CODE_PLACEHOLDER)
日本語にすると「コードブロック → インラインコード → 複数行数式 → 通貨表記、の順に目印へ退避してから、残ったところだけをインライン数式として変換し、逆順に戻す」です。
退避と復元が鏡写しの順番になっているのがポイントで、外側にあるものほど先に退避して、後に戻します。
具体的にどう守っているかというと、たとえばコードブロックを見つける正規表現はこうなっています。
FENCED_CODE_PATTERN = re.compile(
r'(?m)^[ \t]*(?:```[^\n]*\n.*?\n[ \t]*```|~~~[^\n]*\n.*?\n[ \t]*~~~)',
re.DOTALL,
)
ここの ^[ \t]* が地味に効いています。
コードフェンスは「行頭(インデントは可)」でしか始まらない、という条件を付けているんですね。
これを入れる前は、Markdownの書き方を説明する記事の本文中に出てくるバッククォート3つをコードブロックの開始と誤認して、そこから次の本物のフェンスまでが丸ごとコード扱いになり、間にあった表が画像化されない、という事故が起きました。
行頭に錨を下ろすだけで直ります。
通貨のほうは $ に2桁以上の数字か3桁区切りが続くものを退避しているので、$100 や $1,000.50 は数式になりません。
逆に $5dollars のような英数字が続くトークンも、インライン数式のパターン側で除外しています。
変換の詳細④:URLだけの段落は埋め込みカードになる
noteのエディタでURLを単独の行に貼ると、タイトルとサムネイル付きのリンクカードになります。
Amazonの商品URLなら、書影と価格と購入ボタンの付いたカードになります。
これをMarkdownからも再現したかったので、本文HTMLを組み立てるときに「段落まるごとが1つのURL」の場合だけ、カードのプレースホルダーに置き換えています。
def _repl(m: re.Match) -> str:
href = m.group("href")
if href is not None:
text = (m.group("atext") or "").strip()
# 表示テキストが URL と異なる = 意図的なテキストリンクなので変換しない
if text and text != href:
return m.group(0)
url = href
else:
url = m.group("url")
uid = _uuid()
return (
f'<figure name="{uid}" id="{uid}" data-src="{url}" '
f'embedded-service="" embedded-content-key=""></figure>'
)
日本語にすると「段落の中身がURLそのもの(またはURLを表示テキストにしたリンク)なら <figure> のカード枠に置き換える。表示テキストがURLと違うなら、意図してラベルを付けたリンクなので触らない」です。
つまり、[公式サイト](https://example.com) のようにラベルを付けて書けば普通のテキストリンクのまま、URLを単独行にベタ書きすればカードになる、という使い分けができます。
「本文中のリンクは全部カードにする」ではなく書き手が選べるようにしたかったので、この線引きにしました。
embedded-content-key="" が空なのは、この段階ではまだ埋めるものが決まっていないからです。
カードの中身はnote側が記事ごとに発行するので、記事の器を先に作ってキーをもらい、本文を保存する直前に空欄を埋めにいきます。
この順番でないと動かない理由は、調査記事のほうに書きました。
投稿したあとの操作
下書きを作ったら終わり、ではありません。
誤字を見つけたり、公開してからタイトルを直したくなったりします。
そのための操作を、本文を壊さずにできるようにしてあります。
update(本文の修正)は、投稿済みの下書きだけでなく、公開済みの記事も対象にできます。
公開記事を下書きに戻さずに編集できるので、記事のURLも公開日時も変わりません。
価格・コメント可否・マガジン所属・ハッシュタグといった本文以外の設定は、取得した現在値をそのまま引き回して維持します。
再公開時のフォロワー通知は既定でオフです。誤字修正のたびに通知が飛んだら困りますので。
# 本文の一部だけ直す(部分パッチ)。OLD→NEW を複数指定可。
note-uploader update --note-key nXXXXXXXXXXXX --profile myblog \
--replace "旧タイトル表記" "新タイトル表記"
# タイトルだけ変更
note-uploader update --note-key nXXXXXXXXXXXX --profile myblog --title "新しいタイトル"
--replace の部分パッチは、既存の本文HTML(目次・画像・体裁)をそのまま保って、該当する文字列だけを置き換えます。
本文をMarkdownで丸ごと差し替えたいときは --markdown で新しいファイルを渡します。
publish-note / unpublish / delete は、本文を一切変えずに公開状態だけを操作するコマンドです。
非公認のAPIを使っている以上、「叩いたから成功したはず」で済ませるのは危ないので、いずれも実行後にサーバーの実状態を問い合わせ直してから成功と判断しています。
publish-noteは、既に公開済みなら何もしません(本文を上書きしない)unpublishは公開記事を下書きに戻しますが、有料記事・販売実績のある記事・有料マガジン所属の記事はnote側の仕様で戻せません(403が返るので、その旨のエラーで案内します)deleteは元に戻せないので、既定では下書きしか消せません。公開済みを消すには--forceが要ります
stats は、noteのダッシュボードの「アクセス状況」から記事ごとのPV・スキ・コメント数を取ってCSVに書き出します。
読み取り専用なので、投稿には一切影響しません。
note-uploader stats --profile myblog --output ./note_stats.csv --top 20
CSVは1記事1行で、タイトル・ステータス・公開日・取得日・note_key・URLに続けて、期間ごとのPV・スキ・コメントの列が並びます。
既定では全期間・月(30日)・週(7日)の3つを取ります。
公開日と取得日を並べているのは、「新しい記事は週や月の数字を、古い記事は全期間の数字を見る」という判断ができるようにするためです。
Excelで開く前提でUTF-8のBOM付きにしてあります。
status / batch は、過去に投稿した記事がいま下書きか公開済みかをサーバーに問い合わせて監査し(status)、下書きのまま残っているものを件数上限つきで一括公開する(batch)コマンドです。
batch は既定がdry-run(公開せず計画だけ表示)で、--execute を付けて初めて実際に公開します。
1回の上限は既定25件、各公開の間隔は既定3秒です。
エラーが出たらそこで即停止します。
ここで知っておいてほしいのが、noteには1日に投稿できる本数の上限があることです。
下書きを何十本もまとめて公開していると、途中でこの上限に当たって公開APIがエラーを返すようになります。
私も一括公開ではこれに結構な頻度で当たりました。
上限に当たったとき、このツールは次のように動きます。
- エラーの文面に「上限」「429」「投稿可能数」などの手掛かりがあればレート制限らしきエラーと分類し、そこで即停止します(残りを強行しません)
- どこまで公開できたか・なぜ止まったか・未公開が何件残っているかを表示します
- 公開できなかった分は下書きのまま持ち越しになります。翌日など時間を置いて同じ
batchコマンドをもう一度実行すると、公開済みはサーバーへの問い合わせで自動的に除外されるので、続きから再開されます
つまり、上限に当たっても記事が壊れたり二重投稿になったりはせず、「止まる → 翌日また同じコマンドを打つ」の繰り返しで消化していく運用になります。
既定の25件という上限も、1回の実行でnoteの1日上限を使い切らないための値です。
なお status と batch が対象にできるのは、このツールで publish した記事だけです。
note.comの画面で手動作成した記事は、後述する投稿記録に載っていないので対象外になります。
複数アカウントの切り替え(プロファイル)
noteのアカウントを複数使い分けているので、プロファイルという単位を用意しました。
セッション・画像キャッシュ・スクリーンショット・投稿記録が、すべてプロファイル配下に分かれて保存されます。
~/.config/note-uploader/
└── profiles/
├── default/
│ ├── session.json
│ ├── eyecatch_url_cache.json
│ └── screenshots/
└── myblog/
├── session.json
└── ...
どのプロファイルを使うかは、コードでの明示指定 → 環境変数 NOTE_UPLOADER_SESSION_FILE(セッションファイルの直接指定)→ 環境変数 NOTE_UPLOADER_PROFILE(プロファイル名)→ 既定の default、の順で決まります。
保存先のルートは Path.home() の配下なので、Windowsでは C:\Users\<名前>\.config\note-uploader になります(AppData配下ではありません)。
このプロファイル配下に drafts.json という投稿記録があって、これが地味に大事な部品です。
「過去にpublishした記事mdの絶対パス → note_id / note_key」の対応表で、再投稿したときに新規作成ではなく既存下書きの上書きにするための鍵になっています。
キーが絶対パスなので、注意点が1つあります。
記事をコピーしたりリネームしたりエクスポートしてから投稿すると、パスが変わって別記事扱いになり、下書きが2個できます。
これを検知するために、新規作成の直前に「同じタイトルの投稿記録が別パスに無いか」を確認して、見つかれば警告を出すようにしました。
self_key = drafts_key(article_path)
matches: list[dict] = []
for path, info in load_drafts_cache(settings).items():
if path == self_key:
continue
if isinstance(info, dict) and info.get("title") == title:
matches.append({"path": path, **info})
return matches
自分自身のパスを除いて、同じタイトルの記録を探しているだけの短い関数です。
警告と一緒に、上書きに使う update --note-key の案内も出します。
確実に上書きしたいときは --no-create を付けると、投稿記録に無い記事は新規作成せず失敗するので、2個目を作る事故が防げます。
書き込み間隔を60秒空けている理由
このツールで一番「痛い目を見てから入れた」機能が、書き込み間隔の制御です。
2026年8月2日に、公開済み記事の一括更新を間隔なしで20連発したところ、note.comが表示できなくなりました。
アカウントの制限ではなく、回線(IP)ごとCloudFront/WAFの一時ブロックに入ったようで、ブラウザからもcurlからも403が返り、スマホのモバイル回線からは普通に見られる、という状態でした。
このときの状況と、なぜ60秒という数字にしたのかは、noteの非公式APIとエディタ仕様を調べた話に詳しく書いています。
そこで、書き込みを伴う操作(新規投稿・更新・公開・下書き戻し・削除)は、1記事扱うごとに前回の書き込みから最低60秒空くまで自動で待つようにしました。
waited = 0.0
last = _read_last_write(settings)
if last is not None:
elapsed = clock() - last
# クロック巻き戻り等で負になったら安全側に倒して全区間待つ
remaining = interval - elapsed if elapsed >= 0 else interval
if remaining > 0:
waited = remaining
_log.info(
f"note.com への連続書き込みを避けるため {remaining:.0f} 秒待機します"
f"(間隔 {interval:.0f} 秒・{INTERVAL_ENV} で調整可)..."
)
sleep(remaining)
record_write(settings, now=clock())
return waited
前回の書き込み時刻からの経過を引き算して、残りぶんだけ待つ、というだけの処理です。
1つだけ工夫があって、経過時間が負(=時計が巻き戻った)になったときは、計算結果を信じずに全区間を待ちます。
安全側に倒したほうが、待ちすぎて損するだけで済むからです。
もう1つ大事なのは、前回時刻をプロファイル配下の last_write.json というファイルで共有していることです。
Pythonのプロセス内の変数で持つと、外部のスクリプトがCLIを記事ごとに別プロセスで呼ぶループでは効きません。
ファイルに書いておけば、プロセスをまたいでも間隔が守られます。
間隔は環境変数 NOTE_UPLOADER_WRITE_INTERVAL(秒)で変えられて、0 で無効化もできます。
ただしこれはテスト用に付けた口なので、通常の運用では緩めないことをおすすめします。
読み取り専用の操作(status / stats / export)は、そもそも待ちません。
どう作ったか(技術構成)
ここまでの部品が、どのファイルにどう収まっているかをまとめておきます。
src/note_uploader/
├── cli.py / gui.py … エントリポイント(CLI / Tkinter GUI)
│
│ オーケストレーション層(処理の順序だけを担う)
├── publisher.py … 新規投稿(publish_draft / publish_drafts / publish_one)
├── editing.py … 既存記事の修正(update_note)
├── lifecycle.py … 公開⇄下書きの切替・削除
├── batch.py … 公開状態の監査・安全な一括公開
├── stats.py … アクセス統計の取得と CSV 出力
│
│ note.com アクセス層
├── browser.py … Playwright セッション管理
├── note_api.py … note.com 内部 API の個別呼び出し
├── caches.py … プロファイル別キャッシュと note_key 解決
│
│ 変換層(Playwright に依存しない純テキスト/画像処理)
├── convert.py … 投稿・更新時のその場変換
├── html_render.py … Markdown → note.com 用 HTML
├── exporter.py … 基本の Markdown 変換
├── enhanced_exporter.py … 強化エクスポート(テーブル画像化・画像連番集約)
├── table_image.py … テーブル PNG 描画(matplotlib はここだけ)
├── latex.py … 数式記法の変換
├── code_spans.py … コード領域の検出・保護
├── frontmatter.py … YAML フロントマターの読み取り
│
│ 基盤
├── config.py … プロファイル解決と Settings
└── errors.py … 専用例外
設計で意識したのは、依存を上から下への一方向にすることです。
エントリポイント → オーケストレーション → アクセス層/変換層 → 基盤、の順で、逆向きの参照はありません。
特に効いているのが、変換層をPlaywrightから完全に切り離したことです。
latex.py も html_render.py も table_image.py も、入力の文字列や表データを受け取って結果を返すだけで、ブラウザもnote.comも知りません。
おかげで、この記事で抜粋したような変換ロジックは、ブラウザを一切起動せずにテストできます。
データフローを実際の関数名で追うと、投稿はこう流れます。
publish_one()がwait_for_write_slot()で書き込みスロットを待つis_pre_exported()で変換済みの記事かを判定する- 未変換なら
convert_for_publish()が一時フォルダにconvert_enhanced()を通す(表の画像化・画像集約・convert_inline_math()) upload_all_images()が一時フォルダのPNGを順に上げて{画像番号: URL}を得るmarkdown_to_html()が本文をnote用HTMLにする(プレースホルダーの差し替え・目次ブロックの挿入・URL段落のカード化)draft_save_api()で下書きを保存し、remember_draft()が投稿記録に書き込む
変換済みかどうかの判定(is_pre_exported())は、こういう作りです。
path = Path(article_path)
if path.stem.endswith(DEFAULT_FILENAME_SUFFIX):
return True
if "note-export" in path.parts:
return True
ranges = code_ranges(text)
for m in _PLACEHOLDER_MARK_RE.finditer(text):
if not in_code_ranges(ranges, m.start()):
return True
return False
「ファイル名がエクスポートの既定サフィックスで終わる」「パスに note-export フォルダを含む」「本文に **[画像NN]** のプレースホルダーがある」のどれかなら変換済み、という3段の判定です。
3つ目で code_ranges() を通しているのが読みどころで、コードブロックの中に書かれた **[画像01]**(つまりこのツールの説明記事のような文章)を、本物のプレースホルダーと取り違えないようにしています。
自分でこのツールの解説記事を書こうとして踏んだ穴です。
依存ライブラリは2つだけです。
playwright>=1.58 と markdown>=3.5、それに表を使うときだけ matplotlib>=3.8 が追加で要ります。
パッケージには型情報(py.typed)を付けてあるので、他のプロジェクトから使うときにmypyやPyrightの型チェックが効きます。
動作の証拠:テストで何を固定しているか
tests/ にpytestのテストが104件あり、この記事を書いている環境(Ubuntu・Python 3.12)で実行して1.6秒で全件通ることを確認しました。
note.comへの通信が要る部分はモック(偽の応答を返す差し替え)にして、ローカルのロジックの境界を固めてあります。
観点はファイル名のとおりで、エクスポート変換・テーブル画像化・埋め込みカード・フロントマター・マガジン・投稿・公開状態の切替・一括公開・統計・書き込み間隔の10系統です。
この記事で書いた割り切り(コードブロック内の $ を数式にしない、ラベル付きリンクはカードにしない、時計が巻き戻ったら全区間待つ、など)は、そのままテストケースになっています。
正直に書いておくと、note.comの実APIを叩く経路のテストはありません。
非公認のAPIなので、そこは実際に投稿して確かめるしかないのが現状です。
注意点(必ず読んでください)
このツールを使う前に、次の4点は理解しておいてください。
- note.comの非公式な内部APIを利用しています。 note.com公認のツールではありません。
- note.com側の仕様変更により、予告なく動作しなくなることがあります。 実際、記事単位でマガジンに追加するAPIは廃止されて404を返すようになり、公開時にまとめて付与する経路に作り直しました。同じことはいつでも起こり得ます。
- 利用は自己責任でお願いします。 アカウントの制限・停止などのリスクがあり得ます。
- 書き込み間隔の制御(既定60秒)は外さないでください。 前述のとおり、これはIPごとブロックされた実体験から入れているものです。
NOTE_UPLOADER_WRITE_INTERVALを短くしたり0にしたりするのは、おすすめしません。
もう1つ、セキュリティ上の注意です。
エラー解析用として、~/.config/note-uploader/profiles/<名前>/screenshots/ にログイン済み画面のスクリーンショットが保存されることがあります。
第三者に共有・公開しないようにしてください。
よくあるトラブルとQ&A
Q. 起動時にPlaywrightのブラウザが無いというエラーが出る
A. python -m playwright install chromium を実行してください。
Linuxではシステムライブラリが足りず、これに加えて python -m playwright install-deps chromium が必要なことがあります。
Q. GUIが起動しない(Linux)
A. Tkinterが別パッケージになっているためです。
python3-tk などを導入してください。
WindowsとmacOSの公式Pythonには同梱されているので、追加作業は不要です。
Q. セッションが切れた
A. note-uploader login --profile <名前> で再ログインしてください。
SessionExpiredError(セッション失効)と LoginRequiredError(未ログイン)は、これで解消します。
Q. 一括公開(batch)が途中で止まった
A. noteの1日の投稿上限に当たった可能性が高いです。
このツールはレート制限らしきエラーを検知するとそこで停止し、残りは下書きのまま持ち越します。
翌日など時間を置いて同じコマンドを再実行すれば、公開済みは自動で除外されて続きから再開されます(二重投稿にはなりません)。
Q. 同じ記事なのに下書きが2つできた
A. 投稿記録のキーが記事mdの絶対パスなので、コピーやリネームやエクスポートを挟むと別記事扱いになります。
同じ記事は常に同じパスから投稿してください。
上書きのつもりの再投稿には --no-create を付けると、記録に無い記事は新規作成せず失敗するので安全です。
既にできてしまった2個目は delete --note-key で消せます。
Q. 表が画像にならない
A. まず pip install ".[table]" でmatplotlibが入っているか確認してください。
入っているのに変換されない場合は、その表がコードブロックの内側にあると判定されていないかを疑ってみてください。
本文中にバッククォート3つを行頭で書いていると、そこからコードブロック扱いになることがあります。
Q. 公開済みの記事のアイキャッチが差し替わってしまった
A. アイキャッチは既定で「下書きにだけ」設定され、公開済みの記事に渡すとスキップして警告を出します。
本文は作業コピー止まりで公開面に出ないのに対し、アイキャッチにはその仕組みが無く、公開面の画像が即差し替わるためです。
承知のうえで差し替えるときだけ --force-eyecatch を付けてください。
Q. 公開済みの記事を下書きに戻せない
A. note.comの仕様で、有料記事・販売実績のある記事・メンバーシップ特典や有料マガジンに所属する記事は下書きに戻せません。
403が返るので、その旨のエラーメッセージが出ます。
Q. マガジンに入らない
A. note.comが記事単位のマガジン追加APIを廃止したため、下書きのままマガジンに入れる手段がありません。
現行で残っているのは公開時にまとめて渡す経路だけなので、フロントマターに magazines: を書いていても、下書き保存の段階では「保留」と表示して公開時に回します。
公開済みの記事に後から付けるには update --add-magazine を使ってください。
Q. 他のスクリプトからライブラリとして呼べますか
A. 呼べます。
進捗メッセージはすべて note_uploader.* のロガーにINFOで流れるだけで、何も設定しなければ標準出力には一切出ないようにしてあるので、呼び出し側の出力を汚しません。
進捗を見たいときだけハンドラを付けてください。
配布
最新版はこちらからダウンロードできます。
note_uploader_20260901.zip をダウンロード(約130KB)
中身はソース一式(README.md・LICENSE・pyproject.toml・src/・tests/)です。
実行ファイルではないので、前述の「導入と最小フロー」のとおり、展開したフォルダで pip install . を実行してから使ってください。
ライセンスはMITで、無料です。
改造して使っていただいても構いません。
ただし前述のとおり、note.comの仕様変更でいつ動かなくなってもおかしくないものなので、そこは承知のうえでお使いください。
更新履歴
変更内容はこの節に追記し、配布zipも常にこのページに掲載しているものを最新版にします。
2026-09-01版(note_uploader_20260901.zip)
- 初回配布(バージョン 0.3.0)。CLI・GUI・テスト一式を含むソースをzipで公開
まとめ
いかがでしたか。
今回は、Markdownの記事をnoteの下書きとして投稿する自作ツール note_uploader を、内部の仕組みまで開けて紹介しました。
振り返ると、このツールがやっているのは、
- Markdownの表を「
|の行+区切り行」の並びで検出し、matplotlibでPNGに描き直して埋め込む - 記事内の画像をプレースホルダーに置き換えてから、presigned URL経由でnoteのサーバーへ上げてURLで差し替える
- コードブロック・インラインコード・通貨表記を先に退避してから、残った
$...$だけをnoteの$${...}$$に書き換える - 段落まるごとが1つのURLのときだけ、埋め込みカードのプレースホルダーに置き換える
という4つの変換を、Playwrightで開いたログイン済みのブラウザから内部APIを叩いて投稿する流れに乗せたものです。
そこに、投稿記録による上書き判定と、60秒の書き込み間隔という2つの安全装置を付けています。
大した技術は使っていませんが、「Obsidianで書いて、コマンド1つでnoteの下書きにする」が成立すると、note用に書き直す作業がまるごと消えて、記事を書くハードルがだいぶ下がりました。
非公認のAPIに乗っている以上いつまで動くかは分かりませんが、同じところで面倒を感じている方がいれば、上の配布zipから試してみてください。
そのAPIをどうやって見つけたのかという調査のほうは、noteの非公式APIとエディタ仕様を調べた話に分けて書いています。
この記事が誰かの役に立てばうれしいです。
読み込んでいます…