From b41fa23a7716b4bea1b30df1806b21c64c7d7305 Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 10 Aug 2026 11:45:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB=E9=80=9A?= =?UTF-8?q?=E9=85=8D=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODOS.md | 1 + dht-search/README.md | 7 +- dht-search/src/api/handlers.rs | 12 ++-- dht-search/src/api/mod.rs | 20 +++++- dht-search/src/api/request.rs | 4 +- dht-search/src/bin/dht-benchmark/workload.rs | 4 +- dht-search/src/search/filter.rs | 71 ++++++++++++++++++-- dht-search/src/search/indexer.rs | 33 ++++++++- dht-search/src/search/mod.rs | 4 +- dht-search/src/search/query.rs | 10 ++- web/README.md | 2 +- web/src/App.vue | 13 ++-- web/src/lib/api.ts | 2 +- 13 files changed, 154 insertions(+), 29 deletions(-) diff --git a/TODOS.md b/TODOS.md index e141acb..3525f42 100644 --- a/TODOS.md +++ b/TODOS.md @@ -170,6 +170,7 @@ - [x] 实现简单现代并适配移动端的单页搜索界面 - [x] 接入搜索排序分页详情内容变体和磁力链接复制 - [x] 实现名称别名和文件路径的有限状态自动机正则搜索 +- [x] 在普通搜索中自动识别通配符并保留独立正则开关 - [x] 实现种子详情文件列表后端分页并限制浏览器单页节点数量 - [x] 保留种子详情顶部结构并使用 reka-ui 数字分页重构文件条目 - [x] 支持用户选择并持久化文件列表每页数量 diff --git a/dht-search/README.md b/dht-search/README.md index a0998c6..0fce526 100644 --- a/dht-search/README.md +++ b/dht-search/README.md @@ -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®ex=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` diff --git a/dht-search/src/api/handlers.rs b/dht-search/src/api/handlers.rs index 0583bd3..948d053 100644 --- a/dht-search/src/api/handlers.rs +++ b/dht-search/src/api/handlers.rs @@ -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, diff --git a/dht-search/src/api/mod.rs b/dht-search/src/api/mod.rs index 4519d85..f58cda4 100644 --- a/dht-search/src/api/mod.rs +++ b/dht-search/src/api/mod.rs @@ -172,7 +172,7 @@ mod tests { .clone() .oneshot( Request::builder() - .uri("/search?q=example.%2Amovie®ex=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®ex=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(), ) diff --git a/dht-search/src/api/request.rs b/dht-search/src/api/request.rs index 85f53c0..f19eee1 100644 --- a/dht-search/src/api/request.rs +++ b/dht-search/src/api/request.rs @@ -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")] diff --git a/dht-search/src/bin/dht-benchmark/workload.rs b/dht-search/src/bin/dht-benchmark/workload.rs index 224419d..8742230 100644 --- a/dht-search/src/bin/dht-benchmark/workload.rs +++ b/dht-search/src/bin/dht-benchmark/workload.rs @@ -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() }, diff --git a/dht-search/src/search/filter.rs b/dht-search/src/search/filter.rs index b60c288..db81982 100644 --- a/dht-search/src/search/filter.rs +++ b/dht-search/src/search/filter.rs @@ -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, Se )?)) } +fn wildcard_query(pattern: &str, fields: SearchFields) -> Result, 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 { 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")); + } +} diff --git a/dht-search/src/search/indexer.rs b/dht-search/src/search/indexer.rs index c8a627a..09e8a35 100644 --- a/dht-search/src/search/indexer.rs +++ b/dht-search/src/search/indexer.rs @@ -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() }); diff --git a/dht-search/src/search/mod.rs b/dht-search/src/search/mod.rs index 1aab444..6e297d0 100644 --- a/dht-search/src/search/mod.rs +++ b/dht-search/src/search/mod.rs @@ -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 { diff --git a/dht-search/src/search/query.rs b/dht-search/src/search/query.rs index 3f95b76..212b055 100644 --- a/dht-search/src/search/query.rs +++ b/dht-search/src/search/query.rs @@ -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, diff --git a/web/README.md b/web/README.md index 7d9df6d..f07077a 100644 --- a/web/README.md +++ b/web/README.md @@ -50,7 +50,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需 ## 已实现功能 - 关键词 文件名片段和精确 infohash 搜索 -- 普通关键词和名称 文件路径正则搜索 +- 自动识别通配符的普通关键词搜索和名称 文件路径正则搜索 - 相关度 时间 热度 大小和发现次数排序 - 有上限的结果分页和 URL 查询恢复 - 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择 diff --git a/web/src/App.vue b/web/src/App.vue index 20ecab6..4219cda 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -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(() => {
- + @@ -235,7 +238,7 @@ onBeforeUnmount(() => {
正在搜索

无法完成搜索

{{ error }}

-

没有找到匹配内容

尝试其他关键词或正则表达式

+

没有找到匹配内容

尝试其他关键词、通配符或正则表达式

diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1656ba4..cd02aac 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -36,7 +36,7 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise(`/search?${params}`, signal) }