@konemono/nostr-content-parser
Version:
Parse Nostr content into tokens
577 lines • 21.8 kB
JavaScript
// 型安全版 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