メインコンテンツへスキップ
見出し画像

note運営を“感覚”から“見える化”へ|GitHub Pagesでダッシュボードを作る|データ分析|#286

    前にワイは、noteのPVとかスキ数をGitHub Actionsで自動収集する方法を記事にした。

    で、今回はその次や。

    せっかくCSVが毎日たまっていくようになっても、
    見にくかったら、たぶん人はそんなに見返さん。

    これ、割とある笑

    なので今回は、前に集めたnoteのデータを
    ブラウザで見やすく可視化する方法をまとめる。

    やることは、そこまで重くない。

    • HTMLファイルを1個置く

    • 設定をほぼ1行だけ変える

    • GitHub Pagesで開く

    これで、PV推移とかランキングとか資産記事っぽいやつまで、
    ブラウザで見られるようになる。


    作成したダッシュボード:PC向け

    画像
    ダッシュボード画面-1
    画像
    ダッシュボード画面-2
    画像
    ダッシュボード画面-3

    ほな、いくで!



    この記事でできること

    このダッシュボードを入れると、こんな感じのものが見られるようになる。

    • 累計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/ フォルダにアップロードする。

    流れはこんな感じ。

    1. GitHubでリポジトリを開く

    2. docs フォルダを開く

    3. Add file → Upload files

    4. index.html をアップロード

    5. Commit changes

    もし docs フォルダがまだ無いなら、
    docs/index.html って名前で新規作成すれば、フォルダごと作れる。


    Step 6|GitHub Pagesを有効にする

    次に、GitHub Pagesをオンにする。

    やることはこんな感じ。

    1. リポジトリの Settings を開く

    2. 左側の Pages を開く

    3. Source を Deploy from a branch にする

    4. ブランチを main、フォルダを /docs にする

    5. Save

    これでしばらくすると、URLが発行される。

    だいたいこういう形や。

    https://あなたのユーザー名.github.io/リポジトリ名/

    このURLをブラウザで開いて、ダッシュボードが出たら完了や。
    おつかれさまです🎉


    うまくいかんときの確認ポイント

    ① 「⚙️ 設定が必要です」と出る

    だいたい repo: の設定ミスや。

    • 書き換え忘れてないか

    • ユーザー名/リポジトリ名 の形になってるか

    • ' を消してないか

    このへんを見直してな。


    ② 数字が — のまま

    この場合は、だいたい次のどれかや。

    • repo名のスペルミス

    • リポジトリが公開状態じゃない

    • data/ フォルダにCSVが無い

    • trend_analysis.csv と asset_articles.csv がまだ生成されてない

    特に最後の2つは、わりと見落としやすい。


    ③ URLを開いたら404になる

    これは少し待てば直ることもある。

    GitHub Pagesって、設定直後は反映に少し時間がかかることがあるから、
    5〜10分くらい置いてからもう一回見ると出る場合もある。


    まとめ

    今回やったことを、雑にまとめるとこうや。

    1. index.html の repo: を自分用に変える

    2. docs/ に置く

    3. GitHub Pagesを有効にする

    4. 必要なら 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 #仕事 #ビジネス #自分 #日記 #学び #勉強 #ライフハック #情報整理 #業務改善


     
     
    データと生成AIで「つくって壊して、また組み直す」実験ログ。 プロンプト・AIアート・分析ツールを公開中。 ツール・記事ネタを持ち帰れる場所です。 呼び名:にーやん/きたさん|PR・ご相談歓迎|Amazonアソシエイト参加中

    あなたへのおすすめ