feat: hashtag 列表显示按共现计算的相关 tag
- 新增 /api/hashtag/related:从作品描述实时解析 hashtag,按「同一作品里一起出现」计算相关 tag - 打分取子话题(候选多与目标同现)与上位话题(目标多与热门候选同现)两者较大值, 过滤 cos、二次元等万金油 tag 与平台活动、纯数字 tag - 播放页侧栏的 hashtag 列表顶部显示相关 tag,点击在侧栏内预览(不打断当前播放), 在预览里点作品才切换到该 tag 的列表 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
HASHTAG_EXTRACT_PG,
|
||||
META_HASHTAG_PG,
|
||||
META_HASHTAGS,
|
||||
} from "@/lib/hashtag";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type RelatedHashtag = {
|
||||
name: string;
|
||||
/** 和目标 tag 一起出现的作品数 */
|
||||
together: number;
|
||||
/** 这个 tag 自己的作品数 */
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type RelatedHashtagsResult = {
|
||||
name: string;
|
||||
count: number;
|
||||
related: RelatedHashtag[];
|
||||
};
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
const cache = new Map<string, { at: number; value: RelatedHashtagsResult }>();
|
||||
|
||||
/**
|
||||
* 按「同一个作品描述里一起出现」计算相关 hashtag(共现图),每次实时从描述里解析,不需要额外的表。
|
||||
*
|
||||
* 打分取两个方向里较大的那个:
|
||||
* - 子话题:候选 tag 大多和目标一起出现(together / 候选数),乘 ln(1+together) 防止只出现一次的巧合排到前面;
|
||||
* 例:#明日方舟 → #明日方舟七周年、#特蕾西娅
|
||||
* - 上位话题:目标大多和候选一起出现(together / 目标数),乘 ln(1+候选数),让热门的上位 tag 排前面;
|
||||
* 例:#圣聆初雪(只有 2 个作品)→ #明日方舟
|
||||
* cos、二次元这类「万金油」tag 与平台活动 tag 不参与推荐。
|
||||
*/
|
||||
export async function relatedHashtags(
|
||||
rawName: string,
|
||||
limit = 12,
|
||||
): Promise<RelatedHashtagsResult> {
|
||||
const name = rawName.toLowerCase();
|
||||
const key = `${name}:${limit}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ name: string; together: number; count: number; target: number }[]
|
||||
>`
|
||||
WITH ht AS (
|
||||
SELECT DISTINCT w.aweme_id, lower(m[1]) AS tag
|
||||
FROM (
|
||||
SELECT aweme_id, "desc" FROM "Video"
|
||||
UNION ALL
|
||||
SELECT aweme_id, "desc" FROM "ImagePost"
|
||||
) w,
|
||||
regexp_matches(w."desc", ${HASHTAG_EXTRACT_PG}, 'g') m
|
||||
),
|
||||
target AS (SELECT aweme_id FROM ht WHERE tag = ${name}),
|
||||
target_count AS (SELECT count(*)::int AS c FROM target),
|
||||
co AS (
|
||||
SELECT ht.tag, count(*)::int AS together
|
||||
FROM ht JOIN target USING (aweme_id)
|
||||
WHERE ht.tag <> ${name}
|
||||
GROUP BY ht.tag
|
||||
),
|
||||
counts AS (
|
||||
SELECT tag, count(*)::int AS c FROM ht
|
||||
WHERE tag IN (SELECT tag FROM co)
|
||||
GROUP BY tag
|
||||
)
|
||||
SELECT
|
||||
co.tag AS name,
|
||||
co.together,
|
||||
counts.c AS count,
|
||||
tc.c AS target
|
||||
FROM co
|
||||
JOIN counts USING (tag)
|
||||
CROSS JOIN target_count tc
|
||||
WHERE co.together >= CASE WHEN tc.c >= 4 THEN 2 ELSE 1 END
|
||||
AND co.tag <> ALL (${META_HASHTAGS}::text[])
|
||||
AND co.tag !~ ${META_HASHTAG_PG}
|
||||
ORDER BY GREATEST(
|
||||
co.together::float8 / counts.c * ln(1 + co.together),
|
||||
co.together::float8 / tc.c * ln(1 + counts.c)
|
||||
) DESC, co.together DESC, co.tag
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
let count = rows[0]?.target;
|
||||
if (count === undefined) {
|
||||
// 没有相关 tag 时单独数一下目标 tag 的作品数
|
||||
const [{ c }] = await prisma.$queryRaw<{ c: number }[]>`
|
||||
SELECT count(DISTINCT w.aweme_id)::int AS c
|
||||
FROM (
|
||||
SELECT aweme_id, "desc" FROM "Video"
|
||||
UNION ALL
|
||||
SELECT aweme_id, "desc" FROM "ImagePost"
|
||||
) w,
|
||||
regexp_matches(w."desc", ${HASHTAG_EXTRACT_PG}, 'g') m
|
||||
WHERE lower(m[1]) = ${name}
|
||||
`;
|
||||
count = c;
|
||||
}
|
||||
|
||||
const value: RelatedHashtagsResult = {
|
||||
name,
|
||||
count,
|
||||
related: rows.map(({ name, together, count }) => ({
|
||||
name,
|
||||
together,
|
||||
count,
|
||||
})),
|
||||
};
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user