エディタで書いて、コピーして、貼り付けて、体裁を直す。この往復は自動化できる。
下書き作成は2段階。仕組みを理解すれば実装は短く済みます
📋 この記事でわかること
- 下書き作成が2段階に分かれている理由
- 返ってくる id と key の違いと使い分け
- 本文をHTMLで渡すときの注意点
- ローカルの原稿を一括で下書き化する処理
- 下書きの一覧取得・重複チェック・削除
- publish系のフィールドが無視される仕様
記事をどこで書くかは人それぞれですが、テキストエディタやMarkdownで書いてからnoteに移す人は少なくありません。この移す作業が地味に面倒です。コピーして貼り付けると体裁が崩れ、見出しを付け直し、改行を整える——記事1本ごとにこれをやると、書く時間より整える時間のほうが長くなることさえあります。noteの下書き作成はAPIから実行できます。ローカルの原稿ファイルを読み込んで、そのままnoteの下書きとして流し込めるようになります。この記事では、その実装を解説します。認証が必要な処理なので、Cookieの準備を先に済ませてください。
この記事の前提
- 認証が必須です。Cookie認証の記事の
note_auth_client.pyを使います - 扱うのは自分のアカウントの下書きのみです
- 非公式APIのため、仕様は予告なく変更される可能性があります
- 大量の下書きを短時間に作成するのは避けてください
下書き作成は2段階に分かれている
noteの下書き作成は、1回のリクエストで完結しません。まず空の下書きを作り、次にそこへ本文を保存するという2段階の構造になっています。
- 空の下書きを作る——
POST /api/v1/text_notes。ここで記事の枠が作られ、idとkeyが返ります。 - 本文を保存する——
POST /api/v1/text_notes/draft_save?id={id}。1で得た数値idを指定して、タイトルと本文を送ります。
この構造は、noteのエディタの挙動を考えると理解しやすくなります。「新規note作成」を押した瞬間に空の記事が作られ、その後の入力が自動保存されていく——ブラウザ上の動きと同じことを、APIから行っているだけです。
| ステップ | エンドポイント | 送るもの | 返るもの |
|---|---|---|---|
| 1. 枠を作る | POST /api/v1/text_notes | なし(空でよい) | id(数値)と key(n形式) |
| 2. 本文を保存 | POST /api/v1/text_notes/draft_save?id={id} | name(タイトル)、body(HTML) | 保存結果 |
idとkeyの使い分け
ステップ1のレスポンスには2種類の識別子が含まれます。これを取り違えると404が返るため、最初に整理しておきます。
| 識別子 | 形式 | 使う場面 |
|---|---|---|
| id | 数値(例: 12345678) | 下書き保存・公開・削除 |
| key | n + 英数字(例: nabcd1234) | 記事の取得・URL生成 |
覚え方としては、「書き込む操作はid、読み取る操作はkey」と考えると整理しやすくなります。例外もありますが、大枠はこの理解で困りません。
最小の実装:下書きを1本作る
まずは動く最小コードです。note_auth_client.py は認証の記事で作成したものを使います。
"""
下書きを1本作成して本文を保存する、最小の実装。
"""
from note_auth_client import NoteAuthClient, CookieExpiredError
def create_empty_draft(client: NoteAuthClient) -> dict:
"""空の下書きを作成し、id と key を返す"""
data = client.post_json("/api/v1/text_notes", payload={})
note_id = data.get("id")
note_key = data.get("key")
if not note_id:
raise RuntimeError(f"下書きの作成に失敗しました: {data}")
print(f"下書きを作成しました(id={note_id} / key={note_key})")
return {"id": note_id, "key": note_key}
def save_draft(client: NoteAuthClient, note_id: int,
title: str, body_html: str) -> bool:
"""作成済みの下書きにタイトルと本文を保存する"""
payload = {
"name": title,
"body": body_html,
}
res = client.request(
"POST",
"/api/v1/text_notes/draft_save",
params={"id": note_id},
json=payload,
)
if res.status_code not in (200, 201):
print(f"保存に失敗しました: HTTP {res.status_code}")
print(res.text[:300])
return False
print("本文を保存しました")
return True
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
draft = create_empty_draft(client)
title = "APIから作成したテスト記事"
body = (
"<p>これはPythonから作成した下書きです。</p>"
"<h2>見出しのテスト</h2>"
"<p>本文には<strong>強調</strong>も使えます。</p>"
)
if save_draft(client, draft["id"], title, body):
print(f"確認: https://note.com/notes/{draft['key']}/edit")
except CookieExpiredError as e:
print("認証エラー:", e)
実行後、noteの「下書き」一覧を開くと記事が増えているはずです。編集画面のURLは https://note.com/notes/{key}/edit という形式なので、作成直後に確認リンクを出力しておくと便利です。
publish系のフィールドは無視される
draft_save のペイロードに status: "published" や price を含めても、エラーにはならず黙って無視されます。「エラーが出なかったから公開されたはず」と考えると混乱します。公開処理は別のエンドポイント(PUT /api/v1/text_notes/{id})で行う必要があり、これは公開の記事で解説します。
下書き作成をクラスにまとめる
2段階の処理を毎回書くのは冗長です。1つのメソッドで完結する形にまとめます。
"""
noteの下書き操作をまとめたクラス。
以降の記事でもこのモジュールを使う。
"""
import logging
from note_auth_client import NoteAuthClient
logger = logging.getLogger(__name__)
class NoteDraft:
def __init__(self, client: NoteAuthClient):
self.client = client
# ---- 作成 ----
def create(self, title: str, body_html: str) -> dict:
"""
下書きを作成して本文まで保存する。
成功したら {"id":..., "key":..., "title":...} を返す。
"""
data = self.client.post_json("/api/v1/text_notes", payload={})
note_id = data.get("id")
note_key = data.get("key")
if not note_id:
raise RuntimeError(f"下書き枠の作成に失敗: {data}")
ok = self.save(note_id, title, body_html)
if not ok:
logger.warning("枠は作成されましたが本文の保存に失敗しました "
"(id=%s)", note_id)
return {
"id": note_id,
"key": note_key,
"title": title,
"saved": ok,
"edit_url": f"https://note.com/notes/{note_key}/edit",
}
def save(self, note_id: int, title: str, body_html: str) -> bool:
"""既存の下書きを上書き保存する"""
res = self.client.request(
"POST",
"/api/v1/text_notes/draft_save",
params={"id": note_id},
json={"name": title, "body": body_html},
)
if res.status_code not in (200, 201):
logger.error("draft_save 失敗 HTTP %s: %s",
res.status_code, res.text[:200])
return False
return True
# ---- 取得 ----
def list_drafts(self, max_pages: int = 20) -> list:
"""下書きの一覧を取得する"""
drafts = []
for page in range(1, max_pages + 1):
data = self.client.get_json(
"/api/v3/notes",
params={"kind": "note", "status": "draft", "page": page},
)
notes = data.get("notes", data.get("contents", []))
if not notes:
break
drafts.extend(notes)
if data.get("isLastPage") or data.get("is_last_page"):
break
return drafts
def find_by_title(self, title: str) -> dict:
"""同じタイトルの下書きがあるか探す(重複防止用)"""
for d in self.list_drafts():
if (d.get("name") or "").strip() == title.strip():
return d
return {}
# ---- 削除 ----
def delete(self, note_id: int) -> bool:
"""下書きを削除する"""
res = self.client.request(
"DELETE",
"/api/v1/text_notes/draft_delete",
params={"id": note_id},
)
if res.status_code not in (200, 204):
logger.error("削除失敗 HTTP %s", res.status_code)
return False
logger.info("下書きを削除しました (id=%s)", note_id)
return True
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
draft = NoteDraft(client)
result = draft.create(
"クラス経由で作成したテスト",
"<p>NoteDraft クラスから作成しました。</p>",
)
print(result)
本文HTMLで使えるタグの制限
本文はHTMLで渡しますが、すべてのタグが通るわけではありません。特にインライン要素は厳しく制限されており、対応していないタグは保存時に取り除かれます。
| タグ | 扱い | 備考 |
|---|---|---|
| p | 使える | 段落の基本 |
| h2 / h3 | 使える | 見出し |
| ul / ol / li | 使える | リスト |
| blockquote | 使える | 引用 |
| strong | 使える | 太字 |
| em | 使える | 斜体 |
| s | 使える | 打ち消し線 |
| code | 使える | インラインコード |
| a | 使える | リンク |
| span / div | 削除される | 装飾目的のタグは通らない |
| style属性 | 削除される | 色やサイズの指定は不可 |
| table | 非対応 | noteに表機能がないため |
この制限があるため、外部で作ったHTMLをそのまま流し込むと、意図した見た目にならないことがあります。送る前にサニタイズする関数を通しておくと安全です。
"""
noteが受け付けるタグだけを残すサニタイザ。
標準ライブラリの HTMLParser を使うので追加インストール不要。
"""
from html.parser import HTMLParser
from html import escape
ALLOWED_TAGS = {
"p", "h2", "h3", "ul", "ol", "li",
"blockquote", "strong", "em", "s", "code", "a", "br",
}
ALLOWED_ATTRS = {"a": {"href"}}
class NoteSanitizer(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
self.removed = set()
def handle_starttag(self, tag, attrs):
if tag not in ALLOWED_TAGS:
self.removed.add(tag)
return
allowed = ALLOWED_ATTRS.get(tag, set())
kept = [
f'{k}="{escape(v or "")}"'
for k, v in attrs if k in allowed
]
if kept:
self.parts.append(f"<{tag} {' '.join(kept)}>")
else:
self.parts.append(f"<{tag}>")
def handle_endtag(self, tag):
if tag in ALLOWED_TAGS and tag != "br":
self.parts.append(f"</{tag}>")
def handle_data(self, data):
self.parts.append(escape(data))
def result(self) -> str:
return "".join(self.parts)
def sanitize(html: str, verbose: bool = True) -> str:
"""note用にHTMLを整形する"""
parser = NoteSanitizer()
parser.feed(html)
parser.close()
if verbose and parser.removed:
print("除去したタグ:", ", ".join(sorted(parser.removed)))
return parser.result()
if __name__ == "__main__":
dirty = (
'<div class="wrap"><p style="color:red">赤い文字</p>'
'<span>スパン</span><h2>見出し</h2>'
'<p><a href="https://example.com" target="_blank">リンク</a>'
'</p><table><tr><td>表</td></tr></table></div>'
)
print(sanitize(dirty))
▶ Markdownで書いた原稿をnote用に変換する処理は、別途まとめています。
見出し・リスト・引用の変換まで対応した実装です。
テキストファイルから一括で下書き化する
実用的な使い方として、ローカルにある原稿ファイルをまとめて下書きにするスクリプトを作ります。
"""
drafts/ フォルダ内のテキストファイルを、noteの下書きとして一括作成する。
ファイル形式:
1行目 = タイトル
2行目以降 = 本文(空行で段落を区切る)
処理済みのファイルは done/ へ移動する。
"""
import os
import shutil
import time
from html import escape
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
SOURCE_DIR = "drafts"
DONE_DIR = "drafts_done"
SLEEP_SEC = 3.0 # 下書き作成の間隔(余裕をもって)
MAX_PER_RUN = 10 # 1回の実行で作る上限
def text_to_html(text: str) -> str:
"""
プレーンテキストをnote用のHTMLに変換する。
- 空行で段落を分ける
- 行頭の ## を見出しにする
- 行頭の - をリストにする
"""
blocks = [b.strip() for b in text.split("\n\n") if b.strip()]
html_parts = []
for block in blocks:
lines = block.split("\n")
# リストブロック
if all(l.strip().startswith("- ") for l in lines):
items = "".join(
f"<li>{escape(l.strip()[2:])}</li>" for l in lines
)
html_parts.append(f"<ul>{items}</ul>")
continue
# 見出し
if block.startswith("## "):
html_parts.append(f"<h2>{escape(block[3:].strip())}</h2>")
continue
if block.startswith("### "):
html_parts.append(f"<h3>{escape(block[4:].strip())}</h3>")
continue
# 引用
if block.startswith("> "):
body = escape(block[2:].strip())
html_parts.append(f"<blockquote>{body}</blockquote>")
continue
# 通常の段落(内部の改行は br に)
para = "<br>".join(escape(l) for l in lines)
html_parts.append(f"<p>{para}</p>")
return "".join(html_parts)
def read_file(path: str) -> tuple:
"""ファイルを読んで (タイトル, 本文HTML) を返す"""
with open(path, encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
title = lines[0].strip().lstrip("# ").strip()
body_text = "\n".join(lines[1:]).strip()
if not title:
title = os.path.splitext(os.path.basename(path))[0]
return title, text_to_html(body_text)
def main():
if not os.path.isdir(SOURCE_DIR):
print(f"{SOURCE_DIR}/ フォルダを作って、原稿を置いてください")
return
files = sorted(
f for f in os.listdir(SOURCE_DIR)
if f.endswith((".txt", ".md"))
)
if not files:
print("処理対象のファイルがありません")
return
try:
client = NoteAuthClient()
client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
draft = NoteDraft(client)
os.makedirs(DONE_DIR, exist_ok=True)
created = 0
for filename in files[:MAX_PER_RUN]:
path = os.path.join(SOURCE_DIR, filename)
title, body = read_file(path)
# 同名の下書きがあればスキップ(二重投稿の防止)
existing = draft.find_by_title(title)
if existing:
print(f"スキップ(既存): {title}")
continue
print(f"作成中: {title}")
try:
result = draft.create(title, body)
print(f" → {result['edit_url']}")
created += 1
shutil.move(path, os.path.join(DONE_DIR, filename))
except Exception as e:
print(f" エラー: {e}")
time.sleep(SLEEP_SEC)
print(f"\n完了: {created} 件の下書きを作成しました")
if __name__ == "__main__":
main()
重要なのは重複チェックと処理済みファイルの退避です。この2つが無いと、実行するたびに同じ記事が下書きに積み上がります。同名の下書きを探してスキップし、成功したファイルは別フォルダへ移動する——この設計で二重作成を防いでいます。
実行間隔を3秒にしている理由
下書き作成は1本あたり2回のリクエスト(枠の作成+保存)を行います。さらに重複チェックで一覧取得も走るため、実質的には1本で3回以上のリクエストが発生します。取得系より重い処理なので、間隔は広めに取ってください。1回の実行で作る本数にも上限を設けています。
下書きの棚卸しをする
下書きは溜まりやすく、放置されがちです。一覧を取得して状態を確認するスクリプトを用意しておくと管理が楽になります。
"""
下書きの一覧を取得して、状態を報告する。
- タイトル未設定のもの
- 本文が極端に短いもの
- 古いまま放置されているもの
"""
from datetime import datetime, timezone
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
STALE_DAYS = 60
def parse_date(value: str):
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def report(drafts: list):
if not drafts:
print("下書きはありません")
return
now = datetime.now(timezone.utc)
untitled = []
short = []
stale = []
print(f"=== 下書き {len(drafts)} 件 ===\n")
for d in drafts:
title = (d.get("name") or "").strip()
body = d.get("body") or ""
updated = parse_date(
d.get("updatedAt") or d.get("updated_at") or ""
)
label = title or "(無題)"
age = ""
if updated:
days = (now - updated).days
age = f"{days}日前"
if days > STALE_DAYS:
stale.append(label)
print(f" {label[:44]:<46}{len(body):>7}文字 {age}")
if not title:
untitled.append(d.get("id"))
if len(body) < 300:
short.append(label)
print("\n=== 気になるもの ===")
print(f" タイトル未設定 : {len(untitled)} 件")
print(f" 本文300文字未満: {len(short)} 件")
print(f" {STALE_DAYS}日以上放置 : {len(stale)} 件")
if stale:
print("\n 放置されている下書き:")
for s in stale[:10]:
print(f" - {s[:50]}")
print("\n → 仕上げて公開するか、削除するか決めてください")
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
draft = NoteDraft(client)
report(draft.list_drafts())
except CookieExpiredError as e:
print("認証エラー:", e)
放置されている下書きが見えると、次にやることが具体的になります。新しく書き始めるより、あと少しで完成する下書きを仕上げるほうが早く1本増やせます。
▼ RECOMMENDATION ▼
投稿を仕組み化しても、読者は自動では増えない
下書き作成を自動化すれば、書いてから公開までの手間は減ります。ただし、記事を読む人を増やす作業は別に必要です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回します。コードを書く必要はなく、設定するだけで24時間動きます。noteと違ってASPアフィリエイトも使えるため、収益化の選択肢も広がります。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
不要な下書きを整理する
テストで作った下書きや、書き直しで不要になったものを削除する処理です。削除は取り消せないため、確認を挟む実装にします。
"""
条件に合う下書きを削除する。
実行前に必ず対象を表示し、確認を取る。
"""
import time
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
# 削除対象の条件
TITLE_KEYWORDS = ["テスト", "test", "APIから作成"]
MIN_BODY_CHARS = 50
def find_targets(drafts: list) -> list:
"""削除候補を絞り込む"""
targets = []
for d in drafts:
title = (d.get("name") or "").strip()
body = d.get("body") or ""
# テスト用のタイトルを含む
if any(k.lower() in title.lower() for k in TITLE_KEYWORDS):
targets.append((d, "テスト用タイトル"))
continue
# 中身がほぼ無い
if len(body) < MIN_BODY_CHARS and title:
targets.append((d, "本文がほぼ空"))
return targets
def main():
try:
client = NoteAuthClient()
client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
draft = NoteDraft(client)
drafts = draft.list_drafts()
targets = find_targets(drafts)
if not targets:
print("削除対象はありません")
return
print(f"=== 削除候補 {len(targets)} 件 ===")
for d, reason in targets:
title = (d.get("name") or "(無題)")[:44]
print(f" [{reason}] {title}")
answer = input("\nこれらを削除しますか? (yes / no): ").strip().lower()
if answer != "yes":
print("中止しました")
return
deleted = 0
for d, _ in targets:
note_id = d.get("id")
if not note_id:
continue
if draft.delete(note_id):
deleted += 1
time.sleep(1.5)
print(f"\n{deleted} 件を削除しました")
if __name__ == "__main__":
main()
削除は元に戻せない
下書きの削除にゴミ箱のような猶予はありません。実行すると即座に消えます。この記事のコードで input() による確認を挟んでいるのはそのためです。自動実行するスクリプトに削除処理を組み込むのは、条件が明確に限定できる場合を除いて避けてください。
つまずきやすいポイント
| 症状 | 原因 | 対処 |
|---|---|---|
| 401が返る | Cookieが無効 | ブラウザで取り直す |
| 404が返る | idとkeyの取り違え | draft_saveには数値idを使う |
| 保存されるが本文が空 | bodyのキー名が違う | ペイロードを確認する |
| 装飾が消える | 非対応のタグを送っている | サニタイザを通す |
| 改行が反映されない | テキストをそのまま送っている | pタグやbrで囲む |
| 公開されない | draft_saveでは公開できない | PUTのエンドポイントを使う |
| 同じ記事が量産される | 重複チェックが無い | タイトルで既存を確認する |
まとめ:枠を作ってから中身を入れる
noteの下書き作成は、空の枠を作ってから本文を保存する2段階の構造です。この構造さえ理解すれば、実装自体は難しくありません。
この記事の要点
- 下書き作成は
POST /api/v1/text_notes→draft_saveの2段階 - 返る id(数値)と key(n形式)は用途が違う
- 本文はHTML。使えるタグは限られている
- span・div・style属性は削除される
- publish系のフィールドは黙って無視される
- 一括作成には重複チェックと処理済みファイルの退避が必須
- 下書きの棚卸しをすると、公開できる記事が見つかる
- 削除は取り消せない。確認を挟む
下書きまで自動化できれば、書く場所を自由に選べるようになります。エディタでもMarkdownでも、書きやすい環境で書いて、あとはスクリプトに任せる。この分業ができると、書くこと自体に集中しやすくなります。
FAQ|noteの下書きAPIについてよくある質問
noteのエディタが自動保存方式であるためと考えられます。ブラウザで「新規note作成」を押した時点で空の記事が作られ、その後の入力が随時保存されていきます。APIもその構造をそのまま踏襲しているだけです。1回のリクエストで完結させたい場合は、この記事の NoteDraft.create() のように内部で2回呼ぶメソッドを用意すれば実用上は問題ありません。
できます。ただし公開は PUT /api/v1/text_notes/{id} という別のエンドポイントで、しかも全フィールドを送る必要があります。差分更新ができないため、現在の値を取得してマージしてから送る、という手順が必要です。この処理は公開の記事で詳しく解説しています。まずは下書き作成だけを自動化し、公開は画面から行うという段階的な進め方をおすすめします。
画像を先にアップロードしてURLを取得し、そのURLを含むimgタグを本文に埋め込む形になります。アップロードには専用のエンドポイントを使い、S3への2段階アップロードという手順を踏みます。下書き作成とは別の処理になるため、画像を含む記事を自動生成したい場合は画像アップロードの記事も参照してください。
上限は公開されていませんが、実用上問題になる制限は確認されていません。ただし、短時間に大量作成するとサーバー負荷の観点で問題があります。この記事のスクリプトでは1回の実行で10本までという上限を設けています。原稿が大量にある場合は、日を分けて処理してください。
noteは意図的に装飾の自由度を制限しています。文字色やフォントサイズを個別に変えることはできません。使えるのは太字・斜体・打ち消し線・引用・見出しといった基本的な要素だけです。これは制約ではありますが、記事の見た目が統一されるという利点もあります。装飾で強調するのではなく、構成と言葉で伝える設計にするのが、noteでの書き方に合っています。
できます。draft_save に既存の下書きのidを渡せば上書きされます。この記事の NoteDraft.save() がそれにあたります。注意点として、上書き保存は元の内容を復元できません。ローカルの原稿を正としてnote側へ反映する運用にすると、誤って上書きしても手元のファイルから戻せます。
重複チェックを入れていない状態で複数回実行したケースです。この記事の find_by_title() のように、作成前に同名の下書きが無いかを確認してください。すでに大量に作ってしまった場合は、clean_drafts.py のようなスクリプトでタイトル一致のものをまとめて削除できます。削除は取り消せないため、対象の確認は慎重に行ってください。
渡せません。本文はHTMLとして解釈されるため、Markdown記法はそのまま文字として表示されます。この記事の text_to_html() は簡易的な変換ですが、見出し・リスト・引用には対応しています。より本格的な変換が必要な場合は、Markdown変換の記事で扱っている実装を使ってください。
見られません。下書きは自分だけが閲覧できる状態です。ただし、公開設定を誤って操作すると意図せず公開されることはあります。APIで下書きを扱う際は、draft_save が公開処理を行わない仕様であることが安全側に働きます。公開は明示的に別のエンドポイントを呼ばない限り発生しません。
この記事の一括作成スクリプトは、成功したファイルだけを処理済みフォルダへ移動します。そのため、途中で止まっても再実行すれば残りのファイルから続行されます。また重複チェックがあるため、同じ記事が二重に作られることもありません。エラーの内容を確認する場合は、認証切れ(401)か、ペイロードの問題(422)かをステータスコードで切り分けてください。