Files
feie9454andClaude Opus 5.5 182ca9cf05 feat: 搜索改为子串匹配,支持多关键词、排除词与分页加载
- 不再依赖 zhparser 分词:关键词按空格拆分,全部需在描述或语音转写中以子串出现;-词 表示排除
- 英文/数字关键词要求词首对齐,避免 ai 命中 said
- 按描述/转写命中次数打分,同分按发布时间倒序;总数随查询一并返回
- 返回结构化高亮片段(descParts / snippet),前端高亮描述与转写命中位置
- 前端显示描述/语音命中标记、真实结果总数,并支持加载更多

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:43:15 +08:00

173 lines
5.8 KiB
TypeScript

// 子串搜索的查询解析与摘要高亮工具。
// 匹配本身在 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<string>();
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) ? `(?<![a-z0-9])${escaped}` : escaped;
}
function findMatches(text: string, terms: string[]): Range[] {
if (!text || terms.length === 0) return [];
// 长词优先,避免 "老百姓" 被 "百姓" 抢先匹配成更短的高亮
const sorted = [...terms].sort((a, b) => 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<string>();
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);
}