story-research-zapwall/lib/nostrEventParsing.ts
2026-01-10 09:41:57 +01:00

508 lines
15 KiB
TypeScript

import type { Event } from 'nostr-tools'
import type { Article, Page, Purchase, Review, ReviewTip, Series, Sponsoring } from '@/types/nostr'
import { extractTagsFromEvent } from './nostrTagSystem'
import { buildObjectId, parseObjectId } from './urlGenerator'
import { generateHashId } from './hashIdGenerator'
import { extractPurchaseFromEvent, extractReviewTipFromEvent, extractSponsoringFromEvent } from './metadataExtractor'
/**
* Parse article metadata from Nostr event
* Uses new tag system: #publication, #sciencefiction|research, #id_<id>, #paywall, #payment
*/
export async function parseArticleFromEvent(event: Event): Promise<Article | null> {
try {
const tags = extractTagsFromEvent(event)
// Check if it's a publication type
if (tags.type !== 'publication') {
return null
}
const { previewContent } = getPreviewContent(event.content, tags.preview)
return buildArticle(event, tags, previewContent)
} catch (e) {
console.error('Error parsing article:', e)
return null
}
}
export async function parseSeriesFromEvent(event: Event): Promise<Series | null> {
try {
const tags = extractTagsFromEvent(event)
const input = readSeriesInput({ tags, eventContent: event.content })
if (!input) {
return null
}
const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction'
const { hash, version, index } = await resolveObjectIdParts({
...(input.idTag ? { idTag: input.idTag } : {}),
defaultVersion: input.defaultVersion,
defaultIndex: 0,
generateHash: async (): Promise<string> => generateHashId({
type: 'series',
pubkey: event.pubkey,
title: input.title,
description: input.description,
category: input.categoryTag,
coverUrl: input.coverUrl,
}),
})
return buildSeriesFromParsed({
event,
input,
hash,
version,
index,
category,
})
} catch (e) {
console.error('Error parsing series:', e)
return null
}
}
export async function parseReviewFromEvent(event: Event): Promise<Review | null> {
try {
const tags = extractTagsFromEvent(event)
const input = readReviewInput(tags)
if (!input) {
return null
}
const { hash, version, index } = await resolveObjectIdParts({
...(input.idTag ? { idTag: input.idTag } : {}),
defaultVersion: input.defaultVersion,
defaultIndex: 0,
generateHash: async (): Promise<string> => generateHashId({
type: 'quote',
pubkey: event.pubkey,
articleId: input.articleId,
reviewerPubkey: input.reviewerPubkey,
content: event.content,
title: input.title ?? '',
}),
})
const rewardInfo = readRewardInfo(event.tags)
const text = readTextTag(event.tags)
return buildReviewFromParsed({ event, input, hash, version, index, rewardInfo, text })
} catch (e) {
console.error('Error parsing review:', e)
return null
}
}
function buildSeriesFromParsed(params: {
event: Event
input: { title: string; description: string; preview: string; coverUrl: string }
hash: string
version: number
index: number
category: Series['category']
}): Series {
const id = buildObjectId(params.hash, params.index, params.version)
const series: Series = {
id,
hash: params.hash,
version: params.version,
index: params.index,
pubkey: params.event.pubkey,
title: params.input.title,
description: params.input.description,
preview: params.input.preview,
thumbnailUrl: params.input.coverUrl,
category: params.category,
...(params.input.coverUrl ? { coverUrl: params.input.coverUrl } : {}),
}
series.kindType = 'series'
return series
}
function buildReviewFromParsed(params: {
event: Event
input: { articleId: string; reviewerPubkey: string; title: string | undefined; description: string }
hash: string
version: number
index: number
rewardInfo: { rewarded: boolean; rewardAmount: number | undefined }
text: string | undefined
}): Review {
const id = buildObjectId(params.hash, params.index, params.version)
const review: Review = {
id,
hash: params.hash,
version: params.version,
index: params.index,
articleId: params.input.articleId,
authorPubkey: params.event.pubkey,
reviewerPubkey: params.input.reviewerPubkey,
content: params.event.content,
description: params.input.description,
createdAt: params.event.created_at,
...(params.input.title ? { title: params.input.title } : {}),
...(params.text ? { text: params.text } : {}),
...(params.rewardInfo.rewarded ? { rewarded: true } : {}),
...(params.rewardInfo.rewardAmount !== undefined ? { rewardAmount: params.rewardInfo.rewardAmount } : {}),
}
review.kindType = 'review'
return review
}
function readSeriesInput(params: { tags: ReturnType<typeof extractTagsFromEvent>; eventContent: string }): {
idTag: string | undefined
defaultVersion: number
title: string
description: string
preview: string
coverUrl: string
categoryTag: string
} | null {
if (params.tags.type !== 'series' || !params.tags.title || !params.tags.description) {
return null
}
return {
idTag: params.tags.id,
defaultVersion: params.tags.version ?? 0,
title: params.tags.title,
description: params.tags.description,
preview: params.tags.preview ?? params.eventContent.substring(0, 200),
coverUrl: params.tags.coverUrl ?? '',
categoryTag: params.tags.category ?? 'sciencefiction',
}
}
function readReviewInput(tags: ReturnType<typeof extractTagsFromEvent>): {
idTag: string | undefined
defaultVersion: number
articleId: string
reviewerPubkey: string
title: string | undefined
description: string
} | null {
if (tags.type !== 'quote' || !tags.articleId || !tags.reviewerPubkey) {
return null
}
return {
idTag: tags.id,
defaultVersion: tags.version ?? 0,
articleId: tags.articleId,
reviewerPubkey: tags.reviewerPubkey,
title: tags.title,
description: tags.description ?? '',
}
}
function readRewardInfo(eventTags: string[][]): { rewarded: boolean; rewardAmount: number | undefined } {
const rewarded = eventTags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
const rewardAmountTag = eventTags.find((tag) => tag[0] === 'reward_amount')?.[1]
const rewardAmountRaw = rewardAmountTag ? parseInt(rewardAmountTag, 10) : NaN
const rewardAmount = Number.isNaN(rewardAmountRaw) ? undefined : rewardAmountRaw
return { rewarded, rewardAmount }
}
function readTextTag(eventTags: string[][]): string | undefined {
return eventTags.find((tag) => tag[0] === 'text')?.[1]
}
function getPreviewContent(content: string, previewTag?: string): { previewContent: string } {
const lines = content.split('\n')
const previewContent = previewTag ?? lines[0] ?? content.substring(0, 200)
return { previewContent }
}
function mapNostrCategoryToLegacy(
category: string | undefined
): 'science-fiction' | 'scientific-research' | undefined {
if (category === 'sciencefiction') {
return 'science-fiction'
}
if (category === 'research') {
return 'scientific-research'
}
return undefined
}
interface ObjectIdParts {
hash: string
version: number
index: number
}
function parseObjectIdPartsFromTag(params: { idTag: string; defaultVersion: number; defaultIndex: number }): ObjectIdParts {
const parsed = parseObjectId(params.idTag)
const hash = parsed.hash ?? params.idTag
const version = parsed.version ?? params.defaultVersion
const index = parsed.index ?? params.defaultIndex
return { hash, version, index }
}
async function resolveObjectIdParts(params: {
idTag?: string
defaultVersion: number
defaultIndex: number
generateHash: () => Promise<string>
}): Promise<ObjectIdParts> {
if (params.idTag) {
return parseObjectIdPartsFromTag({
idTag: params.idTag,
defaultVersion: params.defaultVersion,
defaultIndex: params.defaultIndex,
})
}
const hash = await params.generateHash()
return { hash, version: params.defaultVersion, index: params.defaultIndex }
}
function resolveThumbnailUrl(tags: ReturnType<typeof extractTagsFromEvent>): string {
if (typeof tags.bannerUrl === 'string') {
return tags.bannerUrl
}
if (typeof tags.pictureUrl === 'string') {
return tags.pictureUrl
}
return ''
}
function parsePagesFromEventJsonTag(event: Event): Page[] | undefined {
const jsonTag = event.tags.find((tag) => tag[0] === 'json')?.[1]
if (!jsonTag) {
return undefined
}
try {
const parsed: unknown = JSON.parse(jsonTag)
if (typeof parsed !== 'object' || parsed === null) {
return undefined
}
const metadata = parsed as { pages?: unknown }
if (!Array.isArray(metadata.pages)) {
return undefined
}
return metadata.pages as Page[]
} catch {
return undefined
}
}
function buildArticlePaymentFields(tags: ReturnType<typeof extractTagsFromEvent>): Partial<Article> {
const result: Partial<Article> = {}
if (tags.invoice) {
result.invoice = tags.invoice
}
if (tags.paymentHash) {
result.paymentHash = tags.paymentHash
}
return result
}
function buildArticleClassificationFields(params: {
tags: ReturnType<typeof extractTagsFromEvent>
category: 'science-fiction' | 'scientific-research' | undefined
isPresentation: boolean
}): Partial<Article> {
const result: Partial<Article> = {}
if (params.category) {
result.category = params.category
}
if (params.isPresentation) {
result.isPresentation = true
}
if (params.tags.type === 'publication' || params.tags.type === 'author') {
result.kindType = 'article'
}
return result
}
function buildArticleOptionalMetaFields(params: {
tags: ReturnType<typeof extractTagsFromEvent>
pages: Page[] | undefined
}): Partial<Article> {
const result: Partial<Article> = {}
if (params.tags.mainnetAddress) {
result.mainnetAddress = params.tags.mainnetAddress
}
if (params.tags.totalSponsoring) {
result.totalSponsoring = params.tags.totalSponsoring
}
if (params.tags.seriesId) {
result.seriesId = params.tags.seriesId
}
if (params.tags.bannerUrl) {
result.bannerUrl = params.tags.bannerUrl
}
if (params.pages && params.pages.length > 0) {
result.pages = params.pages
}
return result
}
async function buildArticle(event: Event, tags: ReturnType<typeof extractTagsFromEvent>, preview: string): Promise<Article> {
const category = mapNostrCategoryToLegacy(tags.category)
const isPresentation = tags.type === 'author'
const { hash, version, index } = await resolveObjectIdParts({
...(tags.id ? { idTag: tags.id } : {}),
defaultVersion: tags.version ?? 0,
defaultIndex: 0,
generateHash: async (): Promise<string> => generateHashId({
type: isPresentation ? 'author' : 'publication',
pubkey: event.pubkey,
title: tags.title ?? 'Untitled',
preview,
category: tags.category ?? 'sciencefiction',
seriesId: tags.seriesId ?? '',
bannerUrl: tags.bannerUrl ?? '',
zapAmount: tags.zapAmount ?? 800,
}),
})
const id = buildObjectId(hash, index, version)
const pages = parsePagesFromEventJsonTag(event)
return {
id,
hash,
version,
index,
pubkey: event.pubkey,
title: tags.title ?? 'Untitled',
preview,
content: '',
description: tags.description ?? '', // Required field with default
contentDescription: tags.description ?? '', // Required field with default (can be improved later)
createdAt: event.created_at,
zapAmount: tags.zapAmount ?? 800,
paid: false,
thumbnailUrl: resolveThumbnailUrl(tags), // Required field with default
...buildArticlePaymentFields(tags),
...buildArticleClassificationFields({ tags, category, isPresentation }),
...buildArticleOptionalMetaFields({ tags, pages }),
}
}
/**
* Parse purchase from zap receipt event (kind 9735)
*/
export async function parsePurchaseFromEvent(event: Event): Promise<Purchase | null> {
try {
const extracted = await extractPurchaseFromEvent(event)
if (!extracted) {
return null
}
// Extract hash, version, index from id
const parsed = parseObjectId(extracted.id)
const { hash: parsedHash, version: parsedVersion, index: parsedIndex } = parsed
const hash = parsedHash ?? extracted.id
const version = parsedVersion ?? 0
const index = parsedIndex ?? 0
const id = buildObjectId(hash, index, version)
return {
id,
hash,
version,
index,
payerPubkey: extracted.payerPubkey,
articleId: extracted.articleId,
authorPubkey: extracted.authorPubkey,
amount: extracted.amount,
paymentHash: extracted.paymentHash,
createdAt: event.created_at,
kindType: 'purchase',
}
} catch (e) {
console.error('Error parsing purchase:', e)
return null
}
}
/**
* Parse sponsoring from zap receipt event (kind 9735)
*/
export async function parseSponsoringFromEvent(event: Event): Promise<Sponsoring | null> {
try {
const extracted = await extractSponsoringFromEvent(event)
if (!extracted) {
return null
}
// Extract hash, version, index from id
const parsed = parseObjectId(extracted.id)
const { hash: parsedHash, version: parsedVersion, index: parsedIndex } = parsed
const hash = parsedHash ?? extracted.id
const version = parsedVersion ?? 0
const index = parsedIndex ?? 0
const id = buildObjectId(hash, index, version)
// Extract text from tags if present
const textTag = event.tags.find((tag) => tag[0] === 'text')?.[1]
return {
id,
hash,
version,
index,
payerPubkey: extracted.payerPubkey,
authorPubkey: extracted.authorPubkey,
amount: extracted.amount,
paymentHash: extracted.paymentHash,
createdAt: event.created_at,
...(extracted.seriesId ? { seriesId: extracted.seriesId } : {}),
...(extracted.articleId ? { articleId: extracted.articleId } : {}),
...(textTag ? { text: textTag } : {}),
kindType: 'sponsoring',
}
} catch (e) {
console.error('Error parsing sponsoring:', e)
return null
}
}
/**
* Parse review tip from zap receipt event (kind 9735)
*/
export async function parseReviewTipFromEvent(event: Event): Promise<ReviewTip | null> {
try {
const extracted = await extractReviewTipFromEvent(event)
if (!extracted) {
return null
}
// Extract hash, version, index from id
const parsed = parseObjectId(extracted.id)
const { hash: parsedHash, version: parsedVersion, index: parsedIndex } = parsed
const hash = parsedHash ?? extracted.id
const version = parsedVersion ?? 0
const index = parsedIndex ?? 0
const id = buildObjectId(hash, index, version)
// Extract text from tags if present
const textTag = event.tags.find((tag) => tag[0] === 'text')?.[1]
return {
id,
hash,
version,
index,
payerPubkey: extracted.payerPubkey,
articleId: extracted.articleId,
reviewId: extracted.reviewId,
reviewerPubkey: extracted.reviewerPubkey,
authorPubkey: extracted.authorPubkey,
amount: extracted.amount,
paymentHash: extracted.paymentHash,
createdAt: event.created_at,
...(textTag ? { text: textTag } : {}),
kindType: 'review_tip',
}
} catch (e) {
console.error('Error parsing review tip:', e)
return null
}
}