跳至主要內容

NIP-99: 分類廣告

在 Nostr 上發布和管理商品、服務、工作等分類廣告

概述

NIP-99 定義了在 Nostr 上發布分類廣告的標準格式。使用 kind 30402 可定址事件, 用戶可以發布商品出售、服務提供、工作機會、租賃等各種類型的廣告, 並通過標籤進行分類和搜尋。

事件結構

事件類型

Kind 說明 類型
30402 分類廣告 可定址(可替換)

必要標籤

標籤 說明 範例
d 唯一識別符 ["d", "macbook-pro-2023"]
title 廣告標題 ["title", "出售 MacBook Pro 2023"]

可選標籤

標籤 說明 範例
summary 簡短摘要 ["summary", "九成新,配件齊全"]
published_at 發布時間戳 ["published_at", "1704067200"]
location 地理位置 ["location", "台北市"]
price 價格(數量、貨幣、頻率) ["price", "50000", "TWD"]
image 圖片 URL ["image", "https://..."]
t 主題標籤 ["t", "電子產品"]
g Geohash 位置 ["g", "wsqqjk"]

廣告類型

使用 t 標籤區分不同類型的廣告:

類型 標籤值 說明
商品出售 for-sale 出售實體商品
徵求購買 wanted 徵求特定商品
服務提供 service 提供專業服務
工作機會 job 招聘資訊
房屋租賃 rental 出租房屋或空間
活動 event 活動公告

價格格式

["price", "<數量>", "<貨幣>", "<頻率>"]

貨幣代碼

  • USD:美元
  • TWD:新台幣
  • EUR:歐元
  • BTC:比特幣
  • SAT:聰

頻率(用於租賃/服務)

  • hour:每小時
  • day:每天
  • week:每週
  • month:每月

範例

商品出售

{
  "kind": 30402,
  "content": "出售九成新 MacBook Pro 2023 M3 晶片版本。\n\n配置:\n- 14 吋螢幕\n- 18GB 記憶體\n- 512GB SSD\n\n附原廠充電器和保護殼。\n\n可面交或順豐到付。",
  "tags": [
    ["d", "macbook-pro-2023-m3"],
    ["title", "MacBook Pro 2023 M3 九成新出售"],
    ["summary", "14吋 18GB/512GB,附配件"],
    ["price", "45000", "TWD"],
    ["t", "for-sale"],
    ["t", "電子產品"],
    ["t", "apple"],
    ["t", "macbook"],
    ["location", "台北市大安區"],
    ["g", "wsqqjk8"],
    ["image", "https://example.com/macbook1.jpg"],
    ["image", "https://example.com/macbook2.jpg"],
    ["published_at", "1704067200"]
  ]
}

服務提供

{
  "kind": 30402,
  "content": "提供專業網站開發服務。\n\n服務內容:\n- 前端開發(React、Vue)\n- 後端開發(Node.js、Python)\n- 全端應用\n- 區塊鏈整合\n\n支援比特幣/閃電網路支付。",
  "tags": [
    ["d", "web-dev-service-2024"],
    ["title", "專業網站開發服務"],
    ["summary", "前後端開發、區塊鏈整合"],
    ["price", "50000", "SAT", "hour"],
    ["t", "service"],
    ["t", "開發"],
    ["t", "web"],
    ["t", "bitcoin"],
    ["location", "遠端"],
    ["published_at", "1704067200"]
  ]
}

工作機會

{
  "kind": 30402,
  "content": "我們正在尋找全端工程師加入團隊!\n\n要求:\n- 3 年以上開發經驗\n- 熟悉 TypeScript、React、Node.js\n- 對 Bitcoin/Nostr 有興趣\n\n待遇:\n- 遠端工作\n- 彈性工時\n- 比特幣薪資可議",
  "tags": [
    ["d", "fullstack-dev-position-2024"],
    ["title", "徵求全端工程師 - 遠端工作"],
    ["summary", "Bitcoin 公司,支援 BTC 薪資"],
    ["price", "80000", "USD", "year"],
    ["t", "job"],
    ["t", "全職"],
    ["t", "工程師"],
    ["t", "遠端"],
    ["t", "bitcoin"],
    ["location", "遠端"],
    ["published_at", "1704067200"]
  ]
}

房屋租賃

{
  "kind": 30402,
  "content": "台北市中心套房出租。\n\n房屋資訊:\n- 獨立衛浴\n- 採光良好\n- 近捷運站\n- 包水電網路\n\n押金兩個月,租期至少一年。接受 Bitcoin 付款。",
  "tags": [
    ["d", "taipei-studio-rental-2024"],
    ["title", "台北市中心套房出租"],
    ["summary", "獨立衛浴,近捷運,包水電網路"],
    ["price", "18000", "TWD", "month"],
    ["t", "rental"],
    ["t", "套房"],
    ["t", "台北"],
    ["location", "台北市中正區"],
    ["g", "wsqqj"],
    ["image", "https://example.com/apt1.jpg"],
    ["image", "https://example.com/apt2.jpg"],
    ["published_at", "1704067200"]
  ]
}

TypeScript 實作

建立分類廣告

import { finalizeEvent } from 'nostr-tools';

type ListingType = 'for-sale' | 'wanted' | 'service' | 'job' | 'rental' | 'event';
type Currency = 'USD' | 'TWD' | 'EUR' | 'BTC' | 'SAT';
type PriceFrequency = 'hour' | 'day' | 'week' | 'month' | 'year';

interface Price {
  amount: string;
  currency: Currency;
  frequency?: PriceFrequency;
}

interface ClassifiedListing {
  identifier: string;
  title: string;
  content: string;
  summary?: string;
  type: ListingType;
  price?: Price;
  location?: string;
  geohash?: string;
  images?: string[];
  tags?: string[];
}

function createClassifiedListing(
  listing: ClassifiedListing,
  secretKey: Uint8Array
) {
  const tags: string[][] = [
    ['d', listing.identifier],
    ['title', listing.title],
    ['t', listing.type],
  ];

  if (listing.summary) {
    tags.push(['summary', listing.summary]);
  }

  if (listing.price) {
    const priceTag = ['price', listing.price.amount, listing.price.currency];
    if (listing.price.frequency) {
      priceTag.push(listing.price.frequency);
    }
    tags.push(priceTag);
  }

  if (listing.location) {
    tags.push(['location', listing.location]);
  }

  if (listing.geohash) {
    tags.push(['g', listing.geohash]);
  }

  listing.images?.forEach((url) => {
    tags.push(['image', url]);
  });

  listing.tags?.forEach((tag) => {
    tags.push(['t', tag]);
  });

  tags.push(['published_at', Math.floor(Date.now() / 1000).toString()]);

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

  return finalizeEvent(event, secretKey);
}

// 使用範例:發布商品
const forSaleListing = createClassifiedListing(
  {
    identifier: 'bitcoin-hardware-wallet',
    title: '全新 Bitcoin 硬體錢包出售',
    content: '全新未拆封 Coldcard Mk4...',
    summary: '全新未拆,原價購入',
    type: 'for-sale',
    price: { amount: '200', currency: 'USD' },
    location: '台北市',
    tags: ['bitcoin', '硬體錢包', 'coldcard'],
    images: ['https://example.com/wallet.jpg'],
  },
  secretKey
);

解析分類廣告

interface ParsedListing {
  identifier: string;
  title: string;
  content: string;
  summary?: string;
  type?: string;
  price?: {
    amount: string;
    currency: string;
    frequency?: string;
  };
  location?: string;
  geohash?: string;
  images: string[];
  tags: string[];
  publishedAt?: number;
}

function parseClassifiedListing(event: any): ParsedListing | null {
  if (event.kind !== 30402) {
    return null;
  }

  const getTag = (name: string) => {
    const tag = event.tags.find((t: string[]) => t[0] === name);
    return tag?.[1];
  };

  const getAllTags = (name: string) => {
    return event.tags
      .filter((t: string[]) => t[0] === name)
      .map((t: string[]) => t[1]);
  };

  const priceTag = event.tags.find((t: string[]) => t[0] === 'price');
  let price;
  if (priceTag && priceTag.length >= 3) {
    price = {
      amount: priceTag[1],
      currency: priceTag[2],
      frequency: priceTag[3],
    };
  }

  const publishedAtStr = getTag('published_at');

  return {
    identifier: getTag('d') || '',
    title: getTag('title') || '無標題',
    content: event.content,
    summary: getTag('summary'),
    type: getAllTags('t').find((t: string) =>
      ['for-sale', 'wanted', 'service', 'job', 'rental', 'event'].includes(t)
    ),
    price,
    location: getTag('location'),
    geohash: getTag('g'),
    images: getAllTags('image'),
    tags: getAllTags('t'),
    publishedAt: publishedAtStr ? parseInt(publishedAtStr) : undefined,
  };
}

// 使用範例
const listing = parseClassifiedListing(event);
if (listing) {
  console.log(`標題: ${listing.title}`);
  console.log(`類型: ${listing.type}`);
  if (listing.price) {
    console.log(`價格: ${listing.price.amount} ${listing.price.currency}`);
  }
}

搜尋分類廣告

import { SimplePool } from 'nostr-tools';

interface ListingSearchOptions {
  type?: ListingType;
  tags?: string[];
  location?: string;
  geohash?: string;
  author?: string;
  limit?: number;
}

async function searchListings(
  pool: SimplePool,
  relays: string[],
  options: ListingSearchOptions = {}
): Promise<any[]> {
  const filter: any = {
    kinds: [30402],
    limit: options.limit || 50,
  };

  if (options.author) {
    filter.authors = [options.author];
  }

  const tTags: string[] = [];
  if (options.type) {
    tTags.push(options.type);
  }
  if (options.tags) {
    tTags.push(...options.tags);
  }
  if (tTags.length > 0) {
    filter['#t'] = tTags;
  }

  if (options.geohash) {
    filter['#g'] = [options.geohash];
  }

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

  // 如果指定了 location,在客戶端過濾
  let filtered = events;
  if (options.location) {
    filtered = events.filter((e) => {
      const locationTag = e.tags.find((t: string[]) => t[0] === 'location');
      return locationTag?.[1]?.includes(options.location!);
    });
  }

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

// 使用範例:搜尋台北的商品
const listings = await searchListings(pool, relays, {
  type: 'for-sale',
  location: '台北',
  tags: ['電子產品'],
  limit: 20,
});

listings.forEach((event) => {
  const listing = parseClassifiedListing(event);
  console.log(listing?.title);
});

React 元件

interface ListingCardProps {
  event: any;
}

function ListingCard({ event }: ListingCardProps) {
  const listing = parseClassifiedListing(event);

  if (!listing) return null;

  const typeLabels: Record<string, string> = {
    'for-sale': '出售',
    wanted: '徵求',
    service: '服務',
    job: '工作',
    rental: '租賃',
    event: '活動',
  };

  const formatPrice = (price: ParsedListing['price']) => {
    if (!price) return '價格面議';
    const freq = price.frequency ? `/${price.frequency}` : '';
    return `${price.amount} ${price.currency}${freq}`;
  };

  return (
    <div className="listing-card">
      {listing.images[0] && (
        <img
          src={listing.images[0]}
          alt={listing.title}
          className="listing-image"
        />
      )}
      <div className="listing-content">
        <div className="listing-header">
          <span className={`type-badge ${listing.type}`}>
            {typeLabels[listing.type || ''] || '其他'}
          </span>
          <span className="price">{formatPrice(listing.price)}</span>
        </div>
        <h3 className="title">{listing.title}</h3>
        {listing.summary && (
          <p className="summary">{listing.summary}</p>
        )}
        {listing.location && (
          <div className="location">
            <span>📍</span> {listing.location}
          </div>
        )}
        <div className="tags">
          {listing.tags
            .filter((t) => !['for-sale', 'wanted', 'service', 'job', 'rental', 'event'].includes(t))
            .map((tag) => (
              <span key={tag} className="tag">#{tag}</span>
            ))}
        </div>
      </div>
    </div>
  );
}

使用場景

二手交易市場

  • Bitcoin 相關商品(硬體錢包、礦機)
  • 電子產品、書籍、收藏品
  • 支援閃電網路支付的 P2P 交易

服務市場

  • 開發、設計、翻譯等專業服務
  • 支援 Bitcoin 支付的自由工作者

工作機會

  • Bitcoin/區塊鏈公司招聘
  • 支援 BTC 薪資的遠端工作

房屋租賃

  • 接受 Bitcoin 支付的房東
  • 數位遊牧者住宿

最佳實踐

  • 清晰標題:使用描述性標題,包含關鍵資訊
  • 詳細內容:在 content 中提供完整描述
  • 合適分類:使用正確的 t 標籤分類
  • 位置資訊:提供 location 和 geohash 方便搜尋
  • 高品質圖片:上傳清晰的商品/服務圖片
  • 及時更新:售出後刪除或標記已售
  • NIP-01:基本協議 - 事件格式
  • NIP-23:長文內容 - 類似的可定址事件
  • NIP-52:日曆活動 - 活動類廣告
  • NIP-57:Zaps - 閃電網路支付
  • NIP-73:外部內容 ID - 地理位置標籤

參考資源

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