docs-search 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docs_search/__init__.py +25 -0
- docs_search/cli.py +231 -0
- docs_search/core.py +215 -0
- docs_search/web.py +450 -0
- docs_search-1.0.0.dist-info/METADATA +146 -0
- docs_search-1.0.0.dist-info/RECORD +10 -0
- docs_search-1.0.0.dist-info/WHEEL +5 -0
- docs_search-1.0.0.dist-info/entry_points.txt +3 -0
- docs_search-1.0.0.dist-info/licenses/LICENSE +21 -0
- docs_search-1.0.0.dist-info/top_level.txt +1 -0
docs_search/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""docs_search: 零依赖本地文档搜索引擎(SQLite 索引,CLI + Web UI)"""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .core import ( # noqa: F401
|
|
6
|
+
ENV_DB_PATH,
|
|
7
|
+
ENV_DOCS_DIR,
|
|
8
|
+
MAX_UPLOAD_BYTES,
|
|
9
|
+
build_db,
|
|
10
|
+
dedupe_target,
|
|
11
|
+
ensure_index,
|
|
12
|
+
files_hash,
|
|
13
|
+
get_conn,
|
|
14
|
+
load_meta,
|
|
15
|
+
meta_path,
|
|
16
|
+
need_reindex,
|
|
17
|
+
rebuild_index,
|
|
18
|
+
resolve_db_path,
|
|
19
|
+
resolve_docs_dir,
|
|
20
|
+
sanitize_filename,
|
|
21
|
+
save_meta,
|
|
22
|
+
scan_docs,
|
|
23
|
+
scan_meta,
|
|
24
|
+
win_utf8,
|
|
25
|
+
)
|
docs_search/cli.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""docs-search: 本地文档搜索引擎(零依赖,SQLite,毫秒级检索)
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
python scripts/docs-search.py index [--dir 目录]
|
|
5
|
+
python scripts/docs-search.py search "关键词" [--dir 目录]
|
|
6
|
+
python scripts/docs-search.py list [--dir 目录]
|
|
7
|
+
python scripts/docs-search.py show <path> [--dir 目录]
|
|
8
|
+
python scripts/docs-search.py status [--dir 目录]
|
|
9
|
+
python scripts/docs-search.py upload <file.md> [--dir 目录] # 上传(复制)文档
|
|
10
|
+
|
|
11
|
+
路径规则(不绑定任何本地路径):
|
|
12
|
+
文档目录: --dir > 环境变量 DOCS_SEARCH_DIR > ./docs
|
|
13
|
+
索引库: ~/.docs-search/<目录哈希>/index.db(按目录隔离,多库并存)
|
|
14
|
+
|
|
15
|
+
跨平台: Windows / macOS / Linux
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .core import (
|
|
24
|
+
MAX_UPLOAD_BYTES,
|
|
25
|
+
dedupe_target,
|
|
26
|
+
ensure_index,
|
|
27
|
+
get_conn,
|
|
28
|
+
load_meta,
|
|
29
|
+
resolve_db_path,
|
|
30
|
+
resolve_docs_dir,
|
|
31
|
+
sanitize_filename,
|
|
32
|
+
win_utf8,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 短命令别名
|
|
36
|
+
ALIAS = {"i": "index", "s": "search", "st": "status", "l": "list", "sh": "show", "u": "upload"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _paths(args):
|
|
40
|
+
docs_dir = resolve_docs_dir(getattr(args, "dir", None))
|
|
41
|
+
db_path = resolve_db_path(docs_dir, getattr(args, "db", None))
|
|
42
|
+
return docs_dir, db_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_index(args):
|
|
46
|
+
win_utf8()
|
|
47
|
+
docs_dir, db_path = _paths(args)
|
|
48
|
+
from .core import rebuild_index
|
|
49
|
+
|
|
50
|
+
n, dt = rebuild_index(docs_dir, db_path)
|
|
51
|
+
print(f"indexed {n} docs in {dt:.0f}ms")
|
|
52
|
+
print(f" docs: {docs_dir}")
|
|
53
|
+
print(f" db: {db_path}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_search(args):
|
|
57
|
+
win_utf8()
|
|
58
|
+
docs_dir, db_path = _paths(args)
|
|
59
|
+
import time
|
|
60
|
+
|
|
61
|
+
t0 = time.time()
|
|
62
|
+
n, rebuilt = ensure_index(docs_dir, db_path)
|
|
63
|
+
if rebuilt:
|
|
64
|
+
print(f"[auto] reindexed {n} docs")
|
|
65
|
+
c = get_conn(db_path)
|
|
66
|
+
q = args.query.strip()
|
|
67
|
+
keywords = q.split()
|
|
68
|
+
if not keywords:
|
|
69
|
+
print('no results for ""')
|
|
70
|
+
return
|
|
71
|
+
conditions, params = [], []
|
|
72
|
+
for kw in keywords:
|
|
73
|
+
conditions.append("(title LIKE ? OR body LIKE ?)")
|
|
74
|
+
params.extend([f"%{kw}%", f"%{kw}%"])
|
|
75
|
+
sql = f"SELECT path, cat, title, body FROM docs WHERE {' AND '.join(conditions)} LIMIT ?"
|
|
76
|
+
params.append(args.limit)
|
|
77
|
+
rows = c.execute(sql, params).fetchall()
|
|
78
|
+
c.close()
|
|
79
|
+
dt = (time.time() - t0) * 1000
|
|
80
|
+
if not rows:
|
|
81
|
+
print(f'no results for "{q}"')
|
|
82
|
+
return
|
|
83
|
+
print(f'"{q}" -> {len(rows)} results ({dt:.0f}ms)\n')
|
|
84
|
+
for path, cat, title, body in rows:
|
|
85
|
+
print(f"[{path}] {title}")
|
|
86
|
+
print(f" {body[:120].replace(chr(10), ' ')}...")
|
|
87
|
+
print()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_list(args):
|
|
91
|
+
win_utf8()
|
|
92
|
+
_docs_dir, db_path = _paths(args)
|
|
93
|
+
c = get_conn(db_path)
|
|
94
|
+
rows = c.execute("SELECT cat, path, title, size FROM docs ORDER BY cat, title").fetchall()
|
|
95
|
+
c.close()
|
|
96
|
+
if not rows:
|
|
97
|
+
print("empty")
|
|
98
|
+
return
|
|
99
|
+
print(f"total: {len(rows)} docs\n")
|
|
100
|
+
cur = None
|
|
101
|
+
for cat, path, title, size in rows:
|
|
102
|
+
if cat != cur:
|
|
103
|
+
cur = cat
|
|
104
|
+
print(f"\n### {cat}/")
|
|
105
|
+
print(f" . {path} -- {title} ({size // 1024}KB)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_show(args):
|
|
109
|
+
win_utf8()
|
|
110
|
+
_docs_dir, db_path = _paths(args)
|
|
111
|
+
c = get_conn(db_path)
|
|
112
|
+
row = c.execute("SELECT title, body, size FROM docs WHERE path=?", (args.path,)).fetchone()
|
|
113
|
+
c.close()
|
|
114
|
+
if not row:
|
|
115
|
+
print(f"not found: {args.path}")
|
|
116
|
+
return
|
|
117
|
+
title, body, size = row
|
|
118
|
+
print(f"\n{title} [{args.path}] ({size // 1024}KB)\n{body[:2000]}")
|
|
119
|
+
if len(body) > 2000:
|
|
120
|
+
print(f"\n...(total {len(body) // 1024}KB)")
|
|
121
|
+
print()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_status(args):
|
|
125
|
+
win_utf8()
|
|
126
|
+
docs_dir, db_path = _paths(args)
|
|
127
|
+
from .core import need_reindex
|
|
128
|
+
|
|
129
|
+
meta = load_meta(db_path)
|
|
130
|
+
if not db_path.exists():
|
|
131
|
+
print(f"docs: {docs_dir}")
|
|
132
|
+
print("no index yet. run: docs-search index")
|
|
133
|
+
return
|
|
134
|
+
c = get_conn(db_path)
|
|
135
|
+
count = c.execute("SELECT COUNT(*) FROM docs").fetchone()[0]
|
|
136
|
+
c.close()
|
|
137
|
+
stale = need_reindex(meta, db_path, docs_dir)
|
|
138
|
+
print(f"docs: {docs_dir}")
|
|
139
|
+
print(f"db: {db_path}")
|
|
140
|
+
print(f"records: {count}")
|
|
141
|
+
if meta:
|
|
142
|
+
print(f"updated: {meta.get('updated', '?')}")
|
|
143
|
+
print(f"stale: {'yes (search will auto-reindex)' if stale else 'no'}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_upload(args):
|
|
147
|
+
"""把一个 .md 文件复制到文档库 uploads/ 并重建索引"""
|
|
148
|
+
win_utf8()
|
|
149
|
+
docs_dir, db_path = _paths(args)
|
|
150
|
+
src = Path(args.file).expanduser().resolve()
|
|
151
|
+
if not src.exists():
|
|
152
|
+
print(f"not found: {src}")
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
if src.stat().st_size > MAX_UPLOAD_BYTES:
|
|
155
|
+
print(f"too large (max {MAX_UPLOAD_BYTES // 1024 // 1024}MB): {src.name}")
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
name = sanitize_filename(src.name)
|
|
158
|
+
if not name:
|
|
159
|
+
print(f"invalid filename: {src.name}")
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
target = dedupe_target(docs_dir, name)
|
|
162
|
+
if not target:
|
|
163
|
+
print("upload failed: cannot allocate target name")
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
target.write_bytes(src.read_bytes())
|
|
166
|
+
from .core import rebuild_index
|
|
167
|
+
|
|
168
|
+
n, dt = rebuild_index(docs_dir, db_path)
|
|
169
|
+
print(f"uploaded: {target.name} -> uploads/{target.name}")
|
|
170
|
+
print(f"reindexed {n} docs in {dt:.0f}ms")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_open(args):
|
|
174
|
+
win_utf8()
|
|
175
|
+
docs_dir, _ = _paths(args)
|
|
176
|
+
p = docs_dir / args.path.replace("\\", "/")
|
|
177
|
+
if not p.exists():
|
|
178
|
+
print(f"not found: {args.path}")
|
|
179
|
+
return
|
|
180
|
+
if sys.platform == "win32":
|
|
181
|
+
subprocess.Popen(["start", "/B", "", str(p)], shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
182
|
+
elif sys.platform == "darwin":
|
|
183
|
+
subprocess.Popen(["open", str(p)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
184
|
+
else:
|
|
185
|
+
subprocess.Popen(["xdg-open", str(p)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main():
|
|
189
|
+
p = argparse.ArgumentParser(prog="docs-search", description="docs-search: 本地文档搜索引擎(零依赖)")
|
|
190
|
+
sub = p.add_subparsers(dest="cmd")
|
|
191
|
+
|
|
192
|
+
def add(name, aliases, help_text):
|
|
193
|
+
sp = sub.add_parser(name, aliases=aliases, help=help_text)
|
|
194
|
+
sp.add_argument("--dir", default=None, help="文档根目录(默认 ./docs 或 $DOCS_SEARCH_DIR)")
|
|
195
|
+
sp.add_argument("--db", default=None, help="索引库路径(默认 ~/.docs-search/<目录哈希>/index.db)")
|
|
196
|
+
return sp
|
|
197
|
+
|
|
198
|
+
add("index", ["i"], "重建索引")
|
|
199
|
+
sp_search = add("search", ["s"], "搜索文档")
|
|
200
|
+
sp_search.add_argument("query", help="搜索关键词(多关键词 AND)")
|
|
201
|
+
sp_search.add_argument("--limit", "-n", type=int, default=8, help="结果条数(默认 8)")
|
|
202
|
+
add("list", ["l"], "列出所有文档")
|
|
203
|
+
sp_show = add("show", ["sh"], "显示文档内容")
|
|
204
|
+
sp_show.add_argument("path", help="文档相对路径(如 infra/mcp.md)")
|
|
205
|
+
add("status", ["st"], "查看索引状态")
|
|
206
|
+
sp_up = add("upload", ["u"], "上传(复制).md 文档到 uploads/ 并重建索引")
|
|
207
|
+
sp_up.add_argument("file", help="要上传的 .md 文件路径")
|
|
208
|
+
sp_open = add("open", ["o"], "用系统默认程序打开文档")
|
|
209
|
+
sp_open.add_argument("path", help="文档相对路径")
|
|
210
|
+
|
|
211
|
+
a = p.parse_args()
|
|
212
|
+
if not a.cmd:
|
|
213
|
+
p.print_help()
|
|
214
|
+
return
|
|
215
|
+
fn = {
|
|
216
|
+
"index": cmd_index,
|
|
217
|
+
"search": cmd_search,
|
|
218
|
+
"list": cmd_list,
|
|
219
|
+
"show": cmd_show,
|
|
220
|
+
"status": cmd_status,
|
|
221
|
+
"upload": cmd_upload,
|
|
222
|
+
"open": cmd_open,
|
|
223
|
+
}.get(ALIAS.get(a.cmd, a.cmd))
|
|
224
|
+
if fn:
|
|
225
|
+
fn(a)
|
|
226
|
+
else:
|
|
227
|
+
p.print_help()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|
docs_search/core.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""docs_search.core: docs-search 共享核心模块(CLI 与 Web 共用,零第三方依赖)
|
|
2
|
+
|
|
3
|
+
路径解析规则(不绑定任何本地/个人路径):
|
|
4
|
+
文档目录: --dir 参数 > 环境变量 DOCS_SEARCH_DIR > ./docs(当前工作目录下)
|
|
5
|
+
索引库: --db 参数 > 环境变量 DOCS_SEARCH_DB > ~/.docs-search/<目录哈希>/index.db
|
|
6
|
+
按文档目录哈希隔离,多个文档库可并存互不干扰。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
ENV_DOCS_DIR = "DOCS_SEARCH_DIR"
|
|
19
|
+
ENV_DB_PATH = "DOCS_SEARCH_DB"
|
|
20
|
+
DEFAULT_DOCS_DIRNAME = "docs"
|
|
21
|
+
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 上传体积上限 10MB
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def win_utf8():
|
|
25
|
+
"""Windows 控制台 GBK 兼容: 强制 UTF-8 输出"""
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
28
|
+
import io
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
32
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
33
|
+
except Exception: # noqa: BLE001, S110 -- 控制台包装失败时静默降级
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_docs_dir(explicit=None):
|
|
38
|
+
"""解析文档根目录: --dir > DOCS_SEARCH_DIR > ./docs"""
|
|
39
|
+
if explicit:
|
|
40
|
+
p = Path(explicit).expanduser()
|
|
41
|
+
elif os.environ.get(ENV_DOCS_DIR):
|
|
42
|
+
p = Path(os.environ[ENV_DOCS_DIR]).expanduser()
|
|
43
|
+
else:
|
|
44
|
+
p = Path.cwd() / DEFAULT_DOCS_DIRNAME
|
|
45
|
+
return p.resolve()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_db_path(docs_dir, explicit=None):
|
|
49
|
+
"""解析索引库路径: --db > DOCS_SEARCH_DB > ~/.docs-search/<hash>/index.db"""
|
|
50
|
+
if explicit:
|
|
51
|
+
return Path(explicit).expanduser().resolve()
|
|
52
|
+
if os.environ.get(ENV_DB_PATH):
|
|
53
|
+
return Path(os.environ[ENV_DB_PATH]).expanduser().resolve()
|
|
54
|
+
key = hashlib.sha1(str(docs_dir).replace("\\", "/").lower().encode("utf-8")).hexdigest()[:12]
|
|
55
|
+
return Path.home() / ".docs-search" / key / "index.db"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def meta_path(db_path):
|
|
59
|
+
return db_path.with_name("index.meta.json")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ============================================================
|
|
63
|
+
# 扫描与索引
|
|
64
|
+
# ============================================================
|
|
65
|
+
def scan_meta(docs_dir):
|
|
66
|
+
"""扫描文档目录元信息(不读内容,用于快速变更检测): [(rel, size, mtime, cat)]"""
|
|
67
|
+
out = []
|
|
68
|
+
if not docs_dir.exists():
|
|
69
|
+
return out
|
|
70
|
+
for root, _, files in os.walk(docs_dir):
|
|
71
|
+
for f in files:
|
|
72
|
+
if not f.lower().endswith(".md"):
|
|
73
|
+
continue
|
|
74
|
+
p = Path(root) / f
|
|
75
|
+
rel = str(p.relative_to(docs_dir)).replace("\\", "/")
|
|
76
|
+
try:
|
|
77
|
+
st = p.stat()
|
|
78
|
+
out.append((rel, st.st_size, int(st.st_mtime), rel.split("/")[0]))
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scan_docs(docs_dir):
|
|
85
|
+
"""扫描并读取文档内容: [(rel, cat, title, body, size, mtime)]"""
|
|
86
|
+
out = []
|
|
87
|
+
if not docs_dir.exists():
|
|
88
|
+
return out
|
|
89
|
+
for root, _, files in os.walk(docs_dir):
|
|
90
|
+
for f in files:
|
|
91
|
+
if not f.lower().endswith(".md"):
|
|
92
|
+
continue
|
|
93
|
+
p = Path(root) / f
|
|
94
|
+
rel = str(p.relative_to(docs_dir)).replace("\\", "/")
|
|
95
|
+
try:
|
|
96
|
+
txt = p.read_text(encoding="utf-8")
|
|
97
|
+
lines = txt.split("\n")
|
|
98
|
+
title = next((l[2:].strip() for l in lines if l.startswith("# ")), f)
|
|
99
|
+
bs = next(
|
|
100
|
+
(i for i, l in enumerate(lines) if l and not l.startswith("#") and not l.startswith(">")),
|
|
101
|
+
len(lines),
|
|
102
|
+
)
|
|
103
|
+
body = "\n".join(lines[bs:]).strip()
|
|
104
|
+
st = p.stat()
|
|
105
|
+
out.append((rel, rel.split("/")[0], title, body, st.st_size, int(st.st_mtime)))
|
|
106
|
+
except (OSError, UnicodeDecodeError):
|
|
107
|
+
pass # 跳过不可读/非 UTF-8 文件
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def files_hash(entries):
|
|
112
|
+
"""按 (rel, mtime, size) 计算目录指纹,不读内容"""
|
|
113
|
+
h = hashlib.sha256()
|
|
114
|
+
for rel, size, mtime, _cat in sorted(entries, key=lambda x: x[0]):
|
|
115
|
+
h.update(f"{rel}:{mtime}:{size}".encode())
|
|
116
|
+
return h.hexdigest()[:16]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_db(db_path):
|
|
120
|
+
"""重建数据库表(幂等:DROP IF EXISTS,不依赖删除文件,DB 被占用时也能重建)"""
|
|
121
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
c = sqlite3.connect(str(db_path))
|
|
123
|
+
c.execute("DROP TABLE IF EXISTS docs")
|
|
124
|
+
c.execute("CREATE TABLE docs(path TEXT PRIMARY KEY, cat TEXT, title TEXT, body TEXT, size INT, mtime INT)")
|
|
125
|
+
c.execute("CREATE INDEX idx_cat ON docs(cat)")
|
|
126
|
+
c.execute("CREATE INDEX idx_title ON docs(title)")
|
|
127
|
+
c.commit()
|
|
128
|
+
return c
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_conn(db_path):
|
|
132
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
c = sqlite3.connect(str(db_path))
|
|
134
|
+
c.execute("SELECT 1")
|
|
135
|
+
return c
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_meta(db_path):
|
|
139
|
+
try:
|
|
140
|
+
return json.loads(meta_path(db_path).read_text(encoding="utf-8"))
|
|
141
|
+
except Exception: # noqa: BLE001 -- 元数据损坏时按无元数据处理
|
|
142
|
+
return {}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def save_meta(db_path, meta):
|
|
146
|
+
meta_path(db_path).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def need_reindex(meta, db_path, docs_dir):
|
|
150
|
+
"""需要重建: 无元数据 / 库文件丢失 / 目录指纹变化"""
|
|
151
|
+
if not meta:
|
|
152
|
+
return True
|
|
153
|
+
if not db_path.exists():
|
|
154
|
+
return True
|
|
155
|
+
return files_hash(scan_meta(docs_dir)) != meta.get("hash", "")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def rebuild_index(docs_dir, db_path):
|
|
159
|
+
"""全量重建索引,返回 (文档数, 耗时ms)。DB 被占用时抛出带提示的 RuntimeError"""
|
|
160
|
+
t0 = time.time()
|
|
161
|
+
try:
|
|
162
|
+
files = scan_docs(docs_dir)
|
|
163
|
+
c = build_db(db_path)
|
|
164
|
+
c.executemany("INSERT INTO docs VALUES(?,?,?,?,?,?)", files)
|
|
165
|
+
c.commit()
|
|
166
|
+
c.close()
|
|
167
|
+
except sqlite3.OperationalError as e:
|
|
168
|
+
raise RuntimeError(f"索引库被占用,无法重建(是否有其他 docs-search 进程正在使用?): {e}") from e
|
|
169
|
+
meta = {
|
|
170
|
+
"hash": files_hash(scan_meta(docs_dir)),
|
|
171
|
+
"count": len(files),
|
|
172
|
+
"updated": datetime.now().astimezone().isoformat(),
|
|
173
|
+
}
|
|
174
|
+
save_meta(db_path, meta)
|
|
175
|
+
return len(files), (time.time() - t0) * 1000
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ensure_index(docs_dir, db_path):
|
|
179
|
+
"""确保索引存在且最新,返回 (文档数, 是否发生重建)"""
|
|
180
|
+
meta = load_meta(db_path)
|
|
181
|
+
if need_reindex(meta, db_path, docs_dir):
|
|
182
|
+
n, _ = rebuild_index(docs_dir, db_path)
|
|
183
|
+
return n, True
|
|
184
|
+
return meta.get("count", 0), False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ============================================================
|
|
188
|
+
# 上传文件名安全化
|
|
189
|
+
# ============================================================
|
|
190
|
+
def sanitize_filename(name):
|
|
191
|
+
"""清洗上传文件名: 仅保留basename、去除危险字符、必须 .md 结尾。非法返回 None"""
|
|
192
|
+
import re
|
|
193
|
+
|
|
194
|
+
name = (name or "").strip()
|
|
195
|
+
name = name.replace("\\", "/")
|
|
196
|
+
name = name.split("/")[-1] # 仅取 basename,防路径穿越
|
|
197
|
+
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name).strip(". ")
|
|
198
|
+
if not name or not name.lower().endswith(".md") or len(name) > 200:
|
|
199
|
+
return None
|
|
200
|
+
return name
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def dedupe_target(docs_dir, name):
|
|
204
|
+
"""在 docs_dir/uploads/ 下生成不重名的目标路径"""
|
|
205
|
+
updir = docs_dir / "uploads"
|
|
206
|
+
updir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
target = updir / name
|
|
208
|
+
if not target.exists():
|
|
209
|
+
return target
|
|
210
|
+
stem, ext = os.path.splitext(name)
|
|
211
|
+
for i in range(1, 1000):
|
|
212
|
+
cand = updir / f"{stem}-{i}{ext}"
|
|
213
|
+
if not cand.exists():
|
|
214
|
+
return cand
|
|
215
|
+
return None
|
docs_search/web.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""docs-search-web: 文档搜索 Web UI(零依赖,含上传接口)
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
python scripts/docs-search-web.py [文档目录] # 启动并打开浏览器
|
|
5
|
+
python scripts/docs-search-web.py --no-browser # 不打开浏览器
|
|
6
|
+
python scripts/docs-search-web.py --port 8080 --host 0.0.0.0
|
|
7
|
+
|
|
8
|
+
路径规则(不绑定任何本地路径):
|
|
9
|
+
文档目录: 命令行 [目录] > 环境变量 DOCS_SEARCH_DIR > ./docs
|
|
10
|
+
索引库: ~/.docs-search/<目录哈希>/index.db(按目录隔离)
|
|
11
|
+
|
|
12
|
+
API:
|
|
13
|
+
GET /api/stats # 统计 {count, updated, categories}
|
|
14
|
+
GET /api/search?q=关键词&cat= # 搜索
|
|
15
|
+
GET /api/list?cat= # 列出文档
|
|
16
|
+
GET /api/show?path=xxx # 文档内容
|
|
17
|
+
POST /api/upload?filename=x.md # 上传文档(raw body = 文件内容,UTF-8)
|
|
18
|
+
POST /api/delete?path=uploads/x.md # 删除 uploads/ 下已上传文档
|
|
19
|
+
|
|
20
|
+
安全: 默认仅监听 127.0.0.1;上传仅限 .md、单文件 ≤10MB、文件名已消毒。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
28
|
+
|
|
29
|
+
from .core import (
|
|
30
|
+
MAX_UPLOAD_BYTES,
|
|
31
|
+
dedupe_target,
|
|
32
|
+
ensure_index,
|
|
33
|
+
get_conn,
|
|
34
|
+
load_meta,
|
|
35
|
+
resolve_db_path,
|
|
36
|
+
resolve_docs_dir,
|
|
37
|
+
sanitize_filename,
|
|
38
|
+
win_utf8,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_PORT = 8765
|
|
42
|
+
|
|
43
|
+
# ============================================================
|
|
44
|
+
# HTML 界面
|
|
45
|
+
# ============================================================
|
|
46
|
+
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
47
|
+
<html lang="zh-CN">
|
|
48
|
+
<head>
|
|
49
|
+
<meta charset="UTF-8">
|
|
50
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
51
|
+
<title>docs-search</title>
|
|
52
|
+
<style>
|
|
53
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
54
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
55
|
+
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
|
56
|
+
h1 { text-align: center; margin-bottom: 20px; color: #1a1a1a; }
|
|
57
|
+
.search-box { display: flex; gap: 10px; margin-bottom: 20px; }
|
|
58
|
+
.search-box input { flex: 1; padding: 12px 16px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; outline: none; }
|
|
59
|
+
.search-box input:focus { border-color: #4a90d9; }
|
|
60
|
+
.search-box button { padding: 12px 24px; background: #4a90d9; color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; }
|
|
61
|
+
.search-box button:hover { background: #357abd; }
|
|
62
|
+
.btn-upload { padding: 12px 18px; background: #34a853; color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; }
|
|
63
|
+
.btn-upload:hover { background: #2d9248; }
|
|
64
|
+
.btn-upload.dragover { outline: 3px dashed #34a853; outline-offset: 2px; }
|
|
65
|
+
.stats { text-align: center; color: #666; margin-bottom: 15px; font-size: 14px; }
|
|
66
|
+
.result-item { background: white; border-radius: 8px; padding: 16px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
67
|
+
.result-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
|
68
|
+
.result-title { font-size: 16px; font-weight: 600; color: #1a73e8; margin-bottom: 6px; }
|
|
69
|
+
.result-path { font-size: 13px; color: #666; margin-bottom: 8px; }
|
|
70
|
+
.result-snippet { font-size: 14px; color: #444; line-height: 1.5; }
|
|
71
|
+
.result-cat { display: inline-block; padding: 2px 8px; background: #e8f0fe; color: #1a73e8; border-radius: 4px; font-size: 12px; margin-right: 8px; }
|
|
72
|
+
.btn-del { float: right; padding: 4px 12px; background: #fff; color: #d93025; border: 1px solid #d93025; border-radius: 6px; font-size: 12px; cursor: pointer; }
|
|
73
|
+
.btn-del:hover { background: #d93025; color: white; }
|
|
74
|
+
.loading { text-align: center; padding: 40px; color: #666; }
|
|
75
|
+
.error { text-align: center; padding: 20px; color: #d32f2f; }
|
|
76
|
+
.empty { text-align: center; padding: 40px; color: #999; }
|
|
77
|
+
.cat-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; justify-content: center; }
|
|
78
|
+
.cat-btn { padding: 6px 14px; background: white; border: 1px solid #ddd; border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
|
|
79
|
+
.cat-btn:hover, .cat-btn.active { background: #4a90d9; color: white; border-color: #4a90d9; }
|
|
80
|
+
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: #333; color: white; padding: 10px 20px; border-radius: 8px; font-size: 14px; display: none; z-index: 9; }
|
|
81
|
+
#content-area { margin-top: 20px; }
|
|
82
|
+
#content-area pre { background: #f8f9fa; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
|
83
|
+
.back-btn { display: inline-block; margin-bottom: 15px; padding: 8px 16px; background: #eee; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
|
84
|
+
.back-btn:hover { background: #ddd; }
|
|
85
|
+
</style>
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
88
|
+
<div class="container">
|
|
89
|
+
<h1>📚 docs-search</h1>
|
|
90
|
+
<div class="search-box">
|
|
91
|
+
<input type="text" id="search-input" placeholder="输入关键词搜索..." autocomplete="off">
|
|
92
|
+
<button onclick="doSearch()">搜索</button>
|
|
93
|
+
<button class="btn-upload" id="upload-btn" title="上传 .md 文档(也可拖拽到本按钮)">⬆ 上传</button>
|
|
94
|
+
<input type="file" id="file-input" accept=".md" multiple hidden>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="stats" id="stats"></div>
|
|
97
|
+
<div class="cat-list" id="cat-list"></div>
|
|
98
|
+
<div id="content-area">
|
|
99
|
+
<div class="empty">输入关键词开始搜索,或选择分类浏览</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div id="toast"></div>
|
|
103
|
+
<script>
|
|
104
|
+
let currentCat = '';
|
|
105
|
+
|
|
106
|
+
const $ = id => document.getElementById(id);
|
|
107
|
+
const escHtml = s => (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
108
|
+
|
|
109
|
+
function toast(msg, ms=2500) {
|
|
110
|
+
const t = $('toast');
|
|
111
|
+
t.textContent = msg;
|
|
112
|
+
t.style.display = 'block';
|
|
113
|
+
clearTimeout(t._timer);
|
|
114
|
+
t._timer = setTimeout(() => t.style.display = 'none', ms);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
$('search-input').addEventListener('keypress', e => { if (e.key === 'Enter') doSearch(); });
|
|
118
|
+
|
|
119
|
+
// ---- 上传(点击选择 / 拖拽 .md 文件)----
|
|
120
|
+
const upBtn = $('upload-btn');
|
|
121
|
+
upBtn.addEventListener('click', () => $('file-input').click());
|
|
122
|
+
['dragover', 'dragenter'].forEach(ev => upBtn.addEventListener(ev, e => { e.preventDefault(); upBtn.classList.add('dragover'); }));
|
|
123
|
+
['dragleave', 'drop'].forEach(ev => upBtn.addEventListener(ev, e => { e.preventDefault(); upBtn.classList.remove('dragover'); }));
|
|
124
|
+
upBtn.addEventListener('drop', e => uploadFiles([...e.dataTransfer.files]));
|
|
125
|
+
$('file-input').addEventListener('change', e => uploadFiles([...e.target.files]));
|
|
126
|
+
|
|
127
|
+
async function uploadFiles(files) {
|
|
128
|
+
const mds = files.filter(f => f.name.toLowerCase().endsWith('.md'));
|
|
129
|
+
if (!mds.length) { toast('请选择 .md 文件'); return; }
|
|
130
|
+
for (const f of mds) {
|
|
131
|
+
if (f.size > 10 * 1024 * 1024) { toast(`跳过(超过 10MB): ${f.name}`); continue; }
|
|
132
|
+
try {
|
|
133
|
+
const text = await f.text();
|
|
134
|
+
const r = await fetch('/api/upload?filename=' + encodeURIComponent(f.name),
|
|
135
|
+
{ method: 'POST', body: text });
|
|
136
|
+
const data = await r.json();
|
|
137
|
+
if (data.error) { toast(`上传失败: ${escHtml(data.error)}`); return; }
|
|
138
|
+
toast(`已上传: ${data.path}(索引 ${data.count} 篇)`);
|
|
139
|
+
} catch (e) { toast('上传失败: ' + escHtml(e.message)); return; }
|
|
140
|
+
}
|
|
141
|
+
$('file-input').value = '';
|
|
142
|
+
loadCategories();
|
|
143
|
+
if (currentCat) filterCat(currentCat);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- 统计与分类 ----
|
|
147
|
+
async function loadCategories() {
|
|
148
|
+
try {
|
|
149
|
+
const r = await fetch('/api/stats');
|
|
150
|
+
const data = await r.json();
|
|
151
|
+
$('stats').textContent = `共 ${data.count} 个文档 | 最后更新: ${data.updated}`;
|
|
152
|
+
const cats = [...new Set(data.categories || [])].sort();
|
|
153
|
+
const list = $('cat-list');
|
|
154
|
+
list.innerHTML = `<button class="cat-btn ${currentCat === '' ? 'active' : ''}" data-cat="">全部</button>`;
|
|
155
|
+
cats.forEach(c => {
|
|
156
|
+
list.innerHTML += `<button class="cat-btn ${currentCat === c ? 'active' : ''}" data-cat="${escHtml(c)}">${escHtml(c)}</button>`;
|
|
157
|
+
});
|
|
158
|
+
} catch (e) { $('stats').textContent = '加载失败'; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- 搜索 ----
|
|
162
|
+
async function doSearch() {
|
|
163
|
+
const q = $('search-input').value.trim();
|
|
164
|
+
if (!q) return;
|
|
165
|
+
const area = $('content-area');
|
|
166
|
+
area.innerHTML = '<div class="loading">搜索中...</div>';
|
|
167
|
+
try {
|
|
168
|
+
const r = await fetch('/api/search?q=' + encodeURIComponent(q) + '&cat=' + encodeURIComponent(currentCat));
|
|
169
|
+
const data = await r.json();
|
|
170
|
+
if (data.error) { area.innerHTML = '<div class="error">' + escHtml(data.error) + '</div>'; return; }
|
|
171
|
+
if (!data.results.length) { area.innerHTML = '<div class="empty">未找到相关文档</div>'; return; }
|
|
172
|
+
let html = '<div class="stats">' + data.results.length + ' 条结果</div>';
|
|
173
|
+
data.results.forEach(d => {
|
|
174
|
+
html += `<div class="result-item">
|
|
175
|
+
${d.path.startsWith('uploads/') ? `<button class="btn-del" data-del="${escHtml(d.path)}">删除</button>` : ''}
|
|
176
|
+
<div class="result-title">${escHtml(d.title)}</div>
|
|
177
|
+
<div class="result-path"><span class="result-cat">${escHtml(d.cat)}</span>${escHtml(d.path)}</div>
|
|
178
|
+
<div class="result-snippet">${escHtml(d.snippet)}...</div>
|
|
179
|
+
</div>`;
|
|
180
|
+
});
|
|
181
|
+
area.innerHTML = html;
|
|
182
|
+
} catch (e) { area.innerHTML = '<div class="error">搜索失败: ' + escHtml(e.message) + '</div>'; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- 分类浏览 ----
|
|
186
|
+
async function filterCat(cat) {
|
|
187
|
+
currentCat = cat;
|
|
188
|
+
document.querySelectorAll('.cat-btn').forEach(b => b.classList.toggle('active', b.dataset.cat === cat));
|
|
189
|
+
const area = $('content-area');
|
|
190
|
+
area.innerHTML = '<div class="loading">加载中...</div>';
|
|
191
|
+
try {
|
|
192
|
+
const r = await fetch('/api/list?cat=' + encodeURIComponent(cat));
|
|
193
|
+
const data = await r.json();
|
|
194
|
+
if (data.error) { area.innerHTML = '<div class="error">' + escHtml(data.error) + '</div>'; return; }
|
|
195
|
+
let html = '<div class="stats">' + data.docs.length + ' 个文档</div>';
|
|
196
|
+
data.docs.forEach(d => {
|
|
197
|
+
html += `<div class="result-item">
|
|
198
|
+
${d.path.startsWith('uploads/') ? `<button class="btn-del" data-del="${escHtml(d.path)}">删除</button>` : ''}
|
|
199
|
+
<div class="result-title" style="cursor:pointer" data-doc="${escHtml(d.path)}">${escHtml(d.title)}</div>
|
|
200
|
+
<div class="result-path"><span class="result-cat">${escHtml(d.cat)}</span>${escHtml(d.path)} (${(d.size/1024).toFixed(1)}KB)</div>
|
|
201
|
+
</div>`;
|
|
202
|
+
});
|
|
203
|
+
area.innerHTML = html;
|
|
204
|
+
} catch (e) { area.innerHTML = '<div class="error">加载失败</div>'; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- 文档详情 ----
|
|
208
|
+
async function showDoc(path) {
|
|
209
|
+
const area = $('content-area');
|
|
210
|
+
area.innerHTML = '<div class="loading">加载中...</div>';
|
|
211
|
+
try {
|
|
212
|
+
const r = await fetch('/api/show?' + new URLSearchParams({ path }));
|
|
213
|
+
const data = await r.json();
|
|
214
|
+
if (data.error) { area.innerHTML = '<div class="error">' + escHtml(data.error) + '</div>'; return; }
|
|
215
|
+
area.innerHTML = `<button class="back-btn" data-back="1">← 返回</button>
|
|
216
|
+
<div class="result-item">
|
|
217
|
+
${data.path.startsWith('uploads/') ? `<button class="btn-del" data-del="${escHtml(data.path)}">删除</button>` : ''}
|
|
218
|
+
<div class="result-title">${escHtml(data.title)}</div>
|
|
219
|
+
<div class="result-path"><span class="result-cat">${escHtml(data.cat)}</span>${escHtml(data.path)} (${(data.size/1024).toFixed(1)}KB)</div>
|
|
220
|
+
<pre>${escHtml(data.body)}</pre>
|
|
221
|
+
</div>`;
|
|
222
|
+
} catch (e) { area.innerHTML = '<div class="error">加载失败</div>'; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function deleteDoc(path) {
|
|
226
|
+
if (!confirm('删除 ' + path + ' ?')) return;
|
|
227
|
+
try {
|
|
228
|
+
const r = await fetch('/api/delete?path=' + encodeURIComponent(path), { method: 'POST' });
|
|
229
|
+
const data = await r.json();
|
|
230
|
+
if (data.error) { toast('删除失败: ' + escHtml(data.error)); return; }
|
|
231
|
+
toast(`已删除: ${path}(索引 ${data.count} 篇)`);
|
|
232
|
+
loadCategories();
|
|
233
|
+
filterCat(currentCat);
|
|
234
|
+
} catch (e) { toast('删除失败: ' + escHtml(e.message)); }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- 事件委托(避免内联 onclick 与转义问题)----
|
|
238
|
+
document.addEventListener('click', e => {
|
|
239
|
+
const del = e.target.closest('[data-del]');
|
|
240
|
+
if (del) { deleteDoc(del.dataset.del); return; }
|
|
241
|
+
const doc = e.target.closest('[data-doc]');
|
|
242
|
+
if (doc) { showDoc(doc.dataset.doc); return; }
|
|
243
|
+
const cat = e.target.closest('[data-cat]');
|
|
244
|
+
if (cat) { filterCat(cat.dataset.cat); return; }
|
|
245
|
+
if (e.target.closest('[data-back]')) { filterCat(currentCat); }
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
loadCategories();
|
|
249
|
+
</script>
|
|
250
|
+
</body>
|
|
251
|
+
</html>
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class DocsSearchServer(HTTPServer):
|
|
256
|
+
# Windows 下 SO_REUSEADDR 允许多进程重复 bind 同一端口,必须禁用
|
|
257
|
+
allow_reuse_address = sys.platform != "win32"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def make_handler(docs_dir, db_path):
|
|
261
|
+
class Handler(BaseHTTPRequestHandler):
|
|
262
|
+
def log_message(self, fmt, *args):
|
|
263
|
+
pass # 静默访问日志
|
|
264
|
+
|
|
265
|
+
# ---------- GET ----------
|
|
266
|
+
def do_GET(self):
|
|
267
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
268
|
+
if parsed.path in ("/", "/index.html"):
|
|
269
|
+
self._send(200, "text/html; charset=utf-8", HTML_TEMPLATE.encode("utf-8"))
|
|
270
|
+
elif parsed.path.startswith("/api/"):
|
|
271
|
+
self._api_get(parsed.path, urllib.parse.parse_qs(parsed.query))
|
|
272
|
+
else:
|
|
273
|
+
self._send(404, "text/plain; charset=utf-8", b"not found")
|
|
274
|
+
|
|
275
|
+
def _api_get(self, path, params):
|
|
276
|
+
if path == "/api/stats":
|
|
277
|
+
n, _ = ensure_index(docs_dir, db_path)
|
|
278
|
+
meta = load_meta(db_path)
|
|
279
|
+
cats = set()
|
|
280
|
+
try:
|
|
281
|
+
c = get_conn(db_path)
|
|
282
|
+
for row in c.execute("SELECT DISTINCT cat FROM docs"):
|
|
283
|
+
cats.add(row[0])
|
|
284
|
+
c.close()
|
|
285
|
+
except Exception: # noqa: BLE001, S110 -- 库不可用时分类列表降级为空
|
|
286
|
+
pass
|
|
287
|
+
self._json(200, {"count": n, "updated": meta.get("updated", "?"), "categories": sorted(cats)})
|
|
288
|
+
|
|
289
|
+
elif path == "/api/search":
|
|
290
|
+
q = params.get("q", [""])[0].strip()
|
|
291
|
+
cat = params.get("cat", [""])[0]
|
|
292
|
+
if not q:
|
|
293
|
+
self._json(200, {"error": "请输入关键词"})
|
|
294
|
+
return
|
|
295
|
+
ensure_index(docs_dir, db_path)
|
|
296
|
+
c = get_conn(db_path)
|
|
297
|
+
conditions, pargs = [], []
|
|
298
|
+
for kw in q.split():
|
|
299
|
+
conditions.append("(title LIKE ? OR body LIKE ?)")
|
|
300
|
+
pargs.extend([f"%{kw}%", f"%{kw}%"])
|
|
301
|
+
sql = f"SELECT path, cat, title, body FROM docs WHERE {' AND '.join(conditions)} LIMIT 20"
|
|
302
|
+
if cat:
|
|
303
|
+
sql += " AND cat = ?"
|
|
304
|
+
pargs.append(cat)
|
|
305
|
+
rows = c.execute(sql, pargs).fetchall()
|
|
306
|
+
c.close()
|
|
307
|
+
results = [
|
|
308
|
+
{"path": p, "cat": c, "title": t, "snippet": b[:150].replace("\n", " ")} for p, c, t, b in rows
|
|
309
|
+
]
|
|
310
|
+
self._json(200, {"results": results})
|
|
311
|
+
|
|
312
|
+
elif path == "/api/list":
|
|
313
|
+
ensure_index(docs_dir, db_path)
|
|
314
|
+
cat = params.get("cat", [""])[0]
|
|
315
|
+
c = get_conn(db_path)
|
|
316
|
+
if cat:
|
|
317
|
+
rows = c.execute("SELECT path, title, size FROM docs WHERE cat=? ORDER BY title", (cat,)).fetchall()
|
|
318
|
+
else:
|
|
319
|
+
rows = c.execute("SELECT path, title, size FROM docs ORDER BY cat, title").fetchall()
|
|
320
|
+
c.close()
|
|
321
|
+
self._json(200, {"docs": [{"path": r[0], "title": r[1], "size": r[2]} for r in rows]})
|
|
322
|
+
|
|
323
|
+
elif path == "/api/show":
|
|
324
|
+
ensure_index(docs_dir, db_path)
|
|
325
|
+
p = params.get("path", [""])[0]
|
|
326
|
+
c = get_conn(db_path)
|
|
327
|
+
row = c.execute("SELECT title, body, size, cat FROM docs WHERE path=?", (p,)).fetchone()
|
|
328
|
+
c.close()
|
|
329
|
+
if not row:
|
|
330
|
+
self._json(200, {"error": "文档不存在"})
|
|
331
|
+
return
|
|
332
|
+
self._json(200, {"title": row[0], "body": row[1], "size": row[2], "cat": row[3], "path": p})
|
|
333
|
+
else:
|
|
334
|
+
self._send(404, "text/plain; charset=utf-8", b"not found")
|
|
335
|
+
|
|
336
|
+
# ---------- POST ----------
|
|
337
|
+
def do_POST(self):
|
|
338
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
339
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
340
|
+
if parsed.path == "/api/upload":
|
|
341
|
+
self._api_upload(params)
|
|
342
|
+
elif parsed.path == "/api/delete":
|
|
343
|
+
self._api_delete(params)
|
|
344
|
+
else:
|
|
345
|
+
self._send(404, "text/plain; charset=utf-8", b"not found")
|
|
346
|
+
|
|
347
|
+
def _read_body(self):
|
|
348
|
+
try:
|
|
349
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
350
|
+
except ValueError:
|
|
351
|
+
return None, (411, {"error": "missing Content-Length"})
|
|
352
|
+
if length <= 0:
|
|
353
|
+
return None, (400, {"error": "empty body"})
|
|
354
|
+
if length > MAX_UPLOAD_BYTES:
|
|
355
|
+
return None, (413, {"error": f"文件过大(上限 {MAX_UPLOAD_BYTES // 1024 // 1024}MB)"})
|
|
356
|
+
return self.rfile.read(length), None
|
|
357
|
+
|
|
358
|
+
def _api_upload(self, params):
|
|
359
|
+
filename = sanitize_filename(params.get("filename", [""])[0])
|
|
360
|
+
if not filename:
|
|
361
|
+
self._json(400, {"error": "文件名非法(仅支持 .md,且不含路径部分)"})
|
|
362
|
+
return
|
|
363
|
+
body, err = self._read_body()
|
|
364
|
+
if err:
|
|
365
|
+
self._json(err[0], err[1])
|
|
366
|
+
return
|
|
367
|
+
try:
|
|
368
|
+
body.decode("utf-8")
|
|
369
|
+
except UnicodeDecodeError:
|
|
370
|
+
self._json(400, {"error": "文件必须是 UTF-8 文本"})
|
|
371
|
+
return
|
|
372
|
+
target = dedupe_target(docs_dir, filename)
|
|
373
|
+
if not target:
|
|
374
|
+
self._json(500, {"error": "无法分配目标文件名"})
|
|
375
|
+
return
|
|
376
|
+
target.write_bytes(body)
|
|
377
|
+
n, _ = ensure_index(docs_dir, db_path)
|
|
378
|
+
self._json(200, {"ok": True, "path": f"uploads/{target.name}", "count": n})
|
|
379
|
+
|
|
380
|
+
def _api_delete(self, params):
|
|
381
|
+
rel = params.get("path", [""])[0].replace("\\", "/")
|
|
382
|
+
uploads_root = (docs_dir / "uploads").resolve()
|
|
383
|
+
target = (docs_dir / rel).resolve()
|
|
384
|
+
# 仅允许删除 uploads/ 下的文件,且防路径穿越
|
|
385
|
+
if uploads_root not in target.parents or target == uploads_root:
|
|
386
|
+
self._json(403, {"error": "仅允许删除 uploads/ 下的文档"})
|
|
387
|
+
return
|
|
388
|
+
if not target.exists():
|
|
389
|
+
self._json(404, {"error": "文档不存在"})
|
|
390
|
+
return
|
|
391
|
+
try:
|
|
392
|
+
target.unlink()
|
|
393
|
+
except OSError as e:
|
|
394
|
+
self._json(500, {"error": f"删除失败: {e}"})
|
|
395
|
+
return
|
|
396
|
+
n, _ = ensure_index(docs_dir, db_path)
|
|
397
|
+
self._json(200, {"ok": True, "deleted": rel, "count": n})
|
|
398
|
+
|
|
399
|
+
# ---------- 输出 ----------
|
|
400
|
+
def _send(self, code, ctype, body):
|
|
401
|
+
self.send_response(code)
|
|
402
|
+
self.send_header("Content-Type", ctype)
|
|
403
|
+
self.send_header("Content-Length", str(len(body)))
|
|
404
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
405
|
+
self.end_headers()
|
|
406
|
+
self.wfile.write(body)
|
|
407
|
+
|
|
408
|
+
def _json(self, code, data):
|
|
409
|
+
self._send(code, "application/json; charset=utf-8", json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
|
410
|
+
|
|
411
|
+
return Handler
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main():
|
|
415
|
+
win_utf8()
|
|
416
|
+
parser = argparse.ArgumentParser(description="docs-search-web: 文档搜索 Web UI(含上传接口)")
|
|
417
|
+
parser.add_argument("dir", nargs="?", default=None, help="文档根目录(默认 ./docs 或 $DOCS_SEARCH_DIR)")
|
|
418
|
+
parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1,勿暴露公网)")
|
|
419
|
+
parser.add_argument("--port", "-p", type=int, default=DEFAULT_PORT, help=f"端口(默认 {DEFAULT_PORT})")
|
|
420
|
+
parser.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
|
|
421
|
+
args = parser.parse_args()
|
|
422
|
+
|
|
423
|
+
docs_dir = resolve_docs_dir(args.dir)
|
|
424
|
+
db_path = resolve_db_path(docs_dir)
|
|
425
|
+
if not docs_dir.exists():
|
|
426
|
+
print(f"错误: 文档目录不存在: {docs_dir}")
|
|
427
|
+
print(f"请指定目录参数,或设置环境变量 {('DOCS_SEARCH_DIR')}")
|
|
428
|
+
sys.exit(1)
|
|
429
|
+
|
|
430
|
+
print(f"文档目录: {docs_dir}")
|
|
431
|
+
n, updated = ensure_index(docs_dir, db_path)
|
|
432
|
+
print(f"索引就绪: {n} 个文档{'(已重建)' if updated else ''}")
|
|
433
|
+
|
|
434
|
+
server = DocsSearchServer((args.host, args.port), make_handler(docs_dir, db_path))
|
|
435
|
+
url = f"http://{'127.0.0.1' if args.host in ('0.0.0.0', '') else args.host}:{args.port}"
|
|
436
|
+
print(f"服务已启动: {url}")
|
|
437
|
+
print("按 Ctrl+C 停止")
|
|
438
|
+
if not args.no_browser:
|
|
439
|
+
import webbrowser
|
|
440
|
+
|
|
441
|
+
webbrowser.open(f"http://127.0.0.1:{args.port}")
|
|
442
|
+
try:
|
|
443
|
+
server.serve_forever()
|
|
444
|
+
except KeyboardInterrupt:
|
|
445
|
+
print("\n已停止")
|
|
446
|
+
server.shutdown()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docs-search
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zero-dependency local document search engine — SQLite index, millisecond retrieval, CLI + Web UI with upload
|
|
5
|
+
Author: ninjasin-labs
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 ninjasin-labs
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/ninjasln-labs/docs-search
|
|
29
|
+
Project-URL: Repository, https://github.com/ninjasln-labs/docs-search
|
|
30
|
+
Project-URL: Issues, https://github.com/ninjasln-labs/docs-search/issues
|
|
31
|
+
Keywords: search,sqlite,markdown,docs,cli
|
|
32
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Environment :: Web Environment
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
44
|
+
Requires-Python: >=3.10
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
License-File: LICENSE
|
|
47
|
+
Provides-Extra: test
|
|
48
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
49
|
+
Requires-Dist: ruff>=0.6; extra == "test"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# docs-search
|
|
53
|
+
|
|
54
|
+
零依赖的本地文档搜索引擎。纯 Python 标准库,SQLite 索引,毫秒级检索。
|
|
55
|
+
|
|
56
|
+
A zero-dependency local document search engine. Pure Python stdlib, SQLite index, millisecond-level retrieval.
|
|
57
|
+
|
|
58
|
+
## 特性 / Features
|
|
59
|
+
|
|
60
|
+
- **零依赖** — 纯 Python 标准库(3.10+),无需 pip install
|
|
61
|
+
- **快** — SQLite FTS 索引,检索 < 50ms
|
|
62
|
+
- **自动索引** — 搜索前自动检测文件变更并增量重建
|
|
63
|
+
- **Web UI** — 内置搜索界面 + 拖拽上传 .md 文档
|
|
64
|
+
- **多库隔离** — 不同文档目录各自独立索引,可并存
|
|
65
|
+
- **不绑定路径** — 文档目录由参数/环境变量指定,不写死任何本地路径
|
|
66
|
+
- **跨平台** — Windows / macOS / Linux
|
|
67
|
+
|
|
68
|
+
## 快速开始 / Quick Start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# 1. 索引一个文档目录(默认 ./docs,也可用 --dir 指定)
|
|
72
|
+
python scripts/docs-search.py index --dir /path/to/your/docs
|
|
73
|
+
|
|
74
|
+
# 2. 搜索
|
|
75
|
+
python scripts/docs-search.py search "关键词" --dir /path/to/your/docs
|
|
76
|
+
|
|
77
|
+
# 3. 启动 Web UI(含上传接口)
|
|
78
|
+
python scripts/docs-search-web.py /path/to/your/docs
|
|
79
|
+
# 访问 http://127.0.0.1:8765
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 路径解析规则
|
|
83
|
+
|
|
84
|
+
| 目标 | 优先级 |
|
|
85
|
+
|------|--------|
|
|
86
|
+
| 文档目录 | `--dir` 参数 > 环境变量 `DOCS_SEARCH_DIR` > `./docs` |
|
|
87
|
+
| 索引库 | `--db` 参数(CLI)> 环境变量 `DOCS_SEARCH_DB` > `~/.docs-search/<目录哈希>/index.db` |
|
|
88
|
+
|
|
89
|
+
索引库按文档目录哈希隔离——多个文档目录可以各自拥有独立索引,互不干扰。
|
|
90
|
+
|
|
91
|
+
## CLI
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python scripts/docs-search.py index [--dir DIR] # 重建索引
|
|
95
|
+
python scripts/docs-search.py search "关键词" [--dir DIR] [-n 8] # 多关键词 AND 搜索
|
|
96
|
+
python scripts/docs-search.py list [--dir DIR] # 列出所有文档
|
|
97
|
+
python scripts/docs-search.py show <path> [--dir DIR] # 显示文档内容
|
|
98
|
+
python scripts/docs-search.py status [--dir DIR] # 查看索引状态
|
|
99
|
+
python scripts/docs-search.py upload <file.md> [--dir DIR] # 复制 .md 到文档库 uploads/ 并重建索引
|
|
100
|
+
python scripts/docs-search.py open <path> [--dir DIR] # 用系统默认程序打开
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Windows 下可用 `scripts/docs-search.bat`。
|
|
104
|
+
|
|
105
|
+
## Web API
|
|
106
|
+
|
|
107
|
+
启动:`python scripts/docs-search-web.py [DIR] [--port 8765] [--host 127.0.0.1] [--no-browser]`
|
|
108
|
+
|
|
109
|
+
| 方法 | 端点 | 说明 |
|
|
110
|
+
|------|------|------|
|
|
111
|
+
| GET | `/api/stats` | 统计信息 `{count, updated, categories}` |
|
|
112
|
+
| GET | `/api/search?q=关键词&cat=` | 搜索(多关键词 AND) |
|
|
113
|
+
| GET | `/api/list?cat=` | 列出文档 |
|
|
114
|
+
| GET | `/api/show?path=x.md` | 文档内容 |
|
|
115
|
+
| POST | `/api/upload?filename=x.md` | 上传文档(raw body = UTF-8 文本) |
|
|
116
|
+
| POST | `/api/delete?path=uploads/x.md` | 删除 `uploads/` 下已上传文档 |
|
|
117
|
+
|
|
118
|
+
上传示例:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
curl -X POST "http://127.0.0.1:8765/api/upload?filename=notes.md" \
|
|
122
|
+
--data-binary @notes.md
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
上传的文件存入 `<文档目录>/uploads/` 并自动进入索引;同名自动加 `-1`、`-2` 后缀。
|
|
126
|
+
|
|
127
|
+
## 安全说明 / Security
|
|
128
|
+
|
|
129
|
+
- 服务默认仅监听 `127.0.0.1`,**请勿用 `--host 0.0.0.0` 暴露到公网**(接口无鉴权)
|
|
130
|
+
- 上传仅接受 `.md` 文件、单文件 ≤ 10MB、文件名经过消毒(防路径穿越)
|
|
131
|
+
- 删除接口仅允许操作 `uploads/` 目录内的文件
|
|
132
|
+
|
|
133
|
+
## 开发 / Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
pip install -r requirements.lock -e . # 可编辑安装 + 锁定的开发工具链
|
|
137
|
+
python scripts/verify.py # 验证链单源:ruff + pytest(单元 + CLI E2E + Web API)
|
|
138
|
+
ruff check src/ tests/ scripts/ # lint
|
|
139
|
+
git config core.hooksPath .githooks # 启用 pre-commit 验证链
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
工程结构见 [DEVELOPMENT.md](DEVELOPMENT.md),贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),AI 协作纪律见 [AGENTS.md](AGENTS.md)。English docs: [README.en.md](README.en.md)
|
|
143
|
+
|
|
144
|
+
## 许可证 / License
|
|
145
|
+
|
|
146
|
+
MIT — 见 [LICENSE](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
docs_search/__init__.py,sha256=jjUNiw2OnZ7tcftThY2zkKHDySipXT7Jh3iJvxTgG6g,477
|
|
2
|
+
docs_search/cli.py,sha256=0pgdZng8ra9fwfiEejA4ETozJWfFQatASOcfiJh8EiU,7470
|
|
3
|
+
docs_search/core.py,sha256=WtTAFsP6rdu6CcduUJ1wbXMUL3py8I9H0FCC34zZL50,7650
|
|
4
|
+
docs_search/web.py,sha256=VPtfXDYqXzTyZoq7pVqnNe-GpOVUiys1MOySiIoFHbg,20921
|
|
5
|
+
docs_search-1.0.0.dist-info/licenses/LICENSE,sha256=jUW20QNnh58muu3QgW63w0OalWUsyHq19pDEiWZU98Q,1070
|
|
6
|
+
docs_search-1.0.0.dist-info/METADATA,sha256=I5fOjSEcI8USDscc9olUDZpe9-e_hIeZSjRIgiutOz4,6282
|
|
7
|
+
docs_search-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
docs_search-1.0.0.dist-info/entry_points.txt,sha256=WpDSCPM2Bzi9yhiqeYMZJ_tAnqYURqPhTGyoUUGnvbE,92
|
|
9
|
+
docs_search-1.0.0.dist-info/top_level.txt,sha256=dceiiLHpugfmmczs4wS95Dl1dfq_CAWi7c9q-yezgB0,12
|
|
10
|
+
docs_search-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ninjasin-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
docs_search
|