// 从作品描述里解析抖音 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}]|$)`; } /** 从描述里提取全部 hashtag 名的 Postgres 正则(ARE),配合 regexp_matches(..., 'g') 使用 */ export const HASHTAG_EXTRACT_PG = `#([^\\s${STOP_CHARS}]+)`; // 「万金油」tag:几乎和什么都一起出现,不提供话题信息,不作为相关 tag 推荐 export const META_HASHTAGS = [ "cos", "cosplay", "coser", "二次元", "ooc致歉", "搞笑", "抽象", "vlog日常", "内容过于真实", ]; /** 平台活动类 tag(xx计划、xx挑战赛…)和纯数字 tag 的 Postgres 正则 */ export const META_HASHTAG_PG = "(计划|挑战赛|大赛|扶持|热门|浪潮)$|^[0-9]+$";