// 从作品描述里解析抖音 hashtag(#xxx)。 // 前端渲染可点击的 hashtag 与后端按 hashtag 精确筛选作品共用这里的边界规则, // 保证「点了 #丰川祥子 看到的列表」不会混进 #丰川祥子cos。 // hashtag 在遇到空白、下一个 #、或这些标点时结束 const STOP_CHARS = "#,。!?、;:,.!?;:()()【】\\[\\]《》<>\"'“”‘’…~~|/\\\\@"; const MAX_HASHTAG_LENGTH = 50; const HASHTAG_RE = new RegExp(`#([^\\s${STOP_CHARS}]+)`, "gu"); export type DescPart = | { type: "text"; text: string } | { type: "hashtag"; text: string; name: string }; /** 把描述切成普通文字与 hashtag 片段。 */ export function parseDescHashtags(desc: string): DescPart[] { const parts: DescPart[] = []; let cursor = 0; for (const m of desc.matchAll(HASHTAG_RE)) { const start = m.index ?? 0; if (start > cursor) { parts.push({ type: "text", text: desc.slice(cursor, start) }); } parts.push({ type: "hashtag", text: m[0], name: m[1] }); cursor = start + m[0].length; } if (cursor < desc.length) { parts.push({ type: "text", text: desc.slice(cursor) }); } return parts; } /** 规范化 hashtag 名(去掉开头的 #、首尾空白,限制长度);不合法时返回 null。 */ export function normalizeHashtag(raw: string): string | null { const name = raw.trim().replace(/^#+/, "").slice(0, MAX_HASHTAG_LENGTH); if (!name) return null; if (new RegExp(`[\\s${STOP_CHARS}]`, "u").test(name)) return null; return name; } /** * 匹配 lower() 后描述文本的 Postgres 正则(ARE):#名字 之后必须是结束符或文本末尾。 * 名字里的正则特殊字符会被转义。 */ export function hashtagToPgPattern(name: string): string { const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return `#${escaped}([\\s${STOP_CHARS}]|$)`; }