跳至主要內容

NIP-73: 外部內容 ID

使用 i 標籤引用外部內容的唯一識別符

概述

NIP-73 定義了 i 標籤,用於將 Nostr 事件關聯到外部系統中的內容。 這讓使用者可以對電影、音樂、書籍、Podcast 等外部內容發表評論、評分或討論, 同時保持與原始內容的可追蹤連結。

標籤格式

["i", "<type>:<id>", "<proof-url>"]
欄位 說明 必要性
type 外部系統/平台類型 必要
id 該系統中的唯一識別符 必要
proof-url 證明連結(如適用) 可選

支援的類型

媒體與娛樂

類型 說明 ID 格式
imdb IMDb 電影/電視 tt1234567
tmdb TheMovieDB movie:12345tv:12345
spotify Spotify 音樂 track:xxxalbum:xxx
tidal Tidal 音樂 track:12345
deezer Deezer 音樂 track:12345
podcast:guid Podcast 劇集 GUID UUID 格式
podcast:item:guid Podcast 項目 GUID 項目唯一識別符

書籍與出版

類型 說明 ID 格式
isbn 國際標準書號 10 或 13 位 ISBN
openlibrary Open Library OL12345W
goodreads Goodreads 書籍 書籍 ID
doi 數位物件識別碼 10.1000/xyz123

遊戲與軟體

類型 說明 ID 格式
steam Steam 遊戲 App ID
igdb IGDB 遊戲資料庫 遊戲 ID

地理位置

類型 說明 ID 格式
geo 地理座標 lat,lon
geohash Geohash 編碼 Geohash 字串
osm OpenStreetMap node:12345way:12345

範例

電影評論

{
  "kind": 1,
  "content": "剛看完《乘風破浪》,非常精彩的電影!劇情緊湊,演員演技出色。推薦!⭐⭐⭐⭐⭐",
  "tags": [
    ["i", "imdb:tt1234567"],
    ["i", "tmdb:movie:54321"]
  ]
}

音樂分享

{
  "kind": 1,
  "content": "這首歌最近單曲循環中 🎵",
  "tags": [
    ["i", "spotify:track:4iV5W9uYEdYUVa79Axb7Rh"],
    ["i", "tidal:track:12345678"]
  ]
}

書籍討論

{
  "kind": 1,
  "content": "終於讀完了《精通比特幣》,強烈推薦給所有想深入了解區塊鏈的人!",
  "tags": [
    ["i", "isbn:9781491954386"],
    ["i", "openlibrary:OL26201858W"]
  ]
}

Podcast 討論

{
  "kind": 1,
  "content": "今天這集 Podcast 聊到了很多有趣的觀點!",
  "tags": [
    ["i", "podcast:guid:917393e3-1b1e-5cef-ace4-edaa54e1f82d"],
    ["i", "podcast:item:guid:ep-2024-01-15"]
  ]
}

地點打卡

{
  "kind": 1,
  "content": "今天來到這家咖啡廳,環境很棒!☕",
  "tags": [
    ["i", "geo:25.0330,121.5654"],
    ["i", "geohash:wsqqj"],
    ["i", "osm:node:123456789"]
  ]
}

遊戲評論

{
  "kind": 1,
  "content": "剛通關這款遊戲,故事線非常感人!",
  "tags": [
    ["i", "steam:1234567"],
    ["i", "igdb:12345"]
  ]
}

TypeScript 實作

建立外部內容引用

import { finalizeEvent } from 'nostr-tools';

type ExternalContentType =
  | 'imdb'
  | 'tmdb'
  | 'spotify'
  | 'tidal'
  | 'deezer'
  | 'isbn'
  | 'openlibrary'
  | 'goodreads'
  | 'doi'
  | 'steam'
  | 'igdb'
  | 'geo'
  | 'geohash'
  | 'osm'
  | 'podcast:guid'
  | 'podcast:item:guid';

interface ExternalReference {
  type: ExternalContentType;
  id: string;
  proofUrl?: string;
}

function createExternalReferenceEvent(
  content: string,
  references: ExternalReference[],
  secretKey: Uint8Array,
  additionalTags: string[][] = []
) {
  const iTags = references.map((ref) => {
    const tag = ['i', `${ref.type}:${ref.id}`];
    if (ref.proofUrl) {
      tag.push(ref.proofUrl);
    }
    return tag;
  });

  const event = {
    kind: 1,
    content,
    tags: [...iTags, ...additionalTags],
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例:電影評論
const movieReview = createExternalReferenceEvent(
  '非常棒的電影!劇情緊湊,特效出色。',
  [
    { type: 'imdb', id: 'tt1234567' },
    { type: 'tmdb', id: 'movie:54321' },
  ],
  secretKey
);

// 使用範例:音樂分享
const musicShare = createExternalReferenceEvent(
  '這首歌太好聽了!🎵',
  [
    { type: 'spotify', id: 'track:4iV5W9uYEdYUVa79Axb7Rh' },
  ],
  secretKey
);

解析外部引用

interface ParsedExternalRef {
  type: string;
  id: string;
  proofUrl?: string;
  fullId: string;
}

function parseExternalReferences(event: any): ParsedExternalRef[] {
  const iTags = event.tags.filter(
    (tag: string[]) => tag[0] === 'i' && tag.length >= 2
  );

  return iTags.map((tag: string[]) => {
    const fullId = tag[1];
    const colonIndex = fullId.indexOf(':');

    let type: string;
    let id: string;

    if (colonIndex > 0) {
      type = fullId.slice(0, colonIndex);
      id = fullId.slice(colonIndex + 1);
    } else {
      type = 'unknown';
      id = fullId;
    }

    return {
      type,
      id,
      proofUrl: tag[2],
      fullId,
    };
  });
}

// 使用範例
const refs = parseExternalReferences(event);
refs.forEach((ref) => {
  console.log(`類型: ${ref.type}, ID: ${ref.id}`);
});

產生外部連結

function getExternalUrl(ref: ParsedExternalRef): string | null {
  switch (ref.type) {
    case 'imdb':
      return `https://www.imdb.com/title/${ref.id}`;

    case 'tmdb':
      const [tmdbType, tmdbId] = ref.id.split(':');
      return `https://www.themoviedb.org/${tmdbType}/${tmdbId}`;

    case 'spotify':
      const [spotifyType, spotifyId] = ref.id.split(':');
      return `https://open.spotify.com/${spotifyType}/${spotifyId}`;

    case 'tidal':
      const [tidalType, tidalId] = ref.id.split(':');
      return `https://tidal.com/${tidalType}/${tidalId}`;

    case 'isbn':
      return `https://openlibrary.org/isbn/${ref.id}`;

    case 'openlibrary':
      return `https://openlibrary.org/works/${ref.id}`;

    case 'goodreads':
      return `https://www.goodreads.com/book/show/${ref.id}`;

    case 'doi':
      return `https://doi.org/${ref.id}`;

    case 'steam':
      return `https://store.steampowered.com/app/${ref.id}`;

    case 'igdb':
      return `https://www.igdb.com/games/${ref.id}`;

    case 'geo':
      const [lat, lon] = ref.id.split(',');
      return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}`;

    case 'geohash':
      return `https://geohash.org/${ref.id}`;

    case 'osm':
      const [osmType, osmId] = ref.id.split(':');
      return `https://www.openstreetmap.org/${osmType}/${osmId}`;

    default:
      return null;
  }
}

// 使用範例
const refs = parseExternalReferences(event);
refs.forEach((ref) => {
  const url = getExternalUrl(ref);
  if (url) {
    console.log(`${ref.type}: ${url}`);
  }
});

搜尋相關事件

import { SimplePool } from 'nostr-tools';

async function findEventsForContent(
  pool: SimplePool,
  relays: string[],
  type: ExternalContentType,
  id: string
): Promise<any[]> {
  const filter = {
    kinds: [1],
    '#i': [`${type}:${id}`],
    limit: 100,
  };

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

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

// 使用範例:查詢關於特定電影的所有討論
const movieDiscussions = await findEventsForContent(
  pool,
  ['wss://relay.example.com'],
  'imdb',
  'tt1234567'
);

console.log(`找到 ${movieDiscussions.length} 則相關討論`);

React 元件

interface ExternalContentCardProps {
  reference: ParsedExternalRef;
}

function ExternalContentCard({ reference }: ExternalContentCardProps) {
  const url = getExternalUrl(reference);

  const typeLabels: Record<string, string> = {
    imdb: 'IMDb',
    tmdb: 'TMDb',
    spotify: 'Spotify',
    tidal: 'Tidal',
    isbn: 'ISBN',
    openlibrary: 'Open Library',
    goodreads: 'Goodreads',
    doi: 'DOI',
    steam: 'Steam',
    igdb: 'IGDB',
    geo: '位置',
    geohash: 'Geohash',
    osm: 'OpenStreetMap',
  };

  const typeIcons: Record<string, string> = {
    imdb: '🎬',
    tmdb: '🎥',
    spotify: '🎵',
    tidal: '🎧',
    isbn: '📚',
    openlibrary: '📖',
    goodreads: '📕',
    doi: '📄',
    steam: '🎮',
    igdb: '🕹️',
    geo: '📍',
    geohash: '🗺️',
    osm: '🌍',
  };

  return (
    <div className="external-content-card">
      <span className="icon">
        {typeIcons[reference.type] || '🔗'}
      </span>
      <span className="label">
        {typeLabels[reference.type] || reference.type}
      </span>
      <span className="id">{reference.id}</span>
      {url && (
        <a
          href={url}
          target="_blank"
          rel="noopener noreferrer"
          className="link"
        >
          開啟
        </a>
      )}
    </div>
  );
}

function EventExternalRefs({ event }: { event: any }) {
  const refs = parseExternalReferences(event);

  if (refs.length === 0) {
    return null;
  }

  return (
    <div className="external-refs">
      {refs.map((ref, i) => (
        <ExternalContentCard key={i} reference={ref} />
      ))}
    </div>
  );
}

使用場景

媒體評論與討論

  • 電影、電視劇評論
  • 音樂專輯、歌曲討論
  • Podcast 節目評論
  • 遊戲評測

書籍社群

  • 讀書筆記與心得
  • 書籍推薦列表
  • 學術論文討論(DOI)

位置相關內容

  • 餐廳、咖啡廳評論
  • 旅遊景點分享
  • 活動地點標記

內容聚合

  • 聚合所有關於特定電影的討論
  • 建立圍繞特定書籍的社群
  • 追蹤特定音樂的熱度

最佳實踐

  • 使用標準識別符:優先使用廣泛認可的 ID 系統(如 IMDb、ISBN)
  • 多重引用:同時使用多個來源 ID 提高可發現性
  • 提供證明連結:在可能的情況下包含 proof URL
  • 客戶端豐富化:從外部 API 獲取詳細資訊並展示
  • 快取外部資料:減少對外部服務的請求
  • NIP-01:基本協議 - 事件格式
  • NIP-32:標籤系統 - 分類標籤
  • NIP-39:外部身份 - 身份驗證
  • NIP-48:代理標籤 - 另一種外部引用方式
  • NIP-52:日曆活動 - 地點標籤

參考資源

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