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 方便搜尋
- 高品質圖片:上傳清晰的商品/服務圖片
- 及時更新:售出後刪除或標記已售
相關 NIPs
- NIP-01:基本協議 - 事件格式
- NIP-23:長文內容 - 類似的可定址事件
- NIP-52:日曆活動 - 活動類廣告
- NIP-57:Zaps - 閃電網路支付
- NIP-73:外部內容 ID - 地理位置標籤
參考資源
已複製連結