UNPKG

@konemono/nostr-content-parser

Version:
577 lines 21.8 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, parseNipIdentifier, isLightningAddress, findCustomEmojiMetadata, cleanUrlEnd, RELAY_URL_PATTERN, NIP19_TYPE_MAP, NIP19SubType, LEGACY_REFERENCE_PATTERN, findLegacyReferenceMetadata, } from "./patterns.js"; // 型安全なトークン作成関数群 function createNip19Token(content, start, end, plainNip19, subType, hasNostrPrefix) { return { type: TokenType.NIP19, content, start, end, metadata: { plainNip19, subType, hasNostrPrefix }, }; } function createNipIdentifierToken(content, start, end, number) { return { type: TokenType.NIP_IDENTIFIER, content, start, end, metadata: { number }, }; } function createCustomEmojiToken(content, start, end, name, url) { 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, start, end, tag, validated) { return { type: TokenType.HASHTAG, content, start, end, metadata: { tag, validated }, }; } function createUrlToken(content, start, end, scheme, type) { const metadata = {}; 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, content, start, end) { return { type, content, start, end }; } function isOverlapping(start1, end1, start2, end2) { return start1 < end2 && start2 < end1; } function detectUrlTypeFromExtension(url) { 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(); async function fetchUrlContentType(url) { 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; 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) { const hashtags = new Map(); 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) { const urlTokens = []; const pattern = new RegExp(URL_PATTERN.source, URL_PATTERN.flags); let match; 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) { const urlTokens = []; const pattern = new RegExp(URL_PATTERN.source, URL_PATTERN.flags); let match; 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, patterns, matches, protectedRanges) { Object.entries(patterns).forEach(([oldType, rawPattern]) => { const pattern = new RegExp(rawPattern.source, rawPattern.flags); let match; 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); matches.push(createNip19Token(matchedContent, start, end, plainNip19, subType, hasNostrPrefix)); } } }); } function processPatterns(content, matches, tags = [], protectedRanges = [], hashtagsFromTagsOnly = true) { 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; 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; 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, }); } } } // RELAY { const pattern = new RegExp(RELAY_URL_PATTERN.source, RELAY_URL_PATTERN.flags); let match; 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 }, }); } } // LN_URL { const pattern = new RegExp(LN_URL_PATTERN.source, LN_URL_PATTERN.flags); let match; 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; 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; 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, }); } } // BITCOIN_ADDRESS { for (const [addressType, rawPattern] of Object.entries(BITCOIN_ADDRESS_PATTERNS)) { const pattern = new RegExp(rawPattern.source, rawPattern.flags); let match; 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 }, }); } } } // EMAIL / LN_ADDRESS { const pattern = new RegExp(EMAIL_PATTERN.source, EMAIL_PATTERN.flags); let match; 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] } } : {}), }); } } // CUSTOM_EMOJI { const pattern = new RegExp(CUSTOM_EMOJI_PATTERN.source, CUSTOM_EMOJI_PATTERN.flags); let match; 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, matches, protectedRanges, validHashtags, validHashtagsMap) { const pattern = new RegExp(HASHTAG_PATTERN.source, HASHTAG_PATTERN.flags); let match; 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 = { [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) { 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 = []; 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, matches) { const filteredMatches = removeOverlaps(matches); const tokens = []; 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 function parseContent(content, tags = [], options = {}) { 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, tags = [], options = {}) { 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(tokens, types) { const typeSet = new Set(Array.isArray(types) ? types : [types]); return tokens.filter((token) => typeSet.has(token.type)); } export function filterTokensBy(tokens, predicate) { return tokens.filter(predicate); } export function getNip19Entities(tokens) { return filterTokens(tokens, TokenType.NIP19); } export function filterNip19BySubType(tokens, subType) { 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) { return filterNip19BySubType(tokens, NIP19SubType.NPUB); } export function getNprofiles(tokens) { return filterNip19BySubType(tokens, NIP19SubType.NPROFILE); } export function getNotes(tokens) { return filterNip19BySubType(tokens, NIP19SubType.NOTE); } export function getNevents(tokens) { return filterNip19BySubType(tokens, NIP19SubType.NEVENT); } export function getNaddrs(tokens) { return filterNip19BySubType(tokens, NIP19SubType.NADDR); } export function getNsecs(tokens) { return filterNip19BySubType(tokens, NIP19SubType.NSEC); } export function getNipIdentifiers(tokens) { return filterTokens(tokens, TokenType.NIP_IDENTIFIER); } export function getLegacyReferences(tokens) { return filterTokens(tokens, TokenType.LEGACY_REFERENCE); } export function getUrls(tokens) { return filterTokens(tokens, TokenType.URL); } export function getCustomEmojis(tokens) { return filterTokens(tokens, TokenType.CUSTOM_EMOJI); } export function getHashtags(tokens) { return filterTokens(tokens, TokenType.HASHTAG); } export function getValidatedHashtags(tokens) { return tokens.filter((token) => token.type === TokenType.HASHTAG && token.metadata.validated === true); } export function getLightningAddresses(tokens) { return filterTokens(tokens, TokenType.LN_ADDRESS); } export function getLightningUrls(tokens) { return filterTokens(tokens, TokenType.LN_URL); } export function getLightningInvoices(tokens) { return filterTokens(tokens, TokenType.LNBC); } export function getBitcoinAddresses(tokens) { return filterTokens(tokens, TokenType.BITCOIN_ADDRESS); } export function getCashuTokens(tokens) { return filterTokens(tokens, TokenType.CASHU_TOKEN); } export function getEmails(tokens) { return filterTokens(tokens, TokenType.EMAIL); } export function resetPatterns() { 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)); } //# sourceMappingURL=parseContent.js.map