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,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";
|
||||||
@@ -471,6 +471,14 @@ export default function AwemeDetailClient({
|
|||||||
return `/search?${params.toString()}`;
|
return `/search?${params.toString()}`;
|
||||||
}, [listKind, contextQuery]);
|
}, [listKind, contextQuery]);
|
||||||
|
|
||||||
|
const hashtagName = useMemo(
|
||||||
|
() =>
|
||||||
|
listKind === "hashtag"
|
||||||
|
? new URLSearchParams(contextQuery).get("hashtag")
|
||||||
|
: null,
|
||||||
|
[listKind, contextQuery],
|
||||||
|
);
|
||||||
|
|
||||||
const listVisible = panel.open && activeTab === "list";
|
const listVisible = panel.open && activeTab === "list";
|
||||||
const worksVisible = panel.open && activeTab === "works";
|
const worksVisible = panel.open && activeTab === "works";
|
||||||
|
|
||||||
@@ -976,11 +984,13 @@ export default function AwemeDetailClient({
|
|||||||
count: listContext.total,
|
count: listContext.total,
|
||||||
content: (
|
content: (
|
||||||
<ContextListTab
|
<ContextListTab
|
||||||
|
key={contextQuery}
|
||||||
active={panel.open && activeTab === "list"}
|
active={panel.open && activeTab === "list"}
|
||||||
context={listContext}
|
context={listContext}
|
||||||
list={contextList}
|
list={contextList}
|
||||||
currentId={data.aweme_id}
|
currentId={data.aweme_id}
|
||||||
contextQuery={contextQuery}
|
contextQuery={contextQuery}
|
||||||
|
hashtagName={hashtagName}
|
||||||
searchPageHref={searchPageHref}
|
searchPageHref={searchPageHref}
|
||||||
onSelect={handleWorkSelect}
|
onSelect={handleWorkSelect}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Hash, Search } from "lucide-react";
|
import { ArrowLeft, Hash, Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { memo } from "react";
|
import { memo, useState } from "react";
|
||||||
import type { PlaylistState } from "../hooks/usePlaylist";
|
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 type { PlaybackContextInfo } from "../types.ts";
|
||||||
import { WorksGrid } from "./WorksGrid";
|
import { WorksGrid } from "./WorksGrid";
|
||||||
|
|
||||||
@@ -11,23 +13,62 @@ interface ContextListTabProps {
|
|||||||
list: PlaylistState;
|
list: PlaylistState;
|
||||||
currentId: string;
|
currentId: string;
|
||||||
contextQuery: string;
|
contextQuery: string;
|
||||||
|
/** hashtag 上下文时的 tag 名(用来取相关 tag) */
|
||||||
|
hashtagName: string | null;
|
||||||
/** 对应的搜索结果页链接(搜索上下文才有) */
|
/** 对应的搜索结果页链接(搜索上下文才有) */
|
||||||
searchPageHref: string | null;
|
searchPageHref: string | null;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致
|
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致。
|
||||||
|
// hashtag 列表上方显示相关 tag(按共现计算);点相关 tag 只在侧栏里预览它的作品,不打断当前播放,
|
||||||
|
// 在预览里点作品才切换到那个 tag 的列表。
|
||||||
export const ContextListTab = memo(function ContextListTab({
|
export const ContextListTab = memo(function ContextListTab({
|
||||||
active,
|
active,
|
||||||
context,
|
context,
|
||||||
list,
|
list,
|
||||||
currentId,
|
currentId,
|
||||||
contextQuery,
|
contextQuery,
|
||||||
|
hashtagName,
|
||||||
searchPageHref,
|
searchPageHref,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: ContextListTabProps) {
|
}: ContextListTabProps) {
|
||||||
|
// 正在预览的相关 tag(null 表示显示当前列表本身)
|
||||||
|
const [browsing, setBrowsing] = useState<string | null>(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 Icon = context.kind === "hashtag" ? Hash : Search;
|
||||||
const header = (
|
const header = browsing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrowsing(null)}
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-white/80 hover:bg-white/20 cursor-pointer"
|
||||||
|
aria-label={`返回 ${context.label}`}
|
||||||
|
title={`返回 ${context.label}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-semibold text-white">#{browsing}</div>
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
|
{related ? `共 ${related.count} 个 · ` : ""}点作品切换到这个 tag
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<Icon size={18} className="shrink-0 text-rose-300" />
|
<Icon size={18} className="shrink-0 text-rose-300" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -47,14 +88,34 @@ export const ContextListTab = memo(function ContextListTab({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const relatedRow =
|
||||||
|
viewTag && related && related.related.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto px-3 py-2 border-b border-white/10 comment-scroll">
|
||||||
|
<span className="shrink-0 text-xs text-white/40">相关</span>
|
||||||
|
{related.related.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickTag(tag.name)}
|
||||||
|
className="shrink-0 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:border-white/30 hover:text-white cursor-pointer"
|
||||||
|
title={`一起出现在 ${tag.together} 个作品里,#${tag.name} 共 ${tag.count} 个作品`}
|
||||||
|
>
|
||||||
|
#{tag.name}
|
||||||
|
<span className="ml-1 text-white/40">{tag.together}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorksGrid
|
<WorksGrid
|
||||||
active={active}
|
active={active}
|
||||||
list={list}
|
list={browsing ? browseList : list}
|
||||||
currentId={currentId}
|
currentId={currentId}
|
||||||
hrefQuery={contextQuery}
|
hrefQuery={browsing ? browseQuery : contextQuery}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
header={header}
|
header={header}
|
||||||
|
subheader={relatedRow}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface WorksGridProps {
|
|||||||
singleHint?: string;
|
singleHint?: string;
|
||||||
/** 网格上方的标题栏 */
|
/** 网格上方的标题栏 */
|
||||||
header: ReactNode;
|
header: ReactNode;
|
||||||
|
/** 标题栏下方的附加内容(如相关 tag) */
|
||||||
|
subheader?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCount(n: number): string {
|
function formatCount(n: number): string {
|
||||||
@@ -46,6 +48,7 @@ export const WorksGrid = memo(function WorksGrid({
|
|||||||
onSelect,
|
onSelect,
|
||||||
singleHint,
|
singleHint,
|
||||||
header,
|
header,
|
||||||
|
subheader,
|
||||||
}: WorksGridProps) {
|
}: WorksGridProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const currentRef = useRef<HTMLAnchorElement>(null);
|
const currentRef = useRef<HTMLAnchorElement>(null);
|
||||||
@@ -74,6 +77,7 @@ export const WorksGrid = memo(function WorksGrid({
|
|||||||
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
|
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
|
{subheader}
|
||||||
|
|
||||||
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
|
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
|
||||||
{list.loading && !list.items && (
|
{list.loading && !list.items && (
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { RelatedHashtagsResult } from "@/lib/hashtag-related.server";
|
||||||
|
|
||||||
|
const cache = new Map<string, RelatedHashtagsResult>();
|
||||||
|
|
||||||
|
/** 取某个 hashtag 的相关 tag(按共现计算);name 为空或 enabled 为 false 时不请求。 */
|
||||||
|
export function useRelatedHashtags(
|
||||||
|
name: string | null,
|
||||||
|
enabled: boolean,
|
||||||
|
): RelatedHashtagsResult | null {
|
||||||
|
const key = name?.toLowerCase() ?? "";
|
||||||
|
const [result, setResult] = useState<RelatedHashtagsResult | null>(
|
||||||
|
() => 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -47,3 +47,21 @@ export function hashtagToPgPattern(name: string): string {
|
|||||||
const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return `#${escaped}([\\s${STOP_CHARS}]|$)`;
|
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]+$";
|
||||||
|
|||||||
Reference in New Issue
Block a user