From 2391abdeea4d7f900c4fc89126a13a556c9370ef Mon Sep 17 00:00:00 2001 From: feiezha Date: Mon, 9 Nov 2026 01:18:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20hashtag=20=E5=88=97=E8=A1=A8=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=8C=89=E5=85=B1=E7=8E=B0=E8=AE=A1=E7=AE=97=E7=9A=84?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/hashtag/related:从作品描述实时解析 hashtag,按「同一作品里一起出现」计算相关 tag - 打分取子话题(候选多与目标同现)与上位话题(目标多与热门候选同现)两者较大值, 过滤 cos、二次元等万金油 tag 与平台活动、纯数字 tag - 播放页侧栏的 hashtag 列表顶部显示相关 tag,点击在侧栏内预览(不打断当前播放), 在预览里点作品才切换到该 tag 的列表 Co-Authored-By: Claude Opus 5.5 --- app/api/hashtag/related/route.ts | 28 +++++ app/aweme/[awemeId]/Client.tsx | 10 ++ .../[awemeId]/components/ContextListTab.tsx | 75 ++++++++++-- app/aweme/[awemeId]/components/WorksGrid.tsx | 4 + .../[awemeId]/hooks/useRelatedHashtags.ts | 37 ++++++ lib/hashtag-related.server.ts | 114 ++++++++++++++++++ lib/hashtag.ts | 18 +++ 7 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 app/api/hashtag/related/route.ts create mode 100644 app/aweme/[awemeId]/hooks/useRelatedHashtags.ts create mode 100644 lib/hashtag-related.server.ts diff --git a/app/api/hashtag/related/route.ts b/app/api/hashtag/related/route.ts new file mode 100644 index 0000000..e0daf0e --- /dev/null +++ b/app/api/hashtag/related/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { normalizeHashtag } from "@/lib/hashtag"; +import { relatedHashtags } from "@/lib/hashtag-related.server"; +import { json } from "@/lib/json"; + +// 相关 hashtag(按共现计算):GET /api/hashtag/related?name=丰川祥子 +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const name = normalizeHashtag(searchParams.get("name") || ""); + if (!name) { + return NextResponse.json({ error: "Invalid hashtag" }, { status: 400 }); + } + const limit = Math.min( + 30, + Math.max(1, Math.floor(Number(searchParams.get("limit")) || 12)), + ); + try { + return json(await relatedHashtags(name, limit)); + } catch (err) { + console.error("Related hashtags error:", err); + return NextResponse.json( + { error: "Related hashtags failed" }, + { status: 500 }, + ); + } +} + +export const dynamic = "force-dynamic"; diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index eaa3d3b..a0d6783 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -471,6 +471,14 @@ export default function AwemeDetailClient({ return `/search?${params.toString()}`; }, [listKind, contextQuery]); + const hashtagName = useMemo( + () => + listKind === "hashtag" + ? new URLSearchParams(contextQuery).get("hashtag") + : null, + [listKind, contextQuery], + ); + const listVisible = panel.open && activeTab === "list"; const worksVisible = panel.open && activeTab === "works"; @@ -976,11 +984,13 @@ export default function AwemeDetailClient({ count: listContext.total, content: ( diff --git a/app/aweme/[awemeId]/components/ContextListTab.tsx b/app/aweme/[awemeId]/components/ContextListTab.tsx index 7909c3c..c254582 100644 --- a/app/aweme/[awemeId]/components/ContextListTab.tsx +++ b/app/aweme/[awemeId]/components/ContextListTab.tsx @@ -1,7 +1,9 @@ -import { Hash, Search } from "lucide-react"; +import { ArrowLeft, Hash, Search } from "lucide-react"; import Link from "next/link"; -import { memo } from "react"; -import type { PlaylistState } from "../hooks/usePlaylist"; +import { memo, useState } from "react"; +import { playbackContextQuery } from "@/lib/playback-context"; +import { type PlaylistState, usePlaylist } from "../hooks/usePlaylist"; +import { useRelatedHashtags } from "../hooks/useRelatedHashtags"; import type { PlaybackContextInfo } from "../types.ts"; import { WorksGrid } from "./WorksGrid"; @@ -11,23 +13,62 @@ interface ContextListTabProps { list: PlaylistState; currentId: string; contextQuery: string; + /** hashtag 上下文时的 tag 名(用来取相关 tag) */ + hashtagName: string | null; /** 对应的搜索结果页链接(搜索上下文才有) */ searchPageHref: string | null; onSelect: () => void; } -// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致 +// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致。 +// hashtag 列表上方显示相关 tag(按共现计算);点相关 tag 只在侧栏里预览它的作品,不打断当前播放, +// 在预览里点作品才切换到那个 tag 的列表。 export const ContextListTab = memo(function ContextListTab({ active, context, list, currentId, contextQuery, + hashtagName, searchPageHref, onSelect, }: ContextListTabProps) { + // 正在预览的相关 tag(null 表示显示当前列表本身) + const [browsing, setBrowsing] = useState(null); + const viewTag = browsing ?? hashtagName; + + const related = useRelatedHashtags(viewTag, active); + const browseQuery = browsing + ? playbackContextQuery({ kind: "hashtag", name: browsing }) + : ""; + const browseList = usePlaylist(browseQuery, active && Boolean(browsing)); + + const pickTag = (name: string) => { + setBrowsing( + name.toLowerCase() === hashtagName?.toLowerCase() ? null : name, + ); + }; + const Icon = context.kind === "hashtag" ? Hash : Search; - const header = ( + const header = browsing ? ( + <> + +
+
#{browsing}
+
+ {related ? `共 ${related.count} 个 · ` : ""}点作品切换到这个 tag +
+
+ + ) : ( <>
@@ -47,14 +88,34 @@ export const ContextListTab = memo(function ContextListTab({ ); + const relatedRow = + viewTag && related && related.related.length > 0 ? ( +
+ 相关 + {related.related.map((tag) => ( + + ))} +
+ ) : null; + return ( ); }); diff --git a/app/aweme/[awemeId]/components/WorksGrid.tsx b/app/aweme/[awemeId]/components/WorksGrid.tsx index e3a5202..a633095 100644 --- a/app/aweme/[awemeId]/components/WorksGrid.tsx +++ b/app/aweme/[awemeId]/components/WorksGrid.tsx @@ -16,6 +16,8 @@ interface WorksGridProps { singleHint?: string; /** 网格上方的标题栏 */ header: ReactNode; + /** 标题栏下方的附加内容(如相关 tag) */ + subheader?: ReactNode; } function formatCount(n: number): string { @@ -46,6 +48,7 @@ export const WorksGrid = memo(function WorksGrid({ onSelect, singleHint, header, + subheader, }: WorksGridProps) { const scrollRef = useRef(null); const currentRef = useRef(null); @@ -74,6 +77,7 @@ export const WorksGrid = memo(function WorksGrid({
{header}
+ {subheader}
{list.loading && !list.items && ( diff --git a/app/aweme/[awemeId]/hooks/useRelatedHashtags.ts b/app/aweme/[awemeId]/hooks/useRelatedHashtags.ts new file mode 100644 index 0000000..9498f3e --- /dev/null +++ b/app/aweme/[awemeId]/hooks/useRelatedHashtags.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import type { RelatedHashtagsResult } from "@/lib/hashtag-related.server"; + +const cache = new Map(); + +/** 取某个 hashtag 的相关 tag(按共现计算);name 为空或 enabled 为 false 时不请求。 */ +export function useRelatedHashtags( + name: string | null, + enabled: boolean, +): RelatedHashtagsResult | null { + const key = name?.toLowerCase() ?? ""; + const [result, setResult] = useState( + () => cache.get(key) ?? null, + ); + + useEffect(() => { + if (!key || !enabled) return; + const cached = cache.get(key); + setResult(cached ?? null); + if (cached) return; + + let cancelled = false; + fetch(`/api/hashtag/related?name=${encodeURIComponent(key)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: RelatedHashtagsResult | null) => { + if (!data) return; + cache.set(key, data); + if (!cancelled) setResult(data); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [key, enabled]); + + return result?.name === key ? result : null; +} diff --git a/lib/hashtag-related.server.ts b/lib/hashtag-related.server.ts new file mode 100644 index 0000000..73de87a --- /dev/null +++ b/lib/hashtag-related.server.ts @@ -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(); + +/** + * 按「同一个作品描述里一起出现」计算相关 hashtag(共现图),每次实时从描述里解析,不需要额外的表。 + * + * 打分取两个方向里较大的那个: + * - 子话题:候选 tag 大多和目标一起出现(together / 候选数),乘 ln(1+together) 防止只出现一次的巧合排到前面; + * 例:#明日方舟 → #明日方舟七周年、#特蕾西娅 + * - 上位话题:目标大多和候选一起出现(together / 目标数),乘 ln(1+候选数),让热门的上位 tag 排前面; + * 例:#圣聆初雪(只有 2 个作品)→ #明日方舟 + * cos、二次元这类「万金油」tag 与平台活动 tag 不参与推荐。 + */ +export async function relatedHashtags( + rawName: string, + limit = 12, +): Promise { + 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; +} diff --git a/lib/hashtag.ts b/lib/hashtag.ts index 5a1396a..f8df1d3 100644 --- a/lib/hashtag.ts +++ b/lib/hashtag.ts @@ -47,3 +47,21 @@ 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]+$";