送らなかったフィールドは、空で上書きされる。これが、このAPIで最も事故が起きる場所だ。
現在値を取得してマージする——それだけで防げます
📋 この記事でわかること
- 公開に使うエンドポイントと必須フィールド
- 差分更新ができない仕様と、その回避策
- 現在値を取得してマージする安全な実装
- 有料記事の分割設定(無料部分と有料部分)
- 公開直後に本文が一時的にnullになる現象
- 下書きに戻せる条件と、403が返る条件
下書きの作成まで自動化できたら、次は公開です。ただしここからは事故が起きやすい領域になります。noteの公開APIは差分更新に対応しておらず、毎回すべてのフィールドを送る必要があります。タイトルだけ変えるつもりで送信したら、ハッシュタグと価格設定が消えていた——こうした事故が起きるのは、この仕様が原因です。この記事では、その落とし穴を避けながら安全に公開する実装を解説します。下書き作成の記事の続きにあたる内容です。
この記事を実行する前に
- 公開処理は実際に記事が世に出ます。テストは必ず自分の下書きで行ってください
- 有料記事で販売実績があると、下書きに戻せなくなります
- フィールドを送り忘れると、その項目は消えます
- この記事のコードは既定で「確認のみ」で動くようにしてあります
公開に使うエンドポイント
公開は PUT /api/v1/text_notes/{数値id} で行います。下書き保存の draft_save とは別のエンドポイントです。
| 操作 | メソッド・パス | 特徴 |
|---|---|---|
| 下書き保存 | POST /api/v1/text_notes/draft_save | 公開されない。部分的な指定でよい |
| 公開・編集 | PUT /api/v1/text_notes/{id} | 全フィールド必須。差分更新できない |
| 下書きに戻す | POST /api/v2/notes/{key}/change_status | 条件により403 |
| 削除 | DELETE /api/v1/notes/{id} | 論理削除 |
主なフィールドを整理します。これらは「送らなければ空になる」ものとして扱ってください。
| フィールド | 内容 | 省略したときの挙動 |
|---|---|---|
| status | "published" で公開 | 公開されない |
| name | タイトル | タイトルが消える |
| free_body | 無料で読める本文 | 本文が消える |
| pay_body | 有料部分の本文 | 有料部分が消える |
| separator | 有料ラインの位置 | 分割位置が失われる |
| price | 価格(0なら無料) | 無料になる |
| hashtags | ハッシュタグの配列 | タグが全部消える |
| magazine_ids | 追加するマガジン | マガジンから外れる |
| disable_comment | コメント欄の無効化 | 設定が戻る |
なぜ事故が起きるのか
一般的なREST APIでは、PUTやPATCHで送ったフィールドだけが更新されると期待します。しかしこのAPIは違います。送られなかったフィールドは「空にしてほしい」という指示として解釈されます。
"""
これは「やってはいけない例」です。
タイトルだけ変えるつもりで送ると、他の設定がすべて消えます。
"""
# NG: これを送ると…
payload = {
"status": "published",
"name": "新しいタイトル",
}
# 結果:
# 本文が消える
# ハッシュタグが全部消える
# 価格設定が消える(有料記事が無料になる)
# マガジンから外れる
#
# 「タイトルだけ更新される」わけではない。
対策はシンプルです。更新前に現在の値をすべて取得し、変更したい部分だけ差し替えてから送る——このマージ処理を必ず挟みます。
現在値を取得する
マージの前提として、記事の現在の状態を取得する必要があります。編集画面と同じ情報を得るには、クエリパラメータの指定が必要です。
"""
記事の現在値を、編集者視点で取得する。
draft=true を付けると下書きの内容も取得できる。
"""
from note_auth_client import NoteAuthClient, CookieExpiredError
def fetch_editable(client: NoteAuthClient, note_key: str) -> dict:
"""編集用の記事データを取得する"""
data = client.get_json(
f"/api/v3/notes/{note_key}",
params={"draft": "true", "draft_reedit": "false"},
)
if not data:
raise RuntimeError(f"記事を取得できません: {note_key}")
return data
def show_current(data: dict):
print("=== 現在の状態 ===")
print(" id :", data.get("id"))
print(" key :", data.get("key"))
print(" status :", data.get("status"))
print(" タイトル :", data.get("name"))
print(" 価格 :", data.get("price"))
print(" separator:", data.get("separator"))
body = data.get("body") or ""
free_body = data.get("free_body") or ""
pay_body = data.get("pay_body") or ""
print(f" body : {len(body)} 文字")
print(f" free_body: {len(free_body)} 文字")
print(f" pay_body : {len(pay_body)} 文字")
tags = data.get("hashtags") or []
print(f" タグ : {len(tags)} 個")
print(" 自分の記事:", data.get("is_my_note"))
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
# 自分の記事キーを指定する
note = fetch_editable(client, "nXXXXXXXXXXXX")
show_current(note)
except CookieExpiredError as e:
print("認証エラー:", e)
bodyとfree_body/pay_bodyの関係
取得時は body に本文全体が入っていますが、更新時は free_body(無料部分)と pay_body(有料部分)に分けて送ります。無料記事の場合は全文を free_body に入れ、pay_body は空にします。この対応関係を理解していないと、更新後に本文が消えたように見えます。
安全に公開するクラス
取得・マージ・送信をまとめたクラスを作ります。これが実務で使う本体になります。
"""
noteの公開・編集を安全に行うクラス。
必ず現在値を取得してからマージして送る。
"""
import logging
from note_auth_client import NoteAuthClient
logger = logging.getLogger(__name__)
# PUTで送るべきフィールドの一覧
PAYLOAD_FIELDS = [
"status", "name", "free_body", "pay_body", "separator",
"price", "hashtags", "magazine_ids", "disable_comment",
"is_refund", "circle_permissions", "limited_note",
]
class NotePublisher:
def __init__(self, client: NoteAuthClient):
self.client = client
# ---- 取得 ----
def fetch(self, note_key: str) -> dict:
"""編集用のデータを取得する"""
data = self.client.get_json(
f"/api/v3/notes/{note_key}",
params={"draft": "true", "draft_reedit": "false"},
)
if not data:
raise RuntimeError(f"記事を取得できません: {note_key}")
if not data.get("is_my_note", True):
raise PermissionError("自分の記事ではありません")
return data
# ---- ペイロード構築 ----
def build_payload(self, current: dict, **changes) -> dict:
"""
現在値をベースに、変更したい項目だけ上書きした
フルペイロードを作る。
"""
payload = {}
for field in PAYLOAD_FIELDS:
if field in current and current[field] is not None:
payload[field] = current[field]
# 本文の扱い(bodyしか無い場合はfree_bodyへ回す)
if "free_body" not in payload:
payload["free_body"] = current.get("body") or ""
payload.setdefault("pay_body", "")
payload.setdefault("price", 0)
payload.setdefault("hashtags", [])
payload.setdefault("magazine_ids", [])
payload.setdefault("disable_comment", False)
# 呼び出し側の指定で上書き
payload.update(changes)
return payload
# ---- 更新 ----
def update(self, note_key: str, dry_run: bool = True,
**changes) -> dict:
"""
記事を更新する。
dry_run=True のときは送信内容を表示するだけ。
"""
current = self.fetch(note_key)
note_id = current.get("id")
payload = self.build_payload(current, **changes)
self._describe(current, payload)
if dry_run:
print("\n[確認モード] 実際には送信していません")
return {"dry_run": True, "payload": payload}
res = self.client.request(
"PUT", f"/api/v1/text_notes/{note_id}", json=payload
)
if res.status_code not in (200, 201):
logger.error("更新失敗 HTTP %s: %s",
res.status_code, res.text[:300])
return {"ok": False, "status": res.status_code}
print("\n更新しました")
return {"ok": True, "key": note_key}
def publish(self, note_key: str, dry_run: bool = True,
**changes) -> dict:
"""下書きを公開する"""
changes["status"] = "published"
return self.update(note_key, dry_run=dry_run, **changes)
def unpublish(self, note_key: str) -> bool:
"""
公開記事を下書きに戻す。
有料・販売実績あり・メンバーシップ紐付きの場合は403。
"""
res = self.client.request(
"POST",
f"/api/v2/notes/{note_key}/change_status",
json={"status": "draft"},
)
if res.status_code == 403:
print("下書きに戻せません(有料記事・販売実績などの制約)")
return False
if res.status_code not in (200, 201, 204):
print(f"失敗: HTTP {res.status_code}")
return False
print("下書きに戻しました")
return True
# ---- 表示 ----
def _describe(self, current: dict, payload: dict):
print("=== 送信内容の確認 ===")
print(" 記事 :", current.get("name"))
print(" 現在 :", current.get("status"))
print(" 送信後 :", payload.get("status"))
print(" 価格 :", payload.get("price"))
free_len = len(payload.get("free_body") or "")
pay_len = len(payload.get("pay_body") or "")
print(f" 無料部分: {free_len} 文字")
print(f" 有料部分: {pay_len} 文字")
tags = payload.get("hashtags") or []
print(f" タグ : {len(tags)} 個")
# 消えるフィールドがないか警告する
for field in ("free_body", "name"):
if not payload.get(field):
print(f" 警告: {field} が空です")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
pub = NotePublisher(client)
# まずは確認モードで実行する
pub.publish("nXXXXXXXXXXXX", dry_run=True)
dry_run を既定でTrueにしてあるのが重要な設計です。意図せず公開してしまう事故を、仕組みで防ぎます。
▶ 投稿する時刻を制御したい場合は、スケジューラを自作する方法があります。
予約投稿をPythonで実装する手順です。
下書きから公開までの流れ
実際の運用では、下書き作成から公開までを一連の流れとして書きます。
"""
Markdownファイルから下書きを作り、確認後に公開する。
python 02_draft_to_publish.py article.md # 下書きまで
python 02_draft_to_publish.py article.md --publish # 公開まで
"""
import os
import sys
import time
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
from note_publisher import NotePublisher
from simple_md import convert
from frontmatter import parse, extract_title, strip_title_heading
def load_article(path: str) -> dict:
with open(path, encoding="utf-8") as f:
content = f.read()
meta, body = parse(content)
fallback = os.path.splitext(os.path.basename(path))[0]
return {
"title": extract_title(meta, body, fallback),
"html": convert(strip_title_heading(body)),
"tags": meta.get("tags") or [],
"price": int(meta.get("price") or 0),
}
def main(path: str, do_publish: bool):
if not os.path.exists(path):
print(f"ファイルがありません: {path}")
return
article = load_article(path)
print("=== 読み込んだ内容 ===")
print(" タイトル:", article["title"])
print(f" 本文 : {len(article['html'])} 文字(HTML)")
print(" タグ :", ", ".join(article["tags"]) or "なし")
print(" 価格 :", article["price"])
try:
client = NoteAuthClient()
client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
draft = NoteDraft(client)
# 重複チェック
if draft.find_by_title(article["title"]):
print("同名の下書きが既にあります。中止します")
return
result = draft.create(article["title"], article["html"])
print(f"\n下書きを作成しました: {result['edit_url']}")
if not do_publish:
print("公開する場合は --publish を付けて実行してください")
return
time.sleep(2.0)
pub = NotePublisher(client)
pub.publish(
result["key"],
dry_run=False,
name=article["title"],
free_body=article["html"],
pay_body="",
price=article["price"],
hashtags=article["tags"],
)
print(f"公開URL: https://note.com/notes/{result['key']}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("使い方: python 02_draft_to_publish.py article.md [--publish]")
sys.exit(1)
main(sys.argv[1], "--publish" in sys.argv)
有料記事を作る
有料記事では、本文を無料部分と有料部分に分けて指定します。読者に見せる部分と、購入後に見える部分の境界を決める処理です。
"""
有料記事を作成する。
本文を区切り文字で分割し、無料部分と有料部分に振り分ける。
"""
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
from note_publisher import NotePublisher
from simple_md import convert
# 原稿内でこの行を境に、無料部分と有料部分を分ける
SPLIT_MARKER = "<!-- paywall -->"
def split_body(md_text: str) -> tuple:
"""原稿を無料部分と有料部分に分割する"""
if SPLIT_MARKER not in md_text:
return convert(md_text), ""
free_md, pay_md = md_text.split(SPLIT_MARKER, 1)
return convert(free_md.strip()), convert(pay_md.strip())
def create_paid_note(client: NoteAuthClient, title: str,
md_text: str, price: int,
tags: list = None, dry_run: bool = True) -> dict:
"""有料記事を作成する"""
free_html, pay_html = split_body(md_text)
if not pay_html:
raise ValueError(
f"有料部分がありません。原稿に {SPLIT_MARKER} を入れてください"
)
print("=== 作成する有料記事 ===")
print(" タイトル :", title)
print(" 価格 :", f"{price:,} 円")
print(f" 無料部分 : {len(free_html)} 文字")
print(f" 有料部分 : {len(pay_html)} 文字")
ratio = len(free_html) / (len(free_html) + len(pay_html)) * 100
print(f" 無料の割合: {ratio:.1f}%")
if ratio < 20:
print(" 注意: 無料部分が少なすぎます。購入判断ができません")
if ratio > 80:
print(" 注意: 無料部分が多すぎます。購入の動機が弱くなります")
if dry_run:
print("\n[確認モード] 実際には作成していません")
return {"dry_run": True}
draft = NoteDraft(client)
result = draft.create(title, free_html + pay_html)
pub = NotePublisher(client)
pub.publish(
result["key"],
dry_run=False,
name=title,
free_body=free_html,
pay_body=pay_html,
price=price,
hashtags=tags or [],
)
return result
if __name__ == "__main__":
sample = """## この記事について
ここは誰でも読める無料部分です。
何が書いてあるかを説明します。
<!-- paywall -->
## 本編
ここから先は購入した人だけが読めます。
"""
try:
client = NoteAuthClient()
client.verify()
create_paid_note(
client,
title="有料記事のテスト",
md_text=sample,
price=500,
tags=["テスト"],
dry_run=True, # 実際に作るときは False
)
except CookieExpiredError as e:
print("認証エラー:", e)
有料記事は後戻りが難しい
一度でも購入された有料記事は、下書きに戻せません。change_status を呼んでも403が返ります。これは購入者の閲覧権を守るための仕様です。有料記事を自動化する場合は、公開前に内容を必ず目視で確認してください。誤字レベルなら公開後も編集できますが、記事そのものを取り下げることはできなくなります。価格設定の考え方は値段設定の記事を参照してください。
公開直後に本文がnullになる現象
公開処理の直後に記事を取得すると、本文が空で返ってくることがあります。これは既知の挙動で、少し待つと正常な値が返るようになります。
"""
公開後に内容が正しく反映されたかを検証する。
公開直後は一時的に本文がnullになることがあるため、
リトライしながら確認する。
"""
import time
from note_auth_client import NoteAuthClient
def verify_published(client: NoteAuthClient, note_key: str,
expected_title: str = "",
max_retry: int = 5,
wait_sec: float = 3.0) -> bool:
"""公開結果を検証する"""
for attempt in range(1, max_retry + 1):
data = client.get_json(f"/api/v3/notes/{note_key}")
status = data.get("status")
name = data.get("name") or ""
body = data.get("body") or ""
print(f" 試行 {attempt}: status={status} / "
f"title={len(name)}文字 / body={len(body)}文字")
if status == "published" and body:
if expected_title and name.strip() != expected_title.strip():
print(" 警告: タイトルが想定と違います")
print(f" 想定: {expected_title}")
print(f" 実際: {name}")
print(" → 公開を確認しました")
return True
if attempt < max_retry:
time.sleep(wait_sec)
print(" → 確認できませんでした。画面で直接確認してください")
return False
if __name__ == "__main__":
client = NoteAuthClient()
client.verify()
print("=== 公開の検証 ===")
verify_published(client, "nXXXXXXXXXXXX")
リトライを入れておけば、この現象に振り回されずに済みます。1回の取得結果だけで「失敗した」と判断しないのがコツです。
▼ RECOMMENDATION ▼
公開まで自動化しても、読者は自動では来ない
記事の公開までスクリプト化できれば、作業の手間は大きく減ります。しかし公開した記事を読む人を増やすには、別の仕組みが必要です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回します。コードの保守も不要で、設定するだけで24時間動きます。noteと違ってASPアフィリエイトも使えるため、収益化の選択肢が広がります。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
公開済み記事を編集する
公開後の修正も同じエンドポイントを使います。ここでもマージ処理が必須です。
"""
公開済み記事の一部だけを安全に編集する。
現在値を保持したまま、指定した項目だけ変更する。
"""
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_publisher import NotePublisher
def add_tags(pub: NotePublisher, note_key: str,
new_tags: list, dry_run: bool = True):
"""既存のタグを保持したまま、タグを追加する"""
current = pub.fetch(note_key)
existing = []
for h in current.get("hashtags") or []:
if isinstance(h, str):
existing.append(h)
elif isinstance(h, dict):
inner = h.get("hashtag", h)
name = inner.get("name")
if name:
existing.append(name)
merged = existing[:]
for t in new_tags:
if t not in merged:
merged.append(t)
print("既存タグ:", existing)
print("追加後 :", merged)
return pub.update(note_key, dry_run=dry_run, hashtags=merged)
def append_text(pub: NotePublisher, note_key: str,
html_to_append: str, dry_run: bool = True):
"""本文の末尾に追記する(追記のお知らせなど)"""
current = pub.fetch(note_key)
free_body = current.get("free_body") or current.get("body") or ""
new_body = free_body + html_to_append
print(f"本文: {len(free_body)} 文字 → {len(new_body)} 文字")
return pub.update(note_key, dry_run=dry_run, free_body=new_body)
def change_title(pub: NotePublisher, note_key: str,
new_title: str, dry_run: bool = True):
"""タイトルだけを変更する(他の設定は保持される)"""
current = pub.fetch(note_key)
print(f"タイトル: {current.get('name')} → {new_title}")
return pub.update(note_key, dry_run=dry_run, name=new_title)
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
pub = NotePublisher(client)
key = "nXXXXXXXXXXXX"
# いずれも確認モードで実行される
add_tags(pub, key, ["Python", "自動化"], dry_run=True)
except CookieExpiredError as e:
print("認証エラー:", e)
ステータス変更の制約
公開した記事を下書きに戻す操作には、明確な制約があります。403が返る条件を把握しておくと、無駄な試行を減らせます。
| 記事の状態 | 下書きに戻せるか | 理由 |
|---|---|---|
| 無料記事 | 戻せる | 制約なし |
| 有料記事(購入者なし) | 戻せる場合がある | 条件により403 |
| 有料記事(購入者あり) | 戻せない | 購入者の閲覧権を守るため |
| 有料マガジンに追加済み | 戻せない | マガジン購読者への影響 |
| メンバーシップ限定 | 戻せない | メンバーへの提供義務 |
戻せない記事を非公開にしたい場合は、削除するしかありません。ただし削除も購入者との関係で問題が生じるため、有料記事は公開前の確認を徹底するのが唯一の対策です。この仕様の詳細は記事の非公開についての記事でも扱っています。
エラーの切り分け
| コード | よくある原因 | 確認すること |
|---|---|---|
| 401 | Cookieの失効 | 認証を取り直す |
| 403 | 権限がない、または操作が許可されない | 自分の記事か、有料の制約に該当しないか |
| 404 | keyとidの取り違え | PUTには数値idを使う |
| 422 | 必須フィールドの欠落 | free_body・nameが入っているか |
| 200だが反映されない | 公開直後の一時的な状態 | 数秒待って再取得する |
| 設定が消えた | マージせずに送った | fetch → build_payload の順を守る |
まとめ:取得してから送る、を徹底する
noteの公開APIで唯一かつ最大の注意点は、差分更新ができないことです。これさえ理解していれば、あとは機械的な処理になります。
この記事の要点
- 公開は
PUT /api/v1/text_notes/{数値id} - 送らなかったフィールドは空になる。差分更新は不可
- 必ず現在値を取得し、マージしてから送る
- 本文は取得時
body、送信時free_body/pay_body - 有料記事は
separatorとpriceの指定が必要 - 購入された有料記事は下書きに戻せない
- 公開直後は本文がnullになることがある。リトライで確認する
- 投稿系スクリプトは dry_run を既定にする
公開まで自動化すると、原稿を書いてコマンドを1つ実行するだけで記事が出せるようになります。ただし公開は取り返しがつかない操作でもあります。確認モードを挟む習慣を、仕組みとして組み込んでおいてください。
FAQ|noteの公開APIについてよくある質問
推測になりますが、noteのエディタが編集画面の全状態をそのまま送信する設計であるためと考えられます。ブラウザ上では常にすべてのフィールドが揃っているので、差分更新の必要がありません。APIとして外部に公開されているわけではないため、この設計のままになっているのでしょう。利用する側としては、現在値を取得してマージするという手順で対応するしかありません。
タグや価格であれば、再度設定し直せば復旧できます。深刻なのは本文が消えたケースです。手元に原稿が残っていれば戻せますが、note上でしか編集していなかった場合は復旧が困難です。この事故を防ぐためにも、原稿はローカルに持ち、noteへは反映するだけという運用にしておくことを強くおすすめします。
APIから予約公開の日時を指定する方法は確認されていません。noteの予約投稿機能はプレミアム会員向けの機能で、APIからの利用は想定されていないようです。代替手段として、公開したい時刻にスクリプトを実行する形でスケジューラを自作する方法があります。この実装は予約投稿の記事で解説しています。
有料ラインの位置を示す値です。実際には free_body と pay_body に分けて送れば分割位置は決まるため、取得した現在値をそのまま引き継ぐのが安全です。この記事の build_payload() が現在値を保持しているのはそのためです。値の意味を推測して設定するより、既存の値を壊さないほうが確実です。
全体の2〜4割程度が目安です。無料部分が少なすぎると、読者が購入を判断する材料を得られません。逆に多すぎると、無料部分だけで満足されてしまいます。この記事のコードでは、無料部分が2割未満または8割超のときに警告を出すようにしています。実際の比率は記事の性質によって変わるため、警告はあくまで目安として扱ってください。
公開処理の直後に取得すると、一時的に本文がnullで返ることがあります。数秒待って再取得すると正常な値になります。この記事の verify_published() がリトライを挟んでいるのはこのためです。実際の記事が消えているわけではないので、慌てて再投稿しないでください。二重投稿になります。
自分が書いた記事を自分のアカウントから投稿する行為自体は問題になりにくいでしょう。問題になるのは、内容の薄い記事を機械的に大量生成して投稿するケースです。noteは一次情報や体験の記録を評価する方針を示しており、量産型のコンテンツは評価されにくくなっています。自動化はあくまで手作業の代行であり、書く内容の質は別問題だと考えてください。
技術的には可能ですが、おすすめしません。同じ日に大量の記事が公開されると、フォロワーのタイムラインを占有することになり、読者の印象を損ねます。また記事同士がアクセスを奪い合う面もあります。書き溜めた記事は、日を分けて公開するほうが1本あたりの露出が増えます。この運用を自動化するのが、予約投稿の記事で扱っている内容です。
記事のkeyは変わらないため、URLも変わりません。タイトルを変更しても、本文を大幅に書き換えても同じURLのままです。ただし、note IDそのものを変更した場合は、URLの前半部分が変わるため過去記事のURLもすべて変わります。SNSでシェアされたリンクが切れる可能性があるので、note IDの変更は慎重に判断してください。
確認モードで表示しているのは、送信するペイロードの内容です。note側での処理結果(サニタイズによるタグ除去など)までは再現できません。本文のタグが除去される可能性がある場合は、Markdown変換の記事で扱っている検証スクリプトを併用してください。それでも不安な場合は、まず下書きとして作成し、noteの編集画面で見た目を確認してから公開するのが確実です。