画面を1ページずつスクロールして数えるのは、もうやめていい。全記事のデータは、30行のコードで手に入る。
ページング処理さえ書ければ、あとは応用が利きます
📋 この記事でわかること
- クリエイター記事一覧APIのページング処理の書き方
- 取得できるフィールドと、その意味
- Excelで開いても文字化けしないCSVの出力方法
- pandasで整形して分析しやすい形にする手順
- 差分だけを追記していく運用スクリプト
- 公式エクスポート機能との使い分け
note上の自分の記事は、増えるほど全体像が見えなくなります。どの記事にスキが集まっているのか、有料記事の比率はどうか、月あたり何本書けているのか——こうした問いに答えるには、まずデータを一箇所に集める必要があります。noteの画面上では一覧をスクロールして数えるしかありませんが、APIを使えば全記事のメタデータを一度に取得できます。この記事では、記事一覧を取得してCSVに落とすところまでを、実際に動くコードで解説します。認証は不要な範囲から始められるので、入門記事を読んだ直後の実践としても最適です。
この記事で使うもの
- Python 3.10以降と
requests、pandas - 自分のnote ID(プロフィールURLの
note.com/【ここ】の部分) - 公開記事だけなら認証不要。下書きも扱うならCookie認証が必要
使うエンドポイントと取得できるもの
記事一覧の取得には /api/v2/creators/{urlname}/contents を使います。ページ番号を指定して、1ページずつ取得していく形式です。
| パラメータ | 値 | 意味 |
|---|---|---|
| kind | note | 記事を取得する(magazine などの指定も可能) |
| page | 1, 2, 3... | ページ番号。1から始まる |
レスポンスの data.contents が記事の配列で、各要素に次のような情報が入っています。フィールド名は変更される可能性があるため、実際のレスポンスで確認してから使ってください。
| フィールド | 内容 | 用途 |
|---|---|---|
| key | 記事キー(n で始まる文字列) | 本文取得やURL生成に使う |
| id | 数値ID | 投稿系APIで使う |
| name | タイトル | 一覧表示・分析 |
| publishAt | 公開日時 | 投稿頻度の集計 |
| likeCount | スキ数 | 反応の指標 |
| commentCount | コメント数 | 反応の指標 |
| price | 価格(0なら無料) | 有料記事の抽出 |
| type | 記事種別(TextNote など) | 音声・つぶやきとの区別 |
| eyecatch | 見出し画像のURL | 画像の有無チェック |
| hashtags | ハッシュタグの配列 | タグ運用の分析 |
最小構成で全記事を取得する
まずは依存を減らした、そのまま動く最小コードです。URLNAME を自分のnote IDに書き換えてください。
import time
import requests
URLNAME = "your_note_id" # ← 自分のnote IDに変更
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.2
MAX_PAGES = 100 # 安全のため上限を置く
def fetch_contents(urlname: str) -> list:
"""クリエイターの記事一覧を全ページ取得する"""
session = requests.Session()
session.headers.update({"User-Agent": UA, "Accept": "application/json"})
all_items = []
for page in range(1, MAX_PAGES + 1):
res = session.get(
f"https://note.com/api/v2/creators/{urlname}/contents",
params={"kind": "note", "page": page},
timeout=20,
)
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:
print(f"page {page}: 記事がないため終了")
break
all_items.extend(contents)
print(f"page {page}: {len(contents)} 件(累計 {len(all_items)} 件)")
if data.get("isLastPage") or data.get("is_last_page"):
print("最終ページに到達しました")
break
time.sleep(SLEEP_SEC)
return all_items
if __name__ == "__main__":
items = fetch_contents(URLNAME)
print(f"\n合計 {len(items)} 件を取得しました")
for item in items[:10]:
likes = item.get("likeCount", item.get("like_count", 0))
print(f" スキ{likes:>4} | {item.get('name', '')}")
ページング処理の要点は3つです。上限を設ける(無限ループの防止)、空配列で抜ける(終端の検出)、1リクエストごとに待つ(負荷への配慮)。この3点は、他のページング系エンドポイントでも同じように使えます。
CSVに保存する
取得したデータをCSVに落とします。日本語を含むCSVをExcelで開くときは、エンコーディングの指定が重要です。
import csv
from datetime import datetime
from fetch_all import fetch_contents, URLNAME
def pick(d: dict, *keys, default=None):
"""camelCase / snake_case の表記ゆれを吸収する"""
for k in keys:
if k in d and d[k] is not None:
return d[k]
return default
def to_rows(items: list, urlname: str) -> list:
"""APIのレスポンスをCSV用の行に変換する"""
rows = []
for item in items:
key = item.get("key", "")
price = pick(item, "price", default=0) or 0
rows.append({
"key": key,
"id": item.get("id", ""),
"title": item.get("name", ""),
"published_at": pick(item, "publishAt", "publish_at", default=""),
"likes": pick(item, "likeCount", "like_count", default=0),
"comments": pick(item, "commentCount", "comment_count", default=0),
"price": price,
"is_paid": "有料" if price else "無料",
"type": item.get("type", ""),
"has_eyecatch": "あり" if item.get("eyecatch") else "なし",
"url": f"https://note.com/{urlname}/n/{key}",
})
return rows
def save_csv(rows: list, filename: str = None) -> str:
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"
# utf-8-sig にするとExcelで開いても文字化けしない
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)} 件を保存しました")
return filename
if __name__ == "__main__":
items = fetch_contents(URLNAME)
rows = to_rows(items, URLNAME)
save_csv(rows)
utf-8 ではなく utf-8-sig を使う理由
Excelは、CSVを開くときにBOM(ファイル先頭の識別子)が無いとShift_JISとして解釈しようとします。その結果、日本語がすべて文字化けします。utf-8-sig を指定するとBOM付きで出力され、Excelでもそのまま正しく表示されます。pandasやテキストエディタで読む場合も問題ありません。日本語CSVを書くときの定番です。
pandasで整形して分析する
CSVに落としたら、pandasで扱うと集計が一気に楽になります。ここでは基本的な集計を通して、自分の投稿傾向を可視化します。
import pandas as pd
def load(filename: str) -> pd.DataFrame:
df = pd.read_csv(filename, encoding="utf-8-sig")
# 日付型に変換(タイムゾーン付き文字列にも対応)
df["published_at"] = pd.to_datetime(
df["published_at"], errors="coerce", utc=True
).dt.tz_convert("Asia/Tokyo")
df["year_month"] = df["published_at"].dt.strftime("%Y-%m")
df["weekday"] = df["published_at"].dt.day_name()
df["hour"] = df["published_at"].dt.hour
df["title_len"] = df["title"].astype(str).str.len()
return df
def summarize(df: pd.DataFrame):
print("=== 全体 ===")
print(f" 記事数 : {len(df)} 本")
print(f" 合計スキ : {df['likes'].sum():,}")
print(f" 平均スキ : {df['likes'].mean():.1f}")
print(f" 中央値スキ : {df['likes'].median():.0f}")
print(f" 有料記事 : {(df['price'] > 0).sum()} 本")
print("\n=== 月別の投稿数と平均スキ ===")
monthly = df.groupby("year_month").agg(
posts=("key", "count"),
avg_likes=("likes", "mean"),
).round(1)
print(monthly.tail(12))
print("\n=== スキが多い記事 TOP10 ===")
top = df.nlargest(10, "likes")[["likes", "title"]]
for _, row in top.iterrows():
print(f" {row['likes']:>5} | {row['title'][:45]}")
print("\n=== タイトル文字数とスキ数の相関 ===")
corr = df[["title_len", "likes"]].corr().iloc[0, 1]
print(f" 相関係数: {corr:.3f}")
print(" (0.3以上なら弱い関連、0.5以上でやや強い関連)")
if __name__ == "__main__":
import sys
filename = sys.argv[1] if len(sys.argv) > 1 else "note_articles.csv"
df = load(filename)
summarize(df)
月別の投稿数を見ると、自分のペースの変動がはっきり分かります。「書けていない月」が可視化されると、続けるための対策が具体的になります。スキの平均値と中央値を両方出しているのは、一部の記事が突出している場合に平均だけでは実態がつかめないためです。
▶ 記事の本文や画像まで含めて完全に保存したい場合は、別の手順が必要です。
本文・画像ごとバックアップする方法を解説しています。
Excel形式で出力する
共有や手作業での確認が前提なら、CSVよりExcel形式のほうが扱いやすい場合があります。シートを分けて出力する例です。
"""
インストール:
pip install pandas openpyxl
"""
from datetime import datetime
import pandas as pd
from analyze import load
def export_excel(df: pd.DataFrame, filename: str = None) -> str:
if filename is None:
stamp = datetime.now().strftime("%Y%m%d")
filename = f"note_report_{stamp}.xlsx"
monthly = df.groupby("year_month").agg(
posts=("key", "count"),
total_likes=("likes", "sum"),
avg_likes=("likes", "mean"),
).round(1).reset_index()
paid = df[df["price"] > 0][
["title", "price", "likes", "published_at", "url"]
].sort_values("price", ascending=False)
top = df.nlargest(30, "likes")[["title", "likes", "comments", "url"]]
with pd.ExcelWriter(filename, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="全記事", index=False)
monthly.to_excel(writer, sheet_name="月別集計", index=False)
top.to_excel(writer, sheet_name="スキ上位", index=False)
if not paid.empty:
paid.to_excel(writer, sheet_name="有料記事", index=False)
# 列幅を自動調整する
for sheet in writer.sheets.values():
for column in sheet.columns:
length = max(
(len(str(cell.value)) for cell in column if cell.value),
default=10,
)
letter = column[0].column_letter
sheet.column_dimensions[letter].width = min(length + 2, 60)
print(f"{filename} を出力しました")
return filename
if __name__ == "__main__":
df = load("note_articles.csv")
export_excel(df)
列幅の自動調整を入れておくと、開いた瞬間から読める状態になります。細かい部分ですが、繰り返し使うレポートでは効いてきます。
差分だけを更新する運用にする
毎回全記事を取得し直すのは、記事数が増えるほど無駄が大きくなります。既存のCSVを読み込み、新しい記事だけを追記する形にします。
"""
既存CSVに無い記事だけを取得して追記する。
毎日実行しても負荷が小さい。
"""
import csv
import os
import time
import requests
URLNAME = "your_note_id"
CSV_FILE = "note_articles.csv"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36")
FIELDS = ["key", "id", "title", "published_at", "likes",
"comments", "price", "is_paid", "type", "url"]
def load_existing_keys(path: str) -> set:
if not os.path.exists(path):
return set()
with open(path, encoding="utf-8-sig", newline="") as f:
return {row["key"] for row in csv.DictReader(f)}
def fetch_new(urlname: str, known: set, max_pages: int = 100) -> list:
"""既知のキーに当たるまで新しい順に取得する"""
session = requests.Session()
session.headers.update({"User-Agent": UA})
new_items = []
for page in range(1, max_pages + 1):
res = session.get(
f"https://note.com/api/v2/creators/{urlname}/contents",
params={"kind": "note", "page": page},
timeout=20,
)
if res.status_code != 200:
break
data = res.json().get("data", {})
contents = data.get("contents", [])
if not contents:
break
hit_known = False
for item in contents:
if item.get("key") in known:
hit_known = True
continue
new_items.append(item)
# 既知の記事に到達したら、それ以降は取得済みとみなす
if hit_known:
print(f"page {page} で既知の記事に到達。取得を終了します")
break
if data.get("isLastPage") or data.get("is_last_page"):
break
time.sleep(1.2)
return new_items
def append_rows(items: list, path: str, urlname: str):
if not items:
print("新しい記事はありません")
return
exists = os.path.exists(path)
with open(path, "a", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
if not exists:
writer.writeheader()
for item in items:
key = item.get("key", "")
price = item.get("price", 0) or 0
writer.writerow({
"key": key,
"id": item.get("id", ""),
"title": item.get("name", ""),
"published_at": item.get("publishAt",
item.get("publish_at", "")),
"likes": item.get("likeCount", item.get("like_count", 0)),
"comments": item.get("commentCount",
item.get("comment_count", 0)),
"price": price,
"is_paid": "有料" if price else "無料",
"type": item.get("type", ""),
"url": f"https://note.com/{urlname}/n/{key}",
})
print(f"{len(items)} 件を追記しました")
if __name__ == "__main__":
known = load_existing_keys(CSV_FILE)
print(f"既存: {len(known)} 件")
new_items = fetch_new(URLNAME, known)
append_rows(new_items, CSV_FILE, URLNAME)
記事一覧は新しい順に返るため、既知のキーに当たった時点で取得を打ち切れます。この工夫だけで、日々の実行にかかるリクエスト数が1〜2ページ分に収まります。
スキ数の推移を記録する
記事の追加だけでなく、既存記事のスキ数の変化も追いたい場合があります。日付付きで別ファイルに履歴として残す方法です。
"""
毎日実行して、その日時点のスキ数を履歴として残す。
後から「どの記事がいつ伸びたか」を追える。
"""
import csv
import os
from datetime import date
from fetch_all import fetch_contents, URLNAME
HISTORY_FILE = "note_likes_history.csv"
def snapshot(urlname: str, path: str = HISTORY_FILE):
items = fetch_contents(urlname)
today = date.today().isoformat()
exists = os.path.exists(path)
with open(path, "a", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
if not exists:
writer.writerow(["date", "key", "title", "likes", "comments"])
for item in items:
writer.writerow([
today,
item.get("key", ""),
item.get("name", ""),
item.get("likeCount", item.get("like_count", 0)),
item.get("commentCount", item.get("comment_count", 0)),
])
print(f"{today} 時点のスナップショットを {len(items)} 件記録しました")
def show_growth(path: str = HISTORY_FILE, days: int = 7):
"""直近の伸び幅を表示する"""
import pandas as pd
df = pd.read_csv(path, encoding="utf-8-sig")
df["date"] = pd.to_datetime(df["date"])
latest = df["date"].max()
past = latest - pd.Timedelta(days=days)
now = df[df["date"] == latest].set_index("key")
before = df[df["date"] <= past]
if before.empty:
print("比較できる過去データがまだありません")
return
before = before[before["date"] == before["date"].max()].set_index("key")
joined = now.join(before["likes"], rsuffix="_before").dropna()
joined["growth"] = joined["likes"] - joined["likes_before"]
print(f"=== 直近{days}日で伸びた記事 ===")
for _, row in joined.nlargest(10, "growth").iterrows():
if row["growth"] > 0:
print(f" +{int(row['growth']):>3} | {row['title'][:45]}")
if __name__ == "__main__":
snapshot(URLNAME)
show_growth()
この履歴が数週間分たまると、「公開直後だけ伸びる記事」と「後からじわじわ伸びる記事」の違いが見えるようになります。後者は検索から読まれている記事で、資産性が高いものです。
下書きも含めて取得する
ここまでは公開記事のみを扱ってきました。下書きを含めて自分の記事全体を把握したい場合は、認証が必要になります。
"""
認証ありで、下書きを含む自分の記事を取得する。
note_auth_client.py(前回の記事で作成)を使う。
"""
from note_auth_client import NoteAuthClient, CookieExpiredError
def fetch_my_notes(client: NoteAuthClient, status: str = "all",
max_pages: int = 50) -> list:
"""
自分の記事を取得する。
status: all / published / draft(環境により対応状況が異なる)
"""
items = []
for page in range(1, max_pages + 1):
data = client.get_json(
"/api/v3/notes",
params={"kind": "note", "status": status, "page": page},
)
contents = data.get("notes", data.get("contents", []))
if not contents:
break
items.extend(contents)
print(f"page {page}: {len(contents)} 件(累計 {len(items)} 件)")
if data.get("isLastPage") or data.get("is_last_page"):
break
return items
def summarize(items: list):
published = [i for i in items if i.get("status") == "published"]
drafts = [i for i in items if i.get("status") == "draft"]
print("\n=== 集計 ===")
print(f" 合計 : {len(items)} 本")
print(f" 公開済 : {len(published)} 本")
print(f" 下書き : {len(drafts)} 本")
if drafts:
print("\n=== 眠っている下書き ===")
for d in drafts[:15]:
title = d.get("name") or "(無題)"
print(f" - {title[:50]}")
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
notes = fetch_my_notes(client)
summarize(notes)
except CookieExpiredError as e:
print("認証エラー:", e)
下書きの棚卸しは効果が大きい
下書きに何本眠っているかを把握していない人は多いものです。書きかけで放置した記事の中に、少し手を入れれば公開できるものが混ざっていることは珍しくありません。新しく書くより、完成間近の下書きを仕上げるほうが早く1本増やせます。
▼ RECOMMENDATION ▼
データを集めたら、次は集客を仕組み化する
記事データが手元に揃うと、どの記事が読まれているかは分かります。しかし「読まれる人を増やす」作業は別に必要です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回します。noteと違ってASPアフィリエイトも使えるため、集めたアクセスを収益につなげる導線まで作れます。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
公式エクスポート機能との使い分け
noteには公式のエクスポート機能があります。APIとどちらを使うべきかは、目的によって変わります。
| 観点 | 公式エクスポート | API取得 |
|---|---|---|
| 形式 | WXR(XML)+メディア | JSON → 任意の形式 |
| 本文 | 含まれる | 別途取得が必要 |
| 画像 | 含まれる | 別途ダウンロードが必要 |
| 回数制限 | 1日3回まで | 制限は非公開(配慮が必要) |
| 定期実行 | 手作業 | 自動化できる |
| 分析への使いやすさ | 加工が必要 | そのまま集計できる |
| WordPress移行 | そのまま取り込める | 変換が必要 |
結論としては、移行や完全保存が目的なら公式エクスポート、定期的な分析や差分監視ならAPIという使い分けになります。両方を併用するのがもっとも安全です。公式機能の詳細はnoteのエクスポート機能の記事で解説しています。
実行を自動化する
ここまでのスクリプトを毎日自動で走らせる設定です。WindowsとLinuxそれぞれの例を示します。
@echo off
rem タスクスケジューラから呼び出すバッチ
rem 「基本タスクの作成」→ 毎日 → このファイルを指定
cd /d C:\note_api
call venv\Scripts\activate.bat
python 05_incremental.py >> logs\incremental.log 2>&1
python 06_snapshot.py >> logs\snapshot.log 2>&1
deactivate
# crontab -e で編集する
# 毎日 朝6時に実行(分 時 日 月 曜日)
0 6 * * * cd /home/user/note_api && venv/bin/python 05_incremental.py >> logs/incremental.log 2>&1
5 6 * * * cd /home/user/note_api && venv/bin/python 06_snapshot.py >> logs/snapshot.log 2>&1
2つのスクリプトを5分ずらしているのは、同時実行によるリクエストの集中を避けるためです。細かい配慮ですが、こうした積み重ねが安定運用につながります。
まとめ:データが集まると判断が変わる
記事一覧の取得は、note APIでもっとも需要が高く、かつ実装が容易な処理です。認証も不要なため、最初の実践として最適です。
この記事の要点
- 記事一覧は
/api/v2/creators/{urlname}/contentsで取得する - ページングは「上限を設ける・空で抜ける・1秒待つ」の3点セット
- CSVは
utf-8-sigで書くとExcelで文字化けしない - pandasに載せると月別集計や相関が数行で出せる
- 既知のキーに当たったら打ち切る差分方式で負荷を抑える
- スナップショットを日次で残すと、後から伸びた記事が分かる
- 下書きを含めるには認証が必要
- 完全保存は公式エクスポート、分析はAPIと使い分ける
データを集めると、感覚で判断していた部分が数字に置き換わります。「今月は書けていない気がする」ではなく「今月は3本、先月は8本」と分かれば、次の行動が具体的になります。まずは全記事のCSVを1本作るところから始めてください。
FAQ|note記事の一括取得についてよくある質問
公開情報なので技術的には取得できます。ただし、大量のクリエイターを機械的に巡回するような使い方はサーバー負荷が大きく、避けるべきです。競合の投稿傾向を調べる程度であれば、対象を絞り、リクエスト間隔を十分に空けてください。取得した本文の転載は著作権侵害にあたるため、メタデータの集計に留めるのが安全な線引きです。
一覧APIには本文が含まれません。本文が必要な場合は、取得したkeyを使って /api/v3/notes/{key} を記事ごとに叩く必要があります。記事数が多いとリクエスト数も増えるため、間隔を1.5秒以上空け、一度に処理する件数を区切ってください。本文取得を含む完全バックアップの手順は別記事で解説しています。
記事一覧APIにはPVが含まれません。PVは /api/v1/stats/pv という別のエンドポイントで、認証が必要です。一覧APIで取れるのはスキ数とコメント数までです。PVを含めた分析を行いたい場合は、両方を取得してキーで結合する形になります。この手順は統計の記事で詳しく扱います。
1ページあたりの取得件数にもよりますが、記事500本で1ページ25件なら20ページ、1.2秒間隔で約25秒です。実用上は問題にならない範囲です。それでも気になる場合は、この記事の差分更新方式を使ってください。既知の記事に到達した時点で打ち切るため、日々の実行は数秒で終わります。
フィールド名は変更される可能性があるため、記事のコードでは isLastPage と is_last_page の両方を確認しています。どちらも無い場合でも、contents が空配列になった時点でループを抜ける処理を入れているので停止します。加えて最大ページ数の上限も設けてあるため、無限ループにはなりません。この三重の保険は、非公式APIを扱ううえで有効な設計です。
エンコーディングに utf-8-sig を指定しているか確認してください。単なる utf-8 で書き出すと、ExcelがShift_JISとして解釈して文字化けします。すでに文字化けしたファイルは、Excelの「データ」タブからテキストファイルとして読み込み、文字コードにUTF-8を指定すれば正しく開けます。書き出し側を直すほうが根本的な解決です。
含まれます。type フィールドで種別が判別できるため、テキスト記事だけを抽出したい場合はここでフィルタしてください。集計時にこれらを混ぜると、平均スキ数などの指標が実態とずれることがあります。分析の目的に応じて、記事種別ごとに分けて集計するのが正確です。
自分の記事のデータであれば、自分の判断で公開して構いません。「1年で何本書いてスキがいくつ付いたか」といった振り返り記事は、noteでも人気のあるジャンルです。一方、他人の記事から取得したデータを一覧化して公開する行為は、規約上も著作権上も問題になる可能性があります。自分のデータに限って使うのが安全です。
ログを確認してください。この記事のバッチ例では、標準出力と標準エラーの両方をログファイルに書き出しています。よくある原因は、仮想環境のパスが違う、実行ディレクトリが想定と異なる、認証が切れている、の3つです。特にcronは環境変数が最小限しか設定されないため、Pythonのフルパスを指定するのが確実です。
できます。標準ライブラリの csv と collections.Counter があれば、月別集計や上位抽出程度は書けます。ただし、日付の変換や複数条件での集計を書くと、pandasを使ったほうが圧倒的に短くなります。分析まで行うなら導入する価値はありますが、CSVに落とすだけならpandasは不要です。目的に合わせて選んでください。