feat: 支持默认搜索自动识别通配符

This commit is contained in:
chuan
2026-08-10 11:45:40 +08:00
parent 5ed8185d77
commit b41fa23a77
13 changed files with 154 additions and 29 deletions
+1
View File
@@ -170,6 +170,7 @@
- [x] 实现简单现代并适配移动端的单页搜索界面
- [x] 接入搜索排序分页详情内容变体和磁力链接复制
- [x] 实现名称别名和文件路径的有限状态自动机正则搜索
- [x] 在普通搜索中自动识别通配符并保留独立正则开关
- [x] 实现种子详情文件列表后端分页并限制浏览器单页节点数量
- [x] 保留种子详情顶部结构并使用 reka-ui 数字分页重构文件条目
- [x] 支持用户选择并持久化文件列表每页数量
+5 -2
View File
@@ -152,9 +152,10 @@ GET /health
GET /ready
GET /stats
GET /search?q=ubuntu&offset=0&limit=20
GET /search?q=%2A.iso
GET /search?q=&min_size=1048576&max_size=10737418240&extension=mkv
GET /search?q=流浪地球&min_files=1&availability=active&heat=hot&sort=heat
GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D&regex=true
GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D&mode=regex
GET /contents/{content_key}?offset=0&limit=20
GET /torrents/{infohash}?file_offset=0&file_limit=100
```
@@ -163,7 +164,9 @@ GET /torrents/{infohash}?file_offset=0&file_limit=100
搜索支持中文英文数字和文件名片段匹配
设置 `regex=true` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 正则最长 256 字节并由 Tantivy 有限状态自动机执行
默认搜索会自动识别不区分大小写的通配符并匹配名称 别名和文件路径 `*` 表示任意长度字符 `?` 表示一个字符 例如 `*.iso` 匹配所有以 `.iso` 结尾的已索引名称或文件路径 不含通配符时保持普通关键词和片段搜索
设置 `mode=regex` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 通配符和正则最长 256 字节并由 Tantivy 有限状态自动机执行
过滤参数还包括 `min_files` `max_files` `first_seen_after` `first_seen_before` `last_seen_after` `last_seen_before` `availability``heat`
+7 -5
View File
@@ -10,7 +10,7 @@ use axum::{
};
use dht_search::{
domain::{InfoHash, MetadataRejectionReason},
search::{SearchOptions, SearchPage, SearchSort},
search::{SearchMode, SearchOptions, SearchPage, SearchSort},
storage::VerificationPriority,
};
@@ -118,8 +118,10 @@ pub(crate) async fn search(
if request.q.len() > 512 {
return Err(ApiError::bad_request("查询文本不能超过 512 字节"));
}
if request.regex && request.q.len() > 256 {
return Err(ApiError::bad_request("正则表达式不能超过 256 字节"));
if (request.mode != SearchMode::Text || request.q.contains('*') || request.q.contains('?'))
&& request.q.len() > 256
{
return Err(ApiError::bad_request("通配符或正则表达式不能超过 256 字节"));
}
validate_range(request.min_size, request.max_size, "min_size", "max_size")?;
validate_range(
@@ -141,7 +143,7 @@ pub(crate) async fn search(
"last_seen_before",
)?;
let mut query = request.q;
let content_key = if !request.regex {
let content_key = if request.mode == SearchMode::Text {
InfoHash::from_str(query.trim()).ok()
} else {
None
@@ -169,7 +171,7 @@ pub(crate) async fn search(
let page = tokio::task::spawn_blocking(move || {
state.search.search_with(SearchOptions {
query,
regex: request.regex,
mode: request.mode,
offset: request.offset,
limit: request.limit,
min_size: request.min_size,
+18 -2
View File
@@ -172,7 +172,7 @@ mod tests {
.clone()
.oneshot(
Request::builder()
.uri("/search?q=example.%2Amovie&regex=true")
.uri("/search?q=example.%2Amovie&mode=regex")
.body(Body::empty())
.unwrap(),
)
@@ -188,7 +188,23 @@ mod tests {
.clone()
.oneshot(
Request::builder()
.uri("/search?q=%5B&regex=true")
.uri("/search?q=%2A.mkv")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(json["total"], 1);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/search?q=%5B&mode=regex")
.body(Body::empty())
.unwrap(),
)
+2 -2
View File
@@ -2,7 +2,7 @@
use dht_search::{
domain::{AvailabilityStatus, HeatLevel},
search::SearchSort,
search::{SearchMode, SearchSort},
};
use serde::Deserialize;
@@ -15,7 +15,7 @@ pub(crate) struct SearchRequest {
#[serde(default)]
pub(crate) q: String,
#[serde(default)]
pub(crate) regex: bool,
pub(crate) mode: SearchMode,
#[serde(default)]
pub(crate) offset: usize,
#[serde(default = "default_limit")]
+2 -2
View File
@@ -2,7 +2,7 @@
use std::error::Error;
use dht_search::search::{SearchOptions, SearchSort};
use dht_search::search::{SearchMode, SearchOptions, SearchSort};
use super::dataset::generate_record;
@@ -56,7 +56,7 @@ pub(crate) fn query_cases(
name: "有限状态正则",
options: SearchOptions {
query: "ubuntu.*desktop".into(),
regex: true,
mode: SearchMode::Regex,
limit: 20,
..SearchOptions::default()
},
+67 -4
View File
@@ -12,7 +12,7 @@ use unicode_normalization::UnicodeNormalization;
use super::{
SearchError,
document::availability_number,
query::{SearchOptions, SearchSort},
query::{SearchMode, SearchOptions, SearchSort},
schema::SearchFields,
};
@@ -29,10 +29,14 @@ pub(crate) fn prepare(
let query_text = options.query.trim();
if query_text.is_empty() || query_text == "*" {
clauses.push(Box::new(AllQuery));
} else if options.regex {
clauses.push(regex_query(query_text, fields)?);
} else {
clauses.push(text_query(query_text, fields));
clauses.push(match options.mode {
SearchMode::Text if has_wildcard_syntax(query_text) => {
wildcard_query(query_text, fields)?
}
SearchMode::Text => text_query(query_text, fields),
SearchMode::Regex => regex_query(query_text, fields)?,
});
}
if let Some(content_key) = options.content_key {
clauses.push(Box::new(TermQuery::new(
@@ -131,6 +135,50 @@ fn regex_query(pattern: &str, fields: SearchFields) -> Result<Box<dyn Query>, Se
)?))
}
fn wildcard_query(pattern: &str, fields: SearchFields) -> Result<Box<dyn Query>, SearchError> {
Ok(Box::new(RegexQuery::from_pattern(
&wildcard_pattern(pattern),
fields.regex_text,
)?))
}
fn has_wildcard_syntax(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?')
}
fn wildcard_pattern(pattern: &str) -> String {
let pattern = normalize_text(pattern);
let mut regex = String::with_capacity(pattern.len());
let mut escaped = false;
for character in pattern.chars() {
if escaped {
push_regex_literal(&mut regex, character);
escaped = false;
continue;
}
match character {
'\\' => escaped = true,
'*' => regex.push_str(".*"),
'?' => regex.push('.'),
literal => push_regex_literal(&mut regex, literal),
}
}
if escaped {
push_regex_literal(&mut regex, '\\');
}
regex
}
fn push_regex_literal(regex: &mut String, character: char) {
if matches!(
character,
'\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
) {
regex.push('\\');
}
regex.push(character);
}
fn text_query(query: &str, fields: SearchFields) -> Box<dyn Query> {
let normalized = normalize_text(query.trim());
if normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
@@ -220,3 +268,18 @@ fn add_range(
.unwrap_or(Bound::Unbounded);
clauses.push(Box::new(RangeQuery::new(lower, upper)));
}
#[cfg(test)]
mod tests {
use super::{has_wildcard_syntax, wildcard_pattern};
#[test]
fn wildcard_conversion_is_case_insensitive_and_escapes_regex_syntax() {
assert_eq!(wildcard_pattern("*.ISO"), r".*\.iso");
assert_eq!(wildcard_pattern("file?.[ch]"), r"file.\.\[ch\]");
assert_eq!(wildcard_pattern(r"literal\*name"), r"literal\*name");
assert!(has_wildcard_syntax("*.iso"));
assert!(has_wildcard_syntax("file?.mkv"));
assert!(has_wildcard_syntax(r"literal\*name"));
}
}
+30 -3
View File
@@ -271,6 +271,7 @@ mod tests {
use crate::domain::{
AvailabilityStatus, ContentGroupBuilder, InfoHash, TorrentFile, TorrentRecord,
};
use crate::search::SearchMode;
use crate::storage::{RocksTorrentRepository, TorrentRepository};
use super::*;
@@ -364,7 +365,7 @@ mod tests {
let name = engine
.search_with(SearchOptions {
query: r"ubuntu\s+linux\s+24\.0[0-9]".into(),
regex: true,
mode: SearchMode::Regex,
limit: 10,
..SearchOptions::default()
})
@@ -373,7 +374,7 @@ mod tests {
let path = engine
.search_with(SearchOptions {
query: r"ubuntu\.(iso|img)$".into(),
regex: true,
mode: SearchMode::Regex,
limit: 10,
..SearchOptions::default()
})
@@ -381,6 +382,32 @@ mod tests {
assert_eq!(path.total, 1);
}
#[test]
fn wildcard_matches_names_and_file_extensions() {
let directory = TempDir::new().unwrap();
let engine = SearchEngine::open(directory.path()).unwrap();
index_records(&engine, &[record()]);
for pattern in ["*.iso", "Ubuntu*", "ubuntu.?so"] {
let page = engine
.search_with(SearchOptions {
query: pattern.into(),
limit: 10,
..SearchOptions::default()
})
.unwrap();
assert_eq!(page.total, 1, "pattern {pattern}");
}
let missing = engine
.search_with(SearchOptions {
query: "*.img".into(),
limit: 10,
..SearchOptions::default()
})
.unwrap();
assert_eq!(missing.total, 0);
}
#[test]
fn invalid_regex_is_rejected() {
let directory = TempDir::new().unwrap();
@@ -388,7 +415,7 @@ mod tests {
index_records(&engine, &[record()]);
let result = engine.search_with(SearchOptions {
query: "[".into(),
regex: true,
mode: SearchMode::Regex,
limit: 10,
..SearchOptions::default()
});
+3 -1
View File
@@ -7,7 +7,9 @@ mod query;
mod schema;
pub use indexer::SearchEngine;
pub use query::{AvailabilitySummary, SearchHit, SearchOptions, SearchPage, SearchSort};
pub use query::{
AvailabilitySummary, SearchHit, SearchMode, SearchOptions, SearchPage, SearchSort,
};
#[derive(Debug, thiserror::Error)]
pub enum SearchError {
+9 -1
View File
@@ -17,10 +17,18 @@ pub enum SearchSort {
Discoveries,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchMode {
#[default]
Text,
Regex,
}
#[derive(Debug, Clone, Default)]
pub struct SearchOptions {
pub query: String,
pub regex: bool,
pub mode: SearchMode,
pub offset: usize,
pub limit: usize,
pub min_size: Option<u64>,
+1 -1
View File
@@ -50,7 +50,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
## 已实现功能
- 关键词 文件名片段和精确 infohash 搜索
- 普通关键词和名称 文件路径正则搜索
- 自动识别通配符的普通关键词搜索和名称 文件路径正则搜索
- 相关度 时间 热度 大小和发现次数排序
- 有上限的结果分页和 URL 查询恢复
- 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择
+8 -5
View File
@@ -43,6 +43,10 @@ let statsRequestActive = false
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1)
const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value))
const searchPlaceholder = computed(() => {
if (regexMode.value) return '输入正则表达式'
return '输入名称、文件名、*.iso 或 40 位 infohash'
})
const resultPageSizeOptions = [
{ value: '10', label: '10 条/页' },
{ value: '20', label: '20 条/页' },
@@ -58,12 +62,11 @@ const sortOptions: ReadonlyArray<{ value: SearchSort; label: string }> = [
{ value: 'size_desc', label: '大小降序' },
{ value: 'size_asc', label: '大小升序' },
]
function updateBrowserUrl() {
const params = new URLSearchParams()
if (submittedQuery.value) params.set('q', submittedQuery.value)
if (sort.value !== 'latest') params.set('sort', sort.value)
if (regexMode.value) params.set('regex', 'true')
if (regexMode.value) params.set('mode', 'regex')
if (offset.value) params.set('offset', String(offset.value))
history.replaceState(null, '', params.size ? `?${params}` : location.pathname)
}
@@ -185,7 +188,7 @@ onMounted(() => {
const params = new URLSearchParams(location.search)
query.value = params.get('q') ?? ''
submittedQuery.value = query.value
regexMode.value = params.get('regex') === 'true'
regexMode.value = params.get('mode') === 'regex'
const initialSort = params.get('sort') as SearchSort | null
if (initialSort && ['relevance', 'latest', 'oldest', 'heat', 'size_desc', 'size_asc', 'discoveries'].includes(initialSort)) sort.value = initialSort
else if (query.value.trim()) sort.value = 'relevance'
@@ -225,7 +228,7 @@ onBeforeUnmount(() => {
<div class="flex flex-wrap items-center gap-2 p-2">
<Search class="ml-2.5 size-4 shrink-0 text-muted-foreground" />
<label class="sr-only" for="search-query">搜索关键词</label>
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="regexMode ? '输入正则表达式' : '输入名称、文件名或 40 位 infohash'" type="search" />
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="searchPlaceholder" type="search" />
<Button size="sm" type="button" :variant="regexMode ? 'secondary' : 'ghost'" :aria-pressed="regexMode" title="正则搜索" @click="toggleRegex"><span class="font-mono">.*</span><span class="hidden sm:inline">正则</span></Button>
<AppSelect :model-value="sort" :options="sortOptions" label="结果排序" @update:model-value="changeSort" />
<Button type="submit">搜索</Button>
@@ -235,7 +238,7 @@ onBeforeUnmount(() => {
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在搜索</div>
<div v-else-if="error" class="mt-4 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border border-dashed text-center"><Server class="size-8 text-muted-foreground" /><div><p class="font-medium">无法完成搜索</p><p class="mt-1 text-sm text-muted-foreground">{{ error }}</p></div><Button variant="outline" @click="loadSearch">重试</Button></div>
<div v-else-if="page?.hits.length" class="mt-4 space-y-3"><SearchResultCard v-for="hit in page.hits" :key="hit.content_key" :hit="hit" @select="openDetail" /></div>
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或正则表达式</p></div>
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词通配符或正则表达式</p></div>
<div v-if="page && page.total > 0" class="mt-6 flex flex-wrap items-center justify-center gap-2">
<AppSelect :model-value="String(limit)" :options="resultPageSizeOptions" label="每页搜索结果数量" @update:model-value="changeResultPageSize" />
+1 -1
View File
@@ -36,7 +36,7 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise<Search
limit: String(input.limit),
sort: input.sort,
})
if (input.regex) params.set('regex', 'true')
if (input.regex) params.set('mode', 'regex')
return request<SearchPage>(`/search?${params}`, signal)
}