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 | 必要 |
E 或 A 或 I | 根項目的識別符 | 必要(其中之一) |
P | 根項目作者的公鑰 | 必要 |
k | 父項目的事件 kind | 必要 |
e 或 a 或 i | 父項目的識別符 | 必要(其中之一) |
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 格式
相關 NIPs
參考資源
已複製連結