global-search-api/server.py

961 lines
39 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.6.0 · 给通用 AI豆包/GLM/ChatGPT/Claude用的跨仓库检索门面
铸渊 ICE-GL-ZY001 · LL-174-20260711 · D170
变更 (LL-174 / v1.6.0):
1. /handshake 端点 — AI 报到,服务器动态适配 AI 的工具和格式
2. /submit 三步写入 — 用 GET 模拟写入(开门→写内容→提交)
3. 密码每位加1变换 — AI 端做简单变换,服务器端还原验证
4. 会话锁 — 同一时间只允许一个写入操作
5. 写入超时自动清理 — 60秒未提交自动丢弃
变更 (LL-173 / v1.5.0):
1. 全端点 HTML 模式 (?format=html)
2. 死仓库自动检测
3. /status 60秒内存缓存
4. /search 超时从 60s 降到 15s
"""
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.6.0"
WRITE_PASSWORD = os.environ.get("GLOBAL_SEARCH_WRITE_PASSWORD", "") # 冰朔设的密码,从环境变量读
WRITE_SESSION_TIMEOUT = 60 # 写入会话60秒超时
# ============== 仓库注册表(原始定义) ==============
_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)}
# ============== 密码变换每位加1 ==============
def encode_password(pwd):
"""AI端用密码每位数字加1。123→234。字母不处理。"""
result = []
for ch in str(pwd):
if ch.isdigit():
result.append(str((int(ch) + 1) % 10))
else:
result.append(ch)
return "".join(result)
def decode_password(encoded):
"""服务器端用还原。每位数字减1。234→123。"""
result = []
for ch in str(encoded):
if ch.isdigit():
result.append(str((int(ch) - 1) % 10))
else:
result.append(ch)
return "".join(result)
def verify_write_password(encoded_pwd):
"""验证AI发来的变换后密码"""
if not WRITE_PASSWORD:
return False, "服务器未设置写入密码"
real_pwd = decode_password(encoded_pwd)
if real_pwd == WRITE_PASSWORD:
return True, "ok"
return False, "密码错误"
# ============== /handshake 会话表 ==============
_handshake_sessions = {} # {session_id: {"agent": "GLM", "format": "html", "max_url": 2000, "ts": ...}}
_handshake_lock = threading.Lock()
def register_handshake(agent, tools, fmt, max_url):
"""注册一个AI的能力"""
sid = f"{agent}_{int(time.time())}"
with _handshake_lock:
_handshake_sessions[sid] = {
"agent": agent,
"tools": tools,
"format": fmt,
"max_url": int(max_url) if max_url else 2000,
"ts": time.time(),
}
return sid
# ============== /submit 写入会话 ==============
_write_sessions = {} # {session_id: {"content_parts": [], "ts": ..., "repo": ...}}
_write_lock = threading.Lock()
def write_session_open(pwd_encoded, repo_id):
"""第一步:开门"""
ok, msg = verify_write_password(pwd_encoded)
if not ok:
return {"ok": False, "error": msg}
# 检查是否已有写入会话在进行
with _write_lock:
now = time.time()
# 清理过期会话
expired = [k for k, v in _write_sessions.items() if now - v["ts"] > WRITE_SESSION_TIMEOUT]
for k in expired:
del _write_sessions[k]
if _write_sessions:
return {"ok": False, "error": "有写入操作正在进行,请稍后再试"}
# 创建新会话
sid = f"write_{int(now)}"
_write_sessions[sid] = {
"content_parts": [],
"ts": now,
"repo": repo_id or DEFAULT_REPO,
}
return {"ok": True, "session": sid, "timeout": WRITE_SESSION_TIMEOUT, "message": "门已开,请发送内容"}
def write_session_content(sid, content):
"""第二步:发送内容(可多次)"""
with _write_lock:
if sid not in _write_sessions:
return {"ok": False, "error": "会话不存在或已过期,请重新开门"}
session = _write_sessions[sid]
session["ts"] = time.time() # 刷新超时
session["content_parts"].append(content)
return {"ok": True, "received": len(session["content_parts"]), "message": "内容已暂存"}
def write_session_commit(sid, filename):
"""第三步:提交写入"""
with _write_lock:
if sid not in _write_sessions:
return {"ok": False, "error": "会话不存在或已过期"}
session = _write_sessions[sid]
full_content = "".join(session["content_parts"])
repo_id = session["repo"]
del _write_sessions[sid]
# 写入仓库
repo_path = REPOS.get(repo_id, {}).get("path", "/tmp/worktree")
inbox_dir = os.path.join(repo_path, "eternal-lake-heart", "archive", "inbox")
os.makedirs(inbox_dir, exist_ok=True)
if not filename:
filename = f"submit-{int(time.time())}.txt"
if not filename.endswith((".txt", ".md", ".hdlp")):
filename += ".txt"
# 安全检查:文件名不能有路径穿越
filename = os.path.basename(filename)
full_path = os.path.join(inbox_dir, filename)
with open(full_path, "w") as f:
f.write(full_content)
return {
"ok": True,
"written": True,
"file": filename,
"repo": repo_id,
"path": os.path.relpath(full_path, repo_path),
"size": len(full_content),
"message": "写入成功,等待铸渊 commit"
}
# ============== 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,
"write_enabled": bool(WRITE_PASSWORD),
"ts": time.time(),
"note": "服务正在运行·read-only 全部免鉴权·写入用 /submit 三步握手"
})
# === /handshake: AI 报到 ===
if path == "/handshake":
agent = query.get("agent", ["unknown"])[0]
tools = query.get("tools", ["browser"])[0]
fmt = query.get("format", ["html"])[0]
max_url = query.get("max_url", ["2000"])[0]
sid = register_handshake(agent, tools, fmt, max_url)
# 根据AI的能力返回适配信息
max_content = int(max_url) if max_url and max_url.isdigit() else 2000
chunk_size = min(max_content - 200, 1800) # 留200给URL参数
if chunk_size < 200:
chunk_size = 200
return self.respond({
"ok": True,
"session": sid,
"agent": agent,
"your_format": fmt,
"your_tools": tools,
"your_max_url": max_content,
"write_chunk_size": chunk_size,
"write_steps": [
"1. GET /submit?action=open&pwd=<变换后密码>&repo=<仓ID> → 开门",
"2. GET /submit?action=write&sid=<会话ID>&content=<base64内容> → 发送内容(可多次)",
"3. GET /submit?action=commit&sid=<会话ID>&filename=<文件名> → 提交写入",
],
"password_rule": "密码每位数字加1。例如密码128515 → 发239626。字母不变。",
"message": f"报到成功,服务器已适配你的能力。写入时每段不超过{chunk_size}字符。"
}, title=f"握手: {agent}")
# === /submit: 三步写入 ===
if path == "/submit":
action = query.get("action", [""])[0]
if action == "open":
pwd_encoded = query.get("pwd", [""])[0]
repo_arg = query.get("repo", [DEFAULT_REPO])[0]
if not pwd_encoded:
return self.respond({"ok": False, "error": "缺少pwd参数", "hint": "密码每位加1后发送"})
result = write_session_open(pwd_encoded, repo_arg)
return self.respond(result, title="写入: 开门")
elif action == "write":
sid = query.get("sid", [""])[0]
content = query.get("content", [""])[0]
if not sid:
return self.respond({"ok": False, "error": "缺少sid参数"})
if not content:
return self.respond({"ok": False, "error": "缺少content参数"})
# 支持 base64 编码的内容
import base64
try:
# 尝试 base64 解码
decoded = base64.b64decode(content).decode("utf-8")
content = decoded
except Exception:
pass # 不是 base64直接用原始内容
result = write_session_content(sid, content)
return self.respond(result, title="写入: 内容")
elif action == "commit":
sid = query.get("sid", [""])[0]
filename = query.get("filename", [""])[0]
if not sid:
return self.respond({"ok": False, "error": "缺少sid参数"})
result = write_session_commit(sid, filename)
return self.respond(result, title="写入: 提交")
elif action == "status":
# 查看当前写入会话状态
with _write_lock:
now = time.time()
active = {}
for k, v in _write_sessions.items():
remaining = WRITE_SESSION_TIMEOUT - (now - v["ts"])
if remaining > 0:
active[k] = {
"parts": len(v["content_parts"]),
"repo": v["repo"],
"remaining_seconds": round(remaining, 1),
}
return self.respond({"ok": True, "active_sessions": active, "count": len(active)})
else:
return self.respond({
"ok": False,
"error": "缺少或无效的action参数",
"hint": "用 action=open/write/commit/status",
"steps": [
"1. GET /submit?action=open&pwd=<变换后密码>&repo=<仓ID>",
"2. GET /submit?action=write&sid=<会话ID>&content=<内容>",
"3. GET /submit?action=commit&sid=<会话ID>&filename=<文件名>",
]
})
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 /handshake?agent=&tools=&format=&max_url=": "AI报到·服务器动态适配(免鉴权)",
"GET /submit?action=open&pwd=&repo=": "写入第一步·开门(需密码)",
"GET /submit?action=write&sid=&content=": "写入第二步·发送内容(可多次)",
"GET /submit?action=commit&sid=&filename=": "写入第三步·提交写入",
"GET /submit?action=status": "查看写入会话状态",
"GET /status": "综合状态(60秒缓存·免鉴权)",
"GET /repos": "列出所有可用仓库(免鉴权)",
"GET /system-status?repo=": "拉 SYSTEM-STATUS(免鉴权)",
"GET /broadcast?repo=": "拉最新 BROADCAST(免鉴权)",
"GET /search?q=&top=&repo=": "全仓库文件内容搜索(免鉴权+限速)",
"GET /tree?path=&depth=&ext=&repo=": "列目录树(免鉴权+限速)",
"GET /file?path=&repo=": "读单文件(限 50KB, 免鉴权+限速)",
"POST /archive": "HLDP 回执归档(需鉴权)",
},
"repos": list(REPOS.keys()),
"default_repo": DEFAULT_REPO,
"rate_limit": f"{RATE_LIMIT} req/s per IP",
"password_rule": "写入密码每位数字加1后发送。例如密码128515→发239626。字母不变。",
"write_session_timeout": f"{WRITE_SESSION_TIMEOUT}",
"html_mode": "加 ?format=html 返回人类可读 HTML",
"new_in_1_6_0": [
"/handshake AI能力协商",
"/submit 三步GET写入 (open→write→commit)",
"密码每位加1变换",
"会话锁+60秒超时",
],
"base_url": "https://guanghubingshuo.com/global-search",
})
# === 业务查询端点(免鉴权 + 限速) ===
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()