Files
douyin-archive/lib/hashtag.ts
T
feie9454andClaude Opus 5.5 2391abdeea feat: hashtag 列表显示按共现计算的相关 tag
- 新增 /api/hashtag/related:从作品描述实时解析 hashtag,按「同一作品里一起出现」计算相关 tag
- 打分取子话题(候选多与目标同现)与上位话题(目标多与热门候选同现)两者较大值,
  过滤 cos、二次元等万金油 tag 与平台活动、纯数字 tag
- 播放页侧栏的 hashtag 列表顶部显示相关 tag,点击在侧栏内预览(不打断当前播放),
  在预览里点作品才切换到该 tag 的列表

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 01:18:04 +08:00

68 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 从作品描述里解析抖音 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]+$";