diff --git a/app/api/search/route.ts b/app/api/search/route.ts index d9ed851..be898cb 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,36 +1,36 @@ import { NextResponse } from "next/server"; -import { isVideoContentTag } from "@/lib/video-tags"; import { json } from "@/lib/json"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; +import { + buildSnippet, + highlightAll, + parseSearchQuery, + termToPgPattern, +} from "@/lib/search"; +import { isVideoContentTag } from "@/lib/video-tags"; type SearchRow = { - id: string; awemeId: string; type: "video" | "image"; - rank: number; - snippet: string; + score: number; + inDesc: boolean; + inTranscript: boolean; + rawDesc: string; + rawTranscript: string; contentTags: string[]; + total: number; }; -function sanitizeSnippetHtml(snippet: string) { - return snippet - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("<mark>", "") - .replaceAll("</mark>", ""); -} - export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = (searchParams.get("q") || "").trim(); const rawTag = (searchParams.get("tag") || "").trim(); const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null; - const page = Math.max(1, Number(searchParams.get("page") || 1)); + const page = Math.max(1, Math.floor(Number(searchParams.get("page")) || 1)); const limit = Math.min( 50, - Math.max(1, Number(searchParams.get("limit") || 20)), + Math.max(1, Math.floor(Number(searchParams.get("limit")) || 20)), ); const offset = (page - 1) * limit; @@ -38,129 +38,85 @@ export async function GET(req: Request) { return NextResponse.json({ error: "Invalid tag" }, { status: 400 }); } - if (!q && !contentTag) { - return NextResponse.json({ results: [], total: 0 }); + const { terms, excludes } = parseSearchQuery(q); + if (terms.length === 0 && !contentTag) { + return json({ results: [], total: 0, page, limit, terms }); } + // 关键词已在 parseSearchQuery 里转为小写,这里转成正则后与 lower() 后的文本匹配 + const patterns = terms.map(termToPgPattern); + const excludePatterns = excludes.map(termToPgPattern); + try { - // 核心查询:Postgres 中文全文检索(视频转写 + 视频desc + 图文desc)+ 相关度排序 + 摘要高亮 - // 说明: - // - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc - // - 使用 UNION ALL 合并视频和图文的搜索结果 - // - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配 - const rows = q - ? await prisma.$queryRaw` - WITH tsq AS ( - SELECT websearch_to_tsquery('zhcfg', ${q}) AS query + // 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写), + // 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。 + // 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。 + // 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。 + const rows = await prisma.$queryRaw` + WITH params AS ( + SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes + ), + docs AS ( + SELECT + v.aweme_id AS "awemeId", + 'video'::text AS type, + v."desc" AS "rawDesc", + COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript", + v.created_at AS created, + v.manual_content_tags AS "contentTags" + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE ${contentTag}::text IS NULL + OR v.manual_content_tags @> ARRAY[${contentTag}]::text[] + + UNION ALL + + SELECT + ip.aweme_id, + 'image'::text, + ip."desc", + '', + ip.created_at, + ARRAY[]::text[] + FROM "ImagePost" ip + WHERE ${contentTag}::text IS NULL + ), + lowered AS ( + SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t + FROM docs + ), + matched AS ( + SELECT + l.*, + COALESCE(( + SELECT SUM( + 4 * LEAST(regexp_count(l.d, term), 3) + + LEAST(regexp_count(l.t, term), 10) + ) + FROM unnest(p.terms) term + ), 0)::float8 AS score, + EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc", + EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript" + FROM lowered l, params p + WHERE NOT EXISTS ( + SELECT 1 FROM unnest(p.terms) term + WHERE l.d !~ term AND l.t !~ term ) - -- 视频搜索结果 - SELECT - COALESCE(vt.id, 'desc:' || v.aweme_id) AS id, - v.aweme_id AS "awemeId", - 'video'::text AS type, - GREATEST( - COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0), - COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0) - ) AS rank, - CASE - WHEN vt.transcript_zh_tsv @@ tsq.query THEN - ts_headline( - 'zhcfg', - array_to_string(vt.transcript, ' '), - tsq.query, - 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' - ) - ELSE - ts_headline( - 'zhcfg', - v.desc, - tsq.query, - 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' - ) - END AS snippet, - v.manual_content_tags AS "contentTags" - FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id, - tsq - WHERE - (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) - AND CASE - WHEN ${contentTag}::text IS NULL THEN TRUE - ELSE v.manual_content_tags @> ARRAY[${contentTag}]::text[] - END - - UNION ALL - - -- 图文搜索结果 - SELECT - 'img_desc:' || ip.aweme_id AS id, - ip.aweme_id AS "awemeId", - 'image'::text AS type, - ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank, - ts_headline( - 'zhcfg', - ip.desc, - tsq.query, - 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' - ) AS snippet, - ARRAY[]::text[] AS "contentTags" - FROM "ImagePost" ip, tsq - WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query - AND ${contentTag}::text IS NULL - - ORDER BY rank DESC - OFFSET ${offset} - LIMIT ${limit}; - ` - : await prisma.$queryRaw` - SELECT - COALESCE(vt.id, 'tag:' || v.aweme_id) AS id, - v.aweme_id AS "awemeId", - 'video'::text AS type, - 1.0::double precision AS rank, - COALESCE( - NULLIF(array_to_string(vt.transcript[1:2], ' '), ''), - v.desc, - '标签匹配' - ) AS snippet, - v.manual_content_tags AS "contentTags" - FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id - WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[] - ORDER BY v.created_at DESC - OFFSET ${offset} - LIMIT ${limit}; - `; - - // 查询总数(参数化,避免注入) - const totalRows = q - ? await prisma.$queryRaw<{ count: number }[]>` - WITH tsq AS ( - SELECT websearch_to_tsquery('zhcfg', ${q}) AS query + AND NOT EXISTS ( + SELECT 1 FROM unnest(p.excludes) x + WHERE l.d ~ x OR l.t ~ x ) - SELECT ( - (SELECT COUNT(*)::int FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id - WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) - AND CASE - WHEN ${contentTag}::text IS NULL THEN TRUE - ELSE v.manual_content_tags @> ARRAY[${contentTag}]::text[] - END) - + - (SELECT COUNT(*)::int FROM "ImagePost" ip - WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query - AND ${contentTag}::text IS NULL) - ) AS count - FROM tsq; - ` - : await prisma.$queryRaw<{ count: number }[]>` - SELECT COUNT(*)::int AS count - FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id - WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[]; - `; + ) + SELECT + "awemeId", type, score, "inDesc", "inTranscript", + "rawDesc", "rawTranscript", "contentTags", + (COUNT(*) OVER ())::int AS total + FROM matched + ORDER BY score DESC, created DESC, "awemeId" + OFFSET ${offset} + LIMIT ${limit}; + `; - // 分离视频和图文ID const videoIds = rows .filter((r) => r.type === "video") .map((r) => r.awemeId); @@ -168,79 +124,92 @@ export async function GET(req: Request) { .filter((r) => r.type === "image") .map((r) => r.awemeId); - // 批量查询视频元信息 - const videos = + const [videos, imagePosts] = await Promise.all([ videoIds.length > 0 - ? ( - await prisma.video.findMany({ - where: { aweme_id: { in: videoIds } }, - select: { - aweme_id: true, - desc: true, - cover_url: true, - video_url: true, - duration_ms: true, - author: true, - }, - }) - ).map((v) => ({ - ...v, - cover_url: getFileUrl(v.cover_url || ""), - author: { - ...v.author, - avatar_url: getFileUrl(v.author.avatar_url || ""), + ? prisma.video.findMany({ + where: { aweme_id: { in: videoIds } }, + select: { + aweme_id: true, + desc: true, + cover_url: true, + video_url: true, + duration_ms: true, + author: true, }, - video_url: getFileUrl(v.video_url || ""), - })) - : []; - - // 批量查询图文元信息 - const imagePosts = + }) + : [], imagePostIds.length > 0 - ? ( - await prisma.imagePost.findMany({ - where: { aweme_id: { in: imagePostIds } }, - select: { - aweme_id: true, - desc: true, - author: true, - images: { - orderBy: { order: "asc" }, - take: 1, - select: { - url: true, - width: true, - height: true, - }, - }, + ? prisma.imagePost.findMany({ + where: { aweme_id: { in: imagePostIds } }, + select: { + aweme_id: true, + desc: true, + author: true, + images: { + orderBy: { order: "asc" }, + take: 1, + select: { url: true, width: true, height: true }, }, - }) - ).map((ip) => ({ - ...ip, - author: { - ...ip.author, - avatar_url: getFileUrl(ip.author.avatar_url || ""), }, - cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null, - })) - : []; + }) + : [], + ]); + + const videoMap = new Map( + videos.map((v) => [ + v.aweme_id, + { + ...v, + cover_url: getFileUrl(v.cover_url || ""), + video_url: getFileUrl(v.video_url || ""), + author: { + ...v.author, + avatar_url: getFileUrl(v.author.avatar_url || ""), + }, + }, + ]), + ); + const imagePostMap = new Map( + imagePosts.map((ip) => [ + ip.aweme_id, + { + ...ip, + author: { + ...ip.author, + avatar_url: getFileUrl(ip.author.avatar_url || ""), + }, + cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null, + }, + ]), + ); return json({ - results: rows.map((r) => ({ - ...r, - snippet: sanitizeSnippetHtml(r.snippet), - video: - r.type === "video" - ? videos.find((v) => v.aweme_id === r.awemeId) - : undefined, - imagePost: - r.type === "image" - ? imagePosts.find((ip) => ip.aweme_id === r.awemeId) - : undefined, - })), - total: totalRows?.[0]?.count ?? 0, + results: rows.map((r) => { + const matchedIn: ("desc" | "transcript")[] = []; + if (r.inDesc) matchedIn.push("desc"); + if (r.inTranscript) matchedIn.push("transcript"); + return { + id: `${r.type}:${r.awemeId}`, + awemeId: r.awemeId, + type: r.type, + score: r.score, + matchedIn, + contentTags: r.contentTags, + descParts: highlightAll(r.rawDesc, terms), + // 转写命中时给出命中片段;只按标签筛选时给出转写开头作为预览 + snippet: + r.inTranscript || (terms.length === 0 && r.rawTranscript) + ? buildSnippet(r.rawTranscript, terms) + : null, + video: r.type === "video" ? videoMap.get(r.awemeId) : undefined, + imagePost: + r.type === "image" ? imagePostMap.get(r.awemeId) : undefined, + }; + }), + total: rows[0]?.total ?? 0, page, limit, + terms, }); } catch (err) { console.error("Search error:", err); diff --git a/app/search/SearchClient.tsx b/app/search/SearchClient.tsx index 3167fe8..9474c39 100644 --- a/app/search/SearchClient.tsx +++ b/app/search/SearchClient.tsx @@ -1,19 +1,28 @@ "use client"; -import { ArrowLeft, MessageSquare, Search, X } from "lucide-react"; +import { ArrowLeft, FileText, Mic, Search, X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { type FormEvent, useCallback, useEffect, useState } from "react"; -import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags"; +import { + type FormEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import type { SnippetPart } from "@/lib/search"; +import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; // 匹配搜索结果类型(适配后端API) type SearchResultItem = { id: string; awemeId: string; type: "video" | "image"; - rank: number; - snippet: string; // 后端返回的高亮片段,已包含标签 + score: number; + matchedIn: ("desc" | "transcript")[]; + descParts: SnippetPart[]; // 完整描述,标出命中位置 + snippet: SnippetPart[] | null; // 语音转写的命中片段 contentTags: string[]; video?: { aweme_id: string; @@ -38,6 +47,27 @@ type SearchResultItem = { }; }; }; + +const PAGE_SIZE = 30; + +function renderParts(parts: SnippetPart[]) { + let offset = 0; + return parts.map((part) => { + const key = `${offset}-${part.text}`; + offset += part.text.length; + return part.hit ? ( + + {part.text} + + ) : ( + {part.text} + ); + }); +} + export default function SearchClient({ initialQuery, }: { @@ -48,7 +78,12 @@ export default function SearchClient({ const [query, setQuery] = useState(initialQuery); const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + // 只采用最近一次请求的结果,避免快速切换关键词时旧结果覆盖新结果 + const requestIdRef = useRef(0); const [searched, setSearched] = useState(false); const [selectedTag, setSelectedTag] = useState(null); @@ -61,30 +96,79 @@ export default function SearchClient({ return queryString ? `/search?${queryString}` : "/search"; }, []); - const performSearch = useCallback(async (q: string, tag: string | null) => { - const trimmed = q.trim(); - if (!trimmed && !tag) { - setResults([]); - setSearched(false); - return; - } - setLoading(true); - setSearched(true); - try { - const params = new URLSearchParams({ limit: "60" }); - if (trimmed) params.set("q", trimmed); + const fetchPage = useCallback( + async (q: string, tag: string | null, pageNum: number) => { + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + page: String(pageNum), + }); + if (q) params.set("q", q); if (tag) params.set("tag", tag); const res = await fetch(`/api/search?${params.toString()}`); if (!res.ok) throw new Error("Search failed"); - const data = await res.json(); - setResults(data.results || []); + return (await res.json()) as { + results?: SearchResultItem[]; + total?: number; + }; + }, + [], + ); + + const performSearch = useCallback( + async (q: string, tag: string | null) => { + const trimmed = q.trim(); + const requestId = ++requestIdRef.current; + if (!trimmed && !tag) { + setResults([]); + setTotal(0); + setSearched(false); + return; + } + setLoading(true); + setSearched(true); + setPage(1); + try { + const data = await fetchPage(trimmed, tag, 1); + if (requestId !== requestIdRef.current) return; + setResults(data.results || []); + setTotal(data.total ?? 0); + } catch (err) { + if (requestId !== requestIdRef.current) return; + console.error("Search error:", err); + setResults([]); + setTotal(0); + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, + [fetchPage], + ); + + const loadMore = async () => { + const q = (searchParams.get("q") || "").trim(); + const tagParam = searchParams.get("tag") || ""; + const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null; + const requestId = requestIdRef.current; + const nextPage = page + 1; + setLoadingMore(true); + try { + const data = await fetchPage(q, tag, nextPage); + if (requestId !== requestIdRef.current) return; + setResults((prev) => { + const seen = new Set(prev.map((r) => r.id)); + return [ + ...prev, + ...(data.results || []).filter((r) => !seen.has(r.id)), + ]; + }); + setTotal(data.total ?? 0); + setPage(nextPage); } catch (err) { console.error("Search error:", err); - setResults([]); } finally { - setLoading(false); + if (requestId === requestIdRef.current) setLoadingMore(false); } - }, []); + }; // 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥) useEffect(() => { @@ -96,7 +180,9 @@ export default function SearchClient({ if (q || tag) { performSearch(q, tag); } else { + requestIdRef.current++; setResults([]); + setTotal(0); setSearched(false); } }, [searchParams, performSearch]); @@ -118,28 +204,11 @@ export default function SearchClient({ setQuery(""); setSelectedTag(null); setResults([]); + setTotal(0); setSearched(false); router.push("/search"); }; - const renderSnippet = (snippet: string) => { - let highlighted = false; - let offset = 0; - return snippet.split(/(|<\/mark>)/g).map((part) => { - offset += part.length; - if (part === "") { - highlighted = true; - return null; - } - if (part === "") { - highlighted = false; - return null; - } - if (!part) return null; - return highlighted ? {part} : part; - }); - }; - return ( {/* 顶部搜索栏 */} @@ -234,7 +303,7 @@ export default function SearchClient({ 输入关键词开始搜索 - 支持搜索视频描述、图文描述及语音转写内容 + 按原文匹配描述和语音转写,多个关键词用空格分隔,以 - 开头表示排除 )} @@ -243,10 +312,7 @@ export default function SearchClient({ - 找到{" "} - - {results.length} - {" "} + 找到 {total}{" "} 个结果 @@ -260,7 +326,7 @@ export default function SearchClient({ return ( {/* 主内容区 */} @@ -298,10 +364,18 @@ export default function SearchClient({ > {item.type === "video" ? "视频" : "图文"} - - - 匹配度: {(item.rank * 100).toFixed(1)}% - + {item.matchedIn.includes("desc") && ( + + + 描述命中 + + )} + {item.matchedIn.includes("transcript") && ( + + + 语音命中 + + )} {item.contentTags.map((tag) => ( - - {content.desc || "无描述"} + + {item.descParts.length > 0 + ? renderParts(item.descParts) + : "无描述"} @@ -341,29 +417,44 @@ export default function SearchClient({ - {/* 匹配详情:展示后端返回的snippet(已包含标签) */} - + {/* 语音转写片段:命中时展示命中位置附近的文字 */} + {item.snippet && item.snippet.length > 0 && ( - - 匹配内容 + + {item.matchedIn.includes("transcript") + ? "语音内容" + : "语音转写"} - {renderSnippet(item.snippet)} + {renderParts(item.snippet)} - + )} ); })} + + {results.length < total && ( + + + {loadingMore + ? "加载中..." + : `加载更多(还有 ${total - results.length} 个)`} + + + )} )} diff --git a/lib/search.ts b/lib/search.ts new file mode 100644 index 0000000..1e7f793 --- /dev/null +++ b/lib/search.ts @@ -0,0 +1,172 @@ +// 子串搜索的查询解析与摘要高亮工具。 +// 匹配本身在 Postgres 里用正则匹配 lower() 后的文本完成,这里负责: +// - 把用户输入拆成关键词(空格分隔,全部需要命中;以 - 开头的词表示排除) +// - 在原文里找命中位置,截取摘要片段并标出高亮 + +export type SnippetPart = { text: string; hit: boolean }; + +export type ParsedQuery = { terms: string[]; excludes: string[] }; + +const MAX_TERMS = 10; +const MAX_TERM_LENGTH = 50; + +export function parseSearchQuery(raw: string): ParsedQuery { + const terms: string[] = []; + const excludes: string[] = []; + const seen = new Set(); + for (const token of raw.trim().split(/\s+/)) { + if (!token) continue; + const isExclude = token.length > 1 && token.startsWith("-"); + const word = (isExclude ? token.slice(1) : token) + .slice(0, MAX_TERM_LENGTH) + .toLowerCase(); + const key = `${isExclude ? "-" : "+"}${word}`; + if (!word || seen.has(key)) continue; + seen.add(key); + (isExclude ? excludes : terms).push(word); + if (terms.length + excludes.length >= MAX_TERMS) break; + } + return { terms, excludes }; +} + +type Range = { start: number; end: number; term: string }; + +function escapeRegExp(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// 纯英文/数字关键词要求「词首」对齐:前面不能紧挨字母数字。 +// 这样 "ai" 不会命中 "said",但 "用AI做"、"AIGC" 仍能命中;中文关键词是纯子串匹配。 +const ASCII_WORD = /^[a-z0-9]/; + +/** 关键词对应的 Postgres 正则(ARE),用于匹配已 lower() 的文本。 */ +export function termToPgPattern(term: string) { + const escaped = escapeRegExp(term); + return ASCII_WORD.test(term) ? `(^|[^a-z0-9])${escaped}` : escaped; +} + +function termToJsPattern(term: string) { + const escaped = escapeRegExp(term); + return ASCII_WORD.test(term) ? `(? b.length - a.length); + const re = new RegExp(sorted.map(termToJsPattern).join("|"), "giu"); + const ranges: Range[] = []; + for (const m of text.matchAll(re)) { + const start = m.index ?? 0; + ranges.push({ + start, + end: start + m[0].length, + term: m[0].toLowerCase(), + }); + } + return ranges; +} + +// 不要把 UTF-16 代理对(emoji 等)切成两半 +function safeStart(text: string, i: number) { + const c = text.charCodeAt(i); + return c >= 0xdc00 && c <= 0xdfff ? i - 1 : i; +} +function safeEnd(text: string, i: number) { + const c = text.charCodeAt(i); + return c >= 0xdc00 && c <= 0xdfff ? i + 1 : i; +} + +function highlightRange( + text: string, + from: number, + to: number, + matches: Range[], +): SnippetPart[] { + const parts: SnippetPart[] = []; + let cursor = from; + for (const m of matches) { + if (m.end <= from || m.start >= to) continue; + const s = Math.max(m.start, from); + const e = Math.min(m.end, to); + if (s < cursor) continue; + if (s > cursor) parts.push({ text: text.slice(cursor, s), hit: false }); + parts.push({ text: text.slice(s, e), hit: true }); + cursor = e; + } + if (cursor < to) parts.push({ text: text.slice(cursor, to), hit: false }); + return parts; +} + +function normalizeWhitespace(parts: SnippetPart[]): SnippetPart[] { + return parts + .map((p) => ({ ...p, text: p.text.replace(/\s*\n\s*/g, " ") })) + .filter((p) => p.text.length > 0); +} + +/** 整段文字全部返回,只标出命中位置(用于描述)。 */ +export function highlightAll(text: string, terms: string[]): SnippetPart[] { + if (!text) return []; + return normalizeWhitespace( + highlightRange(text, 0, text.length, findMatches(text, terms)), + ); +} + +/** + * 截取命中位置附近的片段(用于语音转写这类长文本)。 + * 尽量让不同片段覆盖不同的关键词;片段会对齐到换行(转写的句子边界)。 + */ +export function buildSnippet( + text: string, + terms: string[], + { before = 24, after = 60, maxFragments = 2 } = {}, +): SnippetPart[] { + if (!text) return []; + const matches = findMatches(text, terms); + if (matches.length === 0) { + const end = safeEnd(text, Math.min(text.length, before + after)); + const parts = normalizeWhitespace([ + { text: text.slice(0, end), hit: false }, + ]); + if (end < text.length) parts.push({ text: "…", hit: false }); + return parts; + } + + const windows: { start: number; end: number }[] = []; + const covered = new Set(); + const pickNext = () => + matches.find( + (m) => + !covered.has(m.term) && + windows.every((w) => m.start >= w.end || m.end <= w.start), + ) ?? + matches.find((m) => + windows.every((w) => m.start >= w.end || m.end <= w.start), + ); + + while (windows.length < maxFragments) { + const m = pickNext(); + if (!m) break; + let start = Math.max(0, m.start - before); + let end = Math.min(text.length, m.end + after); + const nlBefore = text.lastIndexOf("\n", m.start - 1); + if (nlBefore >= start) start = nlBefore + 1; + const nlAfter = text.indexOf("\n", m.end); + if (nlAfter !== -1 && nlAfter < end) end = nlAfter; + windows.push({ start: safeStart(text, start), end: safeEnd(text, end) }); + for (const other of matches) { + if (other.start >= start && other.end <= end) covered.add(other.term); + } + } + + windows.sort((a, b) => a.start - b.start); + const parts: SnippetPart[] = []; + windows.forEach((w, i) => { + if (i === 0 ? w.start > 0 : true) parts.push({ text: "…", hit: false }); + parts.push(...highlightRange(text, w.start, w.end, matches)); + }); + if (windows[windows.length - 1].end < text.length) { + parts.push({ text: "…", hit: false }); + } + return normalizeWhitespace(parts); +}
输入关键词开始搜索
- 支持搜索视频描述、图文描述及语音转写内容 + 按原文匹配描述和语音转写,多个关键词用空格分隔,以 - 开头表示排除
- {content.desc || "无描述"} +
+ {item.descParts.length > 0 + ? renderParts(item.descParts) + : "无描述"}