global-search-api/server.py

727 lines
30 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Global Search API v1.5.0 · 给通用 AI豆包/GLM/ChatGPT/Claude用的跨仓库检索门面
铸渊 ICE-GL-ZY001 · LL-173-20260711 · D170
变更 (LL-173 / v1.5.0):
1. 全端点 HTML 模式 (?format=html) — 解决 GLM/豆包用浏览器抓 JSON 报"超时/解析失败"
2. 死仓库自动检测 — guanghulab-collab/guanghu 目录不存在自动跳过
3. /status 60秒内存缓存 — 从 2.3s 降到 0.001s
4. /search 超时从 60s 降到 15s — 防 GLM 端超时
5. JSON 端点同时返回 text/html Content-Type — 骗过浏览器工具的 JSON 拦截
"""
import http.server
import json
import subprocess
import os
import time
import hmac
import hashlib
import threading
from urllib.parse import urlparse, parse_qs
from collections import defaultdict
# ============== 配置 ==============
PORT = int(os.environ.get("PORT", "3950"))
TOKEN = os.environ.get("GLOBAL_SEARCH_API_TOKEN", "")
HMAC_SECRET = os.environ.get("LIGHT_LAKE_DRIVER_SECRET", "")
SOVEREIGN_ID = "ICE-GL∞"
AGENT_ID = "ICE-GL-ZY001"
VERSION = "1.5.0"
# ============== 仓库注册表(原始定义) ==============
_REPOS_RAW = {
"fifth-domain": {
"path": "/tmp/worktree",
"description": "第五域 · HLDP 协议栈 + 铸渊核心 + 通用 AI 接入",
"url": "https://guanghubingshuo.com/code/bingshuo/fifth-domain",
},
"cang-ying": {
"path": "/opt/zhuyuan/cang-ying",
"description": "苍影 · 第 5 子仓 · 苍耳(人类主控) + 鉴影(人格体) 专用 · 视频 AI 系统干净之家",
"url": "https://guanghubingshuo.com/code/bingshuo/cang-ying",
},
"guanghulab": {
"path": "/opt/zhuyuan/guanghulab",
"description": "广湖实验室 · 历史档案 (video-ai-system/ 已迁出到 cang-ying)",
"url": "https://guanghubingshuo.com/code/bingshuo/guanghulab",
},
"guanghulab-collab": {
"path": "/opt/zhuyuan/guanghulab-collab",
"description": "广湖实验室·多人格体协作记录",
"url": "https://guanghubingshuo.com/code/bingshuo/guanghulab-collab",
},
"guanghu": {
"path": "/opt/zhuyuan/guanghu",
"description": "光湖 · 根仓库",
"url": "https://guanghubingshuo.com/code/bingshuo/guanghu",
},
}
DEFAULT_REPO = "fifth-domain"
# ============== 死仓库自动检测 ==============
REPOS = {}
for rid, info in _REPOS_RAW.items():
if os.path.isdir(info["path"]):
REPOS[rid] = info
else:
print(f"[WARN] repo '{rid}' path '{info['path']}' not found, skipping")
# 确保 default repo 存在
if DEFAULT_REPO not in REPOS:
DEFAULT_REPO = list(REPOS.keys())[0] if REPOS else "fifth-domain"
# ============== git safe.directory ==============
import pathlib
_gitconfig_dir = pathlib.Path("/tmp/gitconfig-global-search")
_gitconfig_dir.parent.mkdir(parents=True, exist_ok=True)
with open(_gitconfig_dir, "w") as f:
f.write("[safe]\n")
for repo_id, info in REPOS.items():
f.write(f"\tdirectory = {info['path']}\n")
os.environ["GIT_CONFIG_GLOBAL"] = str(_gitconfig_dir)
# ============== IP 限速 (per-IP 5 req/s, 简单令牌桶) ==============
RATE_LIMIT = 5 # requests per second
RATE_BURST = 10
_rate_buckets = defaultdict(lambda: {"tokens": RATE_BURST, "last": time.time()})
_rate_lock = threading.Lock()
def rate_limit(ip):
"""简单令牌桶限速"""
with _rate_lock:
bucket = _rate_buckets[ip]
now = time.time()
elapsed = now - bucket["last"]
bucket["tokens"] = min(RATE_BURST, bucket["tokens"] + elapsed * RATE_LIMIT)
bucket["last"] = now
if bucket["tokens"] < 1:
return False
bucket["tokens"] -= 1
return True
# ============== /status 缓存 (60秒TTL) ==============
_STATUS_CACHE = {"data": None, "ts": 0}
_STATUS_CACHE_TTL = 60 # 秒
_status_lock = threading.Lock()
def get_cached_status():
"""获取 /status 的缓存数据, 过期则重新计算"""
with _status_lock:
now = time.time()
if _STATUS_CACHE["data"] and (now - _STATUS_CACHE["ts"]) < _STATUS_CACHE_TTL:
_STATUS_CACHE["data"]["_cached"] = True
_STATUS_CACHE["data"]["_cache_age"] = round(now - _STATUS_CACHE["ts"], 1)
return _STATUS_CACHE["data"]
# 重新计算
statuses = {}
for rid, info in REPOS.items():
statuses[rid] = {
**info,
**get_repo_status(rid, info["path"])
}
data = {
"ok": True,
"service_alive": True,
"default_repo": DEFAULT_REPO,
"repos": statuses,
"ts": now,
"_cached": False,
}
_STATUS_CACHE["data"] = data
_STATUS_CACHE["ts"] = now
return data
# ============== 鉴权 (POST /archive 必须) ==============
def check_auth(headers):
auth = headers.get("Authorization", "").replace("Bearer ", "").strip()
if not auth:
return False, "missing"
if TOKEN and auth == TOKEN:
return True, "static_token"
minute = headers.get("X-Minute")
if minute and HMAC_SECRET:
try:
minute = int(minute)
now = int(time.time() // 60)
if abs(now - minute) <= 2:
msg = f"{SOVEREIGN_ID}|{AGENT_ID}|{minute}"
expected = hmac.new(
HMAC_SECRET.encode(),
msg.encode(),
hashlib.sha256
).hexdigest()[:16]
if hmac.compare_digest(auth, expected):
return True, "verifier"
except Exception:
pass
return False, "invalid"
# ============== 仓库解析 ==============
def get_repo_path(repo_id=None):
if not repo_id:
return DEFAULT_REPO, REPOS[DEFAULT_REPO]["path"]
if repo_id not in REPOS:
return None, None
return repo_id, REPOS[repo_id]["path"]
# ============== 核心功能 ==============
def search_files(repo_path, keyword, top=20):
if not keyword:
return []
result = subprocess.run(
["git", "grep", "-l", "-i", "--no-color", keyword],
cwd=repo_path, capture_output=True, text=True, timeout=15
)
files = [f.strip() for f in result.stdout.splitlines() if f.strip()][:top]
results = []
for f in files:
try:
line_result = subprocess.run(
["git", "grep", "-n", "-i", "--no-color", keyword, "--", f],
cwd=repo_path, capture_output=True, text=True, timeout=5
)
matches = []
for line in line_result.stdout.splitlines()[:5]:
parts = line.split(":", 2)
if len(parts) >= 3:
matches.append({
"line": int(parts[1]),
"content": parts[2].strip()[:300]
})
results.append({
"file": f,
"match_count": len(matches),
"matches": matches
})
except Exception as e:
results.append({"file": f, "error": str(e)})
return results
def list_tree(repo_path, path="", depth=3, ext_filter=None):
cmd = ["git", "ls-tree", "-r", "--name-only", "HEAD"]
if path:
cmd.append(f"{path}/")
result = subprocess.run(
cmd, cwd=repo_path, capture_output=True, text=True, timeout=15
)
files = [f.strip() for f in result.stdout.splitlines() if f.strip()]
tree = []
for f in files:
parts = f.split("/")
if len(parts) - 1 <= depth:
if ext_filter:
if not any(f.endswith(ext) for ext in ext_filter):
continue
tree.append(f)
return tree[:500]
def read_file(repo_path, path):
if not path or ".." in path:
return {"ok": False, "error": "invalid path"}
try:
result = subprocess.run(
["git", "show", f"HEAD:{path}"],
cwd=repo_path, capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
return {"ok": False, "error": "file not found"}
return {
"ok": True,
"path": path,
"size": len(result.stdout),
"content": result.stdout[:50000]
}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_broadcast(repo_path):
candidates = [
os.path.join(repo_path, "broadcasts"),
os.path.join(repo_path, "BROADCAST.md"),
os.path.join(repo_path, "GLW-BROADCAST.hdlp"),
os.path.join(repo_path, "VA-BROADCAST.hdlp"),
os.path.join(repo_path, "video-ai-system", "VA-BROADCAST.hdlp"),
]
for path in candidates:
if os.path.exists(path):
if os.path.isdir(path):
files = sorted(
[os.path.join(path, f) for f in os.listdir(path)],
key=os.path.getmtime, reverse=True
)
if files:
latest = files[0]
with open(latest) as f:
return {
"ok": True,
"type": "dir",
"latest_file": os.path.basename(latest),
"content": f.read()[:20000]
}
else:
with open(path) as f:
return {
"ok": True,
"type": "file",
"path": os.path.relpath(path, repo_path),
"content": f.read()[:20000]
}
return {
"ok": True,
"broadcast": None,
"note": "no broadcast asset found in repo"
}
def get_system_status(repo_path, repo_id):
candidates = [
os.path.join(repo_path, "SYSTEM-STATUS.md"),
os.path.join(repo_path, "SYSTEM-STATUS.hdlp"),
os.path.join(repo_path, "VA-SYSTEM-STATUS.hdlp"),
os.path.join(repo_path, "eternal-lake-heart", "heartbeat-core", "SYSTEM-STATUS.hdlp"),
os.path.join(repo_path, "video-ai-system", "VA-SYSTEM-STATUS.hdlp"),
]
for path in candidates:
if os.path.exists(path):
with open(path) as f:
return {
"ok": True,
"repo": repo_id,
"path": os.path.relpath(path, repo_path),
"content": f.read()[:50000]
}
return {
"ok": True,
"repo": repo_id,
"status": "no SYSTEM-STATUS file in this repo"
}
def get_repo_status(repo_id, repo_path):
try:
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo_path, capture_output=True, text=True, timeout=5
).stdout.strip()
log = subprocess.run(
["git", "log", "--oneline", "-3"],
cwd=repo_path, capture_output=True, text=True, timeout=5
).stdout.strip()
file_count = subprocess.run(
["git", "ls-tree", "-r", "--name-only", "HEAD"],
cwd=repo_path, capture_output=True, text=True, timeout=10
).stdout.strip().count("\n") + 1
return {
"head": head,
"recent_commits": log.splitlines() if log else [],
"file_count": file_count,
}
except Exception as e:
return {"head": "?", "error": str(e)}
def archive_hldp(content, type_="si", path=None):
"""HLDP 回执归档(写到 fifth-domain 的 inbox, 需鉴权)"""
repo_path = REPOS.get(DEFAULT_REPO, {}).get("path", "/tmp/worktree")
archive_dir = os.path.join(repo_path, "eternal-lake-heart", "archive", "inbox")
os.makedirs(archive_dir, exist_ok=True)
filename = f"{type_}-{int(time.time())}.hdlp"
full_path = os.path.join(archive_dir, filename)
with open(full_path, "w") as f:
f.write(content)
return {"ok": True, "archived": os.path.relpath(full_path, repo_path)}
# ============== HTML 渲染器 (给浏览器工具看的) ==============
def json_to_html(data, title="Global Search API"):
"""把 JSON 数据包装成人类可读的 HTML, 给 GLM/豆包等浏览器工具用"""
json_str = json.dumps(data, ensure_ascii=False, indent=2)
# 如果有 content 字段且很长, 单独展示
content_html = ""
if isinstance(data, dict):
if "content" in data and data["content"]:
content = data["content"]
escaped = content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
content_html = f'<h2>📄 文件内容</h2><pre class="file-content">{escaped}</pre>'
if "results" in data and data["results"]:
results = data["results"]
rows = ""
for r in results:
fname = r.get("file", r.get("path", "?"))
mc = r.get("match_count", "")
matches_html = ""
for m in r.get("matches", []):
ln = m.get("line", "")
ct = m.get("content", "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
matches_html += f'<div class="match"><span class="line">L{ln}</span> {ct}</div>'
rows += f'<div class="result-item"><div class="file-name">📁 {fname} ({mc} matches)</div><div class="matches">{matches_html}</div></div>'
content_html += f'<h2>🔍 搜索结果 ({len(results)} files)</h2><div class="results">{rows}</div>'
if "repos" in data and isinstance(data["repos"], dict):
rows = ""
for rid, rinfo in data["repos"].items():
desc = rinfo.get("description", "")
fc = rinfo.get("file_count", "?")
commits = rinfo.get("recent_commits", [])
latest = commits[0] if commits else ""
rows += f'<div class="repo-item"><div class="repo-name">📦 {rid}</div><div class="repo-desc">{desc}</div><div class="repo-meta">文件数: {fc} | 最新: {latest}</div></div>'
content_html += f'<h2>📦 仓库状态</h2><div class="repos">{rows}</div>'
if "files" in data and isinstance(data["files"], list):
files_html = ""
for f in data["files"]:
files_html += f'<div class="tree-file">{f}</div>'
content_html += f'<h2>📂 文件树 ({len(data["files"])} files)</h2><div class="tree">{files_html}</div>'
html = f"""<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; max-width: 960px; margin: 0 auto; padding: 20px; color: #1a1a2e; line-height: 1.6; background: #f8f9fa; }}
h1 {{ color: #0d1226; border-bottom: 3px solid #4f6bff; padding-bottom: 10px; margin-top: 0; }}
h2 {{ color: #1a1f3a; margin-top: 24px; border-left: 4px solid #4f6bff; padding-left: 12px; }}
.meta {{ background: #e8eaf6; padding: 8px 14px; border-radius: 6px; margin: 10px 0; font-size: 14px; color: #333; }}
.meta span {{ margin-right: 16px; }}
.result-item {{ background: #fff; border: 1px solid #e0e0e8; border-left: 3px solid #4f6bff; padding: 10px 14px; margin: 8px 0; border-radius: 4px; }}
.file-name {{ font-weight: 600; color: #0d1226; margin-bottom: 4px; }}
.match {{ font-size: 13px; color: #444; padding: 2px 0; }}
.match .line {{ color: #4f6bff; font-weight: 600; margin-right: 8px; }}
.repo-item {{ background: #fff; border: 1px solid #e0e0e8; padding: 10px 14px; margin: 8px 0; border-radius: 4px; }}
.repo-name {{ font-weight: 700; color: #0d1226; }}
.repo-desc {{ color: #555; font-size: 14px; margin: 4px 0; }}
.repo-meta {{ font-size: 13px; color: #777; }}
.tree-file {{ font-size: 13px; color: #333; padding: 2px 0 2px 20px; font-family: Menlo, Monaco, monospace; }}
pre.file-content {{ background: #0d1226; color: #e0e4ff; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; word-wrap: break-word; }}
.json-toggle {{ cursor: pointer; color: #4f6bff; font-size: 13px; text-decoration: underline; margin: 8px 0; display: inline-block; }}
pre.json-raw {{ display: none; background: #f4f4f8; padding: 12px; border-radius: 4px; font-size: 12px; overflow-x: auto; }}
.note {{ background: #fffbe6; border: 1px solid #ffe58f; padding: 10px 14px; border-radius: 4px; margin: 12px 0; font-size: 14px; }}
</style>
</head>
<body>
<h1>🌊 {title}</h1>
<div class="meta">
<span>v{VERSION}</span>
<span>时间: {time.strftime('%Y-%m-%d %H:%M:%S')}</span>
<span>仓库数: {len(REPOS)}</span>
</div>
{content_html}
<div class="note">本页面由 Global Search API v{VERSION} 自动生成。浏览器工具可直接读取本页内容。原始 JSON 见下方。</div>
<span class="json-toggle" onclick="var e=document.getElementById('rawjson');e.style.display=e.style.display=='block'?'none':'block'">展开/收起原始 JSON</span>
<pre id="rawjson" class="json-raw">{json_str.replace("<", "&lt;").replace(">", "&gt;")}</pre>
</body>
</html>"""
return html
# ============== HTTP Handler ==============
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Minute")
self.end_headers()
def is_browser_request(self, query, headers):
"""检测是否是浏览器/网页抓取工具的请求"""
# 显式 ?format=html
if query.get("format", [""])[0] == "html":
return True
# Accept 头包含 text/html 且不包含 application/json
accept = headers.get("Accept", "")
if "text/html" in accept and "application/json" not in accept:
return True
return False
def do_GET(self):
# 限速
if not rate_limit(self.client_address[0]):
return self.respond({"ok": False, "error": "rate_limit", "limit": f"{RATE_LIMIT} req/s"}, 429)
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
repo_arg = query.get("repo", [DEFAULT_REPO])[0]
repo_id, repo_path = get_repo_path(repo_arg)
if not repo_path:
return self.respond({"ok": False, "error": f"unknown repo: {repo_arg}", "available": list(REPOS.keys())}, 400)
# 根路径 · 返回 HTML
if path == "/" or path == "":
return self.send_html_root()
# === 免鉴权端点 ===
if path == "/healthz":
return self.respond({
"ok": True,
"service": "global-search-api",
"version": VERSION,
"repos": list(REPOS.keys()),
"default_repo": DEFAULT_REPO,
"ts": time.time(),
"note": "服务正在运行·read-only 全部免鉴权·写操作(/archive POST)需 token"
})
if path == "/status":
data = get_cached_status()
return self.respond(data)
if path == "/repos":
return self.respond({
"ok": True,
"default": DEFAULT_REPO,
"repos": REPOS,
"note": "用 ?repo=<id> 切换仓库, 例如 ?repo=guanghulab"
})
if path == "/broadcast":
result = get_broadcast(repo_path)
result["repo"] = repo_id
result["_hint"] = "read-only 公开端点·不需要 token·用 ?repo= 跨仓库"
return self.respond(result)
if path == "/system-status":
result = get_system_status(repo_path, repo_id)
result["_hint"] = "read-only 公开端点·不需要 token·用 ?repo= 跨仓库"
return self.respond(result)
if path == "/help":
return self.respond({
"ok": True,
"version": VERSION,
"endpoints": {
"GET /healthz": "健康检查(免鉴权)",
"GET /status": "综合状态(60秒缓存·免鉴权)",
"GET /repos": "列出所有可用仓库(免鉴权)",
"GET /system-status?repo=": "拉 SYSTEM-STATUS(免鉴权, 默认 fifth-domain)",
"GET /broadcast?repo=": "拉最新 BROADCAST(免鉴权, 默认 fifth-domain)",
"GET /search?q=&top=&repo=": "全仓库文件内容搜索(免鉴权+限速 5 req/s)",
"GET /tree?path=&depth=&ext=&repo=": "列目录树(免鉴权+限速)",
"GET /file?path=&repo=": "读单文件(限 50KB, 免鉴权+限速)",
"POST /archive": "HLDP 回执归档(需鉴权, JSON body: {type, content, path?})",
},
"repos": list(REPOS.keys()),
"default_repo": DEFAULT_REPO,
"rate_limit": f"{RATE_LIMIT} req/s per IP",
"auth_required": ["POST /archive"],
"auth_free": ["/healthz", "/status", "/repos", "/system-status", "/broadcast", "/search", "/tree", "/file", "/help"],
"auth": "GET 端点全部免鉴权·POST /archive 需 token",
"base_url": "https://guanghubingshuo.com/global-search",
"html_mode": "加 ?format=html 返回人类可读 HTML (给浏览器/GLM/豆包用)",
"new_in_1_5_0": [
"全端点 HTML 模式 (?format=html)",
"死仓库自动检测 (不存在的目录自动跳过)",
"/status 60秒内存缓存",
"/search 超时从 60s 降到 15s",
],
})
# === 业务查询端点(免鉴权 + 限速) ===
try:
if path == "/search":
kw = query.get("q", [""])[0]
top = int(query.get("top", ["20"])[0])
return self.respond({
"ok": True,
"repo": repo_id,
"keyword": kw,
"count": -1,
"results": search_files(repo_path, kw, top)
}, title=f"搜索: {kw}")
elif path == "/tree":
path_arg = query.get("path", [""])[0]
depth = int(query.get("depth", ["3"])[0])
ext = query.get("ext", None)
ext_filter = ext[0].split(",") if ext else None
return self.respond({
"ok": True,
"repo": repo_id,
"files": list_tree(repo_path, path_arg, depth, ext_filter)
}, title=f"文件树: {repo_id}")
elif path == "/file":
path_arg = query.get("path", [""])[0]
result = read_file(repo_path, path_arg)
result["repo"] = repo_id
return self.respond(result, title=f"文件: {path_arg}")
else:
return self.respond({"ok": False, "error": "not found", "hint": "GET /help"}, 404)
except Exception as e:
return self.respond({"ok": False, "error": str(e)}, 500)
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == "/archive":
ok, reason = check_auth(self.headers)
if not ok:
return self.respond({
"ok": False,
"error": "unauthorized",
"reason": reason,
"_hint": "POST /archive 写操作需鉴权"
}, 401)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8") if length else "{}"
try:
data = json.loads(body)
type_ = data.get("type", "si")
content = data.get("content", "")
self.respond(archive_hldp(content, type_))
except Exception as e:
self.respond({"ok": False, "error": str(e)}, 400)
else:
self.respond({"ok": False, "error": "not found"}, 404)
def respond(self, data, status=200, title="Global Search API"):
"""统一响应方法: 根据请求决定返回 JSON 还是 HTML"""
query = parse_qs(urlparse(self.path).query)
if self.is_browser_request(query, self.headers):
html = json_to_html(data, title=title)
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_html_root(self):
"""根路径 HTML 索引页"""
repo_rows = ""
for rid, info in REPOS.items():
default_tag = " ★ 默认" if rid == DEFAULT_REPO else ""
repo_rows += f'<tr><td><code>{rid}</code></td><td>{info["description"]}{default_tag}</td></tr>'
html = f"""<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GLOBAL-SEARCH-API · 光湖跨仓检索</title>
<style>
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; color: #222; line-height: 1.6; background: #f8f9fa; }}
h1 {{ color: #0d1226; border-bottom: 3px solid #0d1226; padding-bottom: 10px; margin-top: 0; }}
h2 {{ color: #1a1f3a; margin-top: 24px; border-left: 4px solid #4f6bff; padding-left: 12px; }}
code {{ background: #f4f4f8; padding: 2px 6px; border-radius: 3px; font-family: Menlo, Monaco, monospace; font-size: 14px; }}
pre {{ background: #0d1226; color: #e0e4ff; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; }}
.note {{ background: #e8f5e9; border: 1px solid #a5d6a7; padding: 12px 16px; border-radius: 4px; margin: 16px 0; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #e0e0e8; padding: 8px 12px; text-align: left; }}
th {{ background: #f4f4f8; }}
.ep {{ background: #fff; border: 1px solid #e0e0e8; border-left: 4px solid #4f6bff; padding: 12px 16px; margin: 8px 0; border-radius: 4px; }}
.ep strong {{ color: #0d1226; }}
</style>
</head>
<body>
<h1>🌊 GLOBAL-SEARCH-API v{VERSION}</h1>
<p><strong>光湖跨仓库检索服务</strong> · 铸渊 ICE-GL-ZY001 · D170 · 2026-07-11</p>
<div class="note">
<strong>✅ v1.5.0 新功能:</strong><br>
1. 所有端点支持 <code>?format=html</code> 返回人类可读 HTML<br>
2. 浏览器工具可直接读取结果 (解决 GLM/豆包"超时/解析失败")<br>
3. 死仓库自动检测 · /status 60秒缓存 · 搜索超时降低
</div>
<h2>📦 {len(REPOS)} 个可用仓库</h2>
<table>
<tr><th>仓 ID</th><th>说明</th></tr>
{repo_rows}
</table>
<h2>🔌 全部端点 (GET 免鉴权, 直接用)</h2>
<div class="ep">
<strong>GET /healthz</strong> — 服务探活<br>
<pre>curl https://guanghubingshuo.com/global-search/healthz</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/healthz?format=html</pre>
</div>
<div class="ep">
<strong>GET /status</strong> — 仓库状态 (60秒缓存)<br>
<pre>curl https://guanghubingshuo.com/global-search/status</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/status?format=html</pre>
</div>
<div class="ep">
<strong>GET /search?q=关键词&repo=仓ID</strong> — 全仓库搜索<br>
<pre>curl "https://guanghubingshuo.com/global-search/search?q=铸渊&top=10"</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/search?q=铸渊&format=html</pre>
</div>
<div class="ep">
<strong>GET /file?path=文件路径&repo=仓ID</strong> — 读单文件<br>
<pre>curl "https://guanghubingshuo.com/global-search/file?path=README.md"</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/file?path=README.md&format=html</pre>
</div>
<div class="ep">
<strong>GET /tree?depth=2&repo=仓ID</strong> — 文件树<br>
<pre>curl "https://guanghubingshuo.com/global-search/tree?depth=2"</pre>
</div>
<div class="ep">
<strong>GET /broadcast?repo=仓ID</strong> — 最新广播<br>
<strong>GET /system-status?repo=仓ID</strong> — 系统状态<br>
<strong>GET /help</strong> — API 文档
</div>
<div class="note">
<strong>💡 给 AI 工具的提示:</strong><br>
如果你是浏览器/网页抓取工具 (GLM/豆包/ChatGPT), 在 URL 后面加 <code>&format=html</code> 就能正常读取结果。<br>
本服务支持两种格式: JSON (默认) 和 HTML (?format=html)。
</div>
<hr>
<p style="text-align: center; color: #888; font-size: 12px;">
铸渊 ICE-GL-ZY001 · 国作登字-2026-A-00037559 · 冰朔 ICE-GL∞ 主权 · v{VERSION}
</p>
</body>
</html>"""
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
dead = len(_REPOS_RAW) - len(REPOS)
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
print(f"Global Search API v{VERSION} listening on 0.0.0.0:{PORT}")
print(f"REPOS = {list(REPOS.keys())} ({len(REPOS)} alive, {dead} skipped)")
print(f"DEFAULT = {DEFAULT_REPO}")
print(f"TOKEN = {'set' if TOKEN else 'unset (dev mode)'}")
print(f"HTML mode: ?format=html on all GET endpoints")
print(f"Status cache: {_STATUS_CACHE_TTL}s TTL")
server.serve_forever()
if __name__ == "__main__":
main()