STGYの記事外部公開機能

本記事では、STGYの記事をログインしていない閲覧者へ外部公開する仕組みと、公開記事のシェア、コメント、ページビューを数えるアクセスカウンタについて説明する。外部公開ページは、SNS内部の投稿データを別のブログデータへ複製するのではなく、同じ投稿を公開日時で選別して、ブログ風のレイアウトで表示する。シェアとコメントは公開サイト設定の拡張領域から有効化し、コメント本体は専用テーブルへ保存する。アクセスカウンタは、個々のアクセス履歴をPostgreSQLへ蓄積せず、Redis上の日次カウンタを主カウンタとして高速に更新し、疎なチェックポイントだけをPostgreSQLへ保存する。PublicationsのStatsでは両者を日付単位で突き合わせ、直近15日間を集計する。

SNS内部の記事画面は、投稿者、イイネ、返信、タグ、編集操作などを中心に設計される。一方で外部公開ページには、サイト名、記事タイトル、公開日時、前後記事、プロフィール、最近の記事、人気記事、OGPメタデータなどが必要になる。STGYでは、投稿の正本とMarkdown変換処理を共用しながら、内部画面と外部画面の取得APIと表示コンポーネントを分離している。

アクセスカウンタについても、一般的なアクセス解析サービスのように、時刻、URL、IPアドレス、User-Agent、参照元などを一件ずつ保存するものではない。必要なのは、公開サイトの所有者が直近の閲覧状況を把握し、公開ページのサイドバーへPopular entriesを出すことである。目的をそこへ限定し、保存量と集計量を小さくしている。

公開状態の正本

投稿の外部公開状態は、posts.published_at だけで管理する。

CREATE TABLE posts (
  id BIGINT PRIMARY KEY,
  owned_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  reply_to BIGINT,
  published_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ,
  snippet VARCHAR(4096) NOT NULL,
  locale VARCHAR(50),
  allow_likes BOOLEAN NOT NULL,
  allow_replies BOOLEAN NOT NULL
);

published_at がnullなら外部非公開である。値があっても、その時刻が未来なら予約公開の状態であり、外部公開APIはまだ返さない。現在時刻以前になった投稿だけが公開済みとして扱われる。

published_at = NULL       → 非公開
published_at > 現在時刻   → 予約公開
published_at <= 現在時刻  → 公開中

投稿一覧のメニューにあるExternal publicationダイアログでは、Publish this postの有無と公開日時を設定する。公開を解除すると published_at をnullへ戻す。公開用の記事テーブルや静的HTMLファイルは作らないため、公開後に投稿本文を編集すれば、外部公開ページにも同じ内容が反映される。これは公開時点のスナップショットを保存する方式ではなく、SNS内の投稿を現在の内容で外部表示する方式である。

公開日時は投稿IDとは独立している。作成から時間が経った記事を新しい公開記事として出したり、複数の記事へ任意の公開順を設定したりできる。公開一覧と前後記事の順序は、published_at と投稿IDの組で決める。同じ公開日時を持つ投稿があっても、投稿IDを第二キーにすることで順序が一意になる。

外部公開ページの構成

外部公開には、ユーザ単位のサイトページと、投稿単位の記事ページがある。

/sites/{userId}  公開記事の一覧とサイト紹介
/pub/{postId}    一つの公開記事

サイトページは、公開済み投稿を公開日時順で取得する。Rich表示では投稿のJSONスニペットを記事風に描画し、Plain表示では公開日、タイトル、著者、説明を簡潔な一覧にする。検索時にはKWIC/Rich/Plainを切り替えられる。新しい順と古い順を切り替えられ、RichとPlainではそれぞれ別のページサイズを設定できる。

記事ページは、Markdown本文を外部公開用のHTMLへ変換し、公開日時、前後記事、サイドバーを付ける。サイドバーには、設定に応じて次の要素を表示する。

  • サイトのプロフィール
  • 最近公開された記事
  • 直近15日間のPopular entries

最近の記事とPopular entriesは最大20件に制限する。DBに保存された設定値自体は変更せず、取得および表示時に Config.PUB_SIDE_POSTS_MAX で丸める。設定ミスで非常に大きな件数が入っても、公開ページのDB問い合わせやHTML生成が無制限に増えないようにするためである。

公開ページのデザインは、通常の横書き、新聞風、ホワイトボード風、ダークテーマに加えて、縦書き系のテーマを選べる。Markdown本文そのものは共通だが、縦書きテーマではMarkdown変換時にwriting modeを渡して、文字方向に依存する表現を変換する。記事内の画像や経路地図は、サーバ側で基本HTMLを生成した後、ブラウザ上の専用コンポーネントで操作可能な表示へ仕上げる。

公開サイト設定

ユーザごとの外部公開設定は user_pub_configs に保存する。

CREATE TABLE user_pub_configs (
  user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  site_name VARCHAR(50) NOT NULL DEFAULT '',
  subtitle VARCHAR(50) NOT NULL DEFAULT '',
  author VARCHAR(50) NOT NULL DEFAULT '',
  introduction VARCHAR(1000) NOT NULL DEFAULT '',
  design_theme VARCHAR(50) NOT NULL DEFAULT '',
  show_service_header BOOLEAN NOT NULL DEFAULT TRUE,
  show_site_name BOOLEAN NOT NULL DEFAULT TRUE,
  show_pagenation BOOLEAN NOT NULL DEFAULT TRUE,
  show_side_profile BOOLEAN NOT NULL DEFAULT TRUE,
  show_side_recent INTEGER NOT NULL DEFAULT 5,
  show_side_popular INTEGER NOT NULL DEFAULT 5,
  extensions VARCHAR(4096) NOT NULL DEFAULT '{}'
);

サイト名、サブタイトル、既定の著者名、紹介文、デザインテーマのほか、サービスヘッダ、サイト名、前後記事、プロフィールの表示を切り替えられる。最近の記事とPopular entriesはBooleanではなく件数で指定し、0以下なら非表示とする。

extensions は、外部サービス連携などの追加設定を格納する拡張領域である。基本的な公開設定は型を固定した個別カラムに置き、将来追加・変更されやすい設定だけを一つのコンパクトなJSON文字列として保存する。これにより、拡張機能を追加するたびに user_pub_configs のカラムを増やす必要がない。DB上では最大4096文字の VARCHAR だが、APIではJSONオブジェクトとして入出力する。設定行が存在しない場合を含め、拡張設定が無い状態は空オブジェクト {} で表す。

現在の主な内容は次の通りである。

{
  "shareButtons": ["x", "facebook", "line", "hatena"],
  "comments": {
    "mode": "moderated"
  },
  "analytics": {
    "googleAnalytics": {
      "measurementId": "G-XXXXXXXXXX"
    }
  }
}

shareButtons は外部公開記事に表示するシェアサービスの識別子配列で、現在の設定画面はX、Facebook、LINE、Hatenaを扱う。comments.mode は外部コメントを none、moderated、open のいずれかに設定する。未指定の場合は none として扱う。analytics.googleAnalytics.measurementId はGoogle Analytics連携用のMeasurement IDを保持し、最大128文字である。この値が空でない場合、/sites/<ユーザID> と /pub/<投稿ID> は gtag.js を読み込み、そのMeasurement IDへ手動の page_view を送信する。二重計測を避けるため、config コマンドによる自動 page_view は無効にする。イベントには page_location と page_title に加えて content_group=stgy-user-<公開ユーザID> を含め、サイトページでは content_id=<公開ユーザID>、content_type=site、記事ページでは content_id=<投稿ID>、content_type=post を送る。content_group により /sites/<ユーザID> と /pub/<投稿ID> というURL構造に依存せず公開ユーザ単位でサイトと記事をまとめられ、content_id にはサイトまたは投稿の個別IDが残る。設定画面とAPIはMeasurement IDを保存・復元するが、Google Analyticsの計測データそのものをSTGYが保存することはない。未知のトップレベルキーや分析プロバイダ用の追加キーも保持できるため、将来の拡張と前方互換性を確保できる。

バックエンドは extensions を保存前と読出し後の両方で検証する。全体のJSONは4096文字以内とし、shareButtons の各識別子は1〜64文字の英数字・_・- に限定して重複を許さない。comments.mode は none、moderated、open のみを許可する。analytics のプロバイダ名も同じ文字種で1〜64文字に限定する。DBにはJSON文字列として保存するが、GET /users/{id}/pub-config と更新APIの入出力では通常のJSONオブジェクトとして扱う。データエクスポートではこの extensions も pub-config.json に含め、scripts/import-archive.py はシェア、コメント、Google Analyticsを含む extensions をそのまま公開設定更新APIへ戻す。

設定画面はPublicationsページのSettingsタブに置く。設定の読み出しは外部公開ページからも必要なので公開APIであるが、更新は本人または管理者だけに許可する。設定レコードがまだない場合は既定値を返すため、外部公開を始める前に設定行を明示的に作っておく必要はない。

公開記事のシェア

シェア表示は PubShareButtons に集約する。認識する識別子は x、facebook、line、hatena で、表示順もX、Facebook、LINE、Hatenaに固定する。設定配列にそれ以外の値が残っていても表示対象にはしない。全て無効ならシェア領域自体を描画しない。

/pub/{postId} では記事本文の左下にシェアボタンを置く。/sites/{userId} のRich、Plain、KWICスニペットにはシェアボタンを表示せず、記事一覧は閲覧に専念させる。シェア操作は個別の記事ページから行う。

共有先へ渡すURLは、デザイン指定などのクエリ文字列を付けない正規の /pub/{postId} である。横書きでは、Xは platform.twitter.com/widgets.js の公式ボタン、Facebookは facebook.com/plugins/share_button.php の button_count iframeを102px幅で直接埋め込み、LINEはSocial Pluginsのshare URL、HatenaはBookmark公式widgetとHatena Star v2を使う。FacebookはXFBML/JavaScript SDKを使わず、LINEもURLと記事タイトルをURL encodeしてshare URLへ渡すだけなので追加のLINE JavaScript SDKは読み込まない。縦書きでは外部widgetの横方向レイアウトを持ち込まず、X、Facebook、LINEを24pxのcompact linkとして表示し、Hatena BookmarkとStarも同じ縦列へ収める。Hatenaを有効にするとBookmarkとStarを一組として表示する。

外部widget用scriptは必要な場合だけClient Componentのhydration後に読み込む。Facebookの直接iframeとLINEのshare URLには追加scriptを使わない。特にHatena StarはサーバHTMLをhydration前に書き換えないよう、クライアント側からv2 widgetを起動する。Hatena Bookmarkの全document scanはまとめて実行し、同じscriptを重複して走らせない。

外部公開コメント

外部コメントはSNS内部のreplyとは別データである。コメントは匿名でも投稿できるため、SNSユーザとの関係を必須にせず、次の専用テーブルへ保存する。

CREATE TABLE post_pub_comments (
  id BIGINT PRIMARY KEY,
  post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  nickname VARCHAR(50) NOT NULL,
  body VARCHAR(65536) NOT NULL,
  status VARCHAR(32) NOT NULL CHECK (status IN ('pending', 'published')),
  is_author BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_post_pub_comments_post_id ON post_pub_comments(post_id, id DESC);

コメント行には投稿者のSTGYユーザID、メールアドレス、生のIPアドレスを保存しない。nickname は実名を意味しない表示名で、is_author はログイン中の記事所有者または管理者がUI上の as owner を選んで投稿したことを表す。既存API/DBとの互換性のためフィールド名は asAuthor / is_author のまま維持する。作成日時カラムも持たず、他のSnowflake系IDと同様にコメントIDから時刻を復元する。

コメント欄を表示して新規投稿を受け付けるには、記事が現在外部公開中であることに加えて、投稿の allow_replies がtrueで、所有者の extensions.comments.mode が none 以外でなければならない。モードの意味は次の通りである。

*mode**新規コメント*
noneコメント欄を表示せず新規投稿も受け付けない。既存行は削除しない
moderated通常の閲覧者は pending、ログイン中の記事所有者または管理者は published
open新規コメントを published

モード変更では既存コメントのstatusを書き換えない。たとえば moderated から open へ変えても、既存の pending は自動公開しない。記事所有者または管理者がログインして投稿する場合は moderated でも即時公開される。両者ともUI上の as owner を選べるが、初期値は記事所有者ならON、記事所有者ではない管理者ならOFFとし、保存済みの選択があればそれを再利用する。is_author がtrueになるのは as owner を選んだ場合だけである。

コメントAPIと権限

公開記事のコメントUIが使う主なAPIは次の通りである。

*API**用途*
GET /pub-comments?postId=...&page=1&order=newestコメント一覧
GET /pub-comments/form-state?postId=...CAPTCHA要否、保存nickname、as-owner状態、上限到達状態
POST /pub-comments通常のコメント投稿
PATCH /pub-comments/{id}pending承認、または許可されたコメント編集
DELETE /pub-comments/{id}コメント削除
GET /pub-comments/export/{postId}所有者本人のデータエクスポート用全件取得
POST /pub-comments/import管理者専用のアーカイブ復元

通常一覧は1ページ10件固定で、newest と oldest を切り替える。一般閲覧者には published だけを返すが、記事所有者と管理者には同じ公開ページ上で pending も返す。記事所有者は自分の記事の pending を承認でき、自分の記事のコメントを削除できる。編集は、記事所有者についてはその記事の is_author=true のコメントを許可し、管理者は任意のコメントを編集・承認・削除できる。コメント行には投稿者のユーザIDを持たないため、is_author=true なら記事所有者自身が投稿したものか、管理者が as owner で投稿したものかを区別しない。

通常の入力制限はnickname 30文字、本文1000文字である。新規コメントの入力フォームでは、Post commentボタン行の右端に現在の本文文字数/1000を表示し、上限を超えた場合は Exceeding the limit という警告も表示して投稿ボタンを無効化する。nicknameは制御文字を除去して空白を一つへまとめ、本文は改行をLFへ統一し、先頭の空行を除き、3個以上の連続LFを2個へ丸め、末尾を正確に1個のLFにする。DBの50文字/65536文字という余裕は、将来のアプリ側制限変更やアーカイブ復元のためであり、通常フォームの上限ではない。1記事あたりの保存コメント数は STGY_PUB_COMMENT_MAX_COMMENTS で制限し、デフォルトはpendingを含めて100件である。

コメント一覧と入力フォームはClient Componentで動く。記事本文はSSRされるが、コメント内容はhydration後に GET /pub-comments で取得する。入力フォームは初期HTMLへ出さず、Write a comment を押した後に form-state を取得して開く。投稿成功後はNewestの1ページ目へ戻し、Comments見出しへスクロールする。nicknameと、記事所有者または管理者が利用できる as owner の選択は、HttpOnly、SameSite=Laxの1年cookieへ保存して次回入力に再利用する。縦書きテーマでは、コメント本文の表示にも他の縦書きテキストと同じ方向依存変換を適用し、ASCII空白と印字可能ASCII文字を全角化する。DBに保存する本文と編集フォームへ戻す文字列は変換せず、入力された内容をそのまま保持する。

CAPTCHAとpass token

ログイン中の記事所有者または管理者以外がコメントする場合は、CAPTCHA passが有効でなければ6桁数字CAPTCHAを要求する。記事所有者と管理者はCAPTCHAを免除する。challenge画像は200×48pxのPNGで、コード内の5×7 bitmap数字を5倍へ拡大し、各桁を最大±10度回転、配置をX/Y各±1pxずらし、背景の約10%へ黒いditherを入れて生成する。challengeはRedisに保存し、デフォルトTTLは600秒で、一度正解すると削除する。

CAPTCHAの汎用APIはコメントAPIとは分離している。

*API**用途*
GET /captcha/status現在のpass状態
POST /captcha/challengechallenge生成
POST /captcha/verifychallengeだけを事前検証してpass発行
DELETE /captcha/passpass破棄

POST /captcha/challenge と POST /captcha/verify には X-STGY-MinuteHash を要求する。ブラウザはUTC現在分の YYYYMMDDhhmm をSHA-1した40桁hexを送り、Backendは現在分と前後1分を受理する。これは秘密鍵による認証ではなく、汎用botにSTGY固有の手順を追加で実装させるための軽量なプロトコルである。通常の POST /pub-comments は別途 X-STGY-QueryHash を要求する。

CAPTCHAを一度解くと、HttpOnly cookie stgy_pub_comment_pass に対応するpass tokenをRedisへ保存する。TTLのデフォルトは1年で、一つのpassは最大100回の成功コメントに使える。さらに同じpassで利用できる接続元IPは最大4種類で、IPそのものは保存せず、pass tokenを鍵とするHMAC-SHA256をRedis Hashのfield名へ使う。5種類目のIPから使おうとするとpass全体を失効させる。古い used,reserved String形式のpassは最初の利用時にHash形式へ移行する。

既存passを使うコメント投稿では、DB transaction前後で利用回数をreserve/commitする。コメントINSERTがrollbackした場合はreservationもreleaseするため、失敗した投稿で100回の利用枠を消費しない。CAPTCHAをその場で解いてコメント投稿まで成功した場合は、そのコメントをpassの1回目として発行する。VPSのCaddyではchallengeとverifyを合わせて1 IPあたり60回/時、通常の POST /pub-comments を1 IPあたり20回/時に制限する。

コメントの公開キャッシュ

一般閲覧者向けコメント一覧は、DBを正本としたpage単位のread-through cacheをRedisへ持つ。キーは post-pub-comments:public:{postId} という記事単位のHashで、fieldは newest:1、oldest:2 のようにorderとpageを組み合わせる。したがって最大100件を毎回Redisから転送せず、表示する10件だけを取得できる。空ページもキャッシュする。

TTLは投稿の公開日ではなく作成時刻で決める。作成から7日以内の記事は180秒、それより古い記事は60秒である。記事所有者と管理者はpendingを含む最新状態が必要なのでこの公開キャッシュを使わず、PostgreSQLを直接読む。コメントの作成、承認、編集、削除、管理者importが成功した場合は、その記事のHash全体を DEL してnewest/oldestの全pageをまとめて無効化する。Redis障害時はDBへfallbackし、コメント操作そのものはRedis障害で失敗させない。

通知とアーカイブ

外部閲覧者からコメントが成功すると、記事所有者向けの通知eventを生成する。記事所有者自身のコメントでは通知しない。コメントテーブルにSNSユーザIDを保存しないため、通知に記録する投稿者情報もnicknameを中心とする。

データエクスポートでは各投稿ごとに GET /pub-comments/export/{postId} を使い、現在の公開状態やcomments mode、公開キャッシュに関係なく、その所有者の投稿に保存された pending と published をID昇順で全件取得する。投稿JSONには comments 配列として id、createdAt、nickname、body、status、isAuthor を埋め込み、HTMLにも全文を表示する。incremental exportのfingerprintにもコメント配列を含めるので、追加、削除、編集、承認のいずれでも該当投稿だけ再生成される。

scripts/import-archive.py は投稿を作成または更新した後、そのJSONに comments があれば管理者専用 POST /pub-comments/import へ順番に送る。import APIはID、status、isAuthorを指定して復元し、CAPTCHA、comments mode、allow_replies、100件上限、通知を通さない。同じコメントIDが同じ投稿に既にあれば内容を更新し、別投稿に同じIDがあれば409 conflictとする。import時だけはDB上限のnickname 50文字、本文65536文字まで受け付け、成功後は公開コメントキャッシュを無効化する。

公開記事を取得するAPI

通常の投稿APIは、ログインユーザとの関係、ブロック状態、イイネ状態、返信状態などを扱う。外部公開では認証なしで利用できる一方、未公開投稿を絶対に返してはならない。そのため、公開用の取得経路を明示的に分ける。

主なAPIは以下の通りである。

*API**用途*
GET /posts/pub/{id}一つの公開記事と前後記事IDを取得する
GET /posts/pub-by-user/{userId}ユーザの公開記事を公開日時順で取得する
POST /posts/pub-by-ids指定されたID順に公開記事を取得する
GET /users/{id}/pub-config公開サイト設定を取得する
GET /users/{id}/pub-rankingPopular entries用のIDとPVを取得する
GET /users/{id}/pub-stats所有者向けの詳細統計を取得する

GET /posts/pub/{id} は、対象投稿に published_at <= 現在時刻 を要求する。同じ所有者の公開済み投稿から、公開日時とIDを使って直前と直後の記事を一つずつ求める。

WHERE p.id = $1
  AND p.published_at <= $2

前後記事は概念的には次の条件で取得する。

(published_at, id) < (current_published_at, current_id)  -- older
(published_at, id) > (current_published_at, current_id)  -- newer

GET /posts/pub-by-user/{userId} は、サイト一覧とRecent postsに使う。絞り込みと並び替えをPostgreSQLで完結させる。

WHERE p.owned_by = $1
  AND p.published_at <= $2
ORDER BY p.published_at DESC, p.id DESC
OFFSET $3
LIMIT $4

POST /posts/pub-by-ids はPopular entriesに限らない汎用APIである。検索、推薦、ランキングなど、別の処理で決めた投稿ID配列から表示用投稿データを取得する用途に使える。内部では既存の PostsService.listPostsByIds を利用し、PostgreSQLの WITH ORDINALITY で入力順を維持する。

WITH req AS (
  SELECT id, ord
  FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)
)
SELECT ...
FROM req r
JOIN posts p ON p.id = r.id
ORDER BY r.ord

APIは取得後に未公開投稿と未来公開投稿を除外し、入力は最大20件に制限する。Popular entries専用の投稿取得APIを作らず、ランキング処理はIDとPVだけを返し、投稿データの取得はこの汎用経路へ集約している。

Redisで人気順のIDを取得
  ↓
[idA, idZ, idB]
  ↓ POST /posts/pub-by-ids
同じ順序の公開Post[]を取得
  ↓
Recent postsと同じスニペット描画

ランキングに残っている投稿が後から非公開になった場合や削除された場合は、pub-by-ids の結果から自然に除外される。

サーバサイドレンダリングとメタデータ

/sites と /pub はNext.jsのサーバコンポーネントとして描画する。ブラウザでJavaScriptが起動してから本文を取得するのではなく、最初のHTTP応答に記事HTMLを含める。ログインを前提としない閲覧者や検索クローラにも、記事本文と基本ナビゲーションを返せる。

記事ページのメタデータは、Markdownから抽出したタイトル、説明、著者、日付、featured imageと、公開サイト設定を組み合わせて生成する。主な出力は次の通りである。

  • HTML titleとdescription
  • canonical URL
  • Open Graph Protocolのtitle、description、site name、locale、公開日時、画像
  • Twitter Card
  • author
  • Dublin Core系のtitleとdate

canonical URLは /pub/{postId} とする。SNS内部の /posts/{postId} とは表示目的が異なり、外部共有されるURLを一つに固定する。

Next.jsはページ本体とメタデータ生成の双方で同じ記事データを必要とする。STGYではReactの cache を使って、同一レンダリング中の公開記事取得を共有する。これにより、メタデータ生成とページ生成によってバックエンドの公開記事APIが二重に呼ばれ、同じアクセスが二重計上されることを避ける。

アクセスカウンタの要件

アクセスカウンタは、直近15日間について以下を提供する。

保持期間は Config.PUB_VIEW_RETENTION_DAYS で管理し、環境変数 STGY_PUB_VIEW_RETENTION_DAYS で変更できる。現在のデフォルトは15日である。

  • 公開サイト全体のPV合計
  • 投稿別PV上位1000件
  • 公開記事サイドバーのPopular entries

一方で、次の情報は保存しない。

  • 個々のアクセス時刻
  • 生のIPアドレス
  • User-Agentの履歴
  • 参照元URL
  • 閲覧者ごとの行動履歴

アクセス履歴をPostgreSQLへ一件ずつ保存すると、公開ページを表示するたびに永続的な書き込みが発生し、履歴テーブルも閲覧数に比例して増える。STGYで必要なのは高機能なアクセス解析ではないため、通常のPV更新はRedis上の日次カウンタへ直接集約する。

ただし、Redisだけに依存すると、Redisのデータを失った際に直近15日分のPVも失われる。そこで現在は、PVが所定のチェックポイントへ到達した時だけPostgreSQLの post_pub_access_counts へ日別カウンタを保存する。PostgreSQLに保存するのはアクセス履歴ではなく、投稿と日付ごとの集約値だけである。

Redisは高頻度更新を受け持ち、PostgreSQLは疎な復旧用チェックポイントを受け持つ。PublicationsのStatsは両者を日付ごとに比較して大きい値を採用する。一方、公開ページのPopular entriesは表示負荷を増やさないためRedisだけでランキングを作る。

ボットの除外

検索エンジンやSNSのリンクプレビューが記事を取得するたびにPVを増やすと、人間による閲覧数と大きくずれる。そこで、公開ページを受け取ったFrontendは、User-Agentでボットを名乗っているクライアントを、Redisへ到達する前に除外する。

現在は、bot、crawler、spider、slurp、facebookexternalhit、bingpreview などを判定する。ボットと判定した場合は、PV記録用ヘッダをBackendへ送らない。本文とOGPは通常どおり返すので、検索登録やリンクプレビュー自体は妨げない。

これは善意のボットを除く仕組みである。User-Agentを通常ブラウザへ偽装するクライアントを厳密に判別するものではない。アクセスカウンタの目的は課金や広告精算ではなく、サイト所有者が傾向を見ることなので、複雑なボット判定基盤は導入しない。

IPアドレスのフィンガープリント化

同一の閲覧者がページを再読み込みするたびにPVを増やさないため、同一投稿と同一IPアドレスの組を重複排除する。ただし、生のIPアドレスはRedisへ保存しない。

Frontendは、リバースプロキシから渡された X-Forwarded-For、X-Real-IP、CF-Connecting-IP の順でクライアントIPを取得する。IPv4とIPv6を正規化した後、共有秘密を鍵とするHMAC-SHA256を計算し、先頭4バイトだけをフィンガープリントとして使う。

client IP
  ↓ HMAC-SHA256(shared secret)
32 bytes
  ↓ 先頭4 bytes
32-bit fingerprint

鍵なしハッシュでは、IPv4の候補を総当たりして元のIPアドレスを推測できる。HMACにすることで、Redisの値だけを見ても対応するIPを計算しにくくする。4バイトへ縮めるのは、厳密な一意性よりメモリ使用量を優先するためである。異なるIPが同じ32ビット値になる可能性はあるが、その場合はPVが一つ少なく数えられるだけであり、投稿データの安全性には影響しない。

Frontendは、投稿IDとフィンガープリントを結合した値にもHMAC署名を付ける。

HMAC-SHA256(shared secret, POST_ID + "\n" + fingerprint)

Backendは署名を検証できた場合だけPVを記録する。公開Backend APIへ直接アクセスしたクライアントが、任意のフィンガープリントを大量に作ってカウンタを増やすことを難しくするためである。現在の実装では共有秘密として STGY_REDIS_PASSWORD を利用する。

転送元ヘッダをIPの正本として使うため、実運用ではCaddyなどの信頼するリバースプロキシがこれらのヘッダを設定し、外部クライアントからの偽装値をそのまま採用しない構成が前提になる。

投稿別LRUによる重複排除

投稿ごとに、最近見たフィンガープリントを最大150件保持する。Redis Stringの値へ、4バイトの値をそのまま連結する。

古い ← [4 bytes][4 bytes][4 bytes] ... [4 bytes] → 新しい

最大サイズは次の通りである。

4 bytes × 150 = 600 bytes / post

同じフィンガープリントが再び来た場合は、既存位置から取り除いて末尾へ移動するが、PVは加算しない。新しいフィンガープリントなら末尾へ追加し、150件を超えた分だけ先頭から捨てて、PVを1増やす。

これは15日間の全IPを厳密に覚える仕組みではない。ある投稿へ150種類を超えるIPからアクセスがあれば、古いフィンガープリントは追い出され、そのIPからの再訪が後で新規PVとして数えられる。また、LRUキーが期限切れになった後の再訪も新規PVになる。人気の傾向を小さなメモリで見るための近似である。

LRUのTTLは保持期間より1日長く設定する。現在のデフォルト設定では16日で、アクセスされるたびに更新される。投稿へ長期間アクセスがなければ、重複排除データは自動的に消える。

Redisのデータ構造

PV集計に使う主なRedisキーは以下の通りである。

日次投稿別PV

stgy:pub-views:daily:{ownerId}:{YYYYMMDD}

所有者とUTC日付ごとに一つのHashを作る。fieldは投稿ID、valueはその日の重複排除後PVである。

19FD706DCB600000 -> 138
19FD705B39100000 -> 42

一アクセスごとのレコードではないので、PVが100万回あっても、その日の同じ投稿について増えるのはHash fieldの整数値だけである。日次キーのTTLも保持期間より1日長く、現在のデフォルト設定では16日目に期限切れになる。

投稿別LRU

stgy:pub-views:lru:{ownerId}:{postId}

値は最大600バイトのフィンガープリント列である。

Stats用メタデータ

stgy:pub-views:meta:{ownerId}:{postId}

値には、公開日時と150文字のプレーンテキストダイジェストをJSONで保存する。

{
  "publishedAt": "2026-08-06T12:22:58.000Z",
  "digest": "STGYの記事外部公開機能では、公開日時を正本として…"
}

ダイジェストは、すでに投稿へ保存されているJSONスニペットから生成する。Statsを表示するたびにPostgreSQLから最大1000記事を読み出さないための最小情報である。メタデータは、署名済みアクセスが来るたびに更新されるので、同一IPによる重複アクセスでPVが増えない場合でも、公開日時とダイジェストは新しい内容へ更新できる。

Popular entriesのリッチなスニペットはRedisへ保存しない。Redisから得るのは順位付きIDとPVだけであり、表示時に POST /posts/pub-by-ids へID配列を渡して、通常のPostデータをPostgreSQLから一括取得する。Stats用の小さなメタデータと、公開ページ表示用の正規の投稿データを分けている。

集計キャッシュ

stgy:pub-views:ranking:{ownerId}
stgy:pub-views:stats:{ownerId}

ランキングとStatsの計算結果は5分間キャッシュする。アクセスが来るたびにキャッシュを削除すると、人気記事へのアクセスが多いほど再集計が増えるので、最大5分の表示遅延を許容する。PVが0の空結果はキャッシュせず、最初のアクセスが来た直後に古い空キャッシュを見続けないようにする。

Luaによる原子的更新

同じ投稿へ複数のアクセスが同時に来た場合、Backendで GET、判定、SET、HINCRBY を別々に行うと競合が起きる。二つのリクエストが同じ古いLRUを読み、両方とも新規と判断してPVを二回加算する可能性がある。

そこで、次の処理を一つのRedis Luaスクリプトで実行する。

1. 投稿別LRUを読む。
2. 値が4バイト単位でない、または上限より大きい場合は破損値として捨てる。
3. 対象フィンガープリントを既存列から取り除く。
4. 末尾へ追加する。
5. 600バイトを超えた先頭部分を捨てる。
6. LRUとメタデータをTTL付きで保存する。
7. フィンガープリントが既存でなかった場合だけ、日次Hashを HINCRBY する。
8. 日次Hashの期限を設定する。
9. 新規PVなら HINCRBY 後のカウンタ値を返し、重複なら0を返す。

LRU更新
+ 重複判定
+ 日次PV加算
+ TTL設定
+ メタデータ更新
       ↓
一回のLua実行
       ↓
新しいPVカウンタ値

Redisは一つのLuaスクリプトを他のコマンドと割り込ませずに実行するため、LRUとPVの整合性を保てる。 HINCRBY 自体も増加と増加後の値の返却を原子的に行うため、例えば3から4へ増えた時に4という戻り値を受け取るリクエストは一つだけになる。追加モジュールは不要で、ioredisの defineCommand から必要になった時点で登録して呼び出す。

PostgreSQLのPVチェックポイント

Redisの消失に備えて、投稿とUTC日付ごとのPVチェックポイントをPostgreSQLへ保存する。

CREATE TABLE post_pub_access_counts (
  post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  target_date DATE NOT NULL,
  count INTEGER NOT NULL,
  PRIMARY KEY (post_id, target_date)
);

CREATE INDEX idx_post_pub_access_counts_owner_date
  ON post_pub_access_counts (owner_id, target_date);

CREATE INDEX idx_post_pub_access_counts_target_date
  ON post_pub_access_counts (target_date);

主キー (post_id, target_date) は、特定記事の一日分のチェックポイント取得と、同じ記事の直近15日分の範囲取得に使う。 (owner_id, target_date) はPublicationsのStatsで、特定ユーザの直近15日分だけを取得するためのインデックスである。 target_date 単体のインデックスは、所有者を問わず15日より古い行を削除するクリーンアップに使う。

owner_id はStats取得時に posts 全体を所有者で走査しないためにチェックポイント側へ持たせている。投稿の所有者が変更された場合は、投稿更新と同じPostgreSQLトランザクション内で、その投稿のチェックポイント行の owner_id も更新する。

カウンタのUPSERTでは既存値より新しい値が大きい場合だけUPDATEする。

ON CONFLICT (post_id, target_date)
DO UPDATE SET count = EXCLUDED.count
WHERE post_pub_access_counts.count < EXCLUDED.count

したがって、同じ同期が複数回走ってもカウンタは減らず、同じ値なら実UPDATEも発生しない。

チェックポイントとRedis復旧

Node.js側はLuaから返された新しいPVカウンタ値がチェックポイントかどうかを判定する。32PVまでは4刻みで 4, 8, 12, 16, 20, 24, 28, 32 とし、32PVより後は 32 × 2^(n/4) を n = 1, 2, ... として四捨五入した値を使う。

4, 8, 12, 16,
20, 24, 28, 32,
38, 45, 54, 64,
76, 91, 108, 128, ...

通常のチェックポイントでは、その記事の当日分だけPostgreSQLとRedisを比較する。Redisの値が大きければPostgreSQLを単調増加UPSERTし、PostgreSQLの値が大きければRedis側をその値まで引き上げる。二つを加算することはない。

最初のチェックポイントである4PVだけは特別扱いする。その記事について、当日を含む直近15日分のRedis fieldをpipelineで読み、PostgreSQLからも同じ記事の直近15日分を一度に取得する。各日について大きい方を採用し、Redisが大きい日だけをまとめてPostgreSQLへUPSERTし、PostgreSQLが大きい日はRedisを引き上げる。

4PV到達
  ↓
同じ記事の直近15日
  ↓
日ごとに max(Redis, PostgreSQL)
  ├─ Redisが大きい → PostgreSQLへ単調増加UPSERT
  └─ DBが大きい    → Redisをその値まで引き上げる

これにより、前日に4PV未満しかなかったような日でも、その低PV日が15日間の保持期間内にあるうちに、同じ記事が別の日で4PVへ到達すれば、その値もPostgreSQLへ保存される。8PV以降のチェックポイントでは過去日を再同期せず、当日だけを処理する。

Redisが完全に失われると当日のカウンタは0から数え直される。再び4PVへ到達すると直近15日同期が走り、PostgreSQLに残っている値がRedisへ戻される。ただし、DB値とRedisの再開後PVを加算しないため、再起動後に最初のチェックポイントへ達するまでの数PVは失われ得る。この誤差を許容する代わりに、Redisが複数回失われても二重加算による水増しを起こさない。

4PV時の直近15日同期の最後には、 target_date < 当日 - 14日 のチェックポイントを削除するクリーンアップも試行する。Backendプロセス内で最後にクリーンアップしたUTC日付を保持し、同じプロセスでは原則として一日一回だけDELETEする。失敗時は警告を記録し、日付を更新しないので次回の機会に再試行される。専用のワーカーや定期ジョブは使わないため、ある日にどの記事も4PVへ到達しなければ、古い行は次のクリーンアップ機会まで残る。

直近15日間の集計

Popular entries用のランキングはRedisだけで計算する。ランキングキャッシュがない場合、当日を含む直近15日分の日次Hashを読み出し、15回の HGETALL をRedis pipelineへまとめる。各日のfieldを投稿IDごとにMapへ足し合わせる。

Redis日次Hash 1 ┐
Redis日次Hash 2 ├─ 投稿IDごとに合算 ─→ 15日間PV
        ...       │
Redis日次Hash 15┘

投稿別ランキングは最大1000件でよいが、全投稿を完全に並べ替える必要はない。サイズ1000の最小ヒープを使い、現在の上位1000件だけを保持する。

N = 15個の日次Hashにあるfield数の合計
U = 異なる投稿数
K = 1000

計算量は次の通りである。

日次カウンタの合算  O(N)
上位K件の選択       O(U log K)
上位K件の整列       O(K log K)

Kは1000の固定値なので、全体は実質的に日次Hashのfield数へ比例する。PV数そのものには比例しない。ある投稿が一日に100万PVを得ても、一日分のHashでは一つのfieldである。

このRedis-onlyランキングは公開ページのPopular entriesに使う。PublicationsのStatsは復旧用チェックポイントも反映する必要があるため、次節の通り別経路でRedisとPostgreSQLの双方を集計する。

PublicationsのStats

PublicationsページのStatsタブは、ログイン中の本人または管理者だけが取得できる GET /users/{id}/pub-stats を使う。公開ページから必要なPopularランキングとは分け、150文字ダイジェストを含む詳細統計を第三者へ無制限に公開しない。

Statsの集計では、まずRedisから当日を含む直近15日分の日次Hashをpipelineで取得する。続いてPostgreSQLから、 (owner_id, target_date) インデックスを使って同じ所有者の同じ15日分のチェックポイントだけを取得する。投稿と日付の組ごとにRedis値とPostgreSQL値を比較し、大きい方をその日のPVとして採用してから15日分を合算する。

(post A, 8/7)  Redis 103 / DB 91  → 103
(post A, 8/6)  Redis   0 / DB 54  → 54
(post B, 8/6)  Redis  32 / DB 32  → 32
                         ↓
              投稿ごとに15日分を合算

これによりRedisが正常なら現在値をそのまま使い、Redisが失われた後でもPostgreSQLに保存済みのチェックポイントまたは4PV時の強制同期値を使える。RedisとDBの15日合計を最後に比較するのではなく、必ず投稿と日付の組ごとに max を取る。

Statsには次を表示する。

  • 直近15日間のTotal page views
  • 投稿IDと公開日時
  • Contentのプレーンテキストダイジェスト
  • 投稿別PV

投稿別一覧は合算後の上位1000件を一度取得し、Frontend上でID、公開日時、PVの順序を切り替える。並べ替えるたびにRedisやPostgreSQLを再集計しない。表は一ページ50件に分割し、ページ番号をURLの ?page= に保持する。

上位1000件の公開日時とダイジェストは、まずRedisの meta キーから MGET する。Redis消失やTTL切れでメタデータがない投稿については、その不足分の投稿IDだけをPostgreSQLの posts 主キーへ問い合わせて補完する。最大でもランキング上位1000件に限定されるので、所有者の全投稿を走査しない。

Total page viewsは上位1000件へ入らなかった投稿も含めて合算する。投稿別一覧だけを1000件へ制限しているので、PublicationsのTotal page viewsは直近15日分についてRedisとPostgreSQLを日別に統合した全カウンタの合計になる。Stats全体は5分間Redisへキャッシュする。

Popular entries

Popular entriesでは、Frontendがまず次の公開APIを呼ぶ。

GET /users/{ownerId}/pub-ranking?limit=5

返すのはIDとPVだけである。

[
  { "id": "19FD706DCB600000", "pv": 138 },
  { "id": "19FD705B39100000", "pv": 42 }
]

このランキングはPostgreSQLのチェックポイントを参照せず、直近15日分のRedis日次Hashだけから計算する。公開ページの描画ごとにDBの統計テーブルまで読まないためである。Redisを失った直後は順位が一時的に崩れるが、各記事が4PVへ到達するたびに直近15日同期で保存済みチェックポイントがRedisへ戻り、その記事についてはランキングも回復していく。

得られたID配列を POST /posts/pub-by-ids へ渡して、入力順を維持した公開Postを取得する。記事ページのRecent postsもPopular entriesも最終的には同じPost型と同じJSONスニペット描画を使う。異なるのは投稿の選び方だけである。

Recent posts
  PostgreSQLのpublished_at順で選ぶ

Popular entries
  RedisのPV順でIDを選ぶ

共通部分
  Post.snippetを記事スニペットとして描画する

Recent postsとPopular entriesの取得には依存関係がないため、公開記事ページでは両方を並行して取得する。Popularランキングの取得に失敗しても記事本文とRecent postsは表示し、Popular entriesだけを空にする。

負荷と保存量

一つの公開記事表示で、PV記録に関して常に発生する主要処理は次の通りである。

Frontend
  User-Agent判定
  IPのHMACと署名生成

Backend
  署名検証
  Redis Luaを1回実行

Luaの中では投稿別LRUを最大600バイト走査し、新規閲覧なら日次Hashを1加算する。個々のアクセス履歴をPostgreSQLへINSERTすることはない。DBアクセスが追加されるのは、新しいPVカウンタ値がチェックポイントに一致した時だけである。

4PVではその記事の直近15日を同期するため、Redis側は15個の HGET を一つのpipelineにまとめ、PostgreSQL側は主キー (post_id, target_date) を使って同じ記事の15日範囲を一度に読む。DBへ反映すべき日が複数あっても一つのmulti-row UPSERTにまとめる。8PV以降のチェックポイントでは当日の一行だけを比較する。PVがチェックポイントでない通常アクセスでは、この統計永続化のためのPostgreSQL問い合わせは発生しない。

Redisの保存量は、おおむね次の要素で決まる。

  • 閲覧された投稿ごとに最大600バイトのLRUとキーのオーバーヘッド
  • 閲覧された投稿ごとに公開日時と短いダイジェスト
  • 所有者、日付、閲覧された投稿の組ごとに日次Hashのfield一つ
  • 所有者ごとの5分間ランキングキャッシュとStatsキャッシュ

PostgreSQLの post_pub_access_counts は、チェックポイントまたは4PV時の強制同期で保存された投稿と日付の組につき一行である。15日より古い行は4PV時の機会的なクリーンアップで削除する。アクセス件数そのものを保存しないので、同じ投稿へのPVが増えてもRedisメモリやDB行数がPV数に比例して増えることはない。保存量は主に「直近に閲覧された投稿数」と「日ごとにPVが発生した投稿数」に比例する。

制限と割り切り

現在の外部公開とアクセスカウンタには以下の制限がある。

  • 外部公開は投稿の現在内容を表示し、公開時点の版を保存しない。公開後の編集も即座に反映される。
  • published_at をnullへ戻すと外部から取得できなくなるが、RedisとPostgreSQLの過去PVは保持期限まで残り得る。
  • PVの保持期間はデフォルトで直近15日間で、累積PVを恒久保存しない。
  • PostgreSQLへ保存するのは疎なチェックポイントであり、個々のアクセス履歴ではない。
  • Redisを失うとPopular entriesの順位は一時的に崩れる。PublicationsのStatsは保存済みDBチェックポイントで補えるが、最後の同期以後のPVは失われ得る。
  • Redis再開後、DB値と新しいRedis値を加算せず大きい方だけを採用するため、最初の4PVへ到達するまでの数PVは失われ得る。
  • 同一IP判定は投稿別の直近150フィンガープリントだけであり、厳密なユニーク閲覧者数ではない。
  • 32ビットフィンガープリントの衝突により、異なるIPを同一とみなす可能性がある。
  • 150種類を超えるアクセスやLRUの期限切れ後には、同じIPが再び数えられる。
  • User-Agentを偽装するボットは通常アクセスとして数えられる。
  • ランキングとStatsは5分キャッシュされるため、最新PVの反映には遅延がある。
  • PostgreSQLの古いチェックポイント削除は4PV時に機会的に行うため、4PVへ到達する記事がない期間は15日より古い行が一時的に残る。
  • 転送元IPヘッダは、信頼するリバースプロキシが正しく設定する必要がある。
  • 高度なアクセス解析に必要な参照元、地域、端末、閲覧経路などは記録しない。

これらは不具合ではなく、アクセス履歴を永続化せず、小さな負荷でPopular entriesと短期Statsを提供するための選択である。外部コメントもSNSアカウントと匿名投稿を恒久的に結び付けず、CAPTCHA passは本人確認ではなく投稿負荷を下げるための利便機構として扱う。広告課金、厳密なユニークユーザ数、長期分析やコメント投稿者の本人性保証が必要な運用では、専用の基盤を別途導入すべきである。

まとめ

STGYの記事外部公開機能は、posts.published_at を公開状態の正本とし、SNS内部の投稿を別データへ複製せずに、/sites/{userId} と /pub/{postId} へブログ風に表示する。公開記事APIは現在時刻以前の投稿だけを返し、サイト一覧、記事詳細、前後記事、ID集合取得を用途ごとに分ける。Markdown本文とJSONスニペットは内部表示と共用し、外部ページ側ではサイト設定、デザインテーマ、OGP、ページネーション、プロフィール、Recent postsを組み合わせる。extensions からX/Facebook/LINE/Hatenaのシェアとコメントmodeを設定でき、シェアは個別の記事本文へ、コメントは allow_replies とmodeが許す記事ページへ追加する。

アクセスカウンタは、生のIPアドレスや個々のアクセス履歴を保存しない。Frontendで自己申告ボットを除外し、IPを鍵付きハッシュの32ビットフィンガープリントへ変換し、Backendで署名を検証する。Redis Luaが投稿別LRUの更新、重複判定、日次PV加算、TTL設定、Stats用メタデータ更新を一回で行い、増加後のカウンタ値をNode.jsへ返す。

通常のPV更新はRedisだけで完結し、 4, 8, 12, 16, 20, 24, 28, 32, 38, ... の疎なチェックポイントでだけPostgreSQLへ同期する。4PV時にはその記事の直近15日分をまとめて双方向にmax同期し、過去日の低PVも可能な範囲で確定させる。以降のチェックポイントは当日だけを処理する。DB側は単調増加UPSERT、Redis側も単調増加のmax更新とし、両者を加算しないことで再実行やRedis消失後の復旧でも二重計上を避ける。

PublicationsのStatsはRedisとPostgreSQLを投稿・日付単位で統合してから15日分を集計し、固定サイズ1000の最小ヒープで上位投稿を選ぶ。RedisにStats用メタデータがなければ、上位IDだけをPostgreSQLから補完する。一方、公開ページのPopular entriesは表示負荷を抑えるためRedisだけでランキングを作り、得られたIDを汎用の pub-by-ids へ渡して正規のPostデータを一括取得する。

コメント本体もPostgreSQLの post_pub_comments を正本とし、一般閲覧者向けの10件pageだけをRedisへ短時間キャッシュする。匿名コメントにはSNSユーザIDや生IPを保存せず、6桁CAPTCHAと100回/4 IPまでのpass tokenで機械的な連続投稿のコストを上げる。export/importは公開表示用キャッシュと分離し、pendingを含む保存データをIDとstatusごと復元できる。

投稿本文と公開設定の正本はPostgreSQLに置き、高頻度の短期カウンタと短時間の公開コメントcacheはRedisで処理し、復旧に必要な最小限のPV集約値だけをPostgreSQLへ疎に残す。アクセス履歴を大量に永続化せず、Redis消失時の影響も限定しながら、公開記事の共有、外部コメント、Popular entriesと直近15日Statsを提供する構成である。

Next: STGYの管理機能