ObsidianやAIのMarkdownをそのまま「はてなブログ」に投稿する自作アプリを作りました(Hatena Blog Publisher)
YAMLフロントマター付きのMarkdownファイルを選ぶだけで、はてなブログへ投稿・更新できるWindows向けの自作デスクトップアプリ Hatena Blog Publisher の紹介です。AtomPubとWSSE認証、本文をCDATAでそのまま送る仕組み、記事IDをファイルに書き戻して新規と更新を自動判定する設計を、実装コードの抜粋を交えて説明します。ユニットテスト44件の内訳と既知の制限も載せています。
目次
はじめに
ObsidianやAIに書かせたMarkdownを、はてなブログに載せるためだけにブラウザを開いてコピペしている、ということはありませんか。
私はこれが地味に面倒でした。
せっかくMarkdownで書いて、Markdownで保存しているのに、最後の最後でブラウザの管理画面を開いて、本文を貼り付けて、カテゴリを選んで、下書き保存ボタンを押す。
一度直すたびに同じことを繰り返す。
Markdownのまま最後まで運べないのか、と毎回思っていました。
そこで作ったのが Hatena Blog Publisher という、Windows向けのデスクトップアプリ(Python製)です。
YAMLフロントマター付きの .md ファイルを選んでボタンを押すと、はてなブログにそのまま投稿・更新できます。
Obsidianのノートでも、ChatGPTやClaudeに書かせた草稿でも、メモ帳で書いたものでも、.md でフロントマターさえ付いていれば編集ツールを問いません。
この記事は、「こんなアプリを作りました、機能はこれです」で終わらせず、内部でどう動いているのかを実際のコードを抜粋しながら書いてみます。
といっても、はてなブログの仕様に素直に乗っただけの小さなプロジェクトです。
コードの部分は雰囲気だけ眺めて読み飛ばしてもらっても話が繋がるように、抜粋の直後に必ず日本語で種明かしをしますので、「使えればいい」という方も安心して読み進めてください。
逆に技術に興味のある方は、読み終わる頃には自分で同じものを組める程度の解像度になるはずです。
コード一式を使ってみたいという方は、コメントでお知らせください。無料でお渡しします。
このアプリで何ができるか
一言でいうと、「フロントマター付きの .md を入力に、はてなブログへ投稿・更新する」だけのアプリです。
やることを絞った代わりに、その1点については摩擦をかなり減らしました。
- ファイルを選んでボタンを押すだけで、はてなブログに投稿される(ブラウザ不要)
- 本文はMarkdownのまま送るので、ローカルで見たとおりに表示される
- 一度投稿したファイルは、次から投稿し直すだけで自動的に「更新」になる
- フォルダを指定して、配下の
.mdをまとめて一括投稿できる - はてなID・ブログID・APIキーの組を複数登録して切り替えられる
特に効いているのは、最初の取っ手として覚えてもらえれば十分なのですが、「Markdownをそのまま運ぶ」と「新規か更新かを勝手に判断する」の2つです。
この記事はこの2点を軸に掘り下げていきます。
既存の手段との違い
「はてなブログに投稿するだけなら管理画面でいいのでは」と思われるかもしれません。
実際それで困らない方も多いと思います。
私が引っかかっていたのは下の表のあたりです。

このアプリは「編集ツールは何でもいい。.md さえあれば投稿できる」という割り切りに振っています。
書く場所(Obsidian、VS Code、メモ帳、AIチャットからのコピペ)と、投稿する手段を切り離したかった、というのが動機です。
解決したかった具体的な困りごと
作る前に私が踏んでいた面倒は、だいたい次の3つでした。
- AIに書かせたきれいなMarkdownを、管理画面に貼ると一手間かかる。カテゴリを選び直し、下書きにするかを選び、保存する。1記事ならいいのですが、何本も書くと毎回やるのがだるいです。
- 更新がとにかく管理しづらい。ローカルで直して、管理画面で該当記事を探して、本文を貼り替える。「これ何回目の修正だっけ」と、ローカルとブラウザの行き来で消耗していました。
- Obsidianの下書きが、結局コピペでしかブログに渡せない。Markdownで書いているのに、最後だけアナログなのがもったいない。
このうち2番目(更新の管理)が一番効いていて、後述する「記事IDをファイルに書き戻す」設計はここを潰すために入れました。
土台の概念:はてなブログは「AtomPub」で外から書ける
中身に入る前に、このアプリが乗っている前提を1つだけ説明します。
AtomPub(Atom Publishing Protocol)というのは、ブログの記事をHTTP経由で外部のプログラムから投稿・更新・取得するための、わりと古くからある標準的な仕組みです。
はてなブログもこれに対応していて、所定のURL(エンドポイント)にXMLを送ると記事が作れます。
このアプリがやっているのは、要するに次の流れです。
.md を読む
→ フロントマター(YAML)と本文に分ける
→ 本文をXMLに包む(Markdownのまま)
→ WSSE認証ヘッダーを付けて、はてなのAtomPubエンドポイントにHTTPで送る
→ 返ってきた記事IDを .md に書き戻す
ここから、この流れの「肝」になっている4箇所を、実際のコードを抜粋しながら順に見ていきます。
コードはすべて実装からそのままの抜粋で、コピペして動かす用ではなく「こう動いている」を見てもらうためのものです。
各コードの直後に日本語で種明かしを書くので、コードは飛ばして読んでも大丈夫です。
実装を読む①:認証はWSSEヘッダーを1個組み立てるだけ
はてなのAtomPubは WSSE という方式で認証します。
WSSEというのは、パスワード(ここではAPIキー)をそのまま送らず、毎回ランダムな値と時刻を混ぜてハッシュ化した「使い捨ての合言葉」を送る認証方式のことです。
これを作る関数が src/hatena_api.py の _wsse_header() で、中身はこれだけです。
def _wsse_header(self) -> str:
"""WSSE 認証ヘッダーを生成する。"""
jst = timezone(timedelta(hours=9), "JST")
nonce = uuid.uuid4().hex
created = datetime.now(jst).isoformat()
digest = hashlib.sha1(
f"{nonce}{created}{self.api_key}".encode("utf-8")
).digest()
password_digest = base64.b64encode(digest).decode("utf-8")
return (
f'UsernameToken Username="{self.hatena_id}", '
f'PasswordDigest="{password_digest}", '
f'Nonce="{base64.b64encode(nonce.encode()).decode()}", '
f'Created="{created}"'
)
日本語にすると、やっていることは次の4ステップです。
- nonce(使い捨ての乱数)を作る。
uuid.uuid4().hexで毎回違うランダムな文字列を生成します。これが「リプレイ攻撃」(同じ通信を盗み見て再送する攻撃)を防ぐ鍵で、後述しますが毎回新しく作って使い回しません。 - created(時刻)を作る。日本時間(JST、UTC+9)の現在時刻をISO 8601形式の文字列にします。
- 3つを繋いでハッシュ化する。nonce + created + APIキー を連結し、SHA-1 というハッシュ関数に通して、その結果を base64 で文字列化します。これが PasswordDigest(合言葉のダイジェスト)です。APIキーそのものはネットワークに出ていかず、ハッシュ化された結果だけが流れます。
- ヘッダー文字列に組み立てる。ユーザー名(はてなID)、ダイジェスト、base64化したnonce、時刻を、
X-WSSEヘッダーに載せる1行の形に整えます。
正直、WSSEは初見だと「nonceをbase64するのはハッシュの前?後?」みたいなところで地味に詰まるのですが、はてなの仕様に合わせるとこの順番に落ち着きました。
ハッシュに入れるnonceは生の hex 文字列、ヘッダーに載せるnonceはそれをbase64したもの、という非対称が一度ハマったポイントです。
ここはユニットテストで「呼ぶたびにヘッダーが変わる(=nonceとcreatedが毎回変わる)」ことを固定してあるので、うっかりキャッシュしてしまう事故は防げています。
実装を読む②:本文は変換せず、CDATAで「そのまま」包む
このアプリの一番の売りは「Markdownをそのまま送る」ことです。
多くのブログ投稿ツールはローカルでMarkdownをHTMLに変換してから送りますが、はてなブログには「Markdownモード」があり、Markdownの文字列を直接受け取って解釈してくれるので、こちらで変換する必要がありません。
送るXML(ペイロード)を組み立てるのが _build_payload() で、中心はこの部分です。
title_escaped = escape(title)
content = content_markdown.strip()
category_tags = ""
if categories:
for cat in categories:
category_tags += f' <category term="{escape(cat)}" />\n'
draft_value = "yes" if draft else "no"
return (
'<?xml version="1.0" encoding="utf-8"?>\n'
f'<entry xmlns="{ATOM_NS}" xmlns:app="{APP_NS}">\n'
f" <title>{title_escaped}</title>\n"
f" <author><name>{self.hatena_id}</name></author>\n"
f' <content type="text/x-markdown"><![CDATA[{content}]]></content>\n'
f"{category_tags}"
" <app:control>\n"
f" <app:draft>{draft_value}</app:draft>\n"
" </app:control>\n"
"</entry>\n"
)
日本語で読みどころを3つ挙げます。
- 本文は
<content type="text/x-markdown"><![CDATA[本文]]></content>で送る。typeをtext/x-markdownにすることで「これはMarkdownです」とはてな側に伝えています。中身は<![CDATA[ ... ]]>という「この中はXMLとして解釈しないでください」という囲いで包むので、本文に不等号・&・コード・数式が入ってもXMLとして壊れず、そのままの文字列で届きます。多くのツールがMarkdownをHTML変換してから送るのに対し、変換せずそのまま運ぶのがこのアプリの核心です。(テストでは、特殊文字入りの本文がCDATA内にそのまま入ることを検証しています) - タイトルとカテゴリはXMLエスケープする。CDATAの外にあるXML要素・属性値なので、
<や&がそのままだとXMLが壊れます。だからescape()で<→<のように変換しています。本文は素通し・タイトルとカテゴリはエスケープ、という非対称がポイントです。(テストでは、タイトルの<script>が<script>になることを本文とは別に検証しています) - 下書きか公開かは
<app:draft>で1語だけ切り替える。yesなら下書き、noなら公開です。GUIの「下書きとして保存」チェックの状態が、この1語に変換されてXMLに乗ります。
ちなみに content.strip() で本文の前後の空白を落としているのも地味な配慮です。
Markdownファイルはフロントマターの直後に空行が入りがちで、それをそのまま送ると先頭に余計な余白が出るので、ここで掃除しています。
実装を読む③:新規か更新かは「フロントマターのID」で勝手に決まる
私が一番欲しかったのが、ここです。
「この記事は新規投稿?それとも既存記事の更新?」を人間が管理しなくて済む仕組み。
実現方法はシンプルで、投稿に成功したら返ってきた記事IDをファイル自身に書き戻すだけです。
まず、新規投稿が成功したときにレスポンスから記事IDを取り出す部分(post_new_entry() の末尾)。
if response.status_code != 201:
logger.error(" 投稿に失敗しました: %s\n%s", response.status_code, response.text)
return None
root = ET.fromstring(response.text)
entry_id_el = root.find(f"{{{ATOM_NS}}}id")
if entry_id_el is not None and entry_id_el.text:
return entry_id_el.text.split("-")[-1]
return None
日本語にすると、「HTTPの応答が 201(Created=作成成功)でなければ失敗として None を返す。成功なら、返ってきたXMLから <id> 要素を取り出す」です。
少し説明が要るのが最後の .split("-")[-1] です。
はてなが返す記事IDは、tag:blog.hatena.ne.jp,2013:blog-(ユーザー名)-(数字) のように、ハイフン区切りの長い文字列で返ってきます。
このうち欲しいのは末尾の数字部分だけなので、ハイフンで分割して一番最後の塊を取る、というのがこの1行です。
実際の応答を模したテストで「末尾の数字だけが抜けること」を確認しています。
そして、取り出したIDをファイルに書き戻すのが update_markdown_file() です。
front_matter = dict(front_matter) if front_matter else {}
front_matter["hatena_entry_id"] = str(entry_id)
if entry_info:
for key in ("published", "updated"):
if key in entry_info:
front_matter[key] = entry_info[key]
front_matter_yaml = yaml.dump(front_matter, allow_unicode=True, sort_keys=False)
new_content = f"---\n{front_matter_yaml}---\n{original_content}"
日本語で押さえどころは2つあります。
hatena_entry_idと公開・更新日時をフロントマターに足してから書き戻す。元のフロントマター(カテゴリなど)はそのまま残し、そこに記事IDと日時を追記する形です。1行目のdict(front_matter)がさりげなく大事で、渡された辞書をコピーしてから書き換えています。元の辞書を直接いじると、呼び出し元が持っているデータにも勝手に副作用が出てしまうので、それを避けています(「入力の辞書を書き換えない」こともテストで固定済みです)。yaml.dump(..., allow_unicode=True, sort_keys=False)で書き出す。allow_unicode=Trueは日本語のカテゴリ名をエスケープせずそのまま書くため、sort_keys=Falseはキーの並び順を勝手にアルファベット順に変えないためです。
これで、一度投稿したファイルには hatena_entry_id が刻まれます。
次に同じファイルを処理すると、フロントマターにIDがあるので更新ルートに入り、update_existing_entry() が /atom/entry/{記事ID} に対して PUT(更新のHTTPメソッド)を投げます。
新規はPOST、更新はPUTという使い分けが、フロントマターのIDの有無だけで自動的に切り替わる、という仕掛けです。
「どれが新規でどれが更新か」を覚えておく必要が消えるのが、このアプリで一番気に入っている部分です。
実装を読む④:フロントマターの切り出しは「先頭の --- だけ」を見る
最後に、入口の部分です。
.md を読んでフロントマター(YAML)と本文に分けるのが parse_markdown_file() で、核心はこの数行です。
parts = content.split("---", 2)
if len(parts) >= 3 and parts[0] == "":
try:
front_matter = yaml.safe_load(parts[1]) or {}
body = parts[2].lstrip()
return front_matter, body
except yaml.YAMLError as e:
logger.error("フロントマターの解析エラー: %s", e)
return {}, content
return {}, content
日本語にすると、「ファイル全体を --- で最大2回だけ分割し、先頭が空っぽ(=ファイルが --- で始まっている)なら、1つ目の塊をYAMLとして読み、2つ目以降を本文とする」です。
読みどころは3つあります。
split("---", 2)の2が効いている。これは「分割は最大2回まで」という指定です。こうすると本文の中に水平線として---が出てきても、3つ目以降は分割されず本文側にまとめて残ります。フロントマターの閉じ---の後ろにある---を巻き込まない、という地味だけど大事な線引きです(本文中に---があるケースもテストで確認しています)。- 発動条件は
parts[0] == ""(=1文字目から---)。ここがこのアプリの「BOM問題」の正体です。BOMというのは、Windows界隈のUTF-8ファイルの先頭にこっそり付いていることがある、目印用の数バイトのデータです。これが残っていると、ファイルの実際の先頭が「目に見えないBOM+---」になり、parts[0]が空にならないためフロントマターと認識されません。仕様として割り切っていて、テストでも「BOM付きだとフロントマター扱いされない」と明文化してあります。 - YAMLが壊れていたら、空の辞書+全文を返して落ちない。フロントマターのYAML記法が壊れているファイルを渡されても、例外で止まらず「フロントマター無し」として扱って処理を続けます。一括投稿で1ファイルだけ壊れていても、そこで全部止まらないようにするための保険です。
どう作ったか(技術構成)
ここまでの部品が、どのファイルにどう収まっているかをまとめておきます。
技術に興味のある方が、構成を再現する手がかりになればと思います。
hatena-blog-publisher/
├── src/
│ ├── hatena_api.py # AtomPubクライアント+Markdown入出力(中核)
│ ├── config_manager.py # 複数アカウント情報の読み書き
│ ├── gui.py # PyQt5のGUI本体
│ └── cli.py # コマンドライン版エントリポイント
├── tests/
│ └── test_edge_cases.py # ユニットテスト
├── samples/sample_post.md # 動作確認用サンプル
├── setup.bat / run.bat # セットアップ・起動用バッチ
└── requirements.txt
役割分担はこうなっています。
hatena_api.py:ここまで抜粋してきた中核です。HatenaBlogAPIクラス(WSSE認証・AtomPub通信)と、MarkdownProcessorクラス(フロントマターの読み書き)の2つが入っています。config_manager.py:ConfigManagerクラスが、はてなID・ブログID・APIキーの組をblog_accounts.jsonというファイルに読み書きします。複数アカウントを登録して切り替えられるのはこれの担当です。最初に登録したアカウントが自動的に「使用中」になる、といった細かい挙動もテストで固定しています。gui.py:PyQt5(Pythonの定番GUIライブラリ)製の画面です。「単一ファイル」「複数ファイル」の2タブ構成で、ファイル選択・カテゴリ編集・プレビュー・実行をここで受けて、処理はhatena_api.pyを呼ぶだけ、という薄い作りにしています。cli.py:GUIを開かずコマンドラインから投稿するための入口です。config.env(認証情報のテキストファイル)から自動でアカウントを読み込み、引数で渡した.mdを投稿します。--publishを付けなければ下書きです。
依存ライブラリは4つだけです。
requirements.txt の中身は次のとおりで、いずれもPythonでは定番のものです。
PyQt5>=5.15 # GUI
PyYAML>=6.0 # フロントマターのYAML解析
python-dotenv>=1.0 # config.env の読み込み(CLI用)
requests>=2.31 # HTTP通信
データフロー(実際の関数名で)
一連の流れを実装の言葉で追うと、こうなります。
parse_markdown_file()で.mdを (フロントマター, 本文) に分解する(実装を読む④で見た関数)- フロントマターに
hatena_entry_idがあるかどうかで分岐する - 無ければ
post_new_entry()→ 内部で_get_post_url()(投稿先URLの取得)→_build_payload()(XML生成)→_wsse_header()を付けて POST - 投稿成功なら
get_entry()で公開日時などを取り、update_markdown_file()でファイルにIDを書き戻す - あれば
update_existing_entry()で/atom/entry/{記事ID}に PUT
投稿先URLを毎回取りに行くと無駄なので、_get_post_url() は一度取得した投稿用エンドポイントをインスタンス内にキャッシュします(2回目以降はHTTP通信が走らないことをテストで確認しています)。
動作の証拠:何がテストで固定されているか
このアプリには tests/test_edge_cases.py にユニットテストが44件あります(標準ライブラリの unittest 製)。
外部のはてなAPIへの通信はモック(偽の応答を返す差し替え)にして、ローカルのロジックの境界を固めてあります。
観点はだいたい次のとおりです。
- フロントマター解析:通常/無し/空/壊れたYAML/本文中の
---/CRLF改行/BOM付き/日本語・絵文字 - ペイロード生成:タイトルとカテゴリのXMLエスケープ/本文のCDATA素通し/下書き・公開フラグ/本文の前後空白除去
- WSSE:ヘッダーの形式/呼ぶたびに値が変わること
- HTTP層(モック):投稿URL取得の成功・失敗・キャッシュ/投稿成功時のID抽出/失敗時に
None/更新の成功・失敗/記事情報の解析・不正XML - アカウント管理:追加・削除・切替・永続化・壊れたJSONで落ちないこと
- ファイル名ソート:
数字-数字パターンの並び
「壊れた入力でも落ちない」「Markdownは加工せず素通しする」「nonceを使い回さない」といった割り切りや前提を、そのままテストとして書いて固定してあるのがポイントです。
半年後の自分が「これバグでは?」と疑ったときの申し送りにもなります。
なお正直に書いておくと、HTTP通信にタイムアウトを設定しておらず、失敗時のリトライもありません。
ネットワークが切れると待ち続ける可能性があるので、ここは現状の割り切り(既知の制限)です。
導入と設定
導入は「展開して setup.bat をダブルクリック → config.env に認証情報を書く → run.bat をダブルクリック」の流れです。
コマンド操作は要りません。
- 必要なもの:Windows 10/11 と Python 3.9以上(インストール時に「Add Python to PATH」にチェック)、はてなブログのエンドポイントに届くネットワーク。
setup.batが仮想環境の作成と依存ライブラリのインストールまでやってくれます(Pythonコード自体はMac/Linuxでも動きますが、バッチファイルはWindows専用です)。 - 認証情報の取り方:はてなブログの「ダッシュボード → 設定 → 詳細設定」の AtomPub セクションに、
https://blog.hatena.ne.jp/(はてなID)/(ブログID)/atomという形のルートエンドポイントURLが出ています。ここからはてなIDとブログIDが読み取れます。APIキーは、はてなの「アカウント設定」ページ下部に表示されているものを使います。これらをconfig.envという設定ファイルに書きます(GUIの設定ダイアログから登録することもできます)。 - 編集モードの前提:はてなブログ側の編集モードを 「Markdownモード」 にしておく必要があります。「はてな記法」「見たまま」モードのままだと、送ったMarkdownが解釈されず文字のまま表示されます。
APIキーはブログへの投稿権限を持つ大事な情報なので、第三者と共有しないでください。
漏れたら、はてなのアカウント設定から再発行できます。
なお認証情報を保存する config.env と blog_accounts.json は .gitignore で除外済みです。

設定ダイアログ。複数のはてなブログアカウントを登録して切り替えられる。
使い方
操作はどのシナリオも「選ぶ → 設定する → 実行する」だけです。
私が実際に回している3パターンを挙げます。
シナリオA:AIに書かせた記事を1本だけ投稿する
- ChatGPTやClaudeに「このトピックでブログの草稿をMarkdownで書いて」と頼みます
- 出力を
.mdとして保存し、先頭にフロントマター(categoriesだけでOK)を足します - 「単一ファイル」タブでそのファイルを選びます(フロントマターのカテゴリが自動でカテゴリ欄に入ります)
- まずは「下書きとして保存」にチェックを入れたままアップロードします
- はてなブログの下書き一覧に入っているのを確認し、中身を見てから公開します
初回は下書きで挙動を確認するのが安心です。
成功すると .md に記事IDが書き戻されるので、次からは同じファイルを投稿するだけで自動的に「更新」になります。

単一ファイルタブ。ファイルを選び、カテゴリと下書き/公開を設定して、ボタンを押すだけ。
シナリオB:Obsidianのvaultから直接選んで投稿する
- Obsidianで記事用ノートを作り、先頭にフロントマター(カテゴリ)を書きます
- 「複数ファイル」タブで、Obsidianのブログ用フォルダ(例:
vault/blog/)を指定します - 配下の
.mdが一覧表示されるので、投稿したい記事だけチェックします - 必要ならカテゴリを編集し、下書きで一括処理します
vault全体を指定すると内部メモまで全部出てくるので、ブログ用にフォルダを切っておくのがおすすめです。
私はObsidianに blog/ フォルダを作って、そこにブログ用ノートだけ置いています。
シナリオC:連載記事を順番にまとめて下書き登録する
- ファイル名を
01-01_intro.md、01-02_overview.md、02-01_setup.mdのように番号付きで揃えます - 「複数ファイル」タブでフォルダを選びます。ファイル名の
数字-数字を見て自動で番号順にソート表示されます(番号が無いファイルは末尾に回ります) - 全選択し、必要なら「選択行のカテゴリを一括設定」で同じカテゴリをまとめて当てます
- 下書きで一括処理します
処理中は1記事ごとに「成功」「失敗」がログとして流れ、最後に「成功 X件 / 失敗 Y件」が出ます。
途中で1記事が失敗しても、そこで止まらず次のファイルに進む作りなので、どこで詰まったかを後から確認できます。

複数ファイルタブ。フォルダを指定すると配下の .md が一覧表示され、記事ID・カテゴリを表で確認・編集できる。
よくあるトラブルとQ&A
Q. 投稿時に401エラー(Unauthorized)が出る
A. 認証まわりが原因のことが多いです。APIキーをコピーしたときに前後の空白が混ざった、はてな側でキーを再発行して古いキーが残っている、などを確認してください。WSSEは時刻も使うので、PCの時計が大きくずれているのも一応疑ってみてください。
Q. 投稿時に404エラー(Not Found)が出る
A. ブログIDの表記ミスが多いです。username.hatenablog.com のようなフルドメインになっているか、hateblo.jp / hatenadiary.jp などサブドメインの違いがないかを、設定→詳細設定のAtomPubのURLと突き合わせてください。
Q. 見出しが崩れる/数式が文字のまま出る
A. はてなブログの編集モードが「Markdownモード」になっているかを確認してください。このアプリは本文をMarkdownのまま送る前提なので、「はてな記法」「見たまま」モードだと、送ったMarkdownがそのまま文字列として表示されます。
Q. 記事タイトルがファイル名のままになる
A. 仕様です。タイトルはファイル名(拡張子を除いたもの)が使われます。日本語タイトルにしたいときは、ファイル名自体を日本語にしてください。
Q. フロントマターが本文に表示されてしまう
A. ファイルがBOM付きUTF-8で保存されている可能性が高いです(実装を読む④参照)。BOMが先頭にあるとフロントマターとして認識されません。「BOMなしUTF-8」で保存し直してください。
Q. 一括処理で一部のファイルだけ失敗する
A. 個別のファイルでエラーが出ても処理は次へ進み、最後に成功・失敗の件数が出ます。失敗したファイルは「単一ファイル」タブで単独投稿すると原因を特定しやすいです(フロントマターのYAMLが壊れているケースが多いです)。
Q. ネットワークが不安定な環境でも大丈夫ですか
A. 現状、HTTP通信にタイムアウトとリトライを入れていません(既知の割り切り)。回線が切れると待ち続けることがあるので、不安定な環境では1件ずつ様子を見ながら投稿するのが無難です。
Q. Macで動きますか
A. Pythonコード自体はクロスプラットフォームなので動きます。ただし setup.bat / run.bat はWindows専用なので、Mac/Linuxでは手動で仮想環境を作って起動する形になります。
まとめ
いかがでしたか。
今回は、ObsidianやAIのMarkdownをそのままはてなブログに投稿・更新できる自作デスクトップアプリ Hatena Blog Publisher を、内部の仕組みまで開けて紹介しました。
要点を振り返ると、
- はてなブログの AtomPub に WSSE認証で接続し、本文を
text/x-markdownの CDATAでそのまま送るので、ローカルで見たままに表示される(実装を読む①②) - 投稿成功時に記事IDをファイルのフロントマターに書き戻すことで、新規(POST)か更新(PUT)かをIDの有無だけで自動判定する(実装を読む③)
- 入口のフロントマター解析は先頭の
---だけを見る割り切りで、本文中の---やYAMLの破損、一括処理での1件失敗にも耐える(実装を読む④)。これらの前提を44件のユニットテストで固定している
中身は、はてなブログの仕様に素直に乗っただけの小さなアプリです。
それでも、「Obsidianで書いて、軽く整えて、ボタン1つで投稿する」が成立すると、思いついたときにすぐ書けるようになって、ブログのハードルがだいぶ下がりました。
似た悩み(AIに書かせた長文をブログに移すのが面倒、Obsidianの下書きをコピペしたくない、更新のたびに管理画面を開くのがつらい)を持っている方で、コード一式を使ってみたいという方は、コメントでお知らせください。無料でお渡しします。
この記事が誰かの役に立てばうれしいです。