UNPKG

@konemono/nostr-content-parser

Version:
882 lines (761 loc) 22.7 kB
// 型安全版 parseContent.ts import { NIP19_PATTERNS, URL_PATTERN, LN_URL_PATTERN, LNBC_PATTERN, CASHU_TOKEN_PATTERN, BITCOIN_ADDRESS_PATTERNS, EMAIL_PATTERN, CUSTOM_EMOJI_PATTERN, HASHTAG_PATTERN, LN_ADDRESS_PATTERN, NIP19_PLAIN_PATTERNS, NIP_IDENTIFIER_PATTERN, TokenType, type Token, type TokenTypeValue, parseNipIdentifier, isLightningAddress, findCustomEmojiMetadata, cleanUrlEnd, RELAY_URL_PATTERN, NIP19_TYPE_MAP, NIP19SubType, LEGACY_REFERENCE_PATTERN, findLegacyReferenceMetadata, } from "./patterns.js"; // 型安全なトークン作成関数群 function createNip19Token( content: string, start: number, end: number, plainNip19: string, subType: NIP19SubType, hasNostrPrefix: boolean, ): Token { return { type: TokenType.NIP19, content, start, end, metadata: { plainNip19, subType, hasNostrPrefix }, }; } function createNipIdentifierToken( content: string, start: number, end: number, number: string, ): Token { return { type: TokenType.NIP_IDENTIFIER, content, start, end, metadata: { number }, }; } function createCustomEmojiToken( content: string, start: number, end: number, name: string, url?: string, ): Token { if (url) { return { type: TokenType.CUSTOM_EMOJI, content, start, end, metadata: { url, name, hasMetadata: true }, }; } else { return { type: TokenType.CUSTOM_EMOJI, content, start, end, metadata: { name, hasMetadata: false }, }; } } function createHashtagToken( content: string, start: number, end: number, tag: string, validated: boolean, ): Token { return { type: TokenType.HASHTAG, content, start, end, metadata: { tag, validated }, }; } function createUrlToken( content: string, start: number, end: number, scheme?: string, type?: "image" | "video" | "audio", ): Token { const metadata: { scheme?: string; type?: "image" | "video" | "audio" } = {}; if (scheme) metadata.scheme = scheme; if (type) metadata.type = type; return { type: TokenType.URL, content, start, end, ...(Object.keys(metadata).length > 0 ? { metadata } : {}), }; } function createSimpleToken( type: "text" | "ln_url" | "lnbc", content: string, start: number, end: number, ): Token { return { type, content, start, end }; } function isOverlapping( start1: number, end1: number, start2: number, end2: number, ): boolean { return start1 < end2 && start2 < end1; } function detectUrlTypeFromExtension( url: string, ): "image" | "video" | "audio" | undefined { const ext = url.split("?")[0].split("#")[0].split(".").pop()?.toLowerCase(); if (!ext) return; const videoExt = ["mp4", "webm", "mov", "mkv"]; const audioExt = ["mp3", "wav", "ogg", "flac"]; const imageExt = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"]; if (videoExt.includes(ext)) return "video"; if (audioExt.includes(ext)) return "audio"; if (imageExt.includes(ext)) return "image"; return; } const urlTypeCache = new Map<string, "image" | "video" | "audio">(); async function fetchUrlContentType( url: string, ): Promise<"image" | "video" | "audio" | undefined> { if (urlTypeCache.has(url)) { return urlTypeCache.get(url); } try { const res = await fetch(url, { method: "HEAD" }); const contentType = res.headers.get("Content-Type") || ""; let type: "image" | "video" | "audio" | undefined; if (contentType.startsWith("video/")) type = "video"; else if (contentType.startsWith("audio/")) type = "audio"; else if (contentType.startsWith("image/")) type = "image"; if (type) { urlTypeCache.set(url, type); } return type; } catch { return undefined; } } function extractHashtagsFromTags(tags: string[][]): Map<string, string> { const hashtags = new Map<string, string>(); for (const tag of tags) { if (tag.length >= 2 && tag[0] === "t") { const lowerTag = tag[1].toLowerCase(); hashtags.set(lowerTag, tag[1]); } } return hashtags; } function findUrlTokensSync(content: string): Token[] { const urlTokens: Token[] = []; const pattern = new RegExp(URL_PATTERN.source, URL_PATTERN.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const originalUrl = match[0]; const cleanedUrl = cleanUrlEnd(originalUrl); const start = match.index; const end = start + cleanedUrl.length; if (cleanedUrl.length === 0) { continue; } const scheme = cleanedUrl.startsWith("https://") ? "https" : cleanedUrl.startsWith("http://") ? "http" : undefined; const detectedType = detectUrlTypeFromExtension(cleanedUrl); urlTokens.push( createUrlToken(cleanedUrl, start, end, scheme, detectedType), ); if (cleanedUrl !== originalUrl) { const newLastIndex = start + cleanedUrl.length; if (newLastIndex > pattern.lastIndex) { pattern.lastIndex = newLastIndex; } } } return urlTokens; } async function findUrlTokensAsync(content: string): Promise<Token[]> { const urlTokens: Token[] = []; const pattern = new RegExp(URL_PATTERN.source, URL_PATTERN.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const originalUrl = match[0]; const cleanedUrl = cleanUrlEnd(originalUrl); const start = match.index; const end = start + cleanedUrl.length; if (cleanedUrl.length === 0) { continue; } const scheme = cleanedUrl.startsWith("https://") ? "https" : cleanedUrl.startsWith("http://") ? "http" : undefined; let detectedType = detectUrlTypeFromExtension(cleanedUrl); if (!detectedType) { const fetchedType = await fetchUrlContentType(cleanedUrl); if (fetchedType) detectedType = fetchedType; } urlTokens.push( createUrlToken(cleanedUrl, start, end, scheme, detectedType), ); if (cleanedUrl !== originalUrl) { const newLastIndex = start + cleanedUrl.length; if (newLastIndex > pattern.lastIndex) { pattern.lastIndex = newLastIndex; } } } return urlTokens; } function processNip19Patterns( content: string, patterns: typeof NIP19_PATTERNS, matches: Token[], protectedRanges: Token[], ): void { Object.entries(patterns).forEach(([oldType, rawPattern]) => { const pattern = new RegExp(rawPattern.source, rawPattern.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const [matchedContent] = match; const start = match.index; const end = start + matchedContent.length; const hasOverlap = matches.some((m) => isOverlapping(start, end, m.start, m.end), ); const isInProtectedRange = protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end), ); if (!hasOverlap && !isInProtectedRange) { const hasNostrPrefix = matchedContent.startsWith("nostr:"); const plainNip19 = matchedContent.replace(/^nostr:/, ""); const subType = (NIP19_TYPE_MAP[oldType] || oldType) as NIP19SubType; matches.push( createNip19Token( matchedContent, start, end, plainNip19, subType, hasNostrPrefix, ), ); } } }); } function processPatterns( content: string, matches: Token[], tags: string[][] = [], protectedRanges: Token[] = [], hashtagsFromTagsOnly: boolean = true, ): void { const validHashtagsMap = hashtagsFromTagsOnly ? extractHashtagsFromTags(tags) : null; const validHashtags = validHashtagsMap ? new Set(validHashtagsMap.keys()) : null; // NIP_IDENTIFIER { const pattern = new RegExp( NIP_IDENTIFIER_PATTERN.source, NIP_IDENTIFIER_PATTERN.flags, ); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; try { const nipInfo = parseNipIdentifier(match[0]); matches.push( createNipIdentifierToken(match[0], start, end, nipInfo.number), ); } catch { // パース失敗時はスキップ } } } // LEGACY_REFERENCE { const pattern = new RegExp( LEGACY_REFERENCE_PATTERN.source, LEGACY_REFERENCE_PATTERN.flags, ); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; const metadata = findLegacyReferenceMetadata(match[0], tags); if (metadata) { matches.push({ type: TokenType.LEGACY_REFERENCE, content: match[0], start, end, metadata, } as Token); } } } // RELAY { const pattern = new RegExp( RELAY_URL_PATTERN.source, RELAY_URL_PATTERN.flags, ); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; const url = match[0]; const scheme = url.startsWith("wss://") ? "wss" : url.startsWith("ws://") ? "ws" : "wss"; matches.push({ type: TokenType.RELAY, content: match[0], start, end, metadata: { scheme }, } as Token); } } // LN_URL { const pattern = new RegExp(LN_URL_PATTERN.source, LN_URL_PATTERN.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; matches.push(createSimpleToken(TokenType.LN_URL, match[0], start, end)); } } // LNBC { const pattern = new RegExp(LNBC_PATTERN.source, LNBC_PATTERN.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; matches.push(createSimpleToken(TokenType.LNBC, match[0], start, end)); } } // CASHU_TOKEN { const pattern = new RegExp( CASHU_TOKEN_PATTERN.source, CASHU_TOKEN_PATTERN.flags, ); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; matches.push({ type: TokenType.CASHU_TOKEN, content: match[0], start, end, } as Token); } } // BITCOIN_ADDRESS { for (const [addressType, rawPattern] of Object.entries( BITCOIN_ADDRESS_PATTERNS, )) { const pattern = new RegExp(rawPattern.source, rawPattern.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; matches.push({ type: TokenType.BITCOIN_ADDRESS, content: match[0], start, end, metadata: { addressType }, } as Token); } } } // EMAIL / LN_ADDRESS { const pattern = new RegExp(EMAIL_PATTERN.source, EMAIL_PATTERN.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; const emailLike = match[0]; const isLN = isLightningAddress(emailLike); matches.push({ type: isLN ? TokenType.LN_ADDRESS : TokenType.EMAIL, content: match[0], start, end, ...(isLN ? { metadata: { domain: emailLike.split("@")[1] } } : {}), } as Token); } } // CUSTOM_EMOJI { const pattern = new RegExp( CUSTOM_EMOJI_PATTERN.source, CUSTOM_EMOJI_PATTERN.flags, ); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if ( protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end)) ) continue; const emojiName = match[1]; const metadata = findCustomEmojiMetadata(emojiName, tags); // タグありでもなしでもカスタム絵文字トークンを作成 matches.push( createCustomEmojiToken(match[0], start, end, emojiName, metadata?.url), ); } } // HASHTAG processHashtagPatterns( content, matches, protectedRanges, validHashtags, validHashtagsMap, ); } function processHashtagPatterns( content: string, matches: Token[], protectedRanges: Token[], validHashtags: Set<string> | null, validHashtagsMap: Map<string, string> | null, ): void { const pattern = new RegExp(HASHTAG_PATTERN.source, HASHTAG_PATTERN.flags); let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { const start = match.index; const end = start + match[0].length; const hashtag = match[0].slice(1); if (matches.some((m) => isOverlapping(start, end, m.start, m.end))) continue; if (protectedRanges.some((p) => isOverlapping(start, end, p.start, p.end))) continue; if (validHashtags && !validHashtags.has(hashtag.toLowerCase())) { continue; } const tagValue = validHashtagsMap?.get(hashtag.toLowerCase()) ?? hashtag.toLowerCase(); matches.push( createHashtagToken( match[0], start, end, tagValue, validHashtags !== null, ), ); } } const PRIORITY: Record<TokenTypeValue, number> = { [TokenType.URL]: 15, [TokenType.NIP19]: 10, [TokenType.RELAY]: 10, [TokenType.CASHU_TOKEN]: 2, [TokenType.LNBC]: 2, [TokenType.LN_URL]: 2, [TokenType.LN_ADDRESS]: 2, [TokenType.CUSTOM_EMOJI]: 1, [TokenType.BITCOIN_ADDRESS]: 1, [TokenType.EMAIL]: 1, [TokenType.LEGACY_REFERENCE]: 1, [TokenType.HASHTAG]: 0, [TokenType.NIP_IDENTIFIER]: 0, [TokenType.TEXT]: 0, }; function removeOverlaps(matches: Token[]): Token[] { const sorted = [...matches].sort((a, b) => { if (a.start !== b.start) return a.start - b.start; if (a.end !== b.end) return b.end - a.end; return (PRIORITY[b.type] ?? 0) - (PRIORITY[a.type] ?? 0); }); const result: Token[] = []; for (const token of sorted) { const overlapIndex = result.findIndex((t) => isOverlapping(t.start, t.end, token.start, token.end), ); if (overlapIndex === -1) { result.push(token); } else { const existing = result[overlapIndex]; const tokenPriority = PRIORITY[token.type] ?? 0; const existingPriority = PRIORITY[existing.type] ?? 0; const tokenLength = token.end - token.start; const existingLength = existing.end - existing.start; const shouldReplace = tokenPriority > existingPriority || (tokenPriority === existingPriority && tokenLength > existingLength); if (shouldReplace) { result.splice(overlapIndex, 1, token); } } } return result.sort((a, b) => a.start - b.start); } function buildTokens(content: string, matches: Token[]): Token[] { const filteredMatches = removeOverlaps(matches); const tokens: Token[] = []; let currentPos = 0; for (const match of filteredMatches) { if (match.start > currentPos) { tokens.push( createSimpleToken( TokenType.TEXT, content.slice(currentPos, match.start), currentPos, match.start, ), ); } tokens.push(match); currentPos = match.end; } if (currentPos < content.length) { tokens.push( createSimpleToken( TokenType.TEXT, content.slice(currentPos), currentPos, content.length, ), ); } return tokens; } export interface ParseOptions { includeNostrPrefixOnly?: boolean; hashtagsFromTagsOnly?: boolean; } export function parseContent( content: string, tags: string[][] = [], options: ParseOptions = {}, ): Token[] { if (!content) return []; const { includeNostrPrefixOnly = true, hashtagsFromTagsOnly = true } = options; const urlTokens = findUrlTokensSync(content); processNip19Patterns(content, NIP19_PATTERNS, urlTokens, urlTokens); if (!includeNostrPrefixOnly) { processNip19Patterns(content, NIP19_PLAIN_PATTERNS, urlTokens, urlTokens); } processPatterns(content, urlTokens, tags, urlTokens, hashtagsFromTagsOnly); return buildTokens(content, urlTokens); } export async function parseContentAsync( content: string, tags: string[][] = [], options: ParseOptions = {}, ): Promise<Token[]> { if (!content) return []; const { includeNostrPrefixOnly = true, hashtagsFromTagsOnly = true } = options; const urlTokens = await findUrlTokensAsync(content); processNip19Patterns(content, NIP19_PATTERNS, urlTokens, urlTokens); if (!includeNostrPrefixOnly) { processNip19Patterns(content, NIP19_PLAIN_PATTERNS, urlTokens, urlTokens); } processPatterns(content, urlTokens, tags, urlTokens, hashtagsFromTagsOnly); return buildTokens(content, urlTokens); } export function filterTokens<T extends TokenTypeValue>( tokens: Token[], types: T | T[], ): Token[] { const typeSet = new Set(Array.isArray(types) ? types : [types]); return tokens.filter((token) => typeSet.has(token.type as T)); } export function filterTokensBy( tokens: Token[], predicate: (token: Token) => boolean, ): Token[] { return tokens.filter(predicate); } export function getNip19Entities(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.NIP19); } export function filterNip19BySubType( tokens: Token[], subType: NIP19SubType | NIP19SubType[], ): Token[] { const subTypeSet = new Set(Array.isArray(subType) ? subType : [subType]); return tokens.filter( (token) => token.type === TokenType.NIP19 && subTypeSet.has(token.metadata.subType), ); } export function getNpubs(tokens: Token[]): Token[] { return filterNip19BySubType(tokens, NIP19SubType.NPUB); } export function getNprofiles(tokens: Token[]): Token[] { return filterNip19BySubType(tokens, NIP19SubType.NPROFILE); } export function getNotes(tokens: Token[]): Token[] { return filterNip19BySubType(tokens, NIP19SubType.NOTE); } export function getNevents(tokens: Token[]): Token[] { return filterNip19BySubType(tokens, NIP19SubType.NEVENT); } export function getNaddrs(tokens: Token[]): Token[] { return filterNip19BySubType(tokens, NIP19SubType.NADDR); } export function getNsecs(tokens: Token[]): Token[] { return filterNip19BySubType(tokens, NIP19SubType.NSEC); } export function getNipIdentifiers(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.NIP_IDENTIFIER); } export function getLegacyReferences(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.LEGACY_REFERENCE); } export function getUrls(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.URL); } export function getCustomEmojis(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.CUSTOM_EMOJI); } export function getHashtags(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.HASHTAG); } export function getValidatedHashtags(tokens: Token[]): Token[] { return tokens.filter( (token) => token.type === TokenType.HASHTAG && token.metadata.validated === true, ); } export function getLightningAddresses(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.LN_ADDRESS); } export function getLightningUrls(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.LN_URL); } export function getLightningInvoices(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.LNBC); } export function getBitcoinAddresses(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.BITCOIN_ADDRESS); } export function getCashuTokens(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.CASHU_TOKEN); } export function getEmails(tokens: Token[]): Token[] { return filterTokens(tokens, TokenType.EMAIL); } export function resetPatterns(): void { const allPatterns = [ ...Object.values(NIP19_PATTERNS), ...Object.values(NIP19_PLAIN_PATTERNS), ...Object.values(BITCOIN_ADDRESS_PATTERNS), URL_PATTERN, RELAY_URL_PATTERN, LN_ADDRESS_PATTERN, LN_URL_PATTERN, LNBC_PATTERN, EMAIL_PATTERN, CASHU_TOKEN_PATTERN, CUSTOM_EMOJI_PATTERN, HASHTAG_PATTERN, NIP_IDENTIFIER_PATTERN, LEGACY_REFERENCE_PATTERN, ]; allPatterns.forEach((pattern) => (pattern.lastIndex = 0)); }