API自動化 Python

note非公式APIをPythonで叩く入門|仕組み・準備・最初のリクエスト

Python × note

noteに公式APIはない。だが、note自身のWebアプリが使っているAPIは存在する。

ブラウザが叩いている通信を、Pythonから叩き直すだけです

📋 この記事でわかること

  • note非公式APIとは何か、なぜ存在するのか
  • DevToolsを使って自分でエンドポイントを見つける手順
  • requestsで記事データを取得する最小コード
  • レスポンスJSONの構造を安全に調べる方法
  • レート制限とリトライを組み込んだクライアントの実装
  • やってよいこと・避けるべきことの線引き

noteには一般公開された公式APIがありません。しかし、note のWebサイト自体はブラウザ上で動くアプリケーションであり、記事の表示・検索・投稿といった操作はすべて内部のHTTP APIを経由して行われています。このAPIは公開ドキュメントこそありませんが、ブラウザが実際に叩いている以上、同じリクエストをPythonから送ることは技術的に可能です。これがいわゆる「note非公式API」です。この記事では、その仕組みを理解し、実際に requests でデータを取得するところまでを、コピーして動かせるコード付きで解説します。

先に読んでください:この記事の前提とリスク

  1. ここで扱うAPIは非公式です。予告なく仕様変更・停止される可能性があります
  2. 大量アクセスはサーバーへの負荷となり、アクセス制限やアカウント停止の原因になります
  3. 自分のアカウント・自分のデータを扱う範囲で使うことを前提とします
  4. 取得したデータの二次利用は、著作権と利用規約の範囲内で行ってください
  5. 本記事は技術的な解説であり、規約違反となる使い方を推奨するものではありません

note非公式APIとは何か

現在のWebサービスの多くは、画面(フロントエンド)とデータ処理(バックエンド)が分離した構造で作られています。noteも同様で、記事ページを開くとブラウザはまずHTMLの器を受け取り、その後にJavaScriptがバックエンドへリクエストを送って、記事本文やスキ数といったデータをJSON形式で取得しています。

この「JavaScriptが叩いているURL」が、いわゆる非公式APIです。noteが外部開発者向けに公開しているものではないため、ドキュメントもサポートもありません。しかし、ブラウザから送れるリクエストである以上、同じヘッダーとパラメータを付けてPythonから送れば、同じJSONが返ってきます。

項目 公式API(一般的なサービス) note非公式API
ドキュメント 公開されている 存在しない
認証方式 APIキー・OAuth ブラウザと同じCookieセッション
仕様の安定性 バージョン管理され、告知がある 予告なく変わる
レート制限 明示されている 非公開。挙動から推測するしかない
サポート あり なし(自己責任)

この違いを理解しておくことが重要です。非公式APIを使った仕組みは「いつ壊れてもおかしくない」前提で設計する必要があります。業務の根幹に据えるのではなく、手作業を減らす補助として使うのが現実的な距離感です。

スクレイピングとの違い

HTMLを解析して情報を抜き出す「スクレイピング」に対し、APIを叩く方法はJSONを直接受け取ります。HTMLの見た目が変わっても壊れにくく、パースも簡単で、サーバー負荷も小さくて済みます。同じ目的なら、APIを使うほうが技術的に筋が良い選択です。

主要なエンドポイント早見表

note内部のAPIは /api/v1//api/v3/ のパスに分かれています。バージョンが混在しているのは、機能ごとに実装された時期が違うためです。代表的なものを整理します。

メソッド パス 取得・実行できるもの 認証
GET /api/v3/notes/{key} 記事1本の詳細(本文・価格・スキ数) 不要(公開記事)
GET /api/v2/creators/{urlname} クリエイターの基本情報 不要
GET /api/v2/creators/{urlname}/contents そのクリエイターの記事一覧 不要
GET /api/v3/searches 記事・ユーザー・マガジンの横断検索 不要
GET /api/v2/hashtags/{tag} タグの記事数・関連タグ 不要
GET /api/v3/hashtags/{tag}/notes タグが付いた記事一覧 不要
GET /api/v2/current_user ログイン中の自分の情報 必要
GET /api/v1/stats/pv 自分の記事のPV統計 必要
POST /api/v1/text_notes 空の下書きを作成 必要
PUT /api/v1/text_notes/{id} 記事の公開・編集 必要

認証が「不要」となっているものは、ログインしていない状態でも取得できます。最初はこの認証不要の範囲から始めるのが安全です。ログインを伴う操作は、Cookieの取り扱いという別の難しさが加わるため、別記事で扱います。

環境の準備

必要なのはPython 3.10以降と requests ライブラリだけです。分析まで行う場合は pandas も入れておきます。

ターミナル / コマンドプロンプト
# 仮想環境を作る(推奨)
python -m venv venv

# 有効化(Windows)
venv\Scripts\activate

# 有効化(macOS / Linux)
source venv/bin/activate

# 必要なライブラリ
pip install requests pandas python-dotenv

python-dotenv は、後の記事でログイン情報を扱うときに使います。この段階では入れておくだけで構いません。

最初のリクエスト:記事を1本取得する

まずは公開記事を1本取得してみます。noteの記事URLは https://note.com/【ユーザー名】/n/【記事キー】 という形式で、末尾の n から始まる文字列が記事のキーです。このキーをAPIに渡します。

01_first_request.py
import requests

# 記事URLの末尾にある n で始まる文字列が「記事キー」
NOTE_KEY = "n6a10366298b0"

url = f"https://note.com/api/v3/notes/{NOTE_KEY}"

headers = {
    # User-Agent を付けないと弾かれる場合がある
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/120.0.0.0 Safari/537.36",
    "Accept": "application/json",
}

res = requests.get(url, headers=headers, timeout=15)

print("ステータスコード:", res.status_code)

if res.status_code == 200:
    data = res.json()
    print("トップレベルのキー:", list(data.keys()))
else:
    print("取得に失敗しました")
    print(res.text[:500])

実行してステータスコード200が返れば成功です。noteのAPIは多くの場合、実データを data というキーの中に入れて返します。つまりレスポンスは {"data": {...}} という入れ子構造になっています。

User-Agentを付ける理由

requests はデフォルトで python-requests/2.x という User-Agent を送ります。これをそのまま送るとブロックされることがあります。ブラウザと同じ文字列を設定するのは、身元を偽るためではなく、通常のクライアントとして扱ってもらうためです。あわせて、後述するアクセス間隔の配慮を必ず行ってください。

レスポンスの構造を調べる

非公式APIにはドキュメントがないため、返ってきたJSONを自分で観察してキーを把握する作業が必須になります。いきなり data["body"] のように書くとキーが存在せずエラーになるので、まず構造を出力しましょう。

02_inspect_json.py
import json
import requests

NOTE_KEY = "n6a10366298b0"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/120.0.0.0 Safari/537.36")


def fetch_note(note_key: str) -> dict:
    """記事1本を取得して data の中身を返す"""
    url = f"https://note.com/api/v3/notes/{note_key}"
    res = requests.get(url, headers={"User-Agent": UA}, timeout=15)
    res.raise_for_status()
    payload = res.json()
    # 多くのエンドポイントは data の中に本体が入っている
    return payload.get("data", payload)


def describe(obj, prefix="", max_depth=2, depth=0):
    """JSONの構造をざっと把握するための再帰プリンタ"""
    if depth > max_depth:
        return
    if isinstance(obj, dict):
        for key, value in obj.items():
            kind = type(value).__name__
            if isinstance(value, (dict, list)):
                size = len(value)
                print(f"{prefix}{key}: {kind}({size})")
                describe(value, prefix + "  ", max_depth, depth + 1)
            else:
                preview = str(value)
                if len(preview) > 40:
                    preview = preview[:40] + "..."
                print(f"{prefix}{key}: {kind} = {preview}")
    elif isinstance(obj, list) and obj:
        print(f"{prefix}[0]:")
        describe(obj[0], prefix + "  ", max_depth, depth + 1)


if __name__ == "__main__":
    note = fetch_note(NOTE_KEY)
    describe(note)

    # 全体をファイルに保存して、エディタで眺めるのが確実
    with open("note_sample.json", "w", encoding="utf-8") as f:
        json.dump(note, f, ensure_ascii=False, indent=2)
    print("\nnote_sample.json に保存しました")

このスクリプトを実行すると、記事オブジェクトが持つキーの一覧と型が表示されます。同時に note_sample.json として保存されるので、エディタで開いて全体を確認できます。新しいエンドポイントを扱うときは、必ずこの「保存して眺める」工程から始めてください。仕様変更に気づくのもこの工程です。

記事オブジェクトによく含まれる代表的なフィールドを挙げます。ただし、これらは変更される可能性があるため、実際のレスポンスで確認するのが前提です。

フィールド 内容
id 記事の数値ID(投稿系APIで使う)
key 記事のキー(URLに現れる n で始まる文字列)
name 記事タイトル
body 本文HTML
description 説明文・冒頭抜粋
price 価格(0なら無料)
like_count スキの数
publish_at 公開日時
user 執筆者情報のオブジェクト
hashtag_notes 紐づくハッシュタグ

keyとidの違いを最初に理解する

noteのAPIを扱ううえで、最初につまずくのがこの区別です。記事には2種類の識別子があり、エンドポイントによってどちらを使うかが違います。

  • 1
    key——n6a10366298b0 のような文字列。記事URLに現れるもの。取得系のAPI(/api/v3/notes/{key})で使います。
  • 2
    id——数値。下書き保存や公開といった投稿系のAPI(/api/v1/text_notes/{id})で使います。

この2つを取り違えると、404が返るか、まったく別の記事を操作してしまいます。取得系のレスポンスには両方含まれていることが多いので、keyしか手元にない場合は、一度GETしてidを取り出すという手順を踏みます。

03_key_to_id.py
import requests

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/120.0.0.0 Safari/537.36")


def get_note_ids(note_key: str) -> tuple:
    """記事キーから (数値id, key, タイトル) を取り出す"""
    url = f"https://note.com/api/v3/notes/{note_key}"
    res = requests.get(url, headers={"User-Agent": UA}, timeout=15)
    res.raise_for_status()
    data = res.json().get("data", {})
    return data.get("id"), data.get("key"), data.get("name")


if __name__ == "__main__":
    note_id, key, title = get_note_ids("n6a10366298b0")
    print(f"数値ID : {note_id}")
    print(f"キー   : {key}")
    print(f"タイトル: {title}")

ユーザーについても同じ構造があります。urlname(プロフィールURLに現れるID)と、数値の id が別物です。フォロー系のAPIは数値IDを要求するため、/api/v2/creators/{urlname} を先に叩いてIDを取得する流れになります。

クリエイターの記事一覧を取得する

次によく使うのが、特定のクリエイターの投稿一覧です。ページング(複数ページに分かれたデータの巡回)の基本形がここに詰まっています。

04_creator_contents.py
import time
import requests

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/120.0.0.0 Safari/537.36")

SLEEP_SEC = 1.0  # 1リクエストごとに1秒待つ


def fetch_all_contents(urlname: str, max_pages: int = 20) -> list:
    """クリエイターの記事一覧をページングしながら全件取得する"""
    results = []
    page = 1

    while page <= max_pages:
        url = f"https://note.com/api/v2/creators/{urlname}/contents"
        params = {"kind": "note", "page": page}
        res = requests.get(url, params=params,
                           headers={"User-Agent": UA}, timeout=15)

        if res.status_code != 200:
            print(f"page {page} で停止: HTTP {res.status_code}")
            break

        data = res.json().get("data", {})
        contents = data.get("contents", [])
        if not contents:
            break

        results.extend(contents)
        print(f"page {page}: {len(contents)} 件取得(累計 {len(results)} 件)")

        # 最終ページ判定(フィールド名は変わる可能性があるため両対応)
        if data.get("isLastPage") or data.get("is_last_page"):
            break

        page += 1
        time.sleep(SLEEP_SEC)

    return results


if __name__ == "__main__":
    items = fetch_all_contents("marie_222")
    print(f"\n合計 {len(items)} 件")

    for item in items[:5]:
        print("-", item.get("name"))

ポイントは3つあります。最大ページ数の上限を設けること、空のレスポンスで抜けること、1リクエストごとに待つことです。特に3つ目は必須で、これを省くと短時間に大量のリクエストが飛び、制限をかけられる原因になります。

▶ 取得したデータをCSVに落として保存する具体的な手順は、続編で解説しています。
自分の全記事をまとめて手元に残す方法です。

全記事の一括取得を見る

検索とハッシュタグを叩いてみる

リサーチ用途でよく使うのが検索APIとハッシュタグAPIです。どちらも認証不要で、キーワードの需要を調べるのに役立ちます。

05_search.py
import requests

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/120.0.0.0 Safari/537.36")


def search_notes(keyword: str, size: int = 10, sort: str = "popular") -> list:
    """
    note内をキーワード検索する
    sort: popular(人気) / hot(急上昇) / new(新着)
    """
    url = "https://note.com/api/v3/searches"
    params = {
        "context": "note",   # note / user / magazine / hashtag
        "q": keyword,
        "size": size,
        "start": 0,
        "sort": sort,
    }
    res = requests.get(url, params=params,
                       headers={"User-Agent": UA}, timeout=15)
    res.raise_for_status()

    data = res.json().get("data", {})
    notes = data.get("notes", {})
    return notes.get("contents", [])


if __name__ == "__main__":
    for item in search_notes("Python 自動化", size=10):
        title = item.get("name", "")
        likes = item.get("likeCount", item.get("like_count", 0))
        price = item.get("price", 0)
        tag = "有料" if price else "無料"
        print(f"[{tag}] スキ{likes:>4} | {title}")

ハッシュタグの規模を調べるコードも見ておきます。タグ選定の判断材料になります。

06_hashtag.py
import time
import requests

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/120.0.0.0 Safari/537.36")


def hashtag_info(tag: str) -> dict:
    """タグの記事数と関連タグを取得する"""
    url = f"https://note.com/api/v2/hashtags/{tag}"
    res = requests.get(url, headers={"User-Agent": UA}, timeout=15)
    res.raise_for_status()
    return res.json().get("data", {})


if __name__ == "__main__":
    targets = ["Python", "副業", "ブログ運営"]

    for tag in targets:
        info = hashtag_info(tag)
        hashtag = info.get("hashtag", info)
        name = hashtag.get("name", tag)
        count = hashtag.get("count", 0)
        print(f"#{name}: {count:,} 件")

        related = info.get("relatedHashtags", [])[:5]
        if related:
            names = ", ".join(f"#{r.get('name')}" for r in related)
            print(f"  関連: {names}")

        time.sleep(1.0)

記事数が多いタグは母数が大きい一方で埋もれやすく、少なすぎるタグは見る人がいません。関連タグをたどって、母数が中程度のタグを見つけるのがこのAPIの使いどころです。

自分でエンドポイントを見つける手順

ここまで紹介したエンドポイントは一部にすぎません。noteの画面上でできる操作は、原則としてすべて何らかのAPIを経由しています。DevToolsで通信を観察すれば、必要なエンドポイントは自分で特定できます。手順は次のとおりです。

  • 1
    ブラウザでnoteを開き、F12でDevToolsを起動します。Macなら Command + Option + I です。
  • 2
    「ネットワーク」タブを開き、フィルタで Fetch/XHR を選択します。これでHTMLや画像が除外され、API通信だけが残ります。
  • 3
    調べたい操作を実際に行います。記事を開く、検索する、スキを押すなど、目的の動作をブラウザ上で実行します。
  • 4
    一覧に現れたリクエストをクリックし、URL・メソッド・リクエストヘッダー・ペイロード・レスポンスを確認します。
  • 5
    右クリックから「Copy as cURL」を選ぶと、そのリクエストをそのまま再現できるコマンドが得られます。

手順5で得たcURLコマンドは、Pythonコードに変換できます。curlconverter のような変換ツールもありますが、自分で読み解けるようになったほうが応用が利きます。cURLの -H がヘッダー、--data-raw がリクエストボディに対応する、という対応関係さえ掴めば読めます。

観察するときのコツ

  1. 操作前に「クリア」ボタンでログを消してから操作すると、目的の通信が特定しやすい
  2. URLに /api/ が含まれるものだけを見る(他は広告や計測の通信)
  3. レスポンスタブでJSONの中身を確認し、欲しいデータが入っているか先に検証する
  4. 同じ操作を2回行い、毎回同じリクエストが飛ぶかを確認する

レート制限を守るクライアントを作る

ここからが実用上もっとも重要な部分です。非公式APIには公開されたレート制限がありませんが、制限が無いわけではありません。短時間に大量のリクエストを送れば、一時的なアクセス拒否やアカウントへの措置につながります。

そこで、リクエスト間隔の確保・リトライ・エラー処理をまとめたクライアントクラスを作っておきます。以降の記事でも、このクラスを土台に使います。

note_client.py
"""
note非公式APIの共通クライアント
- リクエスト間隔の確保
- 429 / 5xx の自動リトライ(指数バックオフ)
- JSONの data 取り出しを共通化
"""
import time
import random
import logging

import requests

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)

BASE_URL = "https://note.com"
DEFAULT_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
              "AppleWebKit/537.36 (KHTML, like Gecko) "
              "Chrome/120.0.0.0 Safari/537.36")


class NoteClient:

    def __init__(self, interval: float = 1.2, max_retry: int = 3):
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": DEFAULT_UA,
            "Accept": "application/json",
        })
        self.interval = interval      # 最低リクエスト間隔(秒)
        self.max_retry = max_retry
        self._last_request_at = 0.0

    def _wait(self):
        """前回リクエストから interval 秒あけるまで待機する"""
        elapsed = time.time() - self._last_request_at
        if elapsed < self.interval:
            time.sleep(self.interval - elapsed)
        self._last_request_at = time.time()

    def request(self, method: str, path: str, **kwargs):
        """リトライ付きの共通リクエスト"""
        url = f"{BASE_URL}{path}"
        kwargs.setdefault("timeout", 20)

        for attempt in range(1, self.max_retry + 1):
            self._wait()
            try:
                res = self.session.request(method, url, **kwargs)
            except requests.RequestException as e:
                logger.warning("通信エラー(%s/%s): %s", attempt, self.max_retry, e)
                self._backoff(attempt)
                continue

            if res.status_code == 429:
                logger.warning("レート制限(429)。待機します")
                self._backoff(attempt, base=5.0)
                continue

            if 500 <= res.status_code < 600:
                logger.warning("サーバーエラー(%s)", res.status_code)
                self._backoff(attempt)
                continue

            return res

        raise RuntimeError(f"リトライ上限に達しました: {method} {path}")

    def _backoff(self, attempt: int, base: float = 1.5):
        """指数バックオフ(ジッター付き)"""
        wait = base * (2 ** (attempt - 1)) + random.uniform(0, 1.0)
        logger.info("%.1f 秒待機します", wait)
        time.sleep(wait)

    def get_json(self, path: str, params: dict = None) -> dict:
        """GETしてJSONの data を返す"""
        res = self.request("GET", path, params=params)
        if res.status_code != 200:
            logger.error("HTTP %s: %s", res.status_code, path)
            return {}
        try:
            payload = res.json()
        except ValueError:
            logger.error("JSONとして解釈できません: %s", path)
            return {}
        return payload.get("data", payload)

    # ---- 便利メソッド ----

    def get_note(self, note_key: str) -> dict:
        return self.get_json(f"/api/v3/notes/{note_key}")

    def get_creator(self, urlname: str) -> dict:
        return self.get_json(f"/api/v2/creators/{urlname}")

    def get_hashtag(self, tag: str) -> dict:
        return self.get_json(f"/api/v2/hashtags/{tag}")


if __name__ == "__main__":
    client = NoteClient()

    note = client.get_note("n6a10366298b0")
    print("タイトル:", note.get("name"))
    print("スキ数  :", note.get("like_count"))
    print("価格    :", note.get("price"))

このクラスの要点を整理します。

仕組み 目的
Sessionの再利用 接続を使い回して負荷と待ち時間を減らす
_wait による間隔確保 連続アクセスを物理的に防ぐ
指数バックオフ 混雑時に間隔を広げ、追い打ちをかけない
ジッター(乱数) リトライのタイミングが揃うのを避ける
ログ出力 失敗の原因を後から追える

ステータスコードから原因を切り分ける

非公式APIを扱っていると、必ずエラーに遭遇します。返ってきたステータスコードを見れば、原因の見当がつきます。

コード 意味 よくある原因
200 成功
401 未認証 ログインが必要なAPIをCookie無しで叩いた
403 拒否 権限がない、または操作が許可されない条件に該当
404 存在しない keyとidの取り違え、削除済み記事、パスの誤り
422 内容が不正 必須フィールドの欠落、値の形式ミス
429 リクエスト過多 アクセス間隔が短すぎる
500 サーバーエラー 送ったデータの形式が想定外(MIME未指定など)

特に注意したいのが429です。これが出た時点で「送りすぎている」というサインなので、間隔を空けて再実行するだけでなく、スクリプト自体の設計を見直してください。1件ごとに1秒以上空ける、1回の実行で扱う件数に上限を設ける、といった調整が必要です。

07_error_handling.py
import requests

from note_client import NoteClient

MESSAGES = {
    401: "ログインが必要なエンドポイントです。Cookieを設定してください",
    403: "権限がないか、その操作が許可されない条件に該当しています",
    404: "対象が見つかりません。keyとidを取り違えていないか確認してください",
    422: "送信内容に不備があります。必須フィールドを確認してください",
    429: "リクエストが多すぎます。間隔を広げてください",
}


def safe_get(client: NoteClient, path: str, params: dict = None):
    """ステータスコードに応じたメッセージを出しつつ取得する"""
    try:
        res = client.request("GET", path, params=params)
    except RuntimeError as e:
        print("リクエスト失敗:", e)
        return None

    if res.status_code == 200:
        return res.json().get("data")

    hint = MESSAGES.get(res.status_code, "想定外のエラーです")
    print(f"HTTP {res.status_code}: {hint}")
    print("レスポンス:", res.text[:300])
    return None


if __name__ == "__main__":
    client = NoteClient()

    # 存在しない記事キーを叩いてみる(404の確認)
    result = safe_get(client, "/api/v3/notes/nXXXXXXXXXXXX")
    print("結果:", result)

取得したデータをCSVに保存する

最後に、実用的な形として取得結果をCSVに落とすコードを示します。ここまでの部品を組み合わせただけの内容です。

08_save_csv.py
import csv
from datetime import datetime

from note_client import NoteClient


def collect_creator_notes(client: NoteClient, urlname: str,
                          max_pages: int = 10) -> list:
    """クリエイターの記事一覧を集める"""
    rows = []
    for page in range(1, max_pages + 1):
        data = client.get_json(
            f"/api/v2/creators/{urlname}/contents",
            params={"kind": "note", "page": page},
        )
        contents = data.get("contents", [])
        if not contents:
            break

        for item in contents:
            rows.append({
                "key": item.get("key", ""),
                "title": item.get("name", ""),
                "published": item.get("publishAt", item.get("publish_at", "")),
                "likes": item.get("likeCount", item.get("like_count", 0)),
                "price": item.get("price", 0),
                "url": f"https://note.com/{urlname}/n/{item.get('key', '')}",
            })

        print(f"page {page} 完了(累計 {len(rows)} 件)")

        if data.get("isLastPage") or data.get("is_last_page"):
            break

    return rows


def save_csv(rows: list, filename: str = None):
    if not rows:
        print("保存対象がありません")
        return

    if filename is None:
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"note_articles_{stamp}.csv"

    with open(filename, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)

    print(f"{filename} に {len(rows)} 件を保存しました")


if __name__ == "__main__":
    client = NoteClient(interval=1.5)
    articles = collect_creator_notes(client, "marie_222")
    save_csv(articles)

エンコーディングに utf-8-sig を指定しているのは、Excelで開いたときに文字化けを防ぐためです。日本語のCSVを扱うときの定番の指定です。

▼ RECOMMENDATION ▼

コードを書かずに済むなら、そのほうが速い

noteの自動化は、このようにコードを書かなければ実現できません。一方アメブロには、いいね・フォロー・アクセス獲得を自動で回すアメプレスProという専用ツールがあります。プログラミング不要で、設定するだけで24時間動きます。noteと違ってASPアフィリエイトも使えるため、収益化まで含めて考えるなら選択肢に入れる価値があります。月額2,980円、365日LINEサポート付きです。

✦ プログラミング不要 ✦ 365日LINEサポート ✦ 複数ブログ対応 ✦ WordPress連携
アメプレスPro 公式ページを確認する →

※ 当サイトはアフィリエイト広告を含みます。

やってよいこと・避けるべきこと

技術的に可能であることと、やってよいことは別です。非公式APIを使う以上、この線引きは自分で引く必要があります。判断の目安を整理します。

用途 評価 理由
自分の記事のバックアップ 問題なし 自分のデータを自分で保全する行為
自分のPV統計の記録・分析 問題なし 画面で見られる情報を蓄積するだけ
自分の下書き作成・投稿の自動化 おおむね問題なし 間隔を空け、常識的な頻度で行う前提
公開情報の少量取得・調査 配慮のうえで可 間隔を空け、必要最小限に留める
大量の記事本文を機械的に収集 避けるべき 負荷が大きく、著作権上の問題も生じる
他人へのスキ・フォローの機械的な連打 避けるべき スパム判定・アカウント制限の対象
取得データの無断転載・再配布 してはいけない 著作権侵害にあたる
他人のアカウントへの操作 してはいけない 不正アクセスにあたる可能性がある

判断に迷ったときの基準

「同じ操作を手で行ったとして、その回数・速度が自然か」で考えてください。1日に数十回の取得なら、人間がブラウザで行う範囲と大きく変わりません。しかし1分間に数百回のリクエストは、人間の操作としてあり得ない負荷です。自動化の目的は手作業の代行であって、人間には不可能な規模の実行ではない——この線を守れば、大きく外れることはありません。

壊れることを前提に設計する

非公式APIを使った仕組みは、必ずいつか動かなくなります。エンドポイントが変わる、フィールド名が変わる、認証方式が変わる——どれも予告なく起こります。だからこそ、壊れたときに原因がすぐ分かる作りにしておくことが重要です。

  • 1
    レスポンスを保存しておく——正常時のJSONをファイルに残しておくと、壊れたときに差分を比較できます。
  • 2
    フィールド取得は必ず .get() を使う——キーが消えてもクラッシュせず、Noneが返るだけで済みます。
  • 3
    ログを残す——いつ・どのパスで・どのコードが返ったかを記録しておけば、原因の特定が早まります。
  • 4
    処理を小さく分ける——取得・変換・保存を別関数にしておけば、壊れた箇所だけ直せます。
  • 5
    手作業でもできる状態を保つ——自動化が止まっても運用が続けられるよう、手順を残しておきます。

また、複数のフィールド名に対応させる書き方も有効です。noteのAPIはキャメルケース(likeCount)とスネークケース(like_count)が混在しており、エンドポイントによって異なります。前掲のコードで両方を試しているのはこのためです。

09_safe_field.py
def pick(data: dict, *keys, default=None):
    """
    複数の候補キーを順に試して、最初に見つかった値を返す。
    APIの表記ゆれ(camelCase / snake_case)対策。

    使い方:
        likes = pick(item, "likeCount", "like_count", default=0)
    """
    for key in keys:
        if key in data and data[key] is not None:
            return data[key]
    return default


if __name__ == "__main__":
    sample = {"like_count": 42, "name": "サンプル記事"}

    print(pick(sample, "likeCount", "like_count", default=0))  # 42
    print(pick(sample, "publishAt", "publish_at", default="不明"))  # 不明

この小さな関数を挟むだけで、フィールド名の変更に対する耐性がかなり上がります。非公式APIを扱ううえでは、こうした「揺れを吸収する層」を自分側に持つことが安定運用のカギになります。

まとめ:小さく作って、壊れたら直す

note非公式APIは、noteのWebアプリが内部で使っているHTTP APIです。ドキュメントもサポートもありませんが、DevToolsで観察すればエンドポイントは特定でき、Pythonの requests から同じリクエストを送れます。

この記事の要点

  1. noteに公式APIはないが、内部APIは存在する
  2. レスポンスは data キーの中に本体が入っている構造が多い
  3. 記事には key(文字列)と id(数値)があり、用途が違う
  4. まずは認証不要のエンドポイントから始める
  5. User-Agentを設定し、1リクエストごとに1秒以上空ける
  6. 429が出たら送りすぎ。設計から見直す
  7. フィールド取得は .get() で、表記ゆれにも備える
  8. 壊れる前提で、ログとレスポンス保存を仕込んでおく

最初の一歩としては、この記事の note_client.py を手元に置き、自分の記事を1本取得してJSONを眺めるところから始めるのがおすすめです。構造が見えれば、あとは目的に応じて組み立てるだけです。次の記事では、認証が必要なエンドポイントを扱うためのログイン処理を解説します。

FAQ|note非公式APIについてよくある質問

Q. noteに公式APIはないのですか?

一般開発者向けに公開・ドキュメント化された公式APIはありません。この記事で扱っているのは、noteのWebアプリケーションが内部的に使用しているエンドポイントです。公式に提供されているものではないため、仕様変更や停止の可能性を前提に扱う必要があります。業務の根幹に組み込むのではなく、手作業を減らす補助として使うのが現実的です。

Q. APIを使うのは規約違反になりませんか?

一律に違反と決まっているわけではありませんが、サーバーに過度な負荷をかける行為や、スパム的な操作は明確に問題になります。判断の基準は「同じことを手作業でやったとして、その回数と速度が自然か」です。自分のデータのバックアップや、少量の公開情報の取得であれば、常識的な間隔を守る限り大きな問題にはなりにくいでしょう。実行前に利用規約を自分で確認することを推奨します。

Q. どのくらいの間隔でリクエストすればいいですか?

公開された基準はありませんが、1リクエストあたり1〜2秒の間隔を空けるのが無難です。この記事のクライアントクラスでは既定を1.2秒に設定しています。大量取得が必要な場合は、間隔を広げるか、1回の実行で処理する件数に上限を設けて複数日に分けてください。429が返ったら、その時点で明確に送りすぎです。

Q. Python以外の言語でも同じことができますか?

できます。HTTPリクエストが送れる言語であれば、JavaScript(Node.js)でもRubyでもGoでも同じです。この記事でPythonを使っているのは、requestsの記述が簡潔で、pandasによる分析やPillowによる画像処理といった周辺ライブラリが揃っているためです。学習コストと情報量の観点でも、この用途ではPythonが扱いやすい選択肢です。

Q. 認証が必要なAPIはどう扱えばいいですか?

ログイン後に発行されるCookieをリクエストに付与します。ただし、ログインAPIをプログラムから直接叩く方法はreCAPTCHAの導入により難しくなっており、実務ではブラウザでログインした状態のCookieを取り出して使う形が現実的です。この手順は別記事で詳しく解説しています。なお、Cookieは自分のアカウントへのアクセス権そのものなので、コードに直書きせず環境変数で管理してください。

Q. 404が返るのですが原因は何ですか?

もっとも多いのは、記事のkey(nで始まる文字列)と数値idの取り違えです。取得系のエンドポイントはkeyを、投稿系のエンドポイントは数値idを要求します。次に多いのがパスのバージョン違い(v2とv3の取り違え)です。それでも解決しない場合は、DevToolsで実際のリクエストを確認し、URLを1文字ずつ照合してください。

Q. レスポンスの構造が記事の内容と違います

非公式APIであるため、時期によってフィールド名や階層が変わることがあります。この記事のコードで .get() を多用し、複数のキー候補を試しているのはそのためです。まずは 02_inspect_json.py で実際のレスポンスを保存して構造を確認し、自分の環境で返ってくるキー名に合わせて調整してください。この「まず観察する」工程を省かないことが重要です。

Q. 取得した他人の記事データはどう扱えばいいですか?

記事の著作権は執筆者にあります。自分の手元で傾向を分析する程度であれば問題になりにくいですが、本文を転載する、まとめサイトとして再配布する、生成AIの学習データとして提供するといった利用は権利侵害にあたる可能性があります。タイトルやスキ数といったメタデータの集計に留め、本文の二次利用は行わないのが安全な線引きです。

Q. スクリプトが突然動かなくなりました

まずステータスコードを確認してください。401ならCookieの失効、403なら権限や条件の問題、404ならパスの変更、429なら送りすぎです。200が返っているのにデータが取れない場合は、フィールド名が変更された可能性が高いので、レスポンスを保存して以前のJSONと比較します。正常時のレスポンスを保存しておく習慣があると、この切り分けが一気に楽になります。

Q. プログラミング初心者でも扱えますか?

この記事のコードはコピーしてそのまま動く形にしてありますが、エラーが出たときに自分で切り分けられるだけの基礎は必要です。具体的には、Pythonの関数・辞書・リストの扱いと、例外処理の基本を理解していれば十分です。逆に、ブログ運営の効率化が目的で、プログラミング自体には関心がないという場合は、既製のツールを使うほうが目的に対して合理的です。noteには該当するツールがありませんが、アメブロであれば専用ツールが存在します。

アメプレスラボ編集部

AMEPRESS LAB EDITORIAL TEAM

アメブロ・note・WordPressを実際に運営し、それぞれの収益構造を数字で検証しているチーム。プラットフォームごとの得意・不得意を踏まえた、宣伝色に偏らない実践情報の発信を続けています。