note運営を“感覚”から“見える化”へ|GitHub Pagesでダッシュボードを作る|データ分析|#286
前にワイは、noteのPVとかスキ数をGitHub Actionsで自動収集する方法を記事にした。
で、今回はその次や。
せっかくCSVが毎日たまっていくようになっても、
見にくかったら、たぶん人はそんなに見返さん。
これ、割とある笑
なので今回は、前に集めたnoteのデータを
ブラウザで見やすく可視化する方法をまとめる。
やることは、そこまで重くない。
HTMLファイルを1個置く
設定をほぼ1行だけ変える
GitHub Pagesで開く
これで、PV推移とかランキングとか資産記事っぽいやつまで、
ブラウザで見られるようになる。
作成したダッシュボード:PC向け



ほな、いくで!
この記事でできること
このダッシュボードを入れると、こんな感じのものが見られるようになる。
累計PV・スキ・記事数・フォロワー数
ビューとスキの日次推移
Viewランキング / スキランキング
スキ率のベスト / ワースト
PV × スキ率の4象限マップ
資産記事の分析
直近7日間のトレンド動態
要するに、
「なんとなく最近伸びてる気がする」
を
「これが伸びてる」「これは埋もれてる」「これは地味に残ってる」
に変えるやつや。
ただ数字を並べるだけやなくて、
運営ログを“見たくなる画面”にするのが今回の目的やな。
先に大事なことだけ言っとく
このHTMLは、GitHub上にあるCSVをブラウザから直接読みにいく作りになってる。
せやから、この記事のやり方をそのまま使うなら、基本は公開リポジトリ前提で考えてな。
つまり、
自分だけで裏側のデータを貯めたい
でも見せる用の画面も作りたい
この2つを両立したいなら、
収集はPrivate
公開用の整形データやダッシュボードはPublic
みたいに分けるのがわりと相性ええ。
前回の有料記事でも、
「集計はPrivateで回して、見せるCSVだけPublicに出す」
みたいな運用はありやで、って話をしたけど、今回はまさにその“見る側”や。
これ、誰向けなん?
こんな人向けや。
note運営を感覚じゃなく、ログで見たい人
前回の収集記事を入れたあと、次に何をすればええか探してる人
せっかくCSVを貯めてるのに、正直ちゃんと見返せてない人
自分の運営データを、ちょっとカッコよく見たい人
「分析っていうより、まずは見やすくしたい」人
要するに、
集めたデータを“ちゃんと使える状態”にしたい人向けやな。
はじめる前に確認しておくこと
必要なのは主にこの2つ。
① GitHubアカウント
これは無料でOK。
② CSVが入ったリポジトリ
このHTMLは、GitHub上のCSVを読みにいって動く。
せやから、元になるデータが必要や。
最低でも、このへんがある前提で考えてな。
daily_summary.csv
articles.csv
followers.csv
trend_analysis.csv
asset_articles.csv
ここ、地味に大事や。
前回の有料記事をそのまま入れた人は、
収集用のCSVはできてても、分析用のCSVがまだ出てないことがある。
その場合は、analyze.py を回すステップも追加してな。
つまり今回は、
前回の記事 → 集める土台
今回の記事 → 見る画面
って役割分担やと思ってもらえればOKや。
⚠️ GitHub Pagesは、GitHub Free では公開(Public)リポジトリで利用する前提になります。
GitHub Pro 以上では非公開(Private)リポジトリでもPagesを使えます。
ただし、今回のダッシュボードはブラウザからCSVを直接読みにいく実装なので、このままの構成で使う場合は、CSVが外から読める状態になっている必要があります。
「収集はPrivate、公開用の整形データだけPublicに出す」みたいに分ける運用が相性ええです。
全体の構成はこんな感じ
まず先に、完成形の構成を見せるで。

イメージとしてはこんな感じや。
your-repo/
├── data/
│ ├── articles.csv
│ ├── asset_articles.csv
│ ├── daily_summary.csv
│ ├── followers.csv
│ ├── period_ranking.csv
│ └── trend_analysis.csv
└── docs/
└── index.htmlポイントはシンプルで、
data/ にCSVがある
docs/ にHTMLがある
GitHub Pagesで docs/ を公開する
これだけや。
Step 1 | HTMLファイルを入手する
この記事の末尾に index.html のコード全文を掲載している。コピーして、ファイル名を index.html として保存してほしい。
コードの量が多くて戸惑うかもしれないが、触る箇所はStep 3で説明する2か所だけ。
今はまるごとコピーするだけでOK。
Step 2|テキストエディタで開く
次に、そのHTMLファイルをテキストエディタで開く。
「テキストエディタって何?」って人も心配いらん。
Windows → メモ帳
Mac → テキストエディット
で十分や。
開くと、英語とか記号がずらっと並んでて「うわ、無理そう」って一瞬なるかもしれん。
でも安心してええ。
触るのは次のStep 3の1か所だけや。
Step 3 | CONFIGを書き換える
Ctrl+F(Macは Command+F)でファイル内検索を開き、CONFIG を探してみて。
すると、こんな箇所が見つかるはずや。
const CONFIG = {
repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',
dataPath: 'data',
excludeTitles: [],
};基本的に変えるのは repo: の1行だけ。
変更前:
repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',変更後(例):
repo: 'yamada-taro/note-stats-public',GitHubのリポジトリページを開いたときに上部に表示される「ユーザー名/リポジトリ名」をそのまま入れればOK。
前後のシングルクォート(')と末尾のカンマ(,)は消さないこと。
その他の設定(必要な場合のみ)
dataPath と excludeTitles は通常そのままで問題ないが、必要に応じて変更できるようにしておいた。

特定の記事をランキングから除外したい場合は、こんな感じで追記する。
excludeTitles: ['テスト投稿', '下書き確認用'],設定が正しくできているか確認する方法
設定を書き換えずにブラウザで開くと、こんな案内画面が表示される。
⚙️ 設定が必要です
この画面が出なくなればOKってことや。
Step 3.5|前回の記事を入れてる人へ
前回の有料記事をベースにしてる人は、ここだけ追加で見てな。
今回のHTMLは、
trend_analysis.csv
asset_articles.csv
も読みにいく。
せやから、前回の収集だけで止まってると、
画面の一部が表示されないことがある。
その場合は、GitHub Actionsに analyze.py を回す処理を追加する。
たとえばこんな感じ。
- name: Run Analyze Script
run: python scripts/analyze.py --articles data/articles.csv --out data/これを足すと、
収集 → 集計 → 可視化
までつながる。
ここまでくると、
前回の記事と今回の記事がちゃんとセットで効いてくるで。
Step 4|ファイルを保存する
書き換えが終わったら保存。
Windows → Ctrl + S
Mac → Command + S
以上。
ここはほんまに普通に保存でええ。
Step 5|GitHubにアップロードする
保存した index.html を、GitHubの docs/ フォルダにアップロードする。
流れはこんな感じ。
GitHubでリポジトリを開く
docs フォルダを開く
Add file → Upload files
index.html をアップロード
Commit changes
もし docs フォルダがまだ無いなら、
docs/index.html って名前で新規作成すれば、フォルダごと作れる。
Step 6|GitHub Pagesを有効にする
次に、GitHub Pagesをオンにする。
やることはこんな感じ。
リポジトリの Settings を開く
左側の Pages を開く
Source を Deploy from a branch にする
ブランチを main、フォルダを /docs にする
Save
これでしばらくすると、URLが発行される。
だいたいこういう形や。
https://あなたのユーザー名.github.io/リポジトリ名/このURLをブラウザで開いて、ダッシュボードが出たら完了や。
おつかれさまです🎉
うまくいかんときの確認ポイント
① 「⚙️ 設定が必要です」と出る
だいたい repo: の設定ミスや。
書き換え忘れてないか
ユーザー名/リポジトリ名 の形になってるか
' を消してないか
このへんを見直してな。
② 数字が — のまま
この場合は、だいたい次のどれかや。
repo名のスペルミス
リポジトリが公開状態じゃない
data/ フォルダにCSVが無い
trend_analysis.csv と asset_articles.csv がまだ生成されてない
特に最後の2つは、わりと見落としやすい。
③ URLを開いたら404になる
これは少し待てば直ることもある。
GitHub Pagesって、設定直後は反映に少し時間がかかることがあるから、
5〜10分くらい置いてからもう一回見ると出る場合もある。
まとめ
今回やったことを、雑にまとめるとこうや。
index.html の repo: を自分用に変える
docs/ に置く
GitHub Pagesを有効にする
必要なら analyze.py も回す
これで、前回集めたnoteのログを
ブラウザでかなり見やすく扱えるようになる。
前回の記事が
「データを貯める仕組み」
だとしたら、
今回の記事は「そのデータをちゃんと眺める画面」や。
ここまでつながると、note運営がちょっと変わる。
「なんとなく伸びた気がする」が、
「これが伸びてる」「これは埋もれてる」「これは資産っぽい」
に変わるからや。
こういうのって派手ではない。
でも、一回見えるようになると、じわじわ効いてくる。
割とマジで。
おわりに
このツールは、個人開発の「作ってみた」作品や。
せやから、手厚いサポート窓口とか、
何でも即レスで直しますみたいな感じではない。
そこは先に正直に言っとく。
ただ、今の時代ここはそんなに絶望せんでもええ。
動かんときは、
エラー文
この記事
コード
このへんをそのままChatGPTとかClaudeとかGeminiに投げると、
下手したら人に聞くより早いことも普通にある笑
もしこの記事が
「ちょっと参考になった」
「これ、やってみたい」
と思ったら、スキとかコメントもらえるとうれしい。
まずはCSVを自動で貯めるところからやりたい人は、前回の有料記事から入るとスムーズやで。
で、ここまで実装できたら、あとはHTMLを自分好みにいじるのもおもろい。
こんな感じで、見た目を遊ぶ方向にも広げられる。
もしこの記事が「おもろかった」「ちょっと参考になった」と思ったら、スキ・フォロー・コメントで反応もらえるとうれしい。
ワイ、スキとコメント、かなり好物です笑
ほな、また。
補足
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>note / stats</title>
<script src="/https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="/https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #06080f;
--bg2: #0b0e1a;
--surface: #0f1221;
--surface2: #141729;
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--cyan: #00e5ff;
--cyan-dim: rgba(0,229,255,0.12);
--cyan-glow: rgba(0,229,255,0.25);
--pink: #ff4d8d;
--pink-dim: rgba(255,77,141,0.12);
--gold: #ffc947;
--gold-dim: rgba(255,201,71,0.12);
--violet: #9d6fff;
--violet-dim:rgba(157,111,255,0.12);
--green: #00e5a0;
--red: #ff4d6a;
--text: #f0f4ff;
--text2: #b0bcd8;
--text3: #6b7599;
--mono: 'JetBrains Mono', monospace;
--sans: 'Syne', sans-serif;
--r: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
min-height: 100vh;
overflow-x: hidden;
}
/* ─── NOISE OVERLAY ─── */
body::before {
content: '';
position: fixed; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none; z-index: 999;
}
/* ─── SCROLLBAR ─── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
/* ─── HEADER ─── */
header {
position: sticky; top: 0; z-index: 100;
background: rgba(6,8,15,0.92);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 0 32px;
height: 56px;
display: flex; align-items: center; justify-content: space-between;
}
.logo {
font-family: var(--sans);
font-size: 15px;
font-weight: 800;
letter-spacing: 0.05em;
display: flex; align-items: center; gap: 10px;
}
.logo-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--cyan);
box-shadow: 0 0 12px var(--cyan);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.logo-text { color: var(--text); }
.logo-slash { color: var(--text3); margin: 0 2px; }
.logo-sub { color: var(--cyan); }
.header-right {
display: flex; align-items: center; gap: 24px;
}
#last-updated {
font-size: 12px;
color: var(--text2);
letter-spacing: 0.1em;
}
.header-badge {
font-size: 9px;
padding: 3px 8px;
border: 1px solid var(--border2);
border-radius: 2px;
color: var(--text2);
letter-spacing: 0.15em;
text-transform: uppercase;
}
/* ─── LAYOUT ─── */
.dashboard {
padding: 0;
max-width: 1600px;
margin: 0 auto;
}
/* ─── STATUS BAND (3KPI) ─── */
.status-band {
border-bottom: 1px solid var(--border);
padding: 28px 32px;
display: grid;
grid-template-columns: 1fr 1px 1fr 1px 1fr 2px 1fr;
align-items: center;
gap: 0;
animation: fadeUp 0.6s ease both;
}
.band-divider {
width: 1px;
height: 40px;
background: var(--border2);
justify-self: center;
}
.band-divider.thick {
width: 2px;
background: var(--border2);
height: 60px;
}
.kpi3-item { padding: 0 28px; }
.kpi3-label {
font-size: 15px;
color: var(--text2);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 10px;
}
.kpi3-row {
display: flex; align-items: baseline; gap: 10px;
}
.kpi3-val {
font-family: var(--sans);
font-size: 34px;
font-weight: 700;
color: var(--text);
line-height: 1;
}
.kpi3-arrow {
font-size: 20px;
line-height: 1;
font-family: var(--mono);
}
.kpi3-arrow.up { color: var(--green); }
.kpi3-arrow.down { color: var(--red); }
.kpi3-arrow.flat { color: var(--text3); }
.kpi3-sub {
font-size: 12px;
color: var(--text2);
margin-top: 5px;
}
.status-memo-wrap { padding: 0 28px; }
.status-memo-label {
font-size: 18px;
color: var(--text2);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 10px;
}
#status-memo-text {
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
color: var(--cyan);
line-height: 1.5;
min-height: 40px;
}
/* ─── KPI STRIP ─── */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid var(--border);
animation: fadeUp 0.6s 0.1s ease both;
}
.kpi {
padding: 24px 32px;
border-right: 1px solid var(--border);
position: relative;
overflow: hidden;
transition: background 0.2s;
}
.kpi:last-child { border-right: none; }
.kpi::after {
content: '';
position: absolute;
bottom: 0; left: 32px; right: 32px;
height: 2px;
border-radius: 1px;
opacity: 0;
transition: opacity 0.3s;
}
.kpi:nth-child(1)::after { background: var(--cyan); }
.kpi:nth-child(2)::after { background: var(--pink); }
.kpi:nth-child(3)::after { background: var(--gold); }
.kpi:nth-child(4)::after { background: var(--violet); }
.kpi:hover::after { opacity: 1; }
.kpi:hover { background: var(--surface); }
.kpi-label {
font-size: 18px;
color: var(--text2);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.kpi-label::before {
content: '';
width: 5px; height: 5px; border-radius: 50%;
}
.kpi:nth-child(1) .kpi-label::before { background: var(--cyan); box-shadow: 0 0 6px var(--cyan); }
.kpi:nth-child(2) .kpi-label::before { background: var(--pink); box-shadow: 0 0 6px var(--pink); }
.kpi:nth-child(3) .kpi-label::before { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
.kpi:nth-child(4) .kpi-label::before { background: var(--violet); box-shadow: 0 0 6px var(--violet); }
.kpi-value {
font-family: var(--sans);
font-size: 48px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
}
.kpi-value.v { color: var(--cyan); }
.kpi-value.l { color: var(--pink); }
.kpi-value.a { color: var(--gold); }
.kpi-value.f { color: var(--violet); }
.kpi-diff {
margin-top: 8px;
font-size: 12px;
color: var(--text3);
display: flex; align-items: center; gap: 4px;
}
.kpi-diff.up { color: var(--green); }
.kpi-diff.down { color: var(--red); }
/* ─── GRID ─── */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
.panel {
padding: 28px 32px;
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
position: relative;
animation: fadeUp 0.5s ease both;
}
.panel:nth-child(2n) { border-right: none; }
.panel.full {
grid-column: 1 / -1;
border-right: none;
}
.panel.third {
grid-column: span 1;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.panel-title {
font-size: 18px;
color: var(--text2);
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex; align-items: center; gap: 8px;
}
.panel-title-dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--cyan);
flex-shrink: 0;
}
.panel-badge {
font-size: 10px;
padding: 2px 8px;
border: 1px solid var(--border2);
border-radius: 2px;
color: var(--text3);
letter-spacing: 0.1em;
}
.chart-wrap { position: relative; height: 280px; }
.chart-wrap.tall { height: 360px; }
/* ─── RANKING ─── */
.ranking-list {
display: flex; flex-direction: column; gap: 4px;
max-height: 380px; overflow-y: auto;
padding-right: 4px;
}
.rank-item {
display: grid;
grid-template-columns: 28px 1fr 110px;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--r);
border: 1px solid transparent;
background: var(--surface);
transition: all 0.15s ease;
cursor: default;
}
.rank-item:hover {
background: var(--surface2);
border-color: var(--border2);
transform: translateX(2px);
}
.rank-num {
font-size: 11px;
color: var(--text3);
text-align: center;
font-weight: 600;
}
.rank-num.gold { color: var(--gold); }
.rank-num.silver { color: #a8b0c8; }
.rank-num.bronze { color: #c87850; }
.rank-title {
font-size: 16px;
color: var(--text2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.15s;
}
.rank-item:hover .rank-title { color: var(--text); }
.rank-right {
display: flex; align-items: center; gap: 8px;
}
.rank-bar {
flex: 1; height: 2px;
background: var(--border2);
border-radius: 1px;
overflow: hidden;
}
.rank-bar-fill {
height: 100%;
border-radius: 1px;
transition: width 1s cubic-bezier(0.16,1,0.3,1);
}
.rank-val {
font-size: 12px;
min-width: 44px;
text-align: right;
font-weight: 600;
}
.view-bar { background: var(--cyan); }
.view-val { color: var(--cyan); }
.like-bar { background: var(--pink); }
.like-val { color: var(--pink); }
.worst-bar { background: var(--red); }
.worst-val { color: var(--red); }
/* ─── TABLE ─── */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r);
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
}
.data-table th {
padding: 10px 14px;
background: var(--surface);
color: var(--text2);
text-align: left;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
border-bottom: 1px solid var(--border2);
white-space: nowrap;
position: sticky; top: 0; z-index: 5;
}
.data-table td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
color: var(--text2);
font-size: 14px;
transition: background 0.15s;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
.badge-hot { background: rgba(255,77,106,0.15); color: #ff4d6a; border: 1px solid rgba(255,77,106,0.3); }
.badge-grow { background: rgba(0,229,160,0.12); color: var(--green); border: 1px solid rgba(0,229,160,0.25); }
.badge-idle { background: rgba(61,68,102,0.3); color: var(--text3); border: 1px solid var(--border); }
.bar-cell-wrap {
display: flex; align-items: center; gap: 8px;
}
.inline-bar {
width: 80px; height: 5px;
background: var(--border2);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.inline-bar-fill {
height: 100%;
background: var(--cyan);
border-radius: 3px;
opacity: 1;
}
.num-right { text-align: right; font-weight: 600; color: var(--text); }
.num-accent { color: var(--cyan); }
.num-muted { color: var(--text3); }
/* ─── TWO-COL WORST ─── */
.worst-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.worst-section-title {
font-size: 15px;
color: var(--text3);
letter-spacing: 0.2em;
text-transform: uppercase;
margin-bottom: 12px;
display: flex; align-items: center; gap: 6px;
}
.worst-section-title::before {
content: '';
width: 4px; height: 4px; border-radius: 50%;
background: var(--red);
}
.best-section-title {
font-size: 15px;
color: var(--text3);
letter-spacing: 0.2em;
text-transform: uppercase;
margin-bottom: 12px;
display: flex; align-items: center; gap: 6px;
}
.best-section-title::before {
content: '';
width: 4px; height: 4px; border-radius: 50%;
background: var(--green);
}
.best-bar { background: var(--green); }
.best-val { color: var(--green); }
/* ─── LOADING ─── */
.loading {
display: flex; align-items: center; justify-content: center;
height: 160px;
font-size: 10px;
color: var(--text3);
letter-spacing: 0.1em;
}
.loading::after {
content: '...';
animation: dots 1.2s steps(4, end) infinite;
}
@keyframes dots {
0%,20% { content: ''; }
40% { content: '.'; }
60% { content: '..'; }
80%,100% { content: '...'; }
}
/* ─── ANIMATIONS ─── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.panel:nth-child(1) { animation-delay: 0.05s; }
.panel:nth-child(2) { animation-delay: 0.10s; }
.panel:nth-child(3) { animation-delay: 0.15s; }
.panel:nth-child(4) { animation-delay: 0.20s; }
.panel:nth-child(5) { animation-delay: 0.25s; }
.panel:nth-child(6) { animation-delay: 0.30s; }
.panel:nth-child(7) { animation-delay: 0.35s; }
.panel:nth-child(8) { animation-delay: 0.40s; }
/* ─── RESPONSIVE ─── */
@media (max-width: 900px) {
.kpi-strip { grid-template-columns: repeat(2,1fr); }
.grid { grid-template-columns: 1fr; }
.panel { border-right: none !important; }
.status-band { grid-template-columns: 1fr; gap: 16px; }
.band-divider { display: none; }
.worst-grid { grid-template-columns: 1fr; }
header, .kpi, .panel, .kpi3-item, .status-memo-wrap { padding-left: 16px; padding-right: 16px; }
}
</style>
</head>
<body>
<!-- ═══ HEADER ═══ -->
<header>
<div class="logo">
<div class="logo-dot"></div>
<span class="logo-text">note</span>
<span class="logo-slash">/</span>
<span class="logo-sub">stats</span>
</div>
<div class="header-right">
<div id="last-updated">—</div>
<div class="header-badge">live</div>
</div>
</header>
<div class="dashboard">
<!-- ═══ STATUS BAND (3KPI + memo) ═══ -->
<div class="status-band">
<div class="kpi3-item">
<div class="kpi3-label">Reach Power|リーチ力</div>
<div class="kpi3-row">
<span class="kpi3-val" id="k3-reach">—</span>
<span class="kpi3-arrow" id="k3-reach-arrow"></span>
</div>
<div class="kpi3-sub">PV ÷ 記事数</div>
</div>
<div class="band-divider"></div>
<div class="kpi3-item">
<div class="kpi3-label">Action Power|アクション力</div>
<div class="kpi3-row">
<span class="kpi3-val" id="k3-action">—</span>
<span class="kpi3-arrow" id="k3-action-arrow"></span>
</div>
<div class="kpi3-sub">スキ ÷ 記事数</div>
</div>
<div class="band-divider"></div>
<div class="kpi3-item">
<div class="kpi3-label">η スキ率</div>
<div class="kpi3-row">
<span class="kpi3-val" id="k3-eta">—</span>
<span class="kpi3-arrow" id="k3-eta-arrow"></span>
</div>
<div class="kpi3-sub">スキ ÷ PV × 100</div>
</div>
<div class="band-divider thick"></div>
<div class="status-memo-wrap">
<div class="status-memo-label">📋 Status Memo|自動診断</div>
<div id="status-memo-text">データ読み込み中...</div>
</div>
</div>
<!-- ═══ KPI STRIP ═══ -->
<div class="kpi-strip">
<div class="kpi">
<div class="kpi-label">Total Views</div>
<div class="kpi-value v" id="kpi-views">—</div>
<div class="kpi-diff" id="kpi-views-diff">前日比</div>
</div>
<div class="kpi">
<div class="kpi-label">Total Likes</div>
<div class="kpi-value l" id="kpi-likes">—</div>
<div class="kpi-diff" id="kpi-likes-diff">前日比</div>
</div>
<div class="kpi">
<div class="kpi-label">Articles</div>
<div class="kpi-value a" id="kpi-articles">—</div>
<div class="kpi-diff">公開記事数</div>
</div>
<div class="kpi">
<div class="kpi-label">Followers</div>
<div class="kpi-value f" id="kpi-followers">—</div>
<div class="kpi-diff">フォロワー数</div>
</div>
</div>
<!-- ═══ PANELS ═══ -->
<div class="grid">
<!-- 日次推移 -->
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--cyan)"></span>
ビュー / スキ 日次推移
</div>
<span class="panel-badge">60D</span>
</div>
<div class="chart-wrap"><canvas id="chart-daily"></canvas></div>
</div>
<!-- ビューランキング -->
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--cyan)"></span>
View ランキング TOP 20
</div>
<span class="panel-badge" style="color:var(--cyan);border-color:var(--cyan-dim)">PV</span>
</div>
<div class="ranking-list" id="ranking-views"><div class="loading">loading</div></div>
</div>
<!-- フォロワー推移 -->
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--violet)"></span>
フォロワー 日次推移
</div>
<span class="panel-badge">ALL</span>
</div>
<div class="chart-wrap"><canvas id="chart-followers"></canvas></div>
</div>
<!-- スキランキング -->
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--pink)"></span>
スキ ランキング TOP 20
</div>
<span class="panel-badge" style="color:var(--pink);border-color:var(--pink-dim)">LIKE</span>
</div>
<div class="ranking-list" id="ranking-likes"><div class="loading">loading</div></div>
</div>
<!-- ワースト10 -->
<div class="panel full">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--red)"></span>
改善候補 ワースト分析
</div>
<span class="panel-badge" style="color:var(--red);border-color:rgba(255,77,106,0.2)">WORST 10</span>
</div>
<!-- ワースト -->
<div class="worst-grid" style="margin-bottom:28px">
<div>
<div class="worst-section-title">スキ率 ワースト(読まれてるのに刺さってない)</div>
<div class="ranking-list" id="ranking-worst-eta"><div class="loading">loading</div></div>
</div>
<div>
<div class="worst-section-title">View数 ワースト(埋もれている記事)</div>
<div class="ranking-list" id="ranking-worst-views"><div class="loading">loading</div></div>
</div>
</div>
<!-- 区切り -->
<div style="border-top:1px solid var(--border);margin-bottom:28px;position:relative">
<span style="position:absolute;top:-9px;left:50%;transform:translateX(-50%);background:var(--bg2);padding:0 16px;font-size:9px;color:var(--text3);letter-spacing:0.25em">VS</span>
</div>
<!-- ベスト -->
<div class="worst-grid">
<div>
<div class="best-section-title">スキ率 ベスト(読まれて、かつ刺さった)</div>
<div class="ranking-list" id="ranking-best-eta"><div class="loading">loading</div></div>
</div>
<div>
<div class="best-section-title">View数 ベスト(最も読まれた記事)</div>
<div class="ranking-list" id="ranking-best-views"><div class="loading">loading</div></div>
</div>
</div>
</div>
<!-- 4象限 -->
<div class="panel full">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--violet)"></span>
4象限マップ|PV × スキ率
</div>
<span class="panel-badge">SCATTER</span>
</div>
<div class="chart-wrap tall"><canvas id="chart-quad"></canvas></div>
</div>
<!-- 資産記事 -->
<div class="panel full">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--gold)"></span>
資産記事分析|バースト後の定着確認
</div>
<span class="panel-badge" style="color:var(--gold);border-color:var(--gold-dim)">ASSET</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th style="width:44px">#</th>
<th>タイトル</th>
<th>期間 PV 増加</th>
<th style="width:80px">稼働日数</th>
<th>推移</th>
</tr>
</thead>
<tbody id="asset-body">
<tr><td colspan="5" class="loading">スキャン中</td></tr>
</tbody>
</table>
</div>
</div>
<!-- トレンド詳細 -->
<div class="panel full">
<div class="panel-header">
<div class="panel-title">
<span class="panel-title-dot" style="background:var(--pink)"></span>
トレンド動態|7日間詳細
</div>
<span class="panel-badge">← SCROLL →</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="sticky-1" style="width:80px">状態</th>
<th class="sticky-2">タイトル</th>
<th>累計PV</th>
<th>スコア</th>
<th>-1日</th><th>-2日</th><th>-3日</th><th>-4日</th><th>-5日</th><th>-6日</th><th>-7日</th>
</tr>
</thead>
<tbody id="trend-body">
<tr><td colspan="11" class="loading">読み込み中</td></tr>
</tbody>
</table>
</div>
</div>
</div><!-- /grid -->
</div><!-- /dashboard -->
<script>
// ╔══════════════════════════════════════════════════════════════╗
// ║ ⚙️ CONFIG — ここだけ書き換えてください ║
// ╚══════════════════════════════════════════════════════════════╝
const CONFIG = {
// ① あなたの GitHub ユーザー名 と リポジトリ名
// 形式: 'ユーザー名/リポジトリ名'
// 例 : 'yamada-taro/note-stats-public'
repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',
// ② dataフォルダのパス(リポジトリ直下に data/ がある場合はそのまま)
// 変更不要なことがほとんどです
dataPath: 'data',
// ③ ランキングから除外したい記事タイトル
// 不要なら空配列 [] のままでOK
// 例: ['下書き記事', 'テスト投稿']
excludeTitles: [],
};
// ─── 以下は変更不要 ───────────────────────────────────────────
const EXCLUDED_TITLES = CONFIG.excludeTitles;
const BASE = `https://raw.githubusercontent.com/${CONFIG.repo}/main/${CONFIG.dataPath}/`;
async function fetchCSV(name) {
const res = await fetch(BASE + name + '.csv?t=' + Date.now());
if (!res.ok) throw new Error(name + ' not found');
const text = await res.text();
const lines = text.trim().split('\n');
const headers = lines[0].split(',').map(h => h.trim());
return lines.slice(1).map(line => {
const vals = line.split(',');
const obj = {};
headers.forEach((h, i) => obj[h] = (vals[i] || '').trim());
return obj;
});
}
/* ─── Chart.js defaults ─── */
const GRID_COLOR = 'rgba(255,255,255,0.05)';
Chart.defaults.color = '#3d4466';
Chart.defaults.font.family = "'JetBrains Mono', monospace";
Chart.defaults.font.size = 11;
const baseOpts = () => ({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 1000, easing: 'easeOutQuart' },
plugins: {
legend: {
display: true,
labels: { boxWidth: 8, padding: 16, color: 'rgba(255,255,255,0.85)', usePointStyle: true } // 凡例のカラー指定
},
tooltip: {
backgroundColor: '#0f1221',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
titleColor: '#e8ecf8',
bodyColor: '#8891b0',
padding: 12,
cornerRadius: 6
}
},
scales: {
x: {
grid: { color: GRID_COLOR },
ticks: {
maxTicksLimit: 10,
color: 'rgba(255,255,255,0.85)'
}
},
y: {
grid: { color: GRID_COLOR },
ticks: {
color: 'rgba(255,255,255,0.85)'
}
}
}
});
/* ─── KPI diff ─── */
function setDiff(id, val) {
if (isNaN(val)) return;
const el = document.getElementById(id);
const sign = val >= 0 ? '▲' : '▼';
el.textContent = sign + ' ' + Math.abs(val).toFixed(1) + '% 前日比';
el.className = 'kpi-diff ' + (val >= 0 ? 'up' : 'down');
}
/* ─── 日次推移 ─── */
function buildDaily(rows) {
const r = rows.slice(-60);
const labels = r.map(d => d['日付'] ? d['日付'].slice(5) : '');
const views = r.map(d => Number(d['ビュー合計']));
const likes = r.map(d => Number(d['スキ合計']));
new Chart(document.getElementById('chart-daily'), {
type: 'bar',
data: {
labels,
datasets: [
{
type: 'bar', label: 'スキ合計', data: likes,
backgroundColor: 'rgba(255,77,141,0.35)',
borderColor: 'rgba(255,77,141,0.8)',
borderWidth: 1, yAxisID: 'y1', borderRadius: 2
},
{
type: 'line', label: 'ビュー合計', data: views,
borderColor: '#00e5ff',
backgroundColor: 'rgba(0,229,255,0.04)',
fill: true, tension: 0.35,
pointRadius: 0, pointHoverRadius: 4,
borderWidth: 2, yAxisID: 'y'
}
]
},
options: {
...baseOpts(),
scales: {
x: {
grid: { color: GRID_COLOR },
ticks: {
maxTicksLimit: 10,
color: 'rgba(255,255,255,0.85)' // X軸の文字色
}
},
y: {
position: 'left',
grid: { color: GRID_COLOR },
ticks: {
color: 'rgba(255,255,255,0.85)' // Y軸の文字色
}
},
y1: {
position: 'right',
grid: { display: false },
ticks: {
color: 'rgba(255,255,255,0.85)' // Y1軸の文字色
}
}
}
}
});
}
/* ─── ランキング ─── */
function buildRanking(containerId, rows, valueKey, barClass, valClass) {
if (!rows.length) return;
const latest = rows.reduce((a, b) => a.date > b.date ? a : b).date;
const sorted = rows.filter(r => r.date === latest && !EXCLUDED_TITLES.includes(r.title))
.sort((a, b) => Number(b[valueKey]) - Number(a[valueKey])).slice(0, 20);
const max = Number(sorted[0][valueKey] || 1);
const medal = i => i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
document.getElementById(containerId).innerHTML = sorted.map((r, i) => `
<div class="rank-item">
<span class="rank-num ${medal(i)}">${String(i + 1).padStart(2, '0')}</span>
<span class="rank-title" title="${r.title}">${r.title}</span>
<div class="rank-right">
<div class="rank-bar">
<div class="rank-bar-fill ${barClass}" style="width:${(Number(r[valueKey]) / max * 100).toFixed(1)}%"></div>
</div>
<span class="rank-val ${valClass}">${Number(r[valueKey]).toLocaleString()}</span>
</div>
</div>`).join('');
}
/* ─── フォロワー ─── */
function buildFollowers(summary, followers) {
const fRows = followers.filter(r => r['フォロワー数']);
let labels, data;
if (fRows.length > 1) {
labels = fRows.map(r => r['日付'] ? r['日付'].slice(5) : '');
data = fRows.map(r => Number(r['フォロワー数']));
} else {
const s = summary.filter(r => r['フォロワー数']);
labels = s.map(r => r['日付'] ? r['日付'].slice(5) : '');
data = s.map(r => Number(r['フォロワー数']));
}
new Chart(document.getElementById('chart-followers'), {
type: 'line',
data: {
labels,
datasets: [{
label: 'フォロワー数', data,
borderColor: '#9d6fff',
backgroundColor: 'rgba(157,111,255,0.06)',
fill: true, tension: 0.35,
pointRadius: 0, pointHoverRadius: 4,
borderWidth: 2
}]
},
options: baseOpts()
});
}
/* ─── 4象限 ─── */
function buildQuad(rows) {
const data = rows.map(r => ({
x: parseFloat(r['ビュー/記事']) || 0,
y: parseFloat(r['スキ率(%)']) || 0,
label: r['日付']
}));
new Chart(document.getElementById('chart-quad'), {
type: 'scatter',
data: {
datasets: [{
label: '日次動態',
data,
backgroundColor: 'rgba(157,111,255,0.5)',
pointRadius: 5,
pointHoverRadius: 7
}]
},
options: {
...baseOpts(),
scales: {
x: {
grid: { color: GRID_COLOR },
ticks: {
maxTicksLimit: 10,
color: 'rgba(255,255,255,0.85)'
},
title: {
display: true,
text: 'Views / Article',
color: 'rgba(255,255,255,0.85)'
}
},
y: {
grid: { color: GRID_COLOR },
ticks: {
color: 'rgba(255,255,255,0.85)'
},
title: {
display: true,
text: 'Like Rate (%)',
color: 'rgba(255,255,255,0.85)'
}
}
}
}
});
}
/* ─── 資産記事 ─── */
function buildAssetTable(rows) {
const tbody = document.getElementById('asset-body');
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text3);padding:20px">No asset data</td></tr>'; return; }
const maxGain = Math.max(...rows.map(r => Number(r['期間増加PV'] || 0)), 1);
tbody.innerHTML = rows.slice(0, 15).map((r, i) => {
const gain = Number(r['期間増加PV'] || 0);
const barW = (gain / maxGain * 100).toFixed(1);
const trend = r['推移PV'] || '---';
return `
<tr>
<td class="num-muted" style="text-align:center">${i + 1}</td>
<td style="max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text2)">${r.title}</td>
<td>
<div class="bar-cell-wrap">
<div class="inline-bar"><div class="inline-bar-fill" style="width:${barW}%"></div></div>
<span class="num-accent" style="font-weight:700;font-size:16px">${gain.toLocaleString()}</span>
</div>
</td>
<td class="num-right" style="color:var(--gold);font-size:16px;font-weight:700;text-align:center">${r['稼働日数']}</td>
<td style="font-size:15px;color:var(--text3);white-space:nowrap">${trend}</td>
</tr>`;
}).join('');
}
/* ─── トレンド詳細 ─── */
function buildTrendDetailed(rows) {
const tbody = document.getElementById('trend-body');
if (!rows.length) return;
tbody.innerHTML = rows.slice(0, 20).map(r => {
const state = r['状態'] || '';
let badgeClass = 'badge-idle';
if (state.includes('🔥')) badgeClass = 'badge-hot';
else if (state.includes('🟢')) badgeClass = 'badge-grow';
const dailyCells = [1, 2, 3, 4, 5, 6, 7].map(i => {
const v = Number(r[`-${i}日PV`] || 0);
return `<td class="${v > 0 ? 'num-right' : 'num-muted'}" style="text-align:right">${v || '—'}</td>`;
}).join('');
return `
<tr>
<td class="sticky-1"><span class="status-badge ${badgeClass}">${state}</span></td>
<td class="sticky-2" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text2)">${r['title']}</td>
<td class="num-right">${Number(r['累計PV']).toLocaleString()}</td>
<td class="num-accent" style="text-align:center;font-weight:700">${r['トレンドスコア']}</td>
${dailyCells}
</tr>`;
}).join('');
}
/* ─── 3KPI + 状態メモ ─── */
function build3KPI(summary) {
if (summary.length < 2) return;
const today = summary[summary.length - 1];
const yesterday = summary[summary.length - 2];
const ac = Number(today['記事数']) || 1;
const acY = Number(yesterday['記事数']) || 1;
const reach = Number(today['ビュー合計']) / ac;
const reachY = Number(yesterday['ビュー合計']) / acY;
const action = Number(today['スキ合計']) / ac;
const actionY = Number(yesterday['スキ合計']) / acY;
const eta = Number(today['スキ合計']) / (Number(today['ビュー合計']) || 1) * 100;
const etaY = Number(yesterday['スキ合計']) / (Number(yesterday['ビュー合計']) || 1) * 100;
function arrow(now, prev) {
const d = now - prev;
if (d > prev * 0.01) return { s: '↑', c: 'up' };
if (d < -prev * 0.01) return { s: '↓', c: 'down' };
return { s: '→', c: 'flat' };
}
const aR = arrow(reach, reachY);
const aA = arrow(action, actionY);
const aE = arrow(eta, etaY);
document.getElementById('k3-reach').textContent = reach.toFixed(1);
document.getElementById('k3-action').textContent = action.toFixed(2);
document.getElementById('k3-eta').textContent = eta.toFixed(2) + '%';
[['reach', aR], ['action', aA], ['eta', aE]].forEach(([id, a]) => {
const el = document.getElementById(`k3-${id}-arrow`);
el.textContent = a.s;
el.className = `kpi3-arrow ${a.c}`;
});
const key = `${aR.c}_${aA.c}_${aE.c}`;
const memos = {
'up_up_up': '🔥 全指標上昇。コンテンツ・タイトル・スキ率すべて好調',
'up_up_flat': '📈 リーチもアクションも増加。スキ率は維持で安定成長',
'up_up_down': '⚠️ 露出とスキは増えたがスキ率は低下。量で稼いでいる状態',
'up_flat_up': '✅ リーチ増・スキ率改善。質の高い流入が増えている',
'up_flat_flat': '📊 リーチだけ増加。スキ率は変わらず。タイトル効果の可能性',
'up_flat_down': '⚠️ 露出は増えたが刺さってない。タイトル勝ちの可能性',
'up_down_up': '🤔 PV増・スキ減・スキ率上昇。データ確認推奨',
'up_down_flat': '📉 リーチ増だがスキ横ばい。内容が刺さっていない',
'up_down_down': '❌ 露出は増えたが全く刺さってない。内容またはターゲットの見直しを',
'flat_up_up': '✨ PV横ばいでスキとスキ率が上昇。コアなファンに届いている',
'flat_up_flat': '👍 アクション力が上がっている。スキしやすい記事が増えた可能性',
'flat_up_down': '🔄 スキは増えたがスキ率は低下。PVも増えた可能性',
'flat_flat_up': '💡 全体横ばいだがスキ率改善。静かに質が上がっている',
'flat_flat_flat':'😐 全指標横ばい。変化なし。新しいアクションを検討',
'flat_flat_down':'😶 スキ率だけ低下。最近の記事の質を確認',
'flat_down_up': '🤔 スキ減・スキ率上昇は矛盾。データ確認推奨',
'flat_down_flat':'📉 アクション力低下。スキしにくい記事が続いている',
'flat_down_down':'⚠️ アクション・スキ率ともに低下。内容の見直しタイミング',
'down_up_up': '🌱 PV減でもスキとスキ率が上昇。少数精鋭に届いている',
'down_up_flat': '🔄 PV減だがスキは増加。ファン向け記事が増えた可能性',
'down_up_down': '📉 PV減による相対効果の可能性。実態確認を',
'down_flat_up': '💤 PV減・スキ率改善。深いファンには刺さっている',
'down_flat_flat':'📉 リーチのみ低下。スキ率は維持。コンテンツ質は悪くない',
'down_flat_down':'⚠️ リーチ低下・スキ率も低下。発信ペースや内容を見直し',
'down_down_up': '💤 PV減による相対効果の可能性',
'down_down_flat':'😔 リーチ・アクション低下。停滞期の可能性',
'down_down_down':'🚨 全指標低下。内容・頻度・タイトルを総見直し',
};
document.getElementById('status-memo-text').textContent = memos[key] || '状態を解析中...';
}
/* ─── ワースト10 ─── */
function buildWorstRanking(rows) {
if (!rows.length) return;
const latest = rows.reduce((a, b) => a.date > b.date ? a : b).date;
const all = rows.filter(r =>
r.date === latest &&
Number(r['read_count']) > 0 &&
!EXCLUDED_TITLES.includes(r.title)
);
// スキ率ワースト(50PV以上)
const etaWorst = all
.filter(r => Number(r['read_count']) >= 50)
.map(r => ({ ...r, eta: Number(r['like_count']) / Number(r['read_count']) * 100 }))
.sort((a, b) => a.eta - b.eta)
.slice(0, 10);
const etaMax = Math.max(...etaWorst.map(r => r.eta), 1);
document.getElementById('ranking-worst-eta').innerHTML = etaWorst.map((r, i) => `
<div class="rank-item">
<span class="rank-num">${String(i + 1).padStart(2, '0')}</span>
<span class="rank-title" title="${r.title}">${r.title}</span>
<div class="rank-right">
<div class="rank-bar"><div class="rank-bar-fill worst-bar" style="width:${(r.eta / etaMax * 100).toFixed(1)}%"></div></div>
<span class="rank-val worst-val">${r.eta.toFixed(1)}%</span>
</div>
</div>`).join('');
// Viewワースト
const viewWorst = [...all].sort((a, b) => Number(a['read_count']) - Number(b['read_count'])).slice(0, 10);
const viewMax = Math.max(...viewWorst.map(r => Number(r['read_count'])), 1);
document.getElementById('ranking-worst-views').innerHTML = viewWorst.map((r, i) => `
<div class="rank-item">
<span class="rank-num">${String(i + 1).padStart(2, '0')}</span>
<span class="rank-title" title="${r.title}">${r.title}</span>
<div class="rank-right">
<div class="rank-bar"><div class="rank-bar-fill worst-bar" style="width:${(Number(r['read_count']) / viewMax * 100).toFixed(1)}%"></div></div>
<span class="rank-val worst-val">${Number(r['read_count']).toLocaleString()}</span>
</div>
</div>`).join('');
}
/* ─── ベスト10 ─── */
function buildBestRanking(rows) {
if (!rows.length) return;
const latest = rows.reduce((a, b) => a.date > b.date ? a : b).date;
const all = rows.filter(r =>
r.date === latest &&
Number(r['read_count']) > 0 &&
!EXCLUDED_TITLES.includes(r.title)
);
// スキ率ベスト(50PV以上)
const etaBest = all
.filter(r => Number(r['read_count']) >= 50)
.map(r => ({ ...r, eta: Number(r['like_count']) / Number(r['read_count']) * 100 }))
.sort((a, b) => b.eta - a.eta)
.slice(0, 10);
const etaMax = Math.max(...etaBest.map(r => r.eta), 1);
const medal = i => i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
document.getElementById('ranking-best-eta').innerHTML = etaBest.map((r, i) => `
<div class="rank-item">
<span class="rank-num ${medal(i)}">${String(i + 1).padStart(2, '0')}</span>
<span class="rank-title" title="${r.title}">${r.title}</span>
<div class="rank-right">
<div class="rank-bar"><div class="rank-bar-fill best-bar" style="width:${(r.eta / etaMax * 100).toFixed(1)}%"></div></div>
<span class="rank-val best-val">${r.eta.toFixed(1)}%</span>
</div>
</div>`).join('');
// Viewベスト
const viewBest = [...all].sort((a, b) => Number(b['read_count']) - Number(a['read_count'])).slice(0, 10);
const viewMax = Math.max(...viewBest.map(r => Number(r['read_count'])), 1);
document.getElementById('ranking-best-views').innerHTML = viewBest.map((r, i) => `
<div class="rank-item">
<span class="rank-num ${medal(i)}">${String(i + 1).padStart(2, '0')}</span>
<span class="rank-title" title="${r.title}">${r.title}</span>
<div class="rank-right">
<div class="rank-bar"><div class="rank-bar-fill best-bar" style="width:${(Number(r['read_count']) / viewMax * 100).toFixed(1)}%"></div></div>
<span class="rank-val best-val">${Number(r['read_count']).toLocaleString()}</span>
</div>
</div>`).join('');
}
/* ─── CONFIG チェック ─── */
function validateConfig() {
if (CONFIG.repo === 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME' || !CONFIG.repo.includes('/')) {
document.getElementById('last-updated').textContent = '⚠️ CONFIG未設定';
document.querySelector('.dashboard').innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;padding:32px">
<div style="max-width:560px;border:1px solid rgba(255,201,71,0.3);border-radius:8px;padding:40px;background:rgba(255,201,71,0.05)">
<div style="font-size:22px;font-weight:700;color:#ffc947;margin-bottom:16px">⚙️ 設定が必要です</div>
<div style="color:#b0bcd8;line-height:1.9;font-size:14px">
このファイルを使うには、スクリプト冒頭の <code style="color:#00e5ff;background:rgba(0,229,255,0.1);padding:2px 6px;border-radius:3px">CONFIG</code> を編集してください。<br><br>
<strong style="color:#f0f4ff">① HTMLファイルをテキストエディタで開く</strong><br>
(メモ帳 / VSCode など何でもOK)<br><br>
<strong style="color:#f0f4ff">② 以下の1行を書き換える</strong><br>
<div style="background:#0f1221;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:14px;margin:12px 0;font-family:monospace;font-size:13px">
<span style="color:#6b7599">// 変更前</span><br>
<span style="color:#ff4d6a">repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME'</span><br><br>
<span style="color:#6b7599">// 変更後(例)</span><br>
<span style="color:#00e5a0">repo: 'yamada-taro/note-stats-public'</span>
</div>
<strong style="color:#f0f4ff">③ ファイルを保存してブラウザで再度開く</strong>
</div>
</div>
</div>`;
return false;
}
return true;
}
/* ─── INIT ─── */
async function init() {
if (!validateConfig()) return;
try {
const [summary, articles, followers, trend, assets] = await Promise.all([
fetchCSV('daily_summary'),
fetchCSV('articles'),
fetchCSV('followers'),
fetchCSV('trend_analysis'),
fetchCSV('asset_articles')
]);
const latest = summary[summary.length - 1];
document.getElementById('kpi-views').textContent = Number(latest['ビュー合計']).toLocaleString();
document.getElementById('kpi-likes').textContent = Number(latest['スキ合計']).toLocaleString();
document.getElementById('kpi-articles').textContent = Number(latest['記事数']).toLocaleString();
document.getElementById('kpi-followers').textContent = latest['フォロワー数'] || '—';
document.getElementById('last-updated').textContent = `updated ${latest['日付']} ${latest['更新時刻'] || ''}`;
setDiff('kpi-views-diff', parseFloat(latest['ビュー前日比(%)']));
setDiff('kpi-likes-diff', parseFloat(latest['スキ前日比(%)']));
build3KPI(summary);
buildDaily(summary);
buildRanking('ranking-views', articles, 'read_count', 'view-bar', 'view-val');
buildFollowers(summary, followers);
buildRanking('ranking-likes', articles, 'like_count', 'like-bar', 'like-val');
buildWorstRanking(articles);
buildBestRanking(articles);
buildQuad(summary);
buildAssetTable(assets);
buildTrendDetailed(trend);
} catch (e) {
console.error(e);
document.getElementById('last-updated').textContent = 'error: ' + e.message;
}
}
init();
</script>
</body>
</html>#note運営 #GitHubPages #GitHubActions #データ分析 #ダッシュボード #可視化 #HTML #CSV #運営分析 #アクセス解析 #note #仕事 #ビジネス #自分 #日記 #学び #勉強 #ライフハック #情報整理 #業務改善
いいなと思ったら応援しよう!
よろしければ応援お願いします! いただいたチップはクリエイターとしての活動費に使わせていただきます!