599 lines
17 KiB
TypeScript
599 lines
17 KiB
TypeScript
/**
|
|
* Extract objects from invisible metadata in Nostr notes
|
|
* Objects are stored in [Metadata JSON] sections in the note content
|
|
*/
|
|
|
|
import type { Event } from 'nostr-tools'
|
|
import { extractTagsFromEvent } from './nostrTagSystem'
|
|
import { generateAuthorHashId, generateSeriesHashId, generatePublicationHashId, generateReviewHashId, generatePurchaseHashId, generateReviewTipHashId, generateSponsoringHashId } from './hashIdGenerator'
|
|
|
|
export interface ExtractedAuthor {
|
|
type: 'author'
|
|
id: string
|
|
pubkey: string
|
|
authorName: string
|
|
presentation: string
|
|
contentDescription: string
|
|
mainnetAddress?: string
|
|
pictureUrl?: string
|
|
category: string
|
|
url?: string
|
|
eventId: string
|
|
}
|
|
|
|
export interface ExtractedSeries {
|
|
type: 'series'
|
|
id: string
|
|
pubkey: string
|
|
title: string
|
|
description: string
|
|
preview?: string
|
|
coverUrl?: string
|
|
category: string
|
|
eventId: string
|
|
}
|
|
|
|
export interface ExtractedPublication {
|
|
type: 'publication'
|
|
id: string
|
|
pubkey: string
|
|
title: string
|
|
preview: string
|
|
category: string
|
|
seriesId?: string
|
|
bannerUrl?: string
|
|
zapAmount: number
|
|
eventId: string
|
|
}
|
|
|
|
export interface ExtractedReview {
|
|
type: 'review'
|
|
id: string
|
|
pubkey: string
|
|
articleId: string
|
|
reviewerPubkey: string
|
|
content: string
|
|
title?: string
|
|
eventId: string
|
|
}
|
|
|
|
export interface ExtractedPurchase {
|
|
type: 'purchase'
|
|
id: string
|
|
payerPubkey: string
|
|
articleId: string
|
|
authorPubkey: string
|
|
amount: number
|
|
paymentHash: string
|
|
eventId: string
|
|
}
|
|
|
|
export interface ExtractedReviewTip {
|
|
type: 'review_tip'
|
|
id: string
|
|
payerPubkey: string
|
|
articleId: string
|
|
reviewId: string
|
|
reviewerPubkey: string
|
|
authorPubkey: string
|
|
amount: number
|
|
paymentHash: string
|
|
eventId: string
|
|
}
|
|
|
|
export interface ExtractedSponsoring {
|
|
type: 'sponsoring'
|
|
id: string
|
|
payerPubkey: string
|
|
authorPubkey: string
|
|
seriesId?: string
|
|
articleId?: string
|
|
amount: number
|
|
paymentHash: string
|
|
eventId: string
|
|
}
|
|
|
|
export type ExtractedObject =
|
|
| ExtractedAuthor
|
|
| ExtractedSeries
|
|
| ExtractedPublication
|
|
| ExtractedReview
|
|
| ExtractedPurchase
|
|
| ExtractedReviewTip
|
|
| ExtractedSponsoring
|
|
|
|
/**
|
|
* Extract JSON metadata from note content
|
|
*/
|
|
function extractMetadataJsonFromTag(event: { tags: string[][] }): Record<string, unknown> | null {
|
|
const jsonTag = event.tags.find((tag) => tag[0] === 'json')
|
|
if (jsonTag?.[1]) {
|
|
try {
|
|
return JSON.parse(jsonTag[1])
|
|
} catch (e) {
|
|
console.error('Error parsing JSON metadata from tag:', e)
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function extractMetadataJson(content: string): Record<string, unknown> | null {
|
|
// Try invisible format first (with zero-width characters) - for backward compatibility
|
|
const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
|
|
if (invisibleJsonMatch?.[1]) {
|
|
try {
|
|
// Remove zero-width characters from JSON
|
|
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
|
|
return JSON.parse(cleanedJson)
|
|
} catch (e) {
|
|
console.error('Error parsing metadata JSON from invisible content:', e)
|
|
}
|
|
}
|
|
|
|
// Fallback to visible format (for backward compatibility)
|
|
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
|
|
if (jsonMatch?.[1]) {
|
|
try {
|
|
return JSON.parse(jsonMatch[1].trim())
|
|
} catch (e) {
|
|
console.error('Error parsing metadata JSON from content:', e)
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Extract author from event
|
|
*/
|
|
export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAuthor | null> {
|
|
const tags = extractTagsFromEvent(event)
|
|
if (tags.type !== 'author') {
|
|
return null
|
|
}
|
|
|
|
// Try to extract from tag first (new format)
|
|
let metadata = extractMetadataJsonFromTag(event)
|
|
|
|
// Fallback to content format (for backward compatibility)
|
|
if (!metadata) {
|
|
metadata = extractMetadataJson(event.content)
|
|
}
|
|
|
|
if (metadata?.type === 'author') {
|
|
const authorData = {
|
|
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
authorName: (metadata.authorName as string) ?? '',
|
|
presentation: (metadata.presentation as string) ?? '',
|
|
contentDescription: (metadata.contentDescription as string) ?? '',
|
|
mainnetAddress: metadata.mainnetAddress as string | undefined,
|
|
pictureUrl: metadata.pictureUrl as string | undefined,
|
|
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
|
|
}
|
|
|
|
const id = await generateAuthorHashId(authorData)
|
|
|
|
return {
|
|
type: 'author',
|
|
id,
|
|
pubkey: authorData.pubkey,
|
|
authorName: authorData.authorName,
|
|
presentation: authorData.presentation,
|
|
contentDescription: authorData.contentDescription,
|
|
category: authorData.category,
|
|
eventId: event.id,
|
|
...(authorData.mainnetAddress ? { mainnetAddress: authorData.mainnetAddress } : {}),
|
|
...(authorData.pictureUrl ? { pictureUrl: authorData.pictureUrl } : {}),
|
|
...(metadata.url ? { url: metadata.url as string } : {}),
|
|
}
|
|
}
|
|
|
|
// Fallback: extract from tags and visible content
|
|
// This is a simplified extraction - full data should be in metadata JSON
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Extract series from event
|
|
*/
|
|
export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSeries | null> {
|
|
const tags = extractTagsFromEvent(event)
|
|
if (tags.type !== 'series') {
|
|
return null
|
|
}
|
|
|
|
// Try to extract from tag first (new format)
|
|
let metadata = extractMetadataJsonFromTag(event)
|
|
|
|
// Fallback to content format (for backward compatibility)
|
|
if (!metadata) {
|
|
metadata = extractMetadataJson(event.content)
|
|
}
|
|
|
|
if (metadata?.type === 'series') {
|
|
const seriesData = {
|
|
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
title: (metadata.title as string) ?? (tags.title as string) ?? '',
|
|
description: (metadata.description as string) ?? '',
|
|
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
|
|
coverUrl: (metadata.coverUrl as string) ?? (tags.coverUrl as string) ?? undefined,
|
|
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
|
|
}
|
|
|
|
const id = await generateSeriesHashId(seriesData)
|
|
|
|
return {
|
|
type: 'series',
|
|
id,
|
|
pubkey: seriesData.pubkey,
|
|
title: seriesData.title,
|
|
description: seriesData.description,
|
|
category: seriesData.category,
|
|
eventId: event.id,
|
|
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
|
|
...(seriesData.preview ? { preview: seriesData.preview } : {}),
|
|
}
|
|
}
|
|
|
|
// Fallback: extract from tags
|
|
if (tags.title && tags.description) {
|
|
const seriesData = {
|
|
pubkey: event.pubkey,
|
|
title: tags.title,
|
|
description: tags.description,
|
|
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
|
coverUrl: tags.coverUrl,
|
|
category: tags.category ?? 'sciencefiction',
|
|
}
|
|
|
|
const id = await generateSeriesHashId(seriesData)
|
|
|
|
return {
|
|
type: 'series',
|
|
id,
|
|
pubkey: seriesData.pubkey,
|
|
title: seriesData.title,
|
|
description: seriesData.description,
|
|
category: seriesData.category,
|
|
eventId: event.id,
|
|
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
|
|
...(seriesData.preview ? { preview: seriesData.preview } : {}),
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Extract publication from event
|
|
*/
|
|
export async function extractPublicationFromEvent(event: Event): Promise<ExtractedPublication | null> {
|
|
const tags = extractTagsFromEvent(event)
|
|
if (tags.type !== 'publication') {
|
|
return null
|
|
}
|
|
|
|
// Try to extract from tag first (new format)
|
|
let metadata = extractMetadataJsonFromTag(event)
|
|
|
|
// Fallback to content format (for backward compatibility)
|
|
if (!metadata) {
|
|
metadata = extractMetadataJson(event.content)
|
|
}
|
|
|
|
if (metadata?.type === 'publication') {
|
|
const publicationData = {
|
|
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
title: (metadata.title as string) ?? (tags.title as string) ?? '',
|
|
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
|
|
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
|
|
seriesId: (metadata.seriesId as string) ?? tags.seriesId ?? undefined,
|
|
bannerUrl: (metadata.bannerUrl as string) ?? tags.bannerUrl ?? undefined,
|
|
zapAmount: (metadata.zapAmount as number) ?? tags.zapAmount ?? 800,
|
|
}
|
|
|
|
const id = await generatePublicationHashId(publicationData)
|
|
|
|
// Extract pages from metadata if present
|
|
const pages = metadata.pages as Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
|
|
|
|
return {
|
|
type: 'publication',
|
|
id,
|
|
pubkey: publicationData.pubkey,
|
|
title: publicationData.title,
|
|
preview: publicationData.preview,
|
|
category: publicationData.category,
|
|
zapAmount: publicationData.zapAmount,
|
|
eventId: event.id,
|
|
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
|
|
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
|
|
...(pages && pages.length > 0 ? { pages } : {}),
|
|
}
|
|
}
|
|
|
|
// Fallback: extract from tags
|
|
if (tags.title) {
|
|
const publicationData = {
|
|
pubkey: event.pubkey,
|
|
title: tags.title,
|
|
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
|
category: tags.category ?? 'sciencefiction',
|
|
seriesId: tags.seriesId,
|
|
bannerUrl: tags.bannerUrl,
|
|
zapAmount: tags.zapAmount ?? 800,
|
|
}
|
|
|
|
const id = await generatePublicationHashId(publicationData)
|
|
|
|
return {
|
|
type: 'publication',
|
|
id,
|
|
pubkey: publicationData.pubkey,
|
|
title: publicationData.title,
|
|
preview: publicationData.preview,
|
|
category: publicationData.category,
|
|
zapAmount: publicationData.zapAmount,
|
|
eventId: event.id,
|
|
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
|
|
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Extract review from event
|
|
*/
|
|
export async function extractReviewFromEvent(event: Event): Promise<ExtractedReview | null> {
|
|
const tags = extractTagsFromEvent(event)
|
|
if (tags.type !== 'quote') {
|
|
return null
|
|
}
|
|
|
|
// Try to extract from tag first (new format)
|
|
let metadata = extractMetadataJsonFromTag(event)
|
|
|
|
// Fallback to content format (for backward compatibility)
|
|
if (!metadata) {
|
|
metadata = extractMetadataJson(event.content)
|
|
}
|
|
|
|
if (metadata?.type === 'review') {
|
|
const reviewData = {
|
|
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
articleId: (metadata.articleId as string) ?? (tags.articleId as string) ?? '',
|
|
reviewerPubkey: (metadata.reviewerPubkey as string) ?? (tags.reviewerPubkey as string) ?? event.pubkey,
|
|
content: (metadata.content as string) ?? event.content,
|
|
title: (metadata.title as string) ?? (tags.title as string) ?? undefined,
|
|
}
|
|
|
|
if (!reviewData.articleId || !reviewData.reviewerPubkey) {
|
|
return null
|
|
}
|
|
|
|
const id = await generateReviewHashId(reviewData)
|
|
|
|
return {
|
|
type: 'review',
|
|
id,
|
|
...reviewData,
|
|
eventId: event.id,
|
|
}
|
|
}
|
|
|
|
// Fallback: extract from tags
|
|
if (tags.articleId && tags.reviewerPubkey) {
|
|
const reviewData = {
|
|
pubkey: event.pubkey,
|
|
articleId: tags.articleId,
|
|
reviewerPubkey: tags.reviewerPubkey,
|
|
content: event.content,
|
|
title: tags.title,
|
|
}
|
|
|
|
const id = await generateReviewHashId({
|
|
pubkey: reviewData.pubkey,
|
|
articleId: reviewData.articleId,
|
|
reviewerPubkey: reviewData.reviewerPubkey,
|
|
content: reviewData.content,
|
|
...(reviewData.title ? { title: reviewData.title } : {}),
|
|
})
|
|
|
|
return {
|
|
type: 'review',
|
|
id,
|
|
pubkey: reviewData.pubkey,
|
|
articleId: reviewData.articleId,
|
|
reviewerPubkey: reviewData.reviewerPubkey,
|
|
content: reviewData.content,
|
|
eventId: event.id,
|
|
...(reviewData.title ? { title: reviewData.title } : {}),
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Extract purchase from zap receipt (kind 9735)
|
|
*/
|
|
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
|
|
if (event.kind !== 9735) {
|
|
return null
|
|
}
|
|
|
|
// Check for purchase kind_type tag
|
|
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase')
|
|
if (!kindTypeTag) {
|
|
return null
|
|
}
|
|
|
|
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
|
|
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
|
|
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
|
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
|
|
|
|
if (!pTag || !eTag || !amountTag) {
|
|
return null
|
|
}
|
|
|
|
const amount = parseInt(amountTag, 10) / 1000 // Convert millisats to sats
|
|
const paymentHash = paymentHashTag ?? event.id // Use event.id as fallback
|
|
|
|
const purchaseData = {
|
|
payerPubkey: event.pubkey,
|
|
articleId: eTag,
|
|
authorPubkey: pTag,
|
|
amount,
|
|
paymentHash,
|
|
}
|
|
|
|
const id = await generatePurchaseHashId(purchaseData)
|
|
|
|
return {
|
|
type: 'purchase',
|
|
id,
|
|
...purchaseData,
|
|
eventId: event.id,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract review tip from zap receipt (kind 9735)
|
|
*/
|
|
export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> {
|
|
if (event.kind !== 9735) {
|
|
return null
|
|
}
|
|
|
|
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip')
|
|
if (!kindTypeTag) {
|
|
return null
|
|
}
|
|
|
|
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
|
|
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
|
|
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
|
const reviewerTag = event.tags.find((tag) => tag[0] === 'reviewer')?.[1]
|
|
const reviewIdTag = event.tags.find((tag) => tag[0] === 'review_id')?.[1]
|
|
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
|
|
|
|
if (!pTag || !eTag || !amountTag || !reviewerTag || !reviewIdTag) {
|
|
return null
|
|
}
|
|
|
|
const amount = parseInt(amountTag, 10) / 1000
|
|
const paymentHash = paymentHashTag ?? event.id
|
|
|
|
const tipData = {
|
|
payerPubkey: event.pubkey,
|
|
articleId: eTag,
|
|
reviewId: reviewIdTag,
|
|
reviewerPubkey: reviewerTag,
|
|
authorPubkey: pTag,
|
|
amount,
|
|
paymentHash,
|
|
}
|
|
|
|
const id = await generateReviewTipHashId(tipData)
|
|
|
|
return {
|
|
type: 'review_tip',
|
|
id,
|
|
...tipData,
|
|
eventId: event.id,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract sponsoring from zap receipt (kind 9735)
|
|
*/
|
|
export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> {
|
|
if (event.kind !== 9735) {
|
|
return null
|
|
}
|
|
|
|
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring')
|
|
if (!kindTypeTag) {
|
|
return null
|
|
}
|
|
|
|
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
|
|
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
|
|
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
|
const seriesTag = event.tags.find((tag) => tag[0] === 'series')?.[1]
|
|
const articleTag = event.tags.find((tag) => tag[0] === 'article')?.[1]
|
|
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
|
|
|
|
if (!pTag || !amountTag) {
|
|
return null
|
|
}
|
|
|
|
const amount = parseInt(amountTag, 10) / 1000
|
|
const paymentHash = paymentHashTag ?? event.id
|
|
|
|
const sponsoringData = {
|
|
payerPubkey: event.pubkey,
|
|
authorPubkey: pTag,
|
|
seriesId: seriesTag,
|
|
articleId: articleTag ?? eTag, // Use eTag as fallback for articleId
|
|
amount,
|
|
paymentHash,
|
|
}
|
|
|
|
const id = await generateSponsoringHashId({
|
|
payerPubkey: sponsoringData.payerPubkey,
|
|
authorPubkey: sponsoringData.authorPubkey,
|
|
amount: sponsoringData.amount,
|
|
paymentHash: sponsoringData.paymentHash,
|
|
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
|
|
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
|
|
})
|
|
|
|
return {
|
|
type: 'sponsoring',
|
|
id,
|
|
payerPubkey: sponsoringData.payerPubkey,
|
|
authorPubkey: sponsoringData.authorPubkey,
|
|
amount: sponsoringData.amount,
|
|
paymentHash: sponsoringData.paymentHash,
|
|
eventId: event.id,
|
|
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
|
|
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract all objects from an event
|
|
*/
|
|
export async function extractObjectsFromEvent(event: Event): Promise<ExtractedObject[]> {
|
|
const results: ExtractedObject[] = []
|
|
|
|
// Try to extract each type
|
|
const author = await extractAuthorFromEvent(event)
|
|
if (author) {results.push(author)}
|
|
|
|
const series = await extractSeriesFromEvent(event)
|
|
if (series) {results.push(series)}
|
|
|
|
const publication = await extractPublicationFromEvent(event)
|
|
if (publication) {results.push(publication)}
|
|
|
|
const review = await extractReviewFromEvent(event)
|
|
if (review) {results.push(review)}
|
|
|
|
const purchase = await extractPurchaseFromEvent(event)
|
|
if (purchase) {results.push(purchase)}
|
|
|
|
const reviewTip = await extractReviewTipFromEvent(event)
|
|
if (reviewTip) {results.push(reviewTip)}
|
|
|
|
const sponsoring = await extractSponsoringFromEvent(event)
|
|
if (sponsoring) {results.push(sponsoring)}
|
|
|
|
return results
|
|
}
|