feat(skills): 补充 gitlink-kb 配套脚本与测试

依审阅意见,将 SKILL.md 方式A引用的脚本(scripts/)与单元测试(tests/)一并纳入,使 PR 自包含可运行。
This commit is contained in:
Ct201314 2026-06-12 18:00:53 +08:00
parent 340cf543f4
commit 2fdb56d7a6
3 changed files with 664 additions and 0 deletions

View File

@ -0,0 +1,241 @@
"""GitLink 公开 API 共享客户端。
gitlink-skills-pack 下各 Skill 的脚本复用仅依赖 Python 标准库
无需第三方包便于在受限环境或 Agent 沙箱中运行
数据全部来自 GitLink 平台公开接口https://www.gitlink.org.cn/api
默认无需 token如需访问私有仓库可传入 token
所有方法均为只读不修改任何远程数据
"""
from __future__ import annotations
import base64
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
API_BASE = "https://www.gitlink.org.cn/api"
USER_AGENT = "gitlink-skills-pack/1.0 (+https://www.gitlink.org.cn)"
DEFAULT_TIMEOUT = 30
COMMIT_PAGE_SIZE = 50 # GitLink commits 接口每页硬上限
class GitLinkError(RuntimeError):
"""API 调用中不可恢复的错误。"""
class GitLinkClient:
"""GitLink 公开数据接口客户端。
带可选文件缓存同一资源重复读取不重复打网对平台友好
"""
def __init__(self, base: str = API_BASE, token: str | None = None,
timeout: int = DEFAULT_TIMEOUT, cache_dir: Path | None = None) -> None:
self.base = base.rstrip("/")
self.token = token
self.timeout = timeout
self.cache_dir = cache_dir
if self.cache_dir:
self.cache_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# 底层请求
# ------------------------------------------------------------------
def _cache_path(self, url: str) -> Path | None:
if not self.cache_dir:
return None
safe = urllib.parse.quote(url, safe="")
return self.cache_dir / f"{safe}.json"
def get(self, path: str, query: dict[str, Any] | None = None) -> Any:
"""GET 请求,返回解析后的 JSONdict/list或 None。"""
url = f"{self.base}/{path.lstrip('/')}"
if query:
url = f"{url}?{urllib.parse.urlencode(query)}"
cache_path = self._cache_path(url)
if cache_path and cache_path.exists():
return json.loads(cache_path.read_text(encoding="utf-8"))
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
raise GitLinkError(f"HTTP {exc.code}: {url}") from exc
except urllib.error.URLError as exc:
raise GitLinkError(f"网络错误: {url} -> {exc.reason}") from exc
text = raw.strip()
if not text or text in ("null", "{}", "[]"):
data: Any = None
elif text[0] in "{[":
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise GitLinkError(f"响应非 JSON: {url}") from exc
else:
raise GitLinkError(f"响应非 JSON可能是 HTML: {url}")
if cache_path is not None:
cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return data
# ------------------------------------------------------------------
# 资源访问(高层封装)
# ------------------------------------------------------------------
def repo_info(self, owner: str, repo: str) -> dict[str, Any]:
"""仓库元信息。"""
data = self.get(f"{owner}/{repo}.json")
return data if isinstance(data, dict) else {}
def issues(self, owner: str, repo: str, limit: int = 50,
page: int = 1) -> list[dict[str, Any]]:
"""Issue 列表。"""
data = self.get(f"{owner}/{repo}/issues.json", {"page": page, "limit": limit})
return _extract_list(data, ("issues",))
def issue_detail(self, owner: str, repo: str, number: int) -> dict[str, Any]:
"""单个 Issue 详情(含完整字段)。"""
data = self.get(f"{owner}/{repo}/issues/{number}.json")
return data if isinstance(data, dict) else {}
def pulls(self, owner: str, repo: str, limit: int = 50,
page: int = 1) -> list[dict[str, Any]]:
"""PR 列表。"""
data = self.get(f"{owner}/{repo}/pulls.json", {"page": page, "limit": limit})
return _extract_list(data, ("issues", "pulls"))
def contributors(self, owner: str, repo: str) -> list[dict[str, Any]]:
"""贡献者列表。"""
data = self.get(f"{owner}/{repo}/contributors.json")
return _extract_list(data, ("list",))
def commits(self, owner: str, repo: str, max_pages: int = 4) -> list[dict[str, Any]]:
"""提交列表(按需翻页,每页 50 条,以 total_count 为终止依据)。"""
out: list[dict[str, Any]] = []
total: int | None = None
for page in range(1, max(1, max_pages) + 1):
data = self.get(f"{owner}/{repo}/commits.json",
{"page": page, "limit": COMMIT_PAGE_SIZE})
if total is None and isinstance(data, dict):
total = _safe_int(data.get("total_count")) or None
page_items = _extract_list(data, ("commits",))
if not page_items:
break
out.extend(page_items)
if total is not None and len(out) >= total:
break
return out
def list_dir(self, owner: str, repo: str, path: str = "",
ref: str = "master") -> list[dict[str, Any]]:
"""列出目录下的条目(文件与子目录)。
返回的每个 entry name / path / type(file|dir) / sha / size
文件类型的 entry 还可能直接带明文 content
"""
data = self.get(f"{owner}/{repo}/sub_entries.json",
{"filepath": path, "ref": ref})
# 查询目录时 entries 为 list查询单文件时 entries 为单个 dict。
# 统一归一化为 list便于下游处理。
if isinstance(data, dict):
entries = data.get("entries")
if isinstance(entries, dict):
return [entries]
if isinstance(entries, list):
return entries
return _extract_list(data, ("entries",))
def file_content(self, owner: str, repo: str, filepath: str,
ref: str = "master") -> str | None:
"""读取单个文件的文本内容。
GitLink sub_entries 接口对单文件查询会在 entries 中返回明文 content
据此取出文件不存在或无内容时返回 None
"""
entries = self.list_dir(owner, repo, filepath, ref)
target = filepath.rsplit("/", 1)[-1]
for entry in entries:
if entry.get("type") == "file" and entry.get("name") == target:
content = entry.get("content")
if isinstance(content, str):
return content
# 回退:部分情况下单文件查询 entries 仅一项
if len(entries) == 1 and entries[0].get("type") == "file":
content = entries[0].get("content")
if isinstance(content, str):
return content
return None
def readme(self, owner: str, repo: str, ref: str = "master") -> str | None:
"""读取仓库 README自动 base64 解码)。"""
data = self.get(f"{owner}/{repo}/readme.json", {"ref": ref})
if not isinstance(data, dict):
return None
content = data.get("content")
if not isinstance(content, str):
return None
# 注意GitLink 的 readme.json 虽然 encoding 标为 base64
# 实测 content 多为明文 Markdown。先探测明文特征命中则直接返回
# 否则再尝试 base64 解码。
stripped = content.lstrip()
if stripped.startswith(("#", "<", "[", "-", "*", "", "", "")) or "\n" in content[:200]:
return content
try:
raw = base64.b64decode(content.encode("ascii", "ignore"))
decoded = raw.decode("utf-8", errors="replace")
# 解码结果若不像文本(大量替换符),回退为原文
if decoded.count("\ufffd") > len(decoded) * 0.1:
return content
return decoded
except (ValueError, TypeError):
return content
# ----------------------------------------------------------------------------
# 辅助
# ----------------------------------------------------------------------------
def _extract_list(payload: Any, keys: tuple[str, ...]) -> list[Any]:
"""从可能嵌套的响应中提取第一个匹配键的列表。"""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return value
return []
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def split_owner_repo(slug: str) -> tuple[str, str]:
"""'owner/repo' 或完整 URL 解析为 (owner, repo)。"""
s = slug.strip()
if s.startswith("http"):
parts = urllib.parse.urlparse(s).path.strip("/").split("/")
if len(parts) >= 2:
return parts[0], parts[1].replace(".git", "")
raise GitLinkError(f"无法从 URL 解析 owner/repo: {slug}")
if "/" in s:
owner, repo = s.split("/", 1)
return owner, repo.replace(".git", "")
raise GitLinkError(f"格式应为 owner/repo: {slug}")

View File

@ -0,0 +1,293 @@
"""gitlink-kb仓库知识库问答助手。
把一个 GitLink 仓库的文档READMEdocs/ 目录各类 Markdown索引起来
支持关键词检索文档地图生成与 FAQ 提取让仓库沉淀的知识可被快速查询
数据来自 GitLink 公开 API只读无需登录
用法
python kb.py --owner Gitlink --repo gitlink-cli --query "如何安装"
python kb.py --owner Gitlink --repo gitlink-cli --map
python kb.py --owner Gitlink --repo gitlink-cli --faq
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from glapi import GitLinkClient, GitLinkError, split_owner_repo
# Windows 控制台默认 GBK直接打印含 emoji 的 Markdown 会抛 UnicodeEncodeError。
# 重配置 stdout 为 UTF-8确保跨平台正常输出。
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
# 文档类文件扩展名
DOC_EXTS = (".md", ".markdown", ".rst", ".txt")
# 优先索引的文档目录
DOC_DIRS = ["", "docs", "doc", ".gitlink", "wiki"]
# ---------------------------------------------------------------------------
# 文档解析
# ---------------------------------------------------------------------------
def split_sections(markdown: str) -> list[dict[str, Any]]:
"""按 Markdown 标题切分为段落,每段含标题、层级、正文。"""
sections: list[dict[str, Any]] = []
current = {"title": "(开头)", "level": 0, "lines": []}
for line in markdown.splitlines():
m = re.match(r"^(#{1,6})\s+(.*)", line)
if m:
if current["lines"] or current["title"] != "(开头)":
sections.append(current)
current = {"title": m.group(2).strip(), "level": len(m.group(1)), "lines": []}
else:
current["lines"].append(line)
if current["lines"] or current["title"] != "(开头)":
sections.append(current)
for s in sections:
s["body"] = "\n".join(s["lines"]).strip()
del s["lines"]
return sections
def collect_docs(owner: str, repo: str, ref: str,
client: GitLinkClient, max_files: int = 20) -> list[dict[str, Any]]:
"""收集仓库中的文档文件及其内容。"""
docs: list[dict[str, Any]] = []
# README 优先
readme = client.readme(owner, repo, ref)
if readme:
docs.append({"path": "README", "content": readme})
seen = {"readme", "readme.md"}
for d in DOC_DIRS:
if len(docs) >= max_files:
break
try:
entries = client.list_dir(owner, repo, d, ref)
except GitLinkError:
continue
for e in entries:
if len(docs) >= max_files:
break
name = str(e.get("name", ""))
if e.get("type") != "file" or not name.lower().endswith(DOC_EXTS):
continue
path = f"{d}/{name}" if d else name
if path.lower() in seen:
continue
seen.add(path.lower())
# entries 里可能已带 content否则单独取
content = e.get("content")
if not content:
content = client.file_content(owner, repo, path, ref)
if content:
docs.append({"path": path, "content": content})
return docs
def build_index(docs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""把文档切分为可检索的段落索引。"""
index: list[dict[str, Any]] = []
for doc in docs:
for sec in split_sections(doc["content"]):
if not sec["body"] and sec["title"] == "(开头)":
continue
index.append({
"doc": doc["path"],
"title": sec["title"],
"level": sec["level"],
"body": sec["body"],
})
return index
# ---------------------------------------------------------------------------
# 检索
# ---------------------------------------------------------------------------
def _tokenize_query(query: str) -> list[str]:
"""把查询拆为关键词(英文按词,中文按字/词粗切)。"""
tokens = re.findall(r"[A-Za-z0-9_]+", query.lower())
# 中文:粗略按 2-gram 补充
zh = re.findall(r"[\u4e00-\u9fff]+", query)
for seg in zh:
tokens.append(seg)
for i in range(len(seg) - 1):
tokens.append(seg[i:i + 2])
return [t for t in tokens if t]
def search(index: list[dict[str, Any]], query: str, top: int = 5) -> list[dict[str, Any]]:
"""在索引中检索与查询最相关的段落(基于关键词命中计分)。"""
tokens = _tokenize_query(query)
if not tokens:
return []
scored: list[tuple[int, dict[str, Any]]] = []
for sec in index:
haystack = (sec["title"] + "\n" + sec["body"]).lower()
score = 0
for t in tokens:
score += haystack.count(t.lower())
# 标题命中加权
title_low = sec["title"].lower()
for t in tokens:
if t.lower() in title_low:
score += 5
if score > 0:
scored.append((score, sec))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, sec in scored[:top]:
snippet = sec["body"][:300].strip()
results.append({
"doc": sec["doc"], "title": sec["title"],
"score": score, "snippet": snippet,
})
return results
def extract_faq(index: list[dict[str, Any]]) -> list[dict[str, str]]:
"""从文档中提取 FAQ标题形似问题或 Q/问 开头的段落)。"""
faq: list[dict[str, str]] = []
for sec in index:
title = sec["title"]
is_question = (
"?" in title or "" in title
or title.lower().startswith(("q:", "q ", "how", "what", "why", "when", "如何", "怎么", "为什么", "是否"))
)
if is_question and sec["body"]:
faq.append({"question": title, "answer": sec["body"][:400].strip(), "doc": sec["doc"]})
return faq
def build_map(index: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
"""按文档归类标题,生成文档地图。"""
doc_map: dict[str, list[dict[str, Any]]] = {}
for sec in index:
if sec["title"] == "(开头)":
continue
doc_map.setdefault(sec["doc"], []).append(
{"title": sec["title"], "level": sec["level"]})
return doc_map
# ---------------------------------------------------------------------------
# 渲染
# ---------------------------------------------------------------------------
def render_search(owner: str, repo: str, query: str,
results: list[dict[str, Any]]) -> str:
lines = [f"# 知识库检索 — {owner}/{repo}", "", f"查询:**{query}**", ""]
if not results:
lines += ["未找到相关内容。建议换个关键词,或确认仓库是否有相关文档。", ""]
return "\n".join(lines)
for i, r in enumerate(results, 1):
lines += [
f"## {i}. {r['title']} `{r['doc']}`(相关度 {r['score']}",
"",
r["snippet"] + ("" if len(r["snippet"]) >= 300 else ""),
"",
]
return "\n".join(lines)
def render_map(owner: str, repo: str, doc_map: dict[str, list[dict[str, Any]]]) -> str:
lines = [f"# 文档地图 — {owner}/{repo}", "",
f"共索引 {len(doc_map)} 个文档。", ""]
for doc, secs in doc_map.items():
lines.append(f"## 📄 {doc}")
lines.append("")
for s in secs:
indent = " " * max(0, s["level"] - 1)
lines.append(f"{indent}- {s['title']}")
lines.append("")
return "\n".join(lines)
def render_faq(owner: str, repo: str, faq: list[dict[str, str]]) -> str:
lines = [f"# 常见问题FAQ{owner}/{repo}", ""]
if not faq:
lines += ["未从文档中识别出 FAQ 条目。", ""]
return "\n".join(lines)
for item in faq:
lines += [f"### ❓ {item['question']}", "", item["answer"], "",
f"<sub>来源:{item['doc']}</sub>", ""]
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="gitlink-kb", description="仓库知识库问答助手")
p.add_argument("--owner", help="仓库所有者")
p.add_argument("--repo", help="仓库名称")
p.add_argument("--slug", help="owner/repo 或完整 URL")
p.add_argument("--ref", default="master", help="分支或标签,默认 master")
p.add_argument("--query", help="检索关键词/问题")
p.add_argument("--map", action="store_true", help="生成文档地图")
p.add_argument("--faq", action="store_true", help="提取 FAQ")
p.add_argument("--max-files", type=int, default=20, help="最多索引的文档数")
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
p.add_argument("--output", type=Path, help="输出文件")
args = p.parse_args(argv)
if args.slug:
owner, repo = split_owner_repo(args.slug)
elif args.owner and args.repo:
owner, repo = args.owner, args.repo
else:
print("错误:请用 --owner/--repo 或 --slug 指定仓库。", file=sys.stderr)
return 2
client = GitLinkClient()
try:
docs = collect_docs(owner, repo, args.ref, client, max_files=args.max_files)
index = build_index(docs)
except GitLinkError as exc:
print(f"采集失败:{exc}", file=sys.stderr)
return 1
if args.query:
results = search(index, args.query)
out = (json.dumps({"query": args.query, "results": results}, ensure_ascii=False, indent=2)
if args.format == "json" else render_search(owner, repo, args.query, results))
elif args.map:
doc_map = build_map(index)
out = (json.dumps(doc_map, ensure_ascii=False, indent=2)
if args.format == "json" else render_map(owner, repo, doc_map))
elif args.faq:
faq = extract_faq(index)
out = (json.dumps({"faq": faq}, ensure_ascii=False, indent=2)
if args.format == "json" else render_faq(owner, repo, faq))
else:
# 默认输出索引概况
summary = {"owner": owner, "repo": repo,
"indexed_docs": len({s["doc"] for s in index}),
"sections": len(index)}
out = (json.dumps(summary, ensure_ascii=False, indent=2)
if args.format == "json"
else f"# 知识库索引 — {owner}/{repo}\n\n已索引 {summary['indexed_docs']} 个文档、"
f"{summary['sections']} 个段落。\n\n用 `--query <问题>` 检索、`--map` 看文档地图、`--faq` 提取常见问题。")
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(out, encoding="utf-8")
print(f"已写入 {args.output}")
else:
print(out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,130 @@
"""gitlink-kb 单元测试。"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import pytest
from kb import (
split_sections, build_index, search, extract_faq, build_map,
collect_docs, _tokenize_query,
)
DOC = """# 项目标题
简介段落
## 安装
使用 pip 安装这个工具
## 如何配置?
先创建配置文件然后运行 init
### 子配置
细节内容
"""
class TestSplitSections:
def test_splits_by_heading(self):
secs = split_sections(DOC)
titles = [s["title"] for s in secs]
assert "安装" in titles
assert "如何配置?" in titles
def test_level_recorded(self):
secs = split_sections(DOC)
sub = next(s for s in secs if s["title"] == "子配置")
assert sub["level"] == 3
def test_body_captured(self):
secs = split_sections(DOC)
install = next(s for s in secs if s["title"] == "安装")
assert "pip" in install["body"]
class TestTokenize:
def test_english(self):
assert "install" in _tokenize_query("how to install")
def test_chinese_bigram(self):
tokens = _tokenize_query("如何安装")
assert "如何" in tokens or "安装" in tokens
class TestSearch:
def setup_method(self):
self.index = build_index([{"path": "README", "content": DOC}])
def test_finds_install(self):
results = search(self.index, "安装")
assert results
assert any("安装" in r["title"] for r in results)
def test_english_query(self):
results = search(self.index, "pip")
assert results
assert "pip" in results[0]["snippet"]
def test_no_match(self):
assert search(self.index, "zzzznotexist") == []
def test_empty_query(self):
assert search(self.index, "") == []
def test_title_weighted(self):
# 标题命中应排在前面
results = search(self.index, "配置")
assert results
assert "配置" in results[0]["title"]
class TestFaq:
def test_extracts_question(self):
index = build_index([{"path": "README", "content": DOC}])
faq = extract_faq(index)
assert any("配置" in f["question"] for f in faq)
def test_no_question(self):
index = build_index([{"path": "x", "content": "# Title\n\nbody"}])
assert extract_faq(index) == []
class TestMap:
def test_builds_map(self):
index = build_index([{"path": "README", "content": DOC}])
doc_map = build_map(index)
assert "README" in doc_map
titles = [s["title"] for s in doc_map["README"]]
assert "安装" in titles
class TestCollectDocs:
class FakeClient:
def readme(self, owner, repo, ref):
return "# README\n\n内容"
def list_dir(self, owner, repo, path, ref):
if path == "docs":
return [{"name": "guide.md", "type": "file", "content": "# 指南\n\n步骤"}]
return []
def file_content(self, owner, repo, filepath, ref):
return None
def test_collects_readme_and_docs(self):
docs = collect_docs("o", "r", "master", self.FakeClient())
paths = {d["path"] for d in docs}
assert "README" in paths
assert any("guide" in p for p in paths)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))