画像のアップロード先は、noteのサーバーではない。署名を受け取って、S3へ直接送る。
2段階の仕組みを理解すれば、実装は素直に書けます
📋 この記事でわかること
- 署名付きURLを使った2段階アップロードの仕組み
- presigned_postで返る情報の読み方
- S3へmultipartで送るときのフィールド順序
- 返ってきたURLを本文HTMLに差し込む処理
- アップロード前にPillowで最適化する手順
- 添付ファイルAPIとの違いと使い分け
記事に画像を含めて自動投稿したい場合、画像を先にアップロードしてURLを得る必要があります。noteの画像アップロードは、note側のサーバーに直接送るのではなく、S3の署名付きURLを受け取ってそこへ送るという2段階の仕組みになっています。最初は複雑に見えますが、この方式はクラウドサービスでは一般的で、理解すれば他のサービスでも応用が利きます。この記事では、その実装を解説します。見出し画像については別のAPIを使うので、見出し画像の記事を参照してください。
この記事で扱うもの
POST /api/v3/images/upload/presigned_post——署名付きURLの取得- S3へのmultipart POST——画像の実体を送る
POST /api/v2/attachments/upload——添付ファイル(別方式)- 認証が必要です。Cookie認証を先に済ませてください
なぜ2段階なのか
画像のような大きなファイルを、アプリケーションサーバーが受け取って保存するのは非効率です。サーバーの帯域とCPUを消費し、同時アクセスが増えると詰まります。
そこで使われるのが署名付きURLという仕組みです。アプリケーションサーバーは「この条件でアップロードしてよい」という署名だけを発行し、実際のファイルはストレージサービス(S3)へ直接送られます。サーバーは重いデータを扱わずに済み、アップロード先の権限も限定できます。
- 署名をもらう——noteのAPIに「画像を上げたい」と伝え、アップロード先URLと必要なフィールドを受け取ります。
- S3へ送る——受け取ったURLに、指定されたフィールドと一緒にファイルをPOSTします。
- URLを使う——アップロード完了後、その画像のURLを本文のimgタグに埋め込みます。
| ステップ | 送り先 | 認証 | 返るもの |
|---|---|---|---|
| 1. 署名取得 | note.com | Cookie必要 | URLとフィールド一式 |
| 2. アップロード | S3(別ドメイン) | 署名で代替 | 204 No Content など |
S3へのリクエストにCookieを送らない
2段階目のアップロード先はnote.comではなく、外部のストレージサービスです。ここに認証Cookieを送るのはセキュリティ上望ましくありません。この記事のコードでは、S3へのリクエストだけ新しいセッションを使い、Cookieが送られないようにしています。認証済みセッションを使い回すと、意図せず外部ドメインにCookieを送ることになります。
署名を取得する
まず1段階目です。何が返ってくるかを確認するところから始めます。
"""
署名付きURLを取得して、返ってくる内容を確認する。
実際のフィールド名は変わる可能性があるため、まず観察する。
"""
import json
import os
from note_auth_client import NoteAuthClient, CookieExpiredError
def get_presigned(client: NoteAuthClient, filename: str,
content_type: str = "image/jpeg") -> dict:
"""アップロード用の署名付きURLを取得する"""
payload = {
"name": filename,
"content_type": content_type,
}
res = client.request(
"POST",
"/api/v3/images/upload/presigned_post",
json=payload,
)
if res.status_code not in (200, 201):
print(f"署名の取得に失敗: HTTP {res.status_code}")
print(res.text[:300])
return {}
return res.json().get("data", {})
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
data = get_presigned(client, "sample.jpg", "image/jpeg")
if data:
print("=== 返ってきた内容 ===")
print(json.dumps(data, ensure_ascii=False, indent=2)[:1200])
# 構造を保存して、後から確認できるようにする
with open("presigned_sample.json", "w",
encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print("\npresigned_sample.json に保存しました")
except CookieExpiredError as e:
print("認証エラー:", e)
返ってくるのは、アップロード先のURLと、S3が要求するフィールドの一覧です。フィールド名や構造は変わる可能性があるため、まず保存して中身を確認する——非公式APIを扱う際の基本手順です。
アップロード処理を実装する
署名を取得してS3へ送るまでを、1つの関数にまとめます。レスポンス構造の揺れに対応できるよう、複数のキー名を試す形にしています。
"""
noteの記事内画像をアップロードする。
署名取得 → S3へPOST の2段階を1つの関数にまとめる。
"""
import logging
import os
import requests
from note_auth_client import NoteAuthClient
logger = logging.getLogger(__name__)
MIME_MAP = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
MAX_BYTES = 20 * 1024 * 1024 # noteの上限に合わせて20MB
def detect_mime(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
mime = MIME_MAP.get(ext)
if not mime:
raise ValueError(f"対応していない拡張子です: {ext}")
return mime
def pick(d: dict, *keys, default=None):
"""キー名の揺れを吸収する"""
for k in keys:
if k in d and d[k] is not None:
return d[k]
return default
def upload_image(client: NoteAuthClient, image_path: str) -> str:
"""
画像をアップロードして、公開URLを返す。
失敗したら空文字を返す。
"""
if not os.path.exists(image_path):
raise FileNotFoundError(image_path)
size = os.path.getsize(image_path)
if size > MAX_BYTES:
raise ValueError(
f"ファイルが大きすぎます: {size / 1024 / 1024:.1f} MB"
)
filename = os.path.basename(image_path)
mime = detect_mime(image_path)
# --- 1段階目: 署名を取得する ---
res = client.request(
"POST",
"/api/v3/images/upload/presigned_post",
json={"name": filename, "content_type": mime},
)
if res.status_code not in (200, 201):
logger.error("署名の取得に失敗 HTTP %s: %s",
res.status_code, res.text[:200])
return ""
data = res.json().get("data", {})
upload_url = pick(data, "url", "upload_url", "endpoint")
fields = pick(data, "fields", "form_data", default={}) or {}
public_url = pick(data, "public_url", "file_url", "image_url", default="")
if not upload_url:
logger.error("アップロード先URLが取得できません: %s",
list(data.keys()))
return ""
# --- 2段階目: S3へ送る ---
# 認証Cookieを外部に送らないよう、新しいセッションを使う
with requests.Session() as s3:
with open(image_path, "rb") as f:
files = {"file": (filename, f, mime)}
# fields は順序が意味を持つ場合があるためそのまま渡す
upload_res = s3.post(
upload_url,
data=fields,
files=files,
timeout=120,
)
if upload_res.status_code not in (200, 201, 204):
logger.error("アップロード失敗 HTTP %s: %s",
upload_res.status_code, upload_res.text[:200])
return ""
# 公開URLが返っていない場合は、アップロード先から組み立てる
if not public_url:
key = fields.get("key", "")
if key:
public_url = upload_url.rstrip("/") + "/" + key
logger.info("アップロード完了: %s(%s KB)", filename, size // 1024)
return public_url
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
url = upload_image(client, "sample.jpg")
print("画像URL:", url or "(取得できませんでした)")
アップロード前に画像を最適化する
スマホやカメラで撮った画像をそのまま上げると、ファイルサイズが無駄に大きくなります。表示幅を考えると、事前に縮小しておくのが合理的です。
"""
アップロード前に画像を最適化する。
- 長辺を指定サイズに収める
- Exif情報(撮影場所など)を除去する
- 回転情報を反映する
"""
import os
from PIL import Image, ImageOps
# 記事内の表示幅は620px程度。2倍の1240px以上あれば十分きれい
TARGET_WIDTH = 1600
MAX_LONG_SIDE = 4000 # noteの上限に合わせる
QUALITY = 85
def optimize(src_path: str, dest_path: str = None,
target_width: int = TARGET_WIDTH) -> str:
"""画像を最適化して保存する"""
if dest_path is None:
base, ext = os.path.splitext(src_path)
dest_path = f"{base}_opt.jpg"
with Image.open(src_path) as img:
# Exifの回転情報を反映してから、Exifを落とす
img = ImageOps.exif_transpose(img)
if img.mode in ("RGBA", "LA", "P"):
# 透過を白背景に合成する(JPEGは透過を扱えない)
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1]
if img.mode in ("RGBA", "LA") else None)
img = background
else:
img = img.convert("RGB")
original_size = img.size
# 幅が大きい場合は縮小する
if img.width > target_width:
ratio = target_width / img.width
new_size = (target_width, int(img.height * ratio))
img = img.resize(new_size, Image.LANCZOS)
# 長辺の上限を超えないようにする
if max(img.size) > MAX_LONG_SIDE:
ratio = MAX_LONG_SIDE / max(img.size)
img = img.resize(
(int(img.width * ratio), int(img.height * ratio)),
Image.LANCZOS,
)
img.save(dest_path, "JPEG", quality=QUALITY, optimize=True)
before = os.path.getsize(src_path)
after = os.path.getsize(dest_path)
print(f" {os.path.basename(src_path)}: "
f"{original_size[0]}x{original_size[1]} → "
f"{img.width}x{img.height} / "
f"{before // 1024} KB → {after // 1024} KB")
return dest_path
def optimize_dir(source_dir: str, dest_dir: str = "optimized") -> list:
"""フォルダ内の画像をまとめて最適化する"""
os.makedirs(dest_dir, exist_ok=True)
files = sorted(
f for f in os.listdir(source_dir)
if f.lower().endswith((".jpg", ".jpeg", ".png", ".webp"))
)
results = []
print(f"=== {len(files)} 件を最適化します ===")
for filename in files:
src = os.path.join(source_dir, filename)
base = os.path.splitext(filename)[0]
dest = os.path.join(dest_dir, f"{base}.jpg")
try:
results.append(optimize(src, dest))
except Exception as e:
print(f" エラー({filename}): {e}")
return results
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and os.path.isdir(sys.argv[1]):
optimize_dir(sys.argv[1])
else:
print("使い方: python image_optimizer.py <画像フォルダ>")
Exif情報の除去は忘れずに
スマホで撮影した写真には、撮影日時や位置情報がExifとして埋め込まれています。そのまま公開すると、自宅の場所が特定される可能性があります。Pillowで開いて保存し直すと、Exifは基本的に落ちます。この記事の optimize() では、回転情報だけ反映してから保存することで、向きを保ったままExifを除去しています。プライバシー面のリスクはnoteの危険性の記事でも扱っています。
▶ 見出し画像は別のAPIを使います。自動生成も含めて解説しています。
Pillowでアイキャッチを作る実装です。
本文に画像を差し込む
アップロードして得たURLを、本文のHTMLに埋め込みます。Markdownの画像記法をアップロード済みURLに置き換える処理を作ります。
"""
Markdown内のローカル画像パスを、アップロード済みURLに置き換える。
アップロード結果はキャッシュして、再実行時の重複を防ぐ。
"""
import hashlib
import json
import os
import re
import time
from note_auth_client import NoteAuthClient
from image_uploader import upload_image
from image_optimizer import optimize
CACHE_FILE = "uploaded_images.json"
SLEEP_SEC = 2.0
#  形式を拾う
IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
def file_hash(path: str) -> str:
"""ファイルの内容からハッシュを作る(同じ画像の再アップロードを防ぐ)"""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()[:16]
def load_cache() -> dict:
if not os.path.exists(CACHE_FILE):
return {}
with open(CACHE_FILE, encoding="utf-8") as f:
return json.load(f)
def save_cache(cache: dict):
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
def upload_with_cache(client: NoteAuthClient, path: str,
cache: dict) -> str:
"""キャッシュを見て、未アップロードのものだけ送る"""
key = file_hash(path)
if key in cache:
print(f" キャッシュ: {os.path.basename(path)}")
return cache[key]
optimized = optimize(path)
try:
url = upload_image(client, optimized)
finally:
# 最適化した一時ファイルを消す
if optimized != path and os.path.exists(optimized):
os.remove(optimized)
if url:
cache[key] = url
save_cache(cache)
time.sleep(SLEEP_SEC)
return url
def replace_images(client: NoteAuthClient, md_text: str,
base_dir: str = ".") -> tuple:
"""
Markdown内の画像をアップロードし、URLに置き換える。
戻り値: (置換後のMarkdown, アップロード数)
"""
cache = load_cache()
uploaded = 0
def replace(match):
nonlocal uploaded
alt = match.group(1)
src = match.group(2).strip()
# すでにURLならそのまま
if src.startswith(("http://", "https://")):
return match.group(0)
path = src if os.path.isabs(src) else os.path.join(base_dir, src)
if not os.path.exists(path):
print(f" 画像が見つかりません: {src}")
return match.group(0)
url = upload_with_cache(client, path, cache)
if not url:
print(f" アップロード失敗: {src}")
return match.group(0)
uploaded += 1
return f""
result = IMAGE_PATTERN.sub(replace, md_text)
return result, uploaded
def to_img_tags(md_text: str) -> str:
"""Markdownの画像記法を、noteに送るimgタグに変換する"""
def replace(match):
alt = match.group(1)
url = match.group(2).strip()
return f'<p><img src="{url}" alt="{alt}"></p>'
return IMAGE_PATTERN.sub(replace, md_text)
if __name__ == "__main__":
client = NoteAuthClient()
client.verify()
sample = """## テスト

本文が続きます。
"""
replaced, count = replace_images(client, sample)
print(f"\n{count} 件をアップロードしました")
print(replaced)
ファイルの内容からハッシュを作ってキャッシュしているのがポイントです。同じ画像を何度もアップロードすることを防げます。ファイル名ではなく内容で判定しているため、名前を変えただけの同一画像も検出できます。
画像入り記事を丸ごと投稿する
ここまでの部品をつないで、画像を含むMarkdownから下書きを作る処理にします。
"""
画像を含むMarkdownから、画像アップロード込みで下書きを作る。
python 02_post_with_images.py article.md
"""
import os
import sys
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
from simple_md import convert
from frontmatter import parse, extract_title, strip_title_heading
from embed_images import replace_images
def main(path: str, do_run: bool):
if not os.path.exists(path):
print(f"ファイルがありません: {path}")
return
base_dir = os.path.dirname(os.path.abspath(path))
with open(path, encoding="utf-8") as f:
content = f.read()
meta, body = parse(content)
fallback = os.path.splitext(os.path.basename(path))[0]
title = extract_title(meta, body, fallback)
body = strip_title_heading(body)
# 画像の数を数える
import re
image_count = len(re.findall(r"!\[[^\]]*\]\([^)]+\)", body))
print("=== 処理対象 ===")
print(" タイトル:", title)
print(" 画像 :", image_count, "枚")
if not do_run:
print("\n[確認モード] --run を付けると実行します")
return
try:
client = NoteAuthClient()
client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
print("\n=== 画像をアップロードします ===")
body, uploaded = replace_images(client, body, base_dir=base_dir)
print(f" {uploaded} 枚をアップロードしました")
html = convert(body)
draft = NoteDraft(client)
if draft.find_by_title(title):
print("\n同名の下書きが既にあります。中止します")
return
result = draft.create(title, html)
print(f"\n下書きを作成しました: {result['edit_url']}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("使い方: python 02_post_with_images.py article.md [--run]")
sys.exit(1)
main(sys.argv[1], "--run" in sys.argv)
▼ RECOMMENDATION ▼
投稿の手間を減らしても、読者の数は変わらない
画像アップロードまで自動化すれば、記事1本を出すまでの作業は大幅に短縮できます。ただし、公開した記事を読む人を増やす部分は、依然として別の課題です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回します。コードの実装も保守も不要です。noteと違ってASPアフィリエイトが使えるため、書いた記事を収益につなげる設計もできます。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
添付ファイルAPI
画像以外のファイル(PDFや資料など)を記事に添付する場合は、別のエンドポイントを使います。こちらは1段階でアップロードできます。
"""
記事に添付ファイルをアップロードする。
画像のS3方式と違い、noteに直接送る。
"""
import logging
import os
from note_auth_client import NoteAuthClient
logger = logging.getLogger(__name__)
MAX_BYTES = 50 * 1024 * 1024
def upload_attachment(client: NoteAuthClient, note_key: str,
file_path: str) -> dict:
"""
添付ファイルをアップロードする。
note_key は記事のキー(n形式)を使う点に注意。
"""
if not os.path.exists(file_path):
raise FileNotFoundError(file_path)
size = os.path.getsize(file_path)
if size > MAX_BYTES:
raise ValueError(f"ファイルが大きすぎます: {size // 1024 // 1024} MB")
filename = os.path.basename(file_path)
with open(file_path, "rb") as f:
files = {"file": (filename, f, "application/octet-stream")}
data = {
"file_name": filename,
"note_key": note_key,
}
res = client.request(
"POST",
"/api/v2/attachments/upload",
files=files,
data=data,
)
if res.status_code not in (200, 201):
logger.error("添付失敗 HTTP %s: %s",
res.status_code, res.text[:200])
return {}
result = res.json().get("data", {})
logger.info("添付しました: %s(key=%s)",
filename, result.get("attachment_key"))
return result
def download_attachment(client: NoteAuthClient, attachment_key: str,
dest_path: str) -> bool:
"""添付ファイルの実体をダウンロードする"""
res = client.request(
"GET",
f"/api/v2/attachments/download/{attachment_key}",
)
if res.status_code != 200:
logger.error("ダウンロード失敗 HTTP %s", res.status_code)
return False
with open(dest_path, "wb") as f:
f.write(res.content)
logger.info("保存しました: %s(%s KB)",
dest_path, len(res.content) // 1024)
return True
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
result = upload_attachment(client, "nXXXXXXXXXXXX", "document.pdf")
print(result)
| 項目 | 画像アップロード | 添付ファイル |
|---|---|---|
| エンドポイント | presigned_post → S3 | attachments/upload |
| 段階 | 2段階 | 1段階 |
| 識別子 | — | note_key(n形式) |
| 用途 | 本文中の画像 | PDF・資料などの配布 |
| 返るもの | 画像URL | attachment_key |
つまずきやすい点
| 症状 | 原因 | 対処 |
|---|---|---|
| 署名は取れるがS3で403 | fieldsを送っていない | 返ってきたfieldsをそのまま渡す |
| S3で400が返る | content_typeが署名時と違う | 両方で同じMIME typeを使う |
| アップロード先URLが取れない | レスポンスのキー名が変わった | JSONを保存して構造を確認する |
| 本文に画像が出ない | URLの形式が違う | 返ってきた公開URLをそのまま使う |
| 同じ画像が何度も上がる | キャッシュがない | 内容のハッシュで判定する |
| 画像が巨大でエラー | 元画像をそのまま送っている | 事前に最適化する |
| 写真の向きがおかしい | Exifの回転情報を無視した | exif_transposeを通す |
まとめ:署名をもらって、直接送る
2段階アップロードは、慣れないうちは複雑に見えます。しかし「署名をもらう」「その署名でS3へ送る」という2つの動作に分けて考えれば、実装は素直に書けます。
この記事の要点
- 署名取得は
POST /api/v3/images/upload/presigned_post - 実体は返ってきたURLへ、fieldsと一緒にmultipartで送る
- S3へのリクエストにはCookieを送らない(別セッションを使う)
- MIME typeは署名時とアップロード時で一致させる
- アップロード前にPillowで縮小し、Exifを除去する
- 内容のハッシュでキャッシュし、重複アップロードを防ぐ
- 記事内画像の表示幅は620px程度。1600px幅で十分
- 添付ファイルは別のエンドポイントで、1段階で送れる
この処理が動くようになると、画像を含む記事も完全に自動で投稿できるようになります。画像サイズの考え方については記事内画像のサイズの記事も参考にしてください。
FAQ|画像アップロードについてよくある質問
「この条件でのみアップロードを許可する」という一時的な権限が埋め込まれたURLです。有効期限やファイルサイズ、ファイル形式などの条件が署名に含まれており、条件を満たさないリクエストはストレージ側で拒否されます。この仕組みにより、アプリケーションサーバーを経由せずに安全なアップロードが実現できます。AWSやGoogle Cloudなど多くのサービスで使われる標準的な方式です。
署名で受け取った fields を送っていない可能性が高いです。この中には署名そのものやポリシー情報が含まれており、すべて一緒に送る必要があります。また、署名取得時に指定した content_type と、アップロード時のMIME typeが一致していないと拒否されます。両方で同じ値を使ってください。
通常は数分から数十分程度の期限があります。署名を取得してから長時間経ってアップロードすると失効します。この記事のコードのように、署名取得の直後にアップロードする流れであれば問題になりません。大量の画像を処理する場合も、1枚ごとに署名を取り直す設計にしてあるので期限切れは起きません。
1ファイルあたり20MB程度が上限とされています。ただし、記事内での表示幅は620px程度なので、実用的にはそこまで大きなファイルは不要です。1600px幅、品質85のJPEGなら300KB前後に収まり、表示速度も速くなります。この記事の最適化処理を通せば、上限を意識する必要はほぼなくなります。
本文からimgタグを削除すれば、記事上には表示されなくなります。ただし、アップロードされたファイル自体がストレージから消える保証はありません。URLを知っていればアクセスできる可能性があるため、公開したくない画像は最初からアップロードしないのが確実です。個人情報を含む画像を誤って上げてしまった場合は、noteの問い合わせ窓口に相談してください。
PNGのままアップロードすれば透過は保たれます。ただし、この記事の最適化処理はJPEGに変換するため、透過部分が白で塗りつぶされます。透過を保ちたい場合は、最適化を通さずに直接アップロードするか、PNG形式で保存するよう処理を変更してください。noteの背景は白基調なので、白で合成しても違和感が出ないケースは多いはずです。
撮影日時、カメラの機種、そして位置情報が画像ファイルに残ります。位置情報が含まれた写真を公開すると、自宅や勤務先が特定される可能性があります。スマホの設定で位置情報の記録をオフにする方法もありますが、アップロード処理の中で確実に除去するほうが安全です。Pillowで開いて保存し直すだけでExifは落ちます。
非公式APIのため、フィールド名は変更される可能性があります。この記事のコードで pick() を使って複数のキー名を試しているのはそのためです。それでも取得できない場合は、01_get_presigned.py でレスポンスをJSONファイルに保存し、実際に返ってくるキー名を確認してからコードを調整してください。まず観察するという手順が確実です。
Markdown内の記法を順番に処理しているため、置換後の順序は元の原稿と同じになります。並び順が変わる場合は、ファイル名でソートして処理する箇所がないか確認してください。この記事の replace_images() は正規表現の置換で処理するため、本文中の出現順が保たれます。
技術的には可能ですが、1枚ごとに署名取得とアップロードの2リクエストが発生するため、枚数に比例して負荷がかかります。この記事のコードでは2秒の間隔を空けています。数十枚を一度に処理する場合は、間隔を広げるか、複数回に分けてください。またキャッシュ機構により、2回目以降は既にアップロード済みの画像がスキップされます。