跳至主要內容

NIP-45: Counting Results

使用 COUNT 請求高效獲取事件數量統計

概述

NIP-45 定義了 COUNT 請求類型,允許客戶端向中繼器查詢符合條件的事件數量, 而無需下載所有事件。這對於顯示追蹤者數量、按讚數、回覆數等統計資訊非常有用。

COUNT 請求

COUNT 請求的格式與 REQ 類似,但使用 COUNT 動詞:

["COUNT", "<subscription_id>", <filter>, ...]

過濾器

過濾器格式與 NIP-01 的 REQ 請求相同:

{
  "kinds": [1],
  "authors": ["pubkey1", "pubkey2"],
  "#e": ["event_id"],
  "#p": ["pubkey"],
  "since": 1700000000,
  "until": 1700100000
}

COUNT 回應

中繼器回應包含計數結果:

["COUNT", "<subscription_id>", {"count": 238}]

可選的近似值標記

當計數可能不精確時,中繼器可以標記為近似值:

["COUNT", "<subscription_id>", {"count": 238, "approximate": true}]

使用範例

統計追蹤者數量

// 請求
["COUNT", "followers", {"kinds": [3], "#p": ["目標用戶公鑰"]}]

// 回應
["COUNT", "followers", {"count": 1523}]

統計貼文按讚數

// 請求
["COUNT", "likes", {"kinds": [7], "#e": ["貼文事件ID"]}]

// 回應
["COUNT", "likes", {"count": 42}]

統計回覆數

// 請求
["COUNT", "replies", {"kinds": [1], "#e": ["原始貼文ID"]}]

// 回應
["COUNT", "replies", {"count": 15}]

統計用戶貼文數

// 請求
["COUNT", "posts", {"kinds": [1], "authors": ["用戶公鑰"]}]

// 回應
["COUNT", "posts", {"count": 892}]

TypeScript 實作

基礎計數函數

interface CountResult {
  count: number;
  approximate?: boolean;
}

async function countEvents(
  relay: WebSocket,
  filter: object
): Promise<CountResult> {
  return new Promise((resolve, reject) => {
    const subId = Math.random().toString(36).substring(7);
    const timeout = setTimeout(() => {
      reject(new Error('Count request timeout'));
    }, 10000);

    const handler = (event: MessageEvent) => {
      const data = JSON.parse(event.data);

      if (data[0] === 'COUNT' && data[1] === subId) {
        clearTimeout(timeout);
        relay.removeEventListener('message', handler);
        resolve(data[2]);
      }
    };

    relay.addEventListener('message', handler);
    relay.send(JSON.stringify(['COUNT', subId, filter]));
  });
}

// 使用範例
const result = await countEvents(relay, {
  kinds: [7],
  '#e': ['event_id'],
});
console.log(`按讚數: ${result.count}`);

多中繼器計數

interface MultiRelayCountResult {
  total: number;
  byRelay: Map<string, number>;
  hasApproximate: boolean;
}

async function countFromMultipleRelays(
  relayUrls: string[],
  filter: object
): Promise<MultiRelayCountResult> {
  const results = await Promise.allSettled(
    relayUrls.map(async (url) => {
      const ws = new WebSocket(url);
      await new Promise((resolve) => (ws.onopen = resolve));

      try {
        const result = await countEvents(ws, filter);
        return { url, ...result };
      } finally {
        ws.close();
      }
    })
  );

  const byRelay = new Map<string, number>();
  let hasApproximate = false;
  let maxCount = 0;

  for (const result of results) {
    if (result.status === 'fulfilled') {
      byRelay.set(result.value.url, result.value.count);
      maxCount = Math.max(maxCount, result.value.count);
      if (result.value.approximate) {
        hasApproximate = true;
      }
    }
  }

  return {
    total: maxCount, // 取最大值作為總數
    byRelay,
    hasApproximate,
  };
}

社交統計類別

class NostrStats {
  private relays: string[];

  constructor(relays: string[]) {
    this.relays = relays;
  }

  // 獲取追蹤者數量
  async getFollowerCount(pubkey: string): Promise<number> {
    const result = await countFromMultipleRelays(this.relays, {
      kinds: [3],
      '#p': [pubkey],
    });
    return result.total;
  }

  // 獲取關注數量
  async getFollowingCount(pubkey: string): Promise<number> {
    // 需要獲取 kind 3 事件並計算 p 標籤數量
    // COUNT 無法直接計算這個
    return 0;
  }

  // 獲取貼文按讚數
  async getReactionCount(eventId: string): Promise<number> {
    const result = await countFromMultipleRelays(this.relays, {
      kinds: [7],
      '#e': [eventId],
    });
    return result.total;
  }

  // 獲取貼文回覆數
  async getReplyCount(eventId: string): Promise<number> {
    const result = await countFromMultipleRelays(this.relays, {
      kinds: [1],
      '#e': [eventId],
    });
    return result.total;
  }

  // 獲取貼文轉發數
  async getRepostCount(eventId: string): Promise<number> {
    const result = await countFromMultipleRelays(this.relays, {
      kinds: [6],
      '#e': [eventId],
    });
    return result.total;
  }

  // 獲取貼文 Zap 數量
  async getZapCount(eventId: string): Promise<number> {
    const result = await countFromMultipleRelays(this.relays, {
      kinds: [9735],
      '#e': [eventId],
    });
    return result.total;
  }

  // 獲取用戶貼文數量
  async getPostCount(pubkey: string): Promise<number> {
    const result = await countFromMultipleRelays(this.relays, {
      kinds: [1],
      authors: [pubkey],
    });
    return result.total;
  }
}

// 使用範例
const stats = new NostrStats([
  'wss://relay.damus.io',
  'wss://relay.nostr.band',
  'wss://nos.lol',
]);

const pubkey = '用戶公鑰';
const followers = await stats.getFollowerCount(pubkey);
const posts = await stats.getPostCount(pubkey);

console.log(`追蹤者: ${followers}, 貼文: ${posts}`);

React Hook

import { useState, useEffect } from 'react';

interface EventStats {
  reactions: number;
  replies: number;
  reposts: number;
  zaps: number;
  loading: boolean;
}

function useEventStats(eventId: string, relays: string[]): EventStats {
  const [stats, setStats] = useState<EventStats>({
    reactions: 0,
    replies: 0,
    reposts: 0,
    zaps: 0,
    loading: true,
  });

  useEffect(() => {
    const nostrStats = new NostrStats(relays);

    Promise.all([
      nostrStats.getReactionCount(eventId),
      nostrStats.getReplyCount(eventId),
      nostrStats.getRepostCount(eventId),
      nostrStats.getZapCount(eventId),
    ]).then(([reactions, replies, reposts, zaps]) => {
      setStats({
        reactions,
        replies,
        reposts,
        zaps,
        loading: false,
      });
    });
  }, [eventId, relays]);

  return stats;
}

// 使用範例
function PostStats({ eventId }: { eventId: string }) {
  const stats = useEventStats(eventId, RELAYS);

  if (stats.loading) {
    return <div>載入中...</div>;
  }

  return (
    <div className="stats">
      <span>❤️ {stats.reactions}</span>
      <span>💬 {stats.replies}</span>
      <span>🔁 {stats.reposts}</span>
      <span>⚡ {stats.zaps}</span>
    </div>
  );
}

中繼器支援檢測

async function supportsCount(relayUrl: string): Promise<boolean> {
  try {
    const response = await fetch(
      relayUrl.replace('wss://', 'https://').replace('ws://', 'http://'),
      { headers: { Accept: 'application/nostr+json' } }
    );
    const info = await response.json();
    return info.supported_nips?.includes(45) ?? false;
  } catch {
    return false;
  }
}

// 過濾支援 COUNT 的中繼器
async function getCountSupportedRelays(relays: string[]): Promise<string[]> {
  const results = await Promise.all(
    relays.map(async (relay) => ({
      relay,
      supported: await supportsCount(relay),
    }))
  );
  return results.filter((r) => r.supported).map((r) => r.relay);
}

限制與注意事項

  • 中繼器支援:並非所有中繼器都支援 COUNT
  • 準確性:不同中繼器可能返回不同數值
  • 近似值:大量資料時可能返回近似值
  • 效能限制:某些中繼器可能限制 COUNT 請求頻率
  • 無法聚合:無法直接進行 SUM、AVG 等聚合運算

最佳實踐

  • 快取結果:統計數據變化不頻繁,可以快取
  • 降級處理:不支援時改用 REQ 計數
  • 多中繼器查詢:取最大值獲得更準確的統計
  • 顯示近似:當 approximate 為 true 時顯示 ≈ 符號
  • NIP-01:基本協議 - REQ 請求
  • NIP-11:中繼器資訊 - 功能發現
  • NIP-25:反應 - 按讚事件
  • NIP-57:Zaps - 打賞事件

參考資源

已複製連結
已複製到剪貼簿