ダッシュボードの数字は、見た瞬間から古くなる。記録しなければ、比較する対象が残らない。
毎日自動で残せば、3ヶ月後に意味を持ちます
📋 この記事でわかること
- PV統計APIの取得方法とページング
- 日次スナップショットをCSVに蓄積する設計
- pandasで前日比・週次推移を出す処理
- matplotlibでグラフ化する(日本語フォント対応)
- 後から伸びている記事を見つける方法
- HTMLレポートを自動生成する流れ
noteのダッシュボードでは、記事ごとのPVやスキ数を確認できます。しかし表示されるのは「今の数字」だけで、過去との比較はできません。先月より伸びているのか、どの記事が後から読まれ始めたのか——こうした問いに答えるには、自分で日々の数字を記録しておく必要があります。この記事では、PV統計をAPIで取得し、日次で蓄積してグラフ化するまでを実装します。認証が必要な処理です。
必要なもの
- Python 3.10以降、
requests、pandas、matplotlib note_auth_client.py(Cookie認証の記事で作成)- 定期実行の環境(タスクスケジューラまたはcron)
PV統計APIの仕様
使うのは GET /api/v1/stats/pv です。認証が必要で、自分の記事の統計だけが取得できます。
| パラメータ | 値 | 意味 |
|---|---|---|
| filter | all | 全期間の統計 |
| page | 1, 2, 3... | ページ番号 |
| sort | pv | PV順に並べる |
返ってくるのは記事ごとの統計データです。フィールド名は変わる可能性があるため、実際のレスポンスを確認してから使ってください。
"""
PV統計APIのレスポンス構造を確認する。
まず観察してからコードを書く。
"""
import json
from note_auth_client import NoteAuthClient, CookieExpiredError
def fetch_stats_page(client: NoteAuthClient, page: int = 1) -> dict:
return client.get_json(
"/api/v1/stats/pv",
params={"filter": "all", "page": page, "sort": "pv"},
)
if __name__ == "__main__":
try:
client = NoteAuthClient()
client.verify()
data = fetch_stats_page(client, page=1)
print("=== トップレベルのキー ===")
print(list(data.keys()))
# 記事の配列を探す
for key in ("note_stats", "noteStats", "stats", "contents"):
if key in data and isinstance(data[key], list):
items = data[key]
print(f"\n配列のキー: {key}({len(items)} 件)")
if items:
print("\n=== 1件目のフィールド ===")
for k, v in items[0].items():
preview = str(v)
if len(preview) > 40:
preview = preview[:40] + "..."
print(f" {k}: {preview}")
break
with open("stats_sample.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print("\nstats_sample.json に保存しました")
except CookieExpiredError as e:
print("認証エラー:", e)
全ページを取得する
記事数が多い場合、統計も複数ページに分かれます。全件を取得する関数を作ります。
"""
PV統計を全ページ取得する。
"""
import logging
import time
from note_auth_client import NoteAuthClient
logger = logging.getLogger(__name__)
SLEEP_SEC = 1.2
MAX_PAGES = 50
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 extract_items(data: dict) -> list:
"""レスポンスから記事統計の配列を取り出す"""
for key in ("note_stats", "noteStats", "stats", "contents"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def fetch_all_stats(client: NoteAuthClient,
max_pages: int = MAX_PAGES) -> list:
"""全記事のPV統計を取得する"""
results = []
for page in range(1, max_pages + 1):
data = client.get_json(
"/api/v1/stats/pv",
params={"filter": "all", "page": page, "sort": "pv"},
)
items = extract_items(data)
if not items:
break
results.extend(items)
logger.info("page %s: %s 件(累計 %s 件)",
page, len(items), len(results))
if data.get("last_page") or data.get("isLastPage"):
break
time.sleep(SLEEP_SEC)
return results
def normalize(items: list) -> list:
"""扱いやすい形に整える"""
rows = []
for item in items:
rows.append({
"key": pick(item, "key", "note_key", default=""),
"id": pick(item, "id", "note_id", default=""),
"title": pick(item, "name", "title", default=""),
"pv": pick(item, "read_count", "pv", "view_count", default=0),
"likes": pick(item, "like_count", "likeCount", default=0),
"comments": pick(item, "comment_count", "commentCount",
default=0),
"type": pick(item, "type", default=""),
})
return rows
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
stats = normalize(fetch_all_stats(client))
print(f"\n=== 合計 {len(stats)} 件 ===")
total_pv = sum(r["pv"] for r in stats)
print(f" 総PV: {total_pv:,}")
print("\n=== PV上位10件 ===")
for row in sorted(stats, key=lambda r: -r["pv"])[:10]:
print(f" {row['pv']:>7,} PV | {row['title'][:44]}")
日次スナップショットを蓄積する
APIから取れるのは累計値なので、日々の記録を残して差分を見る形にします。
"""
毎日実行して、その日のPVをCSVに追記する。
累計値を記録し、後から差分で日次の伸びを算出する。
"""
import csv
import os
import sys
from datetime import date
from note_auth_client import NoteAuthClient, CookieExpiredError
from stats_fetcher import fetch_all_stats, normalize
HISTORY_FILE = "pv_history.csv"
FIELDS = ["date", "key", "title", "pv", "likes", "comments"]
def already_recorded(target_date: str,
path: str = HISTORY_FILE) -> bool:
"""その日のデータが既にあるか確認する(二重記録の防止)"""
if not os.path.exists(path):
return False
with open(path, encoding="utf-8-sig", newline="") as f:
for row in csv.DictReader(f):
if row.get("date") == target_date:
return True
return False
def record(client: NoteAuthClient, path: str = HISTORY_FILE) -> int:
today = date.today().isoformat()
if already_recorded(today, path):
print(f"{today} は記録済みです")
return 0
rows = normalize(fetch_all_stats(client))
if not rows:
print("統計を取得できませんでした")
return 0
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 r in rows:
writer.writerow({
"date": today,
"key": r["key"],
"title": r["title"],
"pv": r["pv"],
"likes": r["likes"],
"comments": r["comments"],
})
total = sum(r["pv"] for r in rows)
print(f"{today}: {len(rows)} 件を記録しました(総PV {total:,})")
return len(rows)
def main() -> int:
try:
client = NoteAuthClient()
client.verify()
record(client)
return 0
except CookieExpiredError as e:
print("認証エラー:", e)
return 1
except Exception as e:
print("エラー:", e)
return 2
if __name__ == "__main__":
sys.exit(main())
二重記録を防ぐ
同じ日に複数回実行すると、同じ日付のデータが重複して記録されます。集計時に数字が狂うため、実行前にその日のデータが既にあるかを確認しています。タスクスケジューラの設定ミスや手動実行が重なっても安全です。
▶ 記事のメタデータ側は、別の方法で一括取得できます。
認証不要でCSVに落とす手順です。
蓄積したデータを分析する
データが数日分たまったら、pandasで集計します。累計値の差分を取ることで、日次のPVが算出できます。
"""
蓄積したPV履歴を分析する。
累計値の差分から、日次の伸びを算出する。
"""
import pandas as pd
HISTORY_FILE = "pv_history.csv"
def load(path: str = HISTORY_FILE) -> pd.DataFrame:
df = pd.read_csv(path, encoding="utf-8-sig")
df["date"] = pd.to_datetime(df["date"])
return df.sort_values(["key", "date"])
def daily_total(df: pd.DataFrame) -> pd.DataFrame:
"""全記事の合計PVの推移"""
total = df.groupby("date")["pv"].sum().reset_index()
total["daily_pv"] = total["pv"].diff().fillna(0).astype(int)
total["change"] = total["daily_pv"].diff().fillna(0).astype(int)
return total
def per_article_growth(df: pd.DataFrame, days: int = 7) -> pd.DataFrame:
"""記事ごとの、直近N日の伸び幅"""
latest_date = df["date"].max()
past_date = latest_date - pd.Timedelta(days=days)
latest = df[df["date"] == latest_date].set_index("key")
past_rows = df[df["date"] <= past_date]
if past_rows.empty:
return pd.DataFrame()
past = past_rows[
past_rows["date"] == past_rows["date"].max()
].set_index("key")
joined = latest.join(past["pv"], rsuffix="_past", how="inner")
joined["growth"] = joined["pv"] - joined["pv_past"]
joined = joined[joined["growth"] > 0]
return joined.sort_values("growth", ascending=False)
def weekly_summary(df: pd.DataFrame) -> pd.DataFrame:
"""週ごとの合計PV"""
total = daily_total(df)
total["week"] = total["date"].dt.to_period("W").astype(str)
return total.groupby("week")["daily_pv"].sum().reset_index()
def report(path: str = HISTORY_FILE):
df = load(path)
days = df["date"].nunique()
print(f"=== 記録期間: {days} 日分 ===")
if days < 2:
print("比較には2日以上の記録が必要です")
return
total = daily_total(df)
print("\n=== 日次PVの推移(直近14日)===")
for _, row in total.tail(14).iterrows():
bar = "|" * min(int(row["daily_pv"] / 20), 50)
print(f" {row['date'].strftime('%m/%d')} "
f"{int(row['daily_pv']):>6,} {bar}")
recent = total.tail(7)["daily_pv"].mean()
print(f"\n 直近7日の平均: {recent:,.0f} PV/日")
growth = per_article_growth(df, days=7)
if not growth.empty:
print("\n=== 直近7日で伸びた記事 ===")
for _, row in growth.head(12).iterrows():
print(f" +{int(row['growth']):>5,} {row['title'][:44]}")
weekly = weekly_summary(df)
if len(weekly) >= 2:
print("\n=== 週別の合計PV ===")
for _, row in weekly.tail(8).iterrows():
print(f" {row['week']} {int(row['daily_pv']):>7,}")
if __name__ == "__main__":
report()
「直近7日で伸びた記事」がもっとも実用的な指標です。公開直後に伸びる記事と、時間が経ってから検索経由で読まれ始める記事は性質が違います。後者は資産性が高く、同じ方向の記事を増やす価値があります。
グラフ化する
数字の羅列より、グラフのほうが傾向を把握しやすくなります。日本語の表示にはフォント設定が必要です。
"""
PV推移をグラフ化する。
日本語表示のためフォント設定が必要。
"""
import os
import platform
import matplotlib
matplotlib.use("Agg") # 画面表示せずファイルに保存する
import matplotlib.pyplot as plt
import pandas as pd
from analyze_pv import load, daily_total, per_article_growth
OUTPUT_DIR = "charts"
def setup_japanese_font():
"""OSごとに日本語フォントを設定する"""
candidates = {
"Windows": ["Meiryo", "Yu Gothic", "MS Gothic"],
"Darwin": ["Hiragino Sans", "Hiragino Maru Gothic Pro"],
"Linux": ["Noto Sans CJK JP", "IPAGothic", "TakaoGothic"],
}
available = {f.name for f in matplotlib.font_manager.fontManager.ttflist}
for name in candidates.get(platform.system(), []):
if name in available:
plt.rcParams["font.family"] = name
print(f"フォント: {name}")
return
print("日本語フォントが見つかりません。文字化けする可能性があります")
def plot_daily(df: pd.DataFrame, output: str):
total = daily_total(df)
if len(total) < 2:
print("グラフ化には2日以上の記録が必要です")
return
fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(total["date"], total["daily_pv"],
marker="o", linewidth=2, color="#bf8c5e",
markersize=4, label="日次PV")
# 7日移動平均を重ねる
if len(total) >= 7:
rolling = total["daily_pv"].rolling(7).mean()
ax.plot(total["date"], rolling, linewidth=2,
color="#5a3d28", linestyle="--", label="7日移動平均")
ax.set_title("日次PVの推移", fontsize=14, pad=14)
ax.set_ylabel("PV")
ax.grid(alpha=0.3)
ax.legend()
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(output, dpi=110)
plt.close(fig)
print(f"保存: {output}")
def plot_top_articles(df: pd.DataFrame, output: str, top: int = 12):
latest = df[df["date"] == df["date"].max()]
top_df = latest.nlargest(top, "pv").sort_values("pv")
if top_df.empty:
return
fig, ax = plt.subplots(figsize=(11, max(5, top * 0.42)))
labels = [
(t[:26] + "…") if len(t) > 26 else t
for t in top_df["title"].astype(str)
]
ax.barh(labels, top_df["pv"], color="#c99a6e")
ax.set_title(f"PV上位 {top} 記事", fontsize=14, pad=14)
ax.set_xlabel("累計PV")
ax.grid(axis="x", alpha=0.3)
fig.tight_layout()
fig.savefig(output, dpi=110)
plt.close(fig)
print(f"保存: {output}")
def plot_growth(df: pd.DataFrame, output: str, days: int = 7):
growth = per_article_growth(df, days=days)
if growth.empty:
print("伸びのデータがまだありません")
return
top_df = growth.head(12).sort_values("growth")
fig, ax = plt.subplots(figsize=(11, 5.5))
labels = [
(t[:26] + "…") if len(t) > 26 else t
for t in top_df["title"].astype(str)
]
ax.barh(labels, top_df["growth"], color="#8b5e3a")
ax.set_title(f"直近{days}日で伸びた記事", fontsize=14, pad=14)
ax.set_xlabel("PVの増加")
ax.grid(axis="x", alpha=0.3)
fig.tight_layout()
fig.savefig(output, dpi=110)
plt.close(fig)
print(f"保存: {output}")
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
setup_japanese_font()
df = load()
plot_daily(df, os.path.join(OUTPUT_DIR, "daily_pv.png"))
plot_top_articles(df, os.path.join(OUTPUT_DIR, "top_articles.png"))
plot_growth(df, os.path.join(OUTPUT_DIR, "growth.png"))
if __name__ == "__main__":
main()
matplotlib.use("Agg") を先に書く
pyplot をインポートする前にバックエンドを指定する必要があります。順序が逆だと効きません。この設定により、GUI環境のないサーバー上でもグラフを画像として保存できます。定期実行するスクリプトでは必須の設定です。
▼ RECOMMENDATION ▼
数字を見える化しても、数字そのものは増えない
PVを記録・可視化すれば、どの記事が伸びているかは分かります。しかし、その数字を増やすには集客の手を打つ必要があります。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回し、集客そのものを仕組み化できます。noteと違ってASPアフィリエイトも使えるため、増えたアクセスを収益に変換できます。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
HTMLレポートを自動生成する
グラフと数値をまとめたレポートをHTMLで出力すれば、ブラウザで開くだけで状況が把握できます。
"""
PV分析の結果をHTMLレポートとして出力する。
グラフはbase64で埋め込むので、1ファイルで完結する。
"""
import base64
import os
from datetime import datetime
from analyze_pv import load, daily_total, per_article_growth
import plot_pv
OUTPUT = "pv_report.html"
CHART_DIR = "charts"
def embed_image(path: str) -> str:
"""画像をbase64にしてimgタグを返す"""
if not os.path.exists(path):
return ""
with open(path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("ascii")
return (f'<img src="data:image/png;base64,{encoded}" '
f'alt="chart" style="max-width:100%;height:auto;">')
def build_html() -> str:
df = load()
total = daily_total(df)
growth = per_article_growth(df, days=7)
latest = df[df["date"] == df["date"].max()]
total_pv = int(latest["pv"].sum())
article_count = len(latest)
recent_avg = total.tail(7)["daily_pv"].mean() if len(total) >= 2 else 0
parts = [
"<h1>note PVレポート</h1>",
f"<p>作成日時: {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>",
"<h2>サマリー</h2>",
"<ul>",
f"<li>記事数: {article_count} 本</li>",
f"<li>累計PV: {total_pv:,}</li>",
f"<li>直近7日の平均: {recent_avg:,.0f} PV/日</li>",
f"<li>記録日数: {df['date'].nunique()} 日</li>",
"</ul>",
"<h2>日次PVの推移</h2>",
embed_image(os.path.join(CHART_DIR, "daily_pv.png")),
"<h2>PV上位の記事</h2>",
embed_image(os.path.join(CHART_DIR, "top_articles.png")),
"<h2>直近7日で伸びた記事</h2>",
embed_image(os.path.join(CHART_DIR, "growth.png")),
]
if not growth.empty:
parts.append("<table><tr><th>増加</th><th>記事</th></tr>")
for _, row in growth.head(15).iterrows():
title = str(row["title"])[:60]
parts.append(
f"<tr><td>+{int(row['growth']):,}</td>"
f"<td>{title}</td></tr>"
)
parts.append("</table>")
style = (
"body{font-family:sans-serif;max-width:900px;margin:40px auto;"
"padding:0 20px;line-height:1.7;color:#2a1f17;}"
"h1{border-bottom:3px solid #bf8c5e;padding-bottom:10px;}"
"h2{margin-top:36px;color:#5a3d28;}"
"table{border-collapse:collapse;width:100%;margin-top:16px;}"
"th,td{border:1px solid #ddd;padding:8px 10px;text-align:left;}"
"th{background:#f0e8df;}"
"img{margin:16px 0;border:1px solid #eee;border-radius:8px;}"
)
return (
"<!DOCTYPE html><html lang='ja'><head>"
"<meta charset='utf-8'><title>note PVレポート</title>"
f"<style>{style}</style></head><body>"
+ "".join(parts) +
"</body></html>"
)
def main():
plot_pv.main() # グラフを先に生成する
html = build_html()
with open(OUTPUT, "w", encoding="utf-8") as f:
f.write(html)
print(f"レポートを出力しました: {OUTPUT}")
if __name__ == "__main__":
main()
定期実行の設定
@echo off
rem タスクスケジューラで毎日1回実行する
cd /d C:\note_stats
call venv\Scripts\activate.bat
rem 数字を記録
python snapshot.py
rem レポートを更新(週1回でも十分)
python report_html.py
deactivate
# 毎日 深夜1時にPVを記録する
0 1 * * * cd /home/user/note_stats && venv/bin/python snapshot.py >> logs/stats.log 2>&1
# 毎週月曜の朝にレポートを更新する
0 8 * * 1 cd /home/user/note_stats && venv/bin/python report_html.py >> logs/report.log 2>&1
記録は深夜、レポートは週1回
PVの記録は毎日必要ですが、レポートの生成は毎日行う意味がありません。数字は日々変動するので、週単位で見るほうが傾向を捉えやすくなります。毎日数字を見ると、変動に一喜一憂して消耗します。この点は伸びない原因の記事でも触れています。
数字の読み方
データが集まったあと、どう解釈するかを整理します。
| パターン | 意味 | 取るべき行動 |
|---|---|---|
| 公開直後だけ伸びて、あとは横ばい | フォロワー・SNS経由の流入 | 検索を意識したタイトルに変える |
| 公開から数ヶ月後に伸び始めた | 検索から読まれている | 同じ方向の記事を増やす |
| 特定の日だけ急増した | どこかで紹介された | 流入元を確認する |
| 全体的に緩やかに増えている | 記事の蓄積が効いている | 継続する |
| 全体が一斉に下がった | 外部要因の可能性 | 数週間様子を見る |
| PVは多いがスキが少ない | タイトルと中身のずれ | 冒頭と構成を見直す |
まとめ:記録が比較を可能にする
PVの記録は、始めた瞬間には価値がありません。3ヶ月分たまって初めて「先月と比べてどうか」が言えるようになります。だからこそ、早く始めるほど得をします。
この記事の要点
- PV統計は
GET /api/v1/stats/pv。認証が必要 - 取得できるのは累計値。差分から日次を算出する
- 同じ日の二重記録を防ぐチェックを入れる
- 「直近7日で伸びた記事」が最も実用的な指標
- 後から伸びる記事は検索経由。資産性が高い
- matplotlibは
use("Agg")を先に指定する - 日本語フォントはOSごとに探して設定する
- 記録は毎日、レポートは週1回で十分
まずは snapshot.py を毎日走らせるところから始めてください。データが2週間分たまれば、グラフに意味が出てきます。
FAQ|PV統計の自動記録についてよくある質問
APIから取得できるのは現時点の累計値です。過去の日別データを遡って取得することはできません。だからこそ日次で記録を残す必要があります。今日から始めれば今日以降のデータは残りますが、昨日以前は取り戻せません。「早く始めるほど得をする」というのはこの意味です。
APIからは取得できません。noteのダッシュボードでも流入元の詳細は限定的です。ただし、公開直後に伸びるか、時間が経ってから伸びるかで、ある程度推測できます。前者はフォロワーやSNS経由、後者は検索経由である可能性が高くなります。この記事の「直近N日で伸びた記事」の分析は、この推測を支える指標です。
記録の間隔が空くだけで、データが壊れることはありません。累計値を記録しているため、間が飛んでいても差分は計算できます。ただし日次のグラフには穴が空きます。毎日でなくても、週2〜3回程度は記録しておくと傾向が見えます。実行忘れが心配なら、タスクスケジューラで自動化してください。
記事100本を1年間毎日記録すると、36,500行程度になります。CSVとしては数MB程度で、pandasで扱うには何の問題もない規模です。数年分たまって重くなってきたら、月次の集計データに圧縮する処理を追加すればよいでしょう。当面は気にする必要はありません。
matplotlibのフォント設定が効いていません。この記事の setup_japanese_font() はOSごとにフォントを探しますが、環境によっては見つからないことがあります。Linuxサーバーの場合、日本語フォントパッケージ自体がインストールされていないケースが多いので、まずフォントを入れてください。それでも駄目な場合は、フォントファイルのパスを直接指定する方法があります。
目的によります。読まれた量を知りたいならPV、反応の強さを知りたいならスキです。ただしスキは読者の任意の行動であり、未ログインの読者は押せません。検索流入が中心の記事は、構造的にスキ率が低くなります。PVを主指標にし、スキは補助的に見るのが実態に合っています。
まず1週間程度は様子を見てください。日々の変動は大きく、1日の数字で判断すると振り回されます。それでも下がり続ける場合は、外部の検索エンジン側の変動、note内のおすすめ掲載の終了、季節要因などが考えられます。自分でコントロールできない要因も多いため、記事の質を上げることに集中するほうが建設的です。
生成したHTMLをメールで送る処理を追加すれば可能です。Pythonの smtplib でHTMLメールを送信できます。ただし、メール送信の設定は認証やセキュリティの考慮が必要になるため、まずはローカルにHTMLを生成する形から始めることをおすすめします。Webhookで通知だけ飛ばし、詳細はファイルを見るという運用も現実的です。
できます。CSVのフォーマットにプラットフォーム名の列を追加し、それぞれのAPIから取得したデータを同じファイルに書き込めば、横断的な集計が可能になります。ただし各プラットフォームでPVの定義が異なる場合があるため、単純比較には注意が必要です。傾向の比較として扱うのが妥当です。
ログファイルの更新日時を監視するのが確実です。予約投稿の記事で紹介している notify.py と同じ考え方で、一定時間ログが更新されていなければ通知を送る仕組みを入れてください。認証切れは最も多い失敗要因なので、CookieExpiredError を捕捉して通知するようにしておくと安心です。