跳至主要內容

NIP-22: 評論

Nostr 結構化評論系統,支援對各類內容進行層級討論

概述

NIP-22 定義了 kind 1111 評論事件,用於對各種內容類型進行結構化的層級討論。 與 NIP-10 的 kind 1 回覆不同,NIP-22 評論專門設計用於對部落格文章、檔案、 網站、播客等多種內容類型進行評論,並維護清晰的歸因鏈。

重要:NIP-22 評論不能用於回覆 kind 1 短文。 回覆 kind 1 應使用 NIP-10 的標準回覆機制。

事件結構

Kind 1111 評論使用大小寫標籤慣例來區分根範圍和父項目:

標籤類型 大寫(根範圍) 小寫(父項目)
事件類型 K k
事件 ID E e
可定址事件 A a
外部識別符 I i
作者公鑰 P p

基本範例

對部落格文章的評論(根評論)

{
  "kind": 1111,
  "content": "這篇文章寫得很好!對 Nostr 的解釋非常清楚。",
  "tags": [
    ["K", "30023"],
    ["A", "30023:<author-pubkey>:my-blog-post", "wss://relay.example.com"],
    ["P", "<author-pubkey>"],
    ["k", "30023"],
    ["a", "30023:<author-pubkey>:my-blog-post", "wss://relay.example.com"],
    ["p", "<author-pubkey>"]
  ]
}

對評論的回覆(嵌套評論)

{
  "kind": 1111,
  "content": "同意!我也覺得寫得很清楚。",
  "tags": [
    ["K", "30023"],
    ["A", "30023:<author-pubkey>:my-blog-post", "wss://relay.example.com"],
    ["P", "<author-pubkey>"],
    ["k", "1111"],
    ["e", "<parent-comment-id>", "wss://relay.example.com"],
    ["p", "<parent-comment-author>"]
  ]
}

標籤要求

必要標籤

標籤 說明 必要性
K 根項目的事件 kind 必要
EAI 根項目的識別符 必要(其中之一)
P 根項目作者的公鑰 必要
k 父項目的事件 kind 必要
eai 父項目的識別符 必要(其中之一)
p 父項目作者的公鑰 必要

使用場景

對部落格文章評論 (kind 30023)

{
  "kind": 1111,
  "content": "很棒的教學!",
  "tags": [
    ["K", "30023"],
    ["A", "30023:<pubkey>:article-slug", "wss://relay.example.com"],
    ["P", "<pubkey>"],
    ["k", "30023"],
    ["a", "30023:<pubkey>:article-slug", "wss://relay.example.com"],
    ["p", "<pubkey>"]
  ]
}

對檔案評論 (NIP-94)

{
  "kind": 1111,
  "content": "這張照片拍得很美!",
  "tags": [
    ["K", "1063"],
    ["E", "<file-event-id>", "wss://relay.example.com"],
    ["P", "<uploader-pubkey>"],
    ["k", "1063"],
    ["e", "<file-event-id>", "wss://relay.example.com"],
    ["p", "<uploader-pubkey>"]
  ]
}

對外部網站評論

{
  "kind": 1111,
  "content": "這個網站設計得很好!",
  "tags": [
    ["K", "0"],
    ["I", "https://example.com/article"],
    ["k", "0"],
    ["i", "https://example.com/article"]
  ]
}

對播客評論

{
  "kind": 1111,
  "content": "這集播客內容很精彩!",
  "tags": [
    ["K", "0"],
    ["I", "podcast:guid:abc123"],
    ["k", "0"],
    ["i", "podcast:guid:abc123"]
  ]
}

TypeScript 實作

建立評論

import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';

type CommentTarget =
  | { type: 'event'; eventId: string; kind: number; pubkey: string; relay?: string }
  | { type: 'addressable'; kind: number; pubkey: string; identifier: string; relay?: string }
  | { type: 'external'; identifier: string };

interface CommentOptions {
  root: CommentTarget;
  parent?: CommentTarget;  // 如果是回覆評論
  content: string;
}

function createComment(options: CommentOptions, secretKey: Uint8Array) {
  const tags: string[][] = [];
  const { root, parent, content } = options;

  // 添加根範圍標籤(大寫)
  if (root.type === 'event') {
    tags.push(['K', root.kind.toString()]);
    tags.push(['E', root.eventId, root.relay || '']);
    tags.push(['P', root.pubkey]);
  } else if (root.type === 'addressable') {
    const aTag = `${root.kind}:${root.pubkey}:${root.identifier}`;
    tags.push(['K', root.kind.toString()]);
    tags.push(['A', aTag, root.relay || '']);
    tags.push(['P', root.pubkey]);
  } else {
    tags.push(['K', '0']);
    tags.push(['I', root.identifier]);
  }

  // 添加父項目標籤(小寫)
  const actualParent = parent || root;

  if (actualParent.type === 'event') {
    tags.push(['k', actualParent.kind.toString()]);
    tags.push(['e', actualParent.eventId, actualParent.relay || '']);
    tags.push(['p', actualParent.pubkey]);
  } else if (actualParent.type === 'addressable') {
    const aTag = `${actualParent.kind}:${actualParent.pubkey}:${actualParent.identifier}`;
    tags.push(['k', actualParent.kind.toString()]);
    tags.push(['a', aTag, actualParent.relay || '']);
    tags.push(['p', actualParent.pubkey]);
  } else {
    tags.push(['k', '0']);
    tags.push(['i', actualParent.identifier]);
  }

  const event = {
    kind: 1111,
    content,
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例:對部落格文章評論
const sk = generateSecretKey();

const blogComment = createComment(
  {
    root: {
      type: 'addressable',
      kind: 30023,
      pubkey: '<blog-author-pubkey>',
      identifier: 'my-blog-post',
      relay: 'wss://relay.example.com',
    },
    content: '這篇文章寫得很好!',
  },
  sk
);

console.log(blogComment);

回覆評論

// 回覆現有評論
const replyComment = createComment(
  {
    root: {
      type: 'addressable',
      kind: 30023,
      pubkey: '<blog-author-pubkey>',
      identifier: 'my-blog-post',
      relay: 'wss://relay.example.com',
    },
    parent: {
      type: 'event',
      eventId: '<parent-comment-id>',
      kind: 1111,
      pubkey: '<parent-comment-author>',
      relay: 'wss://relay.example.com',
    },
    content: '同意你的觀點!',
  },
  sk
);

對外部內容評論

// 對網站評論
const websiteComment = createComment(
  {
    root: {
      type: 'external',
      identifier: 'https://example.com/article',
    },
    content: '這個網站的資源很有用!',
  },
  sk
);

// 對 YouTube 影片評論
const youtubeComment = createComment(
  {
    root: {
      type: 'external',
      identifier: 'youtube:dQw4w9WgXcQ',
    },
    content: '經典影片!',
  },
  sk
);

查詢評論

import { SimplePool } from 'nostr-tools';

const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];

// 查詢特定內容的所有評論
async function getCommentsForContent(
  targetKind: number,
  targetIdentifier: string,
  isAddressable: boolean = false
) {
  const filter: any = {
    kinds: [1111],
  };

  if (isAddressable) {
    filter['#A'] = [targetIdentifier];
  } else {
    filter['#E'] = [targetIdentifier];
  }
  filter['#K'] = [targetKind.toString()];

  const comments = await pool.querySync(relays, filter);

  return comments.sort((a, b) => a.created_at - b.created_at);
}

// 使用範例:獲取部落格文章的評論
const articleComments = await getCommentsForContent(
  30023,
  '30023:<pubkey>:article-slug',
  true
);

console.log(`找到 ${articleComments.length} 則評論`);

建立評論樹

interface CommentNode {
  event: any;
  replies: CommentNode[];
}

function buildCommentTree(comments: any[]): CommentNode[] {
  const commentMap = new Map<string, CommentNode>();
  const rootComments: CommentNode[] = [];

  // 建立所有評論節點
  comments.forEach((comment) => {
    commentMap.set(comment.id, { event: comment, replies: [] });
  });

  // 建立樹結構
  comments.forEach((comment) => {
    const node = commentMap.get(comment.id)!;

    // 檢查是否是回覆其他評論
    const parentTag = comment.tags.find(
      (t: string[]) => t[0] === 'e' && comment.tags.some(
        (k: string[]) => k[0] === 'k' && k[1] === '1111'
      )
    );

    if (parentTag) {
      const parentNode = commentMap.get(parentTag[1]);
      if (parentNode) {
        parentNode.replies.push(node);
      } else {
        rootComments.push(node);
      }
    } else {
      rootComments.push(node);
    }
  });

  return rootComments;
}

// 使用範例
const tree = buildCommentTree(articleComments);

function printTree(nodes: CommentNode[], indent: number = 0) {
  nodes.forEach((node) => {
    console.log(' '.repeat(indent) + '- ' + node.event.content.slice(0, 50));
    printTree(node.replies, indent + 2);
  });
}

printTree(tree);

內容規範

  • 評論內容必須是純文字,不支援 Markdown 或其他格式
  • 應該簡潔相關,針對被評論的內容
  • 大寫標籤(K、E、A、I、P)始終指向根內容
  • 小寫標籤(k、e、a、i、p)指向直接父項目

與 NIP-10 的區別

特性 NIP-22 (Kind 1111) NIP-10 (Kind 1)
用途 對各類內容評論 短文回覆
根/父區分 大小寫標籤 reply/root marker
外部內容 支援 (I 標籤) 不支援
回覆 Kind 1 不允許 標準用法

最佳實踐

  • 始終包含中繼器提示:幫助客戶端找到相關內容
  • 正確區分大小寫:大寫指根,小寫指父
  • 不要用於 Kind 1:回覆短文請使用 NIP-10
  • 保持內容純文字:不要使用 Markdown 格式
  • NIP-10:回覆與標記 - Kind 1 回覆機制
  • NIP-23:長文內容 - 可被評論的文章
  • NIP-72:審核社群 - 使用 Kind 1111
  • NIP-94:檔案中繼資料 - 可被評論的檔案

參考資源

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