はじめに
プロフィールリンクサービス myna.me で、ユーザーの信頼性を0〜100で数値化する Trust Score(トラストスコア) を実装しました。
GoogleのPageRankからインスピレーションを得たスコアリングアルゴリズムで、「OAuth認証の数と種類」「プロフィール完成度」「アカウント成熟度」「レピュテーション」の4軸から信頼度を算出します。
この記事では、スコア計算のアルゴリズム設計、Drizzle ORMでのキャッシュ実装、バッジティア判定、SVGバッジAPIの実装について解説します。
Trust Scoreとは
Trust Scoreは、プロフィールの信頼性を複合的な指標で数値化するシステムです。
| スコア | ティア | バッジ色 |
|---|---|---|
| 0-19 | (なし) | — |
| 20-39 | Bronze | #CD7F32 |
| 40-59 | Silver | #A0A0A0 |
| 60-79 | Gold | #FFD700 |
| 80-100 | Diamond | #60A5FA |
単に「SNSを何個接続しているか」ではなく、認証方式の多様性、プロフィールの充実度、時間経過による成熟度、他ユーザーからの評価を統合的に評価します。
スコア計算アルゴリズム (v3)
Trust Scoreは4つのカテゴリで構成され、合計0〜100点です。
Trust Score (0-100)
├── 本人認証スコア (0-40点)
├── プロフィール完成度 (0-15点)
├── アカウント成熟度 (0-15点)
└── レピュテーション (0-30点)
1. 本人認証スコア(0-40点)
OAuth認証と認証コード検証で接続されたアカウントの数と多様性を評価します。
function calculateVerificationScore(
accounts: ConnectedAccount[]
): number {
// 認証方式ごとのベーススコア
const BASE_SCORES = {
oauth: 10, // OAuth認証: 10点
verification: 9, // 認証コード: 9点
};
// 同一タイプの複数接続は逓減
// 例: OAuth 1個目=10点, 2個目=7.7点, 3個目=6.25点...
const typeGroups = groupBy(accounts, a => a.connectType);
let score = 0;
for (const [type, group] of Object.entries(typeGroups)) {
const base = BASE_SCORES[type] || 0;
group.forEach((_, index) => {
score += base / (1 + 0.3 * index);
});
}
// 認証方式の多様性ボーナス
// OAuth + 認証コードの両方を使っていれば +20%
const uniqueTypes = new Set(
accounts.map(a => a.connectType)
);
if (uniqueTypes.size >= 2) {
score *= 1.2;
}
return Math.min(40, Math.round(score));
}
設計のポイント:
- 同じタイプの認証を増やしても逓減する(
1/(1+0.3*n)) - 複数の認証方式を使うと多様性ボーナス(+20%)
- 上限40点でキャップ
2. プロフィール完成度(0-15点)
プロフィールの充実度を複数の観点から評価します。
function calculateProfileScore(user: User): number {
let score = 0;
if (user.avatarUrl) score += 3; // アバター画像
if (user.bio && user.bio.length >= 20) score += 3; // bio 20字以上
if (user.bioEn) score += 2; // 多言語bio
if (user.subtitle) score += 2; // 肩書き
if (timelineCount >= 2) score += 2; // タイムライン2件以上
if (user.showCertificate) score += 1; // 証明書有効
if (user.location) score += 1; // 位置情報
if (user.showQr) score += 1; // QR有効
return Math.min(15, score);
}
3. アカウント成熟度(0-15点)
アカウント作成からの経過日数を 指数関数的減衰曲線 で評価します。
function calculateMaturityScore(createdAt: Date): number {
const days = daysSince(createdAt);
// 指数関数的減衰: 15 * (1 - e^(-days/180))
// 180日で約63%(9.5点)、1年で約87%(13点)に到達
return Math.round(15 * (1 - Math.exp(-days / 180)));
}
ステップ関数(30日=5点、90日=10点...)ではなく指数関数を使うことで、新規ユーザーでも日々スコアが上がる 実感を得られます。
4. レピュテーション(0-30点)
他ユーザーからの評価を時間重み付きで算出します。
function calculateReputationScore(metrics: UserMetrics): number {
// 時間バケットの重み
const TIME_WEIGHTS = {
recent: 1.0, // 30日以内
medium: 0.7, // 90日以内
old: 0.4, // 180日以内
ancient: 0.2, // それ以前
};
// 各指標にlog2圧縮 + 時間重みを適用
const likeScore = calcWeighted(metrics.likes, 8); // 0-8
const bookmarkScore = calcWeighted(metrics.bookmarks, 7); // 0-7
const connectionScore = calcWeighted(metrics.connections, 8); // 0-8
const engagementScore = calcEngagement(metrics, 4); // 0-4
const messageScore = calcWeighted(metrics.messages, 3); // 0-3
return Math.min(30, Math.round(
likeScore + bookmarkScore + connectionScore +
engagementScore + messageScore
));
}
設計思想:
-
log2圧縮で、最初の数件は大きく効くが大量に集めても上限に収束 - 最近のアクティビティほど高く評価(時間重み付き)
- エンゲージメント率(クリック/PV)はPV≥10の場合のみ有効化
Drizzle ORMでのキャッシュ実装
Trust Scoreの計算には複数テーブルのJOINが必要で、毎リクエスト計算するとコストが高いです。そこで usersテーブルにキャッシュカラム を持たせています。
スキーマ定義
// schema.ts (Drizzle ORM)
export const users = pgTable('users', {
id: text('id').primaryKey(),
// ... 他のカラム
// Trust Score キャッシュ
trustScore: integer('trust_score').default(0),
trustScoreTier: varchar('trust_score_tier', { length: 10 }),
trustScoreUpdatedAt: timestamp('trust_score_updated_at'),
showTrustScore: boolean('show_trust_score').default(true),
});
fire-and-forget更新パターン
export async function calculateTrustScore(
userId: string
): Promise<TrustScoreResult> {
// 1. 全データを並列取得
const [user, accounts, likes, bookmarks, connections, views] =
await Promise.all([
db.query.users.findFirst({ where: eq(users.id, userId) }),
db.query.connectedAccounts.findMany({ where: eq(connectedAccounts.userId, userId) }),
db.query.likes.findMany({ where: eq(likes.profileUserId, userId) }),
db.query.bookmarks.findMany({ where: eq(bookmarks.profileUserId, userId) }),
db.query.connections.findMany({ /* ... */ }),
db.query.profileViews.findMany({ where: eq(profileViews.userId, userId) }),
]);
// 2. 4カテゴリのスコアを計算
const verification = calculateVerificationScore(accounts);
const profile = calculateProfileScore(user);
const maturity = calculateMaturityScore(user.createdAt);
const reputation = calculateReputationScore({ likes, bookmarks, connections, views });
const total = verification + profile + maturity + reputation;
const tier = getTierFromScore(total);
// 3. キャッシュを非同期更新(fire-and-forget)
db.update(users)
.set({
trustScore: total,
trustScoreTier: tier,
trustScoreUpdatedAt: new Date(),
})
.where(eq(users.id, userId))
.then(() => {}) // fire-and-forget
.catch(console.error);
return { total, tier, breakdown: { verification, profile, maturity, reputation } };
}
fire-and-forgetのメリット:
- スコア計算結果はすぐ返し、DB更新は非同期
- OG画像生成(Edge Runtime)ではキャッシュカラムから読み取り、APIコール不要
- ユーザーのリクエストをブロックしない
SVGバッジAPI
Trust Scoreを外部サイトに埋め込めるSVGバッジAPIも実装しました。GitHubのREADMEなどに貼り付けることができます。
エンドポイント
GET /api/badge/[username]?type=trust&style=default
実装
// app/api/badge/[username]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
req: NextRequest,
{ params }: { params: { username: string } }
) {
// レート制限: 60req/min (IP単位)
const rateLimitResult = await rateLimit(req, 60);
if (!rateLimitResult.success) {
return new NextResponse('Rate limit exceeded', { status: 429 });
}
const user = await db.query.users.findFirst({
where: eq(users.username, params.username),
});
if (!user) {
return new NextResponse('User not found', { status: 404 });
}
// キャッシュカラムからスコア取得(計算不要)
const score = user.trustScore ?? 0;
const tier = user.trustScoreTier ?? '';
const tierColor = getTierColor(tier);
// XSSエスケープしてSVG生成
const svg = generateTrustBadgeSvg(
escapeXml(user.username),
score,
escapeXml(tier),
tierColor
);
return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
},
});
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
セキュリティ考慮
- XSSエスケープ: ユーザー名やティア名をSVGに埋め込む前にXMLエスケープ
- レート制限: IPアドレス単位のsliding windowで60req/min
-
CORS:
Cross-Origin-Resource-Policy: cross-originで外部サイトからの読み込みを許可 - キャッシュ: 1時間のpublicキャッシュでCDN最適化
ティア判定のシンプルな実装
type TrustTier = '' | 'Bronze' | 'Silver' | 'Gold' | 'Diamond';
function getTierFromScore(score: number): TrustTier {
if (score >= 80) return 'Diamond';
if (score >= 60) return 'Gold';
if (score >= 40) return 'Silver';
if (score >= 20) return 'Bronze';
return '';
}
function getTierColor(tier: TrustTier): string {
const colors: Record<TrustTier, string> = {
'': '#6B7280',
Bronze: '#CD7F32',
Silver: '#A0A0A0',
Gold: '#FFD700',
Diamond: '#60A5FA',
};
return colors[tier];
}
Pro会員とスコアの独立性
重要な設計判断として、Pro会員であることはTrust Scoreに影響しない ようにしています。
信頼性 ≠ 課金状態
Trust Scoreはあくまで「本人認証」「プロフィール充実度」「アカウント成熟度」「他者からの評価」で算出され、お金で買えるものではありません。これにより、スコアの信頼性自体を担保しています。
まとめ
Trust Scoreの実装で重要だったポイントをまとめます。
- PageRankの思想を応用: 単純なカウントではなく、逓減関数・多様性ボーナス・時間重みで多角的に評価
- 指数関数的減衰曲線: ステップ関数より自然な成長体験を提供
- fire-and-forgetキャッシュ: 計算結果をDBにキャッシュし、OG画像生成などでの再計算を回避
- SVGバッジAPI: XSSエスケープ + レート制限 + CORS設定で安全に外部公開
- 課金との独立性: Trust Scoreの信頼性を担保するための重要な設計判断
信頼性の数値化は正解のない問題ですが、アルゴリズムをバージョニング(現在v3)しながら改善を続けています。
myna.meは現在ベータ版として公開中です。OAuth認証で安全なプロフィールリンクを作成できます。
2026年2月25日に Product Hunt でのローンチを予定しています。