NIP-84: 精選標記
Highlights - 標記、引用與註解內容
概述
NIP-84 定義了精選標記(Highlights)事件,讓用戶可以標記和引用文章中的精彩段落。 這類似於 Medium 的文字標記功能或 Kindle 的劃線功能,讓讀者可以保存和分享有價值的內容片段。
核心概念:
精選標記使用 kind 9802,將標記的文字存放在 content 欄位, 並透過 a、e 或
r 標籤引用來源。
事件結構
精選標記事件使用 kind 9802:
| 欄位/標籤 | 必要 | 說明 |
|---|---|---|
| kind | ✓ | 9802 |
| content | ✓ | 標記的文字內容 |
| a / e / r | ✓ | 來源引用(擇一) |
| p | - | 原作者(含角色) |
| context | - | 周圍上下文 |
| comment | - | 引用評論 |
來源引用
精選標記可以引用三種類型的來源:
a 可定址事件
引用 NIP-23 長文等可定址事件
30023:pubkey:slug e 一般事件
引用一般貼文或其他事件
event-id r 外部 URL
引用網頁文章或外部內容
https://... 最佳實踐: 引用外部 URL 時,客戶端應清理追蹤參數和無用資訊,保持 URL 簡潔。
作者歸屬
可以透過 p 標籤標記原作者,特別適用於引用非 Nostr 內容時:
["p", "<author-pubkey>", "<relay-url>", "author"]
["p", "<editor-pubkey>", "<relay-url>", "editor"] author
原始內容的作者
editor
內容的編輯者
使用範例
標記 Nostr 長文
{
"kind": 9802,
"tags": [
["a", "30023:<author-pubkey>:my-article-slug", "wss://relay.example.com"],
["p", "<author-pubkey>", "wss://relay.example.com", "author"]
],
"content": "比特幣的核心創新在於解決了數位貨幣的雙重支付問題,而無需依賴中央機構。"
} 標記一般貼文
{
"kind": 9802,
"tags": [
["e", "<event-id>", "wss://relay.example.com"],
["p", "<author-pubkey>", "", "author"]
],
"content": "Nostr 的簡單性是它最大的優勢"
} 標記外部網頁
{
"kind": 9802,
"tags": [
["r", "https://bitcoin.org/bitcoin.pdf"]
],
"content": "A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution."
} 帶上下文的標記
{
"kind": 9802,
"tags": [
["a", "30023:<pubkey>:nostr-guide", "wss://relay.example.com"],
["p", "<pubkey>", "", "author"],
["context", "在討論 Nostr 協議的設計理念時,作者提到了一個重要觀點:"]
],
"content": "簡單性是 Nostr 最大的特色,核心協議只需幾百行代碼就能實現。"
} 引用評論(Quote Highlight)
{
"kind": 9802,
"tags": [
["e", "<event-id>", "wss://relay.example.com"],
["p", "<author-pubkey>", "", "author"],
["comment", "這段話完美說明了為什麼我們需要去中心化的社交網路"]
],
"content": "在傳統社交平台上,你的帳號、你的關注者、你的內容都不屬於你。"
} TypeScript 實作
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
type AuthorRole = 'author' | 'editor'
interface HighlightSource {
type: 'event' | 'address' | 'url'
value: string
relay?: string
}
interface Author {
pubkey: string
relay?: string
role: AuthorRole
}
interface HighlightOptions {
content: string
source: HighlightSource
authors?: Author[]
context?: string
comment?: string
}
// 創建精選標記事件
function createHighlight(
options: HighlightOptions,
secretKey: Uint8Array
): object {
const tags: string[][] = []
// 添加來源引用
switch (options.source.type) {
case 'event':
tags.push(['e', options.source.value, options.source.relay || ''])
break
case 'address':
tags.push(['a', options.source.value, options.source.relay || ''])
break
case 'url':
tags.push(['r', cleanUrl(options.source.value)])
break
}
// 添加作者
if (options.authors) {
for (const author of options.authors) {
tags.push(['p', author.pubkey, author.relay || '', author.role])
}
}
// 添加上下文
if (options.context) {
tags.push(['context', options.context])
}
// 添加評論
if (options.comment) {
tags.push(['comment', options.comment])
}
const event = {
kind: 9802,
created_at: Math.floor(Date.now() / 1000),
tags,
content: options.content,
}
return finalizeEvent(event, secretKey)
}
// 清理 URL(移除追蹤參數)
function cleanUrl(url: string): string {
try {
const parsed = new URL(url)
// 移除常見追蹤參數
const trackingParams = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
'fbclid', 'gclid', 'ref', 'source'
]
trackingParams.forEach(param => parsed.searchParams.delete(param))
return parsed.toString()
} catch {
return url
}
}
// 標記 Nostr 長文
function highlightArticle(
authorPubkey: string,
articleSlug: string,
highlightedText: string,
relay: string,
secretKey: Uint8Array,
options?: { context?: string; comment?: string }
): object {
return createHighlight({
content: highlightedText,
source: {
type: 'address',
value: `30023:${authorPubkey}:${articleSlug}`,
relay,
},
authors: [{ pubkey: authorPubkey, relay, role: 'author' }],
context: options?.context,
comment: options?.comment,
}, secretKey)
}
// 標記一般貼文
function highlightNote(
eventId: string,
authorPubkey: string,
highlightedText: string,
relay: string,
secretKey: Uint8Array
): object {
return createHighlight({
content: highlightedText,
source: {
type: 'event',
value: eventId,
relay,
},
authors: [{ pubkey: authorPubkey, role: 'author' }],
}, secretKey)
}
// 標記外部網頁
function highlightWebpage(
url: string,
highlightedText: string,
secretKey: Uint8Array,
options?: { context?: string; comment?: string }
): object {
return createHighlight({
content: highlightedText,
source: {
type: 'url',
value: url,
},
context: options?.context,
comment: options?.comment,
}, secretKey)
}
// 使用範例
const sk = generateSecretKey()
const pk = getPublicKey(sk)
// 標記長文中的精彩段落
const articleHighlight = highlightArticle(
'author-pubkey-here',
'intro-to-nostr',
'Nostr 是一個簡單、開放的去中心化社交協議。',
'wss://relay.example.com',
sk,
{
context: '在介紹 Nostr 的基本概念時:',
comment: '這句話完美總結了 Nostr 的核心價值'
}
)
// 標記外部文章
const webHighlight = highlightWebpage(
'https://bitcoin.org/bitcoin.pdf?utm_source=twitter',
'A purely peer-to-peer version of electronic cash',
sk,
{ comment: '比特幣白皮書的開篇就點明了核心概念' }
)
console.log('Article highlight:', articleHighlight)
console.log('Web highlight:', webHighlight) 查詢精選標記
import { SimplePool } from 'nostr-tools'
const pool = new SimplePool()
const relays = ['wss://relay.example.com']
// 查詢用戶的所有精選標記
async function getUserHighlights(pubkey: string) {
const highlights = await pool.querySync(relays, {
kinds: [9802],
authors: [pubkey],
})
return highlights
}
// 查詢特定文章的所有精選標記
async function getArticleHighlights(authorPubkey: string, slug: string) {
const aTag = `30023:${authorPubkey}:${slug}`
const highlights = await pool.querySync(relays, {
kinds: [9802],
'#a': [aTag],
})
return highlights
}
// 查詢特定事件的精選標記
async function getEventHighlights(eventId: string) {
const highlights = await pool.querySync(relays, {
kinds: [9802],
'#e': [eventId],
})
return highlights
}
// 查詢特定 URL 的精選標記
async function getUrlHighlights(url: string) {
const highlights = await pool.querySync(relays, {
kinds: [9802],
'#r': [url],
})
return highlights
}
// 解析精選標記
function parseHighlight(event: any) {
const getTag = (name: string) =>
event.tags.find((t: string[]) => t[0] === name)
// 判斷來源類型
let sourceType: 'event' | 'address' | 'url' | null = null
let sourceValue: string | null = null
let sourceRelay: string | undefined
const eTag = getTag('e')
const aTag = getTag('a')
const rTag = getTag('r')
if (eTag) {
sourceType = 'event'
sourceValue = eTag[1]
sourceRelay = eTag[2]
} else if (aTag) {
sourceType = 'address'
sourceValue = aTag[1]
sourceRelay = aTag[2]
} else if (rTag) {
sourceType = 'url'
sourceValue = rTag[1]
}
// 解析作者
const authors = event.tags
.filter((t: string[]) => t[0] === 'p')
.map((t: string[]) => ({
pubkey: t[1],
relay: t[2] || undefined,
role: t[3] || 'author',
}))
const contextTag = getTag('context')
const commentTag = getTag('comment')
return {
id: event.id,
pubkey: event.pubkey,
content: event.content,
source: sourceType ? {
type: sourceType,
value: sourceValue,
relay: sourceRelay,
} : null,
authors,
context: contextTag?.[1],
comment: commentTag?.[1],
createdAt: event.created_at,
}
}
// 獲取用戶最近的精選
const myHighlights = await getUserHighlights('my-pubkey')
console.log('我的精選:', myHighlights.map(h => parseHighlight(h))) React 元件範例
import { useState } from 'react'
interface Highlight {
content: string
context?: string
comment?: string
source: {
type: 'event' | 'address' | 'url'
value: string
}
authors: { pubkey: string; role: string }[]
createdAt: number
}
function HighlightCard({ highlight }: { highlight: Highlight }) {
return (
<div className="rounded-xl bg-gray-800 border border-gray-700 overflow-hidden">
<div className="p-4">
{/* 上下文 */}
{highlight.context && (
<p className="text-sm text-gray-500 mb-2 italic">
{highlight.context}
</p>
)}
{/* 標記內容 */}
<blockquote className="border-l-4 border-purple-500 pl-4 py-2">
<p className="text-white text-lg leading-relaxed">
"{highlight.content}"
</p>
</blockquote>
{/* 評論 */}
{highlight.comment && (
<div className="mt-4 p-3 bg-gray-700/50 rounded-lg">
<p className="text-gray-300 text-sm">
💬 {highlight.comment}
</p>
</div>
)}
{/* 來源與作者 */}
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center gap-2">
{highlight.source.type === 'url' ? (
<a
href={highlight.source.value}
target="_blank"
rel="noopener noreferrer"
className="hover:text-purple-400 truncate max-w-xs"
>
🔗 {new URL(highlight.source.value).hostname}
</a>
) : (
<span>📝 Nostr 內容</span>
)}
</div>
<span>
{new Date(highlight.createdAt * 1000).toLocaleDateString()}
</span>
</div>
</div>
</div>
)
}
// 文字選取標記元件
function TextHighlighter({
onHighlight
}: {
onHighlight: (text: string, context: string) => void
}) {
const [selectedText, setSelectedText] = useState('')
const handleMouseUp = () => {
const selection = window.getSelection()
if (selection && selection.toString().trim()) {
const text = selection.toString().trim()
// 獲取上下文(前後 50 個字元)
const range = selection.getRangeAt(0)
const container = range.commonAncestorContainer
const fullText = container.textContent || ''
const startIndex = Math.max(0, fullText.indexOf(text) - 50)
const endIndex = Math.min(fullText.length, fullText.indexOf(text) + text.length + 50)
const context = fullText.slice(startIndex, endIndex)
setSelectedText(text)
}
}
const handleHighlight = () => {
if (selectedText) {
onHighlight(selectedText, '')
setSelectedText('')
window.getSelection()?.removeAllRanges()
}
}
return (
<div>
<div onMouseUp={handleMouseUp}>
{/* 文章內容放這裡 */}
</div>
{selectedText && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-purple-600 text-white
px-4 py-2 rounded-full shadow-lg flex items-center gap-2">
<span className="text-sm truncate max-w-xs">
"{selectedText.slice(0, 30)}..."
</span>
<button
onClick={handleHighlight}
className="px-3 py-1 bg-white/20 rounded-full hover:bg-white/30"
>
✨ 標記
</button>
</div>
)}
</div>
)
} 應用場景
📚 閱讀筆記
閱讀長文時標記重點段落,建立個人知識庫。
💬 引用分享
引用精彩段落並加上評論,分享給關注者。
🔖 書籤功能
比單純書籤更有價值,保存具體內容片段。
📰 內容策展
策展優質內容,建立主題精選集。
🎓 學習工具
標記學習材料中的重點,方便複習。
🌐 網頁標記
標記任何網頁內容,不限於 Nostr 原生內容。
最佳實踐
✓ 清理 URL 追蹤參數
引用外部 URL 時,移除 utm_* 等追蹤參數,保持 URL 簡潔。
✓ 添加作者歸屬
使用 p 標籤標記原作者,特別是引用非 Nostr 內容時。
✓ 提供上下文
使用 context 標籤提供周圍內容,幫助讀者理解標記的意義。
⚠ 引用評論的特殊處理
使用 comment 標籤時,客戶端應將其渲染為引用貼文,避免在時間線上產生重複內容。
相關 NIP
總結
NIP-84 精選標記讓用戶可以保存和分享內容中的精彩片段。無論是 Nostr 原生內容還是外部網頁, 都可以輕鬆標記並加上評論。這為內容消費和知識管理提供了強大的工具。