diff --git a/.devops/自动构建部署.yml b/.devops/自动构建部署.yml
index 369e951..a01bbdd 100644
--- a/.devops/自动构建部署.yml
+++ b/.devops/自动构建部署.yml
@@ -20,7 +20,7 @@ workflow:
ssh_ip: '"121.41.222.73"'
ssh_port: '"22"'
ssh_user: '"root"'
- ssh_cmd: '"if [ -d /root/gitlink-cli/.git ]; then cd /root/gitlink-cli && git fetch origin && git checkout master && git reset --hard origin/master; else rm -rf /root/gitlink-cli && git clone https://gitlink.org.cn/whale_hihihi/gitlink-cli.git /root/gitlink-cli && cd /root/gitlink-cli && git checkout master; fi && docker stop gitlink-cli 2>/dev/null; docker rm gitlink-cli 2>/dev/null; docker rmi gitlink-cli:latest 2>/dev/null; docker build --no-cache -t gitlink-cli:latest . && docker run -d --name gitlink-cli gitlink-cli:latest && echo Deploy success"'
+ ssh_cmd: '"if [ -d /root/gitlink-cli/.git ]; then cd /root/gitlink-cli && git fetch origin && git checkout master && git reset --hard origin/master; else rm -rf /root/gitlink-cli && git clone https://gitlink.org.cn/whale_hihihi/gitlink-cli.git /root/gitlink-cli && cd /root/gitlink-cli && git checkout master; fi && docker stop gitlink-cli 2>/dev/null; docker rm gitlink-cli 2>/dev/null; docker rmi gitlink-cli:latest 2>/dev/null; docker build --no-cache -t gitlink-cli:latest . && docker run -d --name gitlink-cli -p 8080:8080 -e GITLINK_TOKEN=((gitlink_token.token)) gitlink-cli:latest && echo Deploy success"'
needs:
- start
- ref: ssh_cmd_1
diff --git a/.gitignore b/.gitignore
index 49b26ae..cef5673 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
docs/
doc/
-# Python(demo 网页后端)
+# Pythondemo ҳˣ
__pycache__/
*.pyc
demo/bin/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 6a62c31..7ca321f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,36 @@
+# ============================================================
+# 多阶段构建:gitlink-cli 子赛题四网页终端
+# ============================================================
+# 阶段1 builder —— Go 静态编译
+# ============================================================
FROM golang:1.26-alpine AS builder
+
ENV GOPROXY=https://goproxy.cn,direct
-WORKDIR /app
+WORKDIR /src
+
+# 先拷依赖清单,利用 Docker 层缓存
COPY go.mod go.sum ./
RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o gitlink-cli .
-FROM alpine:latest
-RUN apk --no-cache add ca-certificates git
+COPY . .
+# modernc.org/sqlite 是 pure-Go,CGO_ENABLED=0 即可编译纯静态二进制
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/gitlink-cli .
+
+# ============================================================
+# 阶段2 runtime —— Python + Go 二进制
+# ============================================================
+FROM python:3.12-slim
+
+# Go CLI 放入 PATH
+COPY --from=builder /out/gitlink-cli /usr/local/bin/gitlink-cli
+
+# 科研算法层:先拷 requirements.txt 安装依赖(利用层缓存),再拷源码
+COPY scripts/research/requirements.txt /app/scripts/research/requirements.txt
+RUN pip install --no-cache-dir -r /app/scripts/research/requirements.txt
+COPY scripts/research/ /app/scripts/research/
+
WORKDIR /app
-COPY --from=builder /app/gitlink-cli /usr/local/bin/gitlink-cli
-ENTRYPOINT ["gitlink-cli"]
+
+# 子赛题四网页终端 HTTP 服务
+EXPOSE 8080
+ENTRYPOINT ["gitlink-cli", "server", "--port", "8080", "--research-dir", "/app/scripts/research", "--work-dir", "/app/research-output"]
diff --git a/README.md b/README.md
index 289eb16..e5e4318 100644
--- a/README.md
+++ b/README.md
@@ -104,7 +104,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
-| 🐛 Issue | Create, update, close, batch close, comment on issues |
+| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
@@ -114,6 +114,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
+| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
| 👤 User | View user profiles and info |
@@ -273,6 +274,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
+### Wiki Management
+
+```bash
+# List wiki pages (table of contents)
+gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
+
+# View a wiki page by page name
+gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
+
+# Create a wiki page
+gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
+ -n getting-started -t "Getting Started" -c "# Getting Started Guide"
+
+# Update a wiki page title and/or content
+gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title"
+gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content"
+gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content"
+
+# Delete a wiki page
+gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
+```
+
### Member Management
```bash
@@ -322,6 +345,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# Batch close issues from a CSV file
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
+# Preview batch metadata update by API issue IDs
+# Note: --ids uses API issue IDs, not web URL issue numbers.
+gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
+
+# Destructive batch delete requires both dry-run first and --yes for real execution
+gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
+gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
+
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
@@ -707,7 +738,7 @@ See [skills/README.md](./skills/README.md) for details.
|-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
-| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
+| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ce6f964..6a8879d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -104,7 +104,7 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
-| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
+| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
@@ -113,6 +113,7 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
+| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
@@ -284,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
+### Wiki 管理
+
+```bash
+# 列出 Wiki 页面(目录结构)
+gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
+
+# 查看 Wiki 页面
+gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
+
+# 创建 Wiki 页面
+gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
+ -n getting-started -t "快速开始" -c "# 快速开始指南"
+
+# 更新 Wiki 页面标题和/或内容
+gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
+gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
+gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
+
+# 删除 Wiki 页面
+gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
+```
+
### 成员管理
```bash
@@ -333,6 +356,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# 从 CSV 文件批量关闭 Issue
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
+# 按 API issue id 预览批量更新元数据
+# 注意:--ids 是 API issue id,不是网页 URL 中的 Issue 编号。
+gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
+
+# 危险批量删除必须先 dry-run,真实执行还要显式 --yes
+gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
+gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
+
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
@@ -581,7 +612,7 @@ git push gitlink
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) |
-| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
+| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
diff --git a/cmd/root.go b/cmd/root.go
index 75f8532..96a39c7 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -14,6 +14,7 @@ import (
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
+ serverCmd "github.com/gitlink-org/gitlink-cli/cmd/server"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
@@ -58,6 +59,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
+ rootCmd.AddCommand(serverCmd.NewServerCmd())
rootCmd.AddCommand(newVersionCmd(version, tr))
shortcuts.RegisterAll(rootCmd, tr)
diff --git a/cmd/server/server.go b/cmd/server/server.go
new file mode 100644
index 0000000..9ac4298
--- /dev/null
+++ b/cmd/server/server.go
@@ -0,0 +1,293 @@
+// Package server 提供子赛题四的「网页终端」演示服务(子赛题四·Phase 7)。
+//
+// 在云服务器上 `gitlink-cli server` 启动一个轻量 HTTP 服务:前端按 7 个科研场景按钮
+// 触发后端运行对应的 Python 算法脚本(scripts/research/*.py),并把 JSON / Mermaid /
+// 报告等产物回显/渲染。所有数据仍由 gitlink-cli 获取,本服务只做命令编排与产物转发。
+//
+// 安全:仅接受 7 个固定场景 + owner/repo/keyword 结构化参数(非任意 shell);
+// 若设置 --token / DEMO_TOKEN 环境变量,则请求需带 X-Demo-Token 头匹配。
+package server
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+//go:embed static/*
+var staticFS embed.FS
+
+const defaultPort = 8080
+
+// 场景定义:前端按钮 ↔ 后端 Python 脚本。
+type scenarioDef struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Desc string `json:"desc"`
+ Script string `json:"script"`
+ Needs []string `json:"needs"` // 需要的输入: "owner","repo","keyword"
+ Timeout int `json:"timeout_s"`
+}
+
+var scenarios = []scenarioDef{
+ {Key: "s1", Label: "S1 仓库洞悉", Desc: "科研项目演进谱系 + 创新点", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180},
+ {Key: "s2", Label: "S2 知识图谱", Desc: "科研领域知识图谱(networkx)", Script: "graph_build.py", Needs: []string{"keyword"}, Timeout: 240},
+ {Key: "s3", Label: "S3 合规复现", Desc: "许可证/密钥/复现性检查", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120},
+ {Key: "s4", Label: "S4 协作匹配", Desc: "学者×缺口 智能匹配", Script: "match.py", Needs: []string{"owner", "repo"}, Timeout: 180},
+ {Key: "s5", Label: "S5 进度预警", Desc: "周报 + 风险预警", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180},
+ {Key: "s6", Label: "S6 成果可视化", Desc: "交互图表(plotly)", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240},
+ {Key: "hotspot", Label: "🔥 热点追踪", Desc: "科研热点全景观测:飙升项目+活跃讨论+主题热度+学者团队", Script: "hotspot.py", Needs: []string{"keyword"}, Timeout: 300},
+}
+
+type Options struct {
+ Port int
+ ResearchDir string // scripts/research 目录
+ WorkDir string // 产物输出根目录
+ Token string // 可选鉴权 token
+}
+
+func NewServerCmd() *cobra.Command {
+ opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output"}
+ cmd := &cobra.Command{
+ Use: "server",
+ Short: "启动子赛题四网页终端(HTTP 演示服务)",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return Run(opts)
+ },
+ }
+ cmd.Flags().IntVarP(&opts.Port, "port", "p", defaultPort, "监听端口")
+ cmd.Flags().StringVar(&opts.ResearchDir, "research-dir", "scripts/research", "scripts/research 目录")
+ cmd.Flags().StringVar(&opts.WorkDir, "work-dir", "research-output", "产物输出根目录")
+ cmd.Flags().StringVar(&opts.Token, "token", "", "可选鉴权 token(亦可用 DEMO_TOKEN 环境变量)")
+ return cmd
+}
+
+// Run 启动 HTTP 服务(阻塞)。
+func Run(opts Options) error {
+ if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" {
+ opts.Token = t
+ }
+ _ = os.MkdirAll(opts.WorkDir, 0o755)
+
+ mux := http.NewServeMux()
+ h := &handler{opts: opts}
+
+ mux.HandleFunc("GET /api/scenarios", h.handleScenarios)
+ mux.HandleFunc("POST /api/run", h.handleRun)
+ mux.HandleFunc("GET /api/result/{key}", h.handleResult)
+ mux.HandleFunc("GET /api/health", h.handleHealth)
+
+ sub, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ return fmt.Errorf("static fs: %w", err)
+ }
+ mux.Handle("GET /", http.FileServer(http.FS(sub)))
+
+ addr := fmt.Sprintf(":%d", opts.Port)
+ fmt.Fprintf(os.Stderr, "子赛题四 网页终端已启动: http://localhost%s\n", addr)
+ fmt.Fprintf(os.Stderr, " research-dir=%s work-dir=%s auth=%v\n", opts.ResearchDir, opts.WorkDir, opts.Token != "")
+ srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+ return srv.ListenAndServe()
+}
+
+type handler struct {
+ opts Options
+ mu sync.Mutex // 串行化场景执行,避免并发打爆 GitLink API
+}
+
+func (h *handler) authed(r *http.Request) bool {
+ if h.opts.Token == "" {
+ return true
+ }
+ return r.Header.Get("X-Demo-Token") == h.opts.Token
+}
+
+func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, map[string]any{"ok": true, "scenarios": len(scenarios)})
+}
+
+func (h *handler) handleScenarios(w http.ResponseWriter, r *http.Request) {
+ if !h.authed(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "scenarios": scenarios})
+}
+
+// handleResult 返回某场景最近一次运行的产物(供 result.html 独立结果页按 key 读取,
+// URL 可刷新/分享,便于演示讲解)。无需鉴权串行锁——只读已落盘产物。
+func (h *handler) handleResult(w http.ResponseWriter, r *http.Request) {
+ if !h.authed(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ key := r.PathValue("key")
+ sc, ok := findScenario(key)
+ if !ok {
+ writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + key})
+ return
+ }
+ outDir := filepath.Join(h.opts.WorkDir, sc.Key)
+ writeJSON(w, map[string]any{
+ "ok": true,
+ "scenario": sc.Key,
+ "label": sc.Label,
+ "desc": sc.Desc,
+ "artifacts": readArtifacts(outDir),
+ })
+}
+
+type runRequest struct {
+ Scenario string `json:"scenario"`
+ Owner string `json:"owner"`
+ Repo string `json:"repo"`
+ Keyword string `json:"keyword"`
+}
+
+func (h *handler) handleRun(w http.ResponseWriter, r *http.Request) {
+ if !h.authed(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ var req runRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": "bad request: " + err.Error()})
+ return
+ }
+ sc, ok := findScenario(req.Scenario)
+ if !ok {
+ writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + req.Scenario})
+ return
+ }
+ for _, need := range sc.Needs {
+ if (need == "keyword" && req.Keyword == "") ||
+ (need == "owner" && req.Owner == "") ||
+ (need == "repo" && req.Repo == "") {
+ writeJSON(w, map[string]any{"ok": false, "error": "missing parameter: " + need})
+ return
+ }
+ }
+
+ // 串行执行:一次只跑一个场景,保护 GitLink API。
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ scriptPath := filepath.Join(h.opts.ResearchDir, sc.Script)
+ outDir := filepath.Join(h.opts.WorkDir, sc.Key)
+ _ = os.MkdirAll(outDir, 0o755)
+
+ argv := []string{scriptPath, "--out", outDir}
+ if contains(sc.Needs, "owner") {
+ argv = append(argv, "--owner", req.Owner, "--repo", req.Repo)
+ }
+ if contains(sc.Needs, "keyword") {
+ argv = append(argv, "--keywords", req.Keyword)
+ }
+
+ // python3 优先,回退 python
+ py, err := pythonBin()
+ if err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ cmd := exec.Command(py, argv...)
+ cmd.Env = os.Environ()
+ start := time.Now()
+ out, err := cmd.CombinedOutput()
+ dur := time.Since(start)
+ resp := map[string]any{
+ "ok": err == nil,
+ "scenario": sc.Key,
+ "command": py + " " + strings.Join(argv, " "),
+ "duration": dur.Truncate(time.Millisecond).String(),
+ "stdout": string(out),
+ "out_dir": outDir,
+ }
+ if err != nil {
+ resp["error"] = err.Error()
+ }
+ // 附带读取关键产物(json + 第一个 mmd + report.md),便于前端直接渲染
+ resp["artifacts"] = readArtifacts(outDir)
+ writeJSON(w, resp)
+}
+
+func findScenario(key string) (scenarioDef, bool) {
+ for _, s := range scenarios {
+ if s.Key == key || strings.EqualFold(s.Key, key) {
+ return s, true
+ }
+ }
+ return scenarioDef{}, false
+}
+
+func readArtifacts(dir string) map[string]string {
+ out := map[string]string{}
+ // json 产物(取第一个 *.json)
+ if entries, err := os.ReadDir(dir); err == nil {
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ switch {
+ case strings.HasSuffix(name, ".json"):
+ b, _ := os.ReadFile(filepath.Join(dir, name))
+ out["json"] = string(b)
+ case strings.HasSuffix(name, ".mmd"):
+ b, _ := os.ReadFile(filepath.Join(dir, name))
+ out["mermaid"] = string(b)
+ case name == "visual.html":
+ b, _ := os.ReadFile(filepath.Join(dir, name))
+ out["html"] = string(b)
+ case strings.HasSuffix(name, ".md"):
+ // 第一份 .md 报告(report.md / weekly_report.md / compliance_report.md)
+ if _, ok := out["report"]; !ok {
+ b, _ := os.ReadFile(filepath.Join(dir, name))
+ out["report"] = string(b)
+ }
+ }
+ }
+ }
+ return out
+}
+
+func pythonBin() (string, error) {
+ // 候选按 Linux 习惯 python3 优先,再 python / py(Windows)。
+ // 必须实测能产出:Windows 的 WindowsApps\python3.exe 是 Store 桩,对 -c 也可能 exit 0 但不真正执行,
+ // 故用「stdout 必须含 PYOK」来拦截桩。
+ for _, name := range []string{"python3", "python", "py"} {
+ path, err := exec.LookPath(name)
+ if err != nil {
+ continue
+ }
+ if out, err := exec.Command(path, "-c", "print('PYOK')").Output(); err == nil &&
+ strings.Contains(string(out), "PYOK") {
+ return path, nil
+ }
+ }
+ return "", fmt.Errorf("python 未安装;容器需内置 python3 并 pip install -r scripts/research/requirements.txt")
+}
+
+func contains(xs []string, s string) bool {
+ for _, x := range xs {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/cmd/server/static/hotspot.html b/cmd/server/static/hotspot.html
new file mode 100644
index 0000000..e38f902
--- /dev/null
+++ b/cmd/server/static/hotspot.html
@@ -0,0 +1,485 @@
+
+
+
+
+
+科研热点追踪 · GitLink Research Atlas
+
+
+
+
+
+
+
+
+
← 仪表盘
+
+
科研热点追踪
+
输入领域/关键词,勾选追踪角度,扫描 GitLink 科研仓库 → 飙升项目 · 活跃讨论 · 主题热度 · 学者团队
+
+
+
+
+
+
+ 领域 / 关键词
+
+
+
+
+
示例
+
+ 深度学习computer vision
+ 自然语言处理强化学习
+ knowledge graph联邦学习
+ LLM agent自动驾驶
+
+
+
+
追踪角度
+
+ 🔥 飙升项目
+ 💬 活跃讨论
+ 📊 热门主题
+ 👥 核心学者·团队
+
+
仓库上限
+
+
+
就绪 · 点"开始追踪"扫描 GitLink
+
+
+
+
+
+
+
+
输入关键词并选择角度,点 开始追踪 —— 服务器将通过 gitlink-cli 实时搜索并构建该领域的科研热点全景。
+
+
+
+
+
+
diff --git a/cmd/server/static/index.html b/cmd/server/static/index.html
new file mode 100644
index 0000000..3ab56a9
--- /dev/null
+++ b/cmd/server/static/index.html
@@ -0,0 +1,594 @@
+
+
+
+
+
+GitLink Research Atlas · 科研代码图谱智能体
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/server/static/result.html b/cmd/server/static/result.html
new file mode 100644
index 0000000..5aeed02
--- /dev/null
+++ b/cmd/server/static/result.html
@@ -0,0 +1,504 @@
+
+
+
+
+
+GitLink Research Atlas · 结果详情
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/changes/issue-batch-maintenance.md b/doc/changes/issue-batch-maintenance.md
new file mode 100644
index 0000000..b2f945d
--- /dev/null
+++ b/doc/changes/issue-batch-maintenance.md
@@ -0,0 +1,68 @@
+# Issue batch maintenance shortcuts
+
+## Summary
+
+Add OpenAPI-backed Issue batch maintenance shortcuts:
+
+- `issue +batch-update` — batch update Issue status, priority, milestone, tags, and assigners by API issue IDs.
+- `issue +batch-delete` — batch delete Issues by API issue IDs with explicit confirmation.
+
+This complements the existing `issue +batch-close` command. `batch-close` uses web URL issue numbers, while the OpenAPI batch update/delete endpoints use API issue IDs.
+
+## OpenAPI coverage
+
+| Command | Method | Endpoint |
+|---|---|---|
+| `issue +batch-update` | PATCH | `/api/v1/{owner}/{repo}/issues/batch_update.json` |
+| `issue +batch-delete` | DELETE | `/api/v1/{owner}/{repo}/issues/batch_destroy.json` |
+
+## ID semantics
+
+- `issue +batch-close --numbers` uses web URL Issue numbers (`project_issues_index`).
+- `issue +batch-update --ids` and `issue +batch-delete --ids` use API Issue IDs returned by Issue APIs.
+
+The docs and help text explicitly call this out to avoid mixing the two ID types.
+
+## Safety and usability
+
+- Both commands support `--dry-run`.
+- `issue +batch-update` requires at least one update field.
+- `issue +batch-delete` is destructive and requires `--yes` for real execution.
+- ID lists are validated as positive integers and de-duplicated.
+
+## Examples
+
+```bash
+gitlink-cli issue +batch-update \
+ --owner Gitlink \
+ --repo forgeplus \
+ --ids 101,102 \
+ --status-id 3 \
+ --priority-id 2 \
+ --tag-ids 7,8 \
+ --assigner-ids 11,12 \
+ --dry-run
+
+gitlink-cli issue +batch-delete \
+ --owner Gitlink \
+ --repo forgeplus \
+ --ids 101,102 \
+ --dry-run
+
+gitlink-cli issue +batch-delete \
+ --owner Gitlink \
+ --repo forgeplus \
+ --ids 101,102 \
+ --yes
+```
+
+## Tests
+
+```bash
+GOPROXY=https://goproxy.cn,direct go test ./...
+go vet ./...
+go run . issue +batch-update --help
+go run . issue +batch-delete --help
+go run . issue +batch-update --owner wangyue111 --repo gitlink-cli --ids 101,102 --status-id 3 --dry-run --format json
+go run . issue +batch-delete --owner wangyue111 --repo gitlink-cli --ids 101,102 --dry-run --format json
+```
diff --git a/doc/changes/wiki-shortcut.md b/doc/changes/wiki-shortcut.md
new file mode 100644
index 0000000..b48af22
--- /dev/null
+++ b/doc/changes/wiki-shortcut.md
@@ -0,0 +1,23 @@
+# Wiki Shortcut
+
+新增 `wiki` Shortcut 组,支持 Wiki 页面管理:
+
+- `wiki +list` - 列出 Wiki 页面(目录结构)
+- `wiki +view` - 按页面名称查看 Wiki 页面详情
+- `wiki +create` - 创建新的 Wiki 页面
+- `wiki +update` - 更新 Wiki 页面标题和/或内容
+- `wiki +delete` - 删除 Wiki 页面
+
+## 实现要点
+
+- **API 端点**:基于 `/api/wiki/open/{action}` 扁平路径结构,覆盖 5 个 Wiki 管理接口:
+ - `GET /api/wiki/open/wikiPages` — 目录列表
+ - `GET /api/wiki/open/getWiki` — 查看页面
+ - `POST /api/wiki/open/createWiki` — 创建页面
+ - `PUT /api/wiki/open/updateWiki` — 更新页面
+ - `DELETE /api/wiki/open/deleteWiki` — 删除页面
+- **标识方式**:Wiki 页面通过 `pageName`(slug)标识,所有操作需要 `projectId`(GitLink 项目数字 ID)
+- **内容编码**:创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送
+- **更新保护**:`+update` 要求必须提供 `--title` 和 `--page-name`;`--content` 为可选
+- **Shortcut 模式**:使用 `common.Shortcut` + `RuntimeContext` 框架,与其他模块保持一致
+
diff --git a/internal/client/client.go b/internal/client/client.go
index beb4106..59c276e 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -43,6 +43,7 @@ func New() (*Client, error) {
// Do makes an API call with automatic .json suffix appended.
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
+ path = normalizeAPIPath(c.BaseURL, path)
return c.do(method, path, body, query, true, "json")
}
@@ -62,10 +63,10 @@ func (c *Client) do(method, path string, body interface{}, query url.Values, app
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
- if !strings.HasSuffix(basePath, ".json") {
+ if shouldAppendJSONSuffix(basePath) {
path = basePath + ".json" + queryStr
}
- } else if !strings.HasSuffix(path, ".json") {
+ } else if shouldAppendJSONSuffix(path) {
path += ".json"
}
}
@@ -144,22 +145,36 @@ func (c *Client) do(method, path string, body interface{}, query url.Values, app
return output.SuccessEnvelope(string(respData), nil), nil
}
+ // Check GitLink error-in-body pattern
+ // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
+ var bodyCode float64
+ var bodyMsg string
if status, ok := raw["status"]; ok {
- var statusCode float64
switch v := status.(type) {
case float64:
- statusCode = v
+ bodyCode = v
case int:
- statusCode = float64(v)
+ bodyCode = float64(v)
}
- if statusCode != 0 && statusCode != 200 && statusCode != 1 {
- msg, _ := raw["message"].(string)
- suggestion := suggestFix(int(statusCode))
- return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
- StatusCode: int(statusCode),
- Code: int(statusCode),
- Message: msg,
- }
+ bodyMsg, _ = raw["message"].(string)
+ } else if code, ok := raw["code"]; ok {
+ switch v := code.(type) {
+ case float64:
+ bodyCode = v
+ case int:
+ bodyCode = float64(v)
+ }
+ bodyMsg, _ = raw["msg"].(string)
+ if bodyMsg == "" {
+ bodyMsg, _ = raw["message"].(string)
+ }
+ }
+ if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 {
+ suggestion := suggestFix(int(bodyCode))
+ return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
+ StatusCode: int(bodyCode),
+ Code: int(bodyCode),
+ Message: bodyMsg,
}
}
@@ -197,6 +212,10 @@ func shouldAppendJSONSuffix(path string) bool {
return false
}
}
+ // Wiki open API endpoints do not use .json suffix
+ if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" {
+ return false
+ }
return true
}
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
index 9a55bcb..c46226b 100644
--- a/internal/client/client_test.go
+++ b/internal/client/client_test.go
@@ -169,6 +169,45 @@ func TestClientDoStatusError(t *testing.T) {
}
}
+func TestClientDoGatewayCodeError(t *testing.T) {
+ // Gateway returns {"code":N, "msg":"..."} instead of {"status":N, "message":"..."}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"code":400,"msg":"Bad Request"}`))
+ }))
+ defer server.Close()
+
+ c := &Client{HTTP: server.Client(), BaseURL: server.URL}
+ env, err := c.Do("GET", "/api/test", nil, nil)
+ if err == nil {
+ t.Fatal("expected error for code=400")
+ }
+ if env == nil {
+ t.Fatal("expected envelope for code error")
+ }
+ if env.OK {
+ t.Fatal("expected OK=false for code=400")
+ }
+}
+
+func TestClientDoGatewayCode201Success(t *testing.T) {
+ // Gateway returns code=201 with JSON string data — should be treated as success
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"code":201,"msg":"","data":"{\"title\":\"test\"}"}`))
+ }))
+ defer server.Close()
+
+ c := &Client{HTTP: server.Client(), BaseURL: server.URL}
+ env, err := c.Do("POST", "/api/test", map[string]string{"title": "test"}, nil)
+ if err != nil {
+ t.Fatalf("unexpected error for code=201: %v", err)
+ }
+ if !env.OK {
+ t.Fatal("expected OK=true for code=201")
+ }
+}
+
func TestClientDoStatusZero(t *testing.T) {
// status=0, 200, 1 are treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -525,3 +564,18 @@ func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
t.Fatal("existing .json path should not get another suffix")
}
}
+
+func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) {
+ paths := []string{
+ "/wiki/open/createWiki",
+ "/wiki/open/getWiki",
+ "/wiki/open/updateWiki",
+ "/wiki/open/deleteWiki",
+ "/wiki/open/wikiPages",
+ }
+ for _, p := range paths {
+ if shouldAppendJSONSuffix(p) {
+ t.Errorf("wiki/open path %q should not get .json suffix", p)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 0b3f269..de17f5b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -8,22 +8,25 @@ import (
)
const (
- DefaultBaseURL = "https://www.gitlink.org.cn/api"
- DefaultFormat = "table"
+ DefaultBaseURL = "https://www.gitlink.org.cn/api"
+ DefaultGatewayURL = "https://gateway.gitlink.org.cn/api"
+ DefaultFormat = "table"
)
type Config struct {
- BaseURL string `yaml:"base_url"`
- Format string `yaml:"default_format"`
- Editor string `yaml:"editor,omitempty"`
- Pager string `yaml:"pager,omitempty"`
- Lang string `yaml:"lang,omitempty"`
+ BaseURL string `yaml:"base_url"`
+ GatewayURL string `yaml:"gateway_url"`
+ Format string `yaml:"default_format"`
+ Editor string `yaml:"editor,omitempty"`
+ Pager string `yaml:"pager,omitempty"`
+ Lang string `yaml:"lang,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
- BaseURL: DefaultBaseURL,
- Format: DefaultFormat,
+ BaseURL: DefaultBaseURL,
+ GatewayURL: DefaultGatewayURL,
+ Format: DefaultFormat,
}
}
@@ -54,6 +57,9 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
+ if cfg.GatewayURL == "" {
+ cfg.GatewayURL = DefaultGatewayURL
+ }
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@@ -80,6 +86,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
+ case "gateway_url":
+ return cfg.GatewayURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@@ -101,6 +109,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
+ case "gateway_url":
+ cfg.GatewayURL = value
case "default_format":
cfg.Format = value
case "editor":
diff --git a/scripts/research/README.md b/scripts/research/README.md
new file mode 100644
index 0000000..8b1fdbe
--- /dev/null
+++ b/scripts/research/README.md
@@ -0,0 +1,59 @@
+# scripts/research — 子赛题四·科研辅助算法层
+
+本目录是子赛题四「应用 GitLink 辅助科研」的 **Python 工具代码**(赛题交付物之一)。
+采用 **Go 出数据 + Python 做算法** 的分工:所有原始数据经现有 gitlink-cli(25 个域)获取,
+Python 负责知识图谱 / 协作匹配 / 可视化 / 复现性 / 报告等算法与产物生成。
+
+## 文件
+
+| 文件 | 场景 | 说明 |
+|------|------|------|
+| `gitlink_data.py` | 共享 | 调 gitlink-cli、解析 envelope、限速、分页、只读 health SQLite |
+| `collect.py` | 共享 | 各命令的采集器 + 字段归一化(login/repo 全名等) |
+| `lineage.py` | S1 | 仓库级科研项目洞悉:提交/分支/PR/文档/实验代码演进 + 创新点 |
+| `graph_build.py` | S2 | 科研知识图谱(networkx):节点/边模型 + 主题词典 + mermaid/DOT |
+| `repro.py` | S3 | 合规与复现性:license/密钥/依赖/复现清单 + 评分 |
+| `match.py` | S4 | 科研协作智能匹配:学者画像 × 仓库缺口 TF-IDF + 余弦 |
+| `report.py` | S5 | 科研进度智能跟踪与预警:周统计 + 里程碑 + 风险阈值 |
+| `visual.py` | S6 | 科研成果可视化:plotly 时间线/热力/饼/甘特/论文关联 |
+| `templates/*.j2` | 全部 | jinja2 中文报告模板 |
+| `test_*.py` | — | 单元测试(`pytest scripts/research/`) |
+
+## 安装
+
+```bash
+pip install -r scripts/research/requirements.txt
+```
+
+## 环境变量
+
+| 变量 | 默认 | 说明 |
+|------|------|------|
+| `GITLINK_CLI` | `gitlink-cli` | CLI 可执行路径;本地 Windows 开发可设 `./gitlink-cli.exe` |
+| `GITLINK_CLI_INTERVAL` | `0.6` | CLI 调用最小间隔(秒),防 API 限流 |
+| `GITLINK_HEALTH_DB` | `~/.agents/skills/gitlink-health/data/gitlink_health.db` | health SQLite 路径(图谱/匹配读历史协作数据) |
+
+## 数据来源
+
+- **结构化数据**:`repo/issue/pr/search/user/org/milestone/file/license` 等域,输出统一 envelope `{ok,data,meta}`。
+- **提交历史**:Raw API `gitlink-cli api GET /{owner}/{repo}/commits`(shortcuts 无 repo +commits,路径已在 mindspore 仓库核验通过)。
+- **历史协作**:直接只读 `health` SQLite(`users/repos/issues/pulls/tags` 表)。
+
+## 验证仓库
+
+**主验证仓库:`mindspore-Ecosystem/mindspore`**(华为 MindSpore 深度学习框架镜像,真实科研级 AI 框架):
+issues≈20346 / PR=9 / 贡献者=6 / star=31,协作数据充足,适合 S2/S4/S5;同时是 Python 研究代码仓库,适合 S1/S3/S6。
+S2 知识图谱为多仓库场景,按关键词 `search +repos` 跨仓库构建。
+
+### 已核验响应形状(mindspore 实测,供各场景脚本参考)
+- `repo +info` → `default_branch`/`issues_count`/`pull_requests_count`/`contributor_users_count`/`size`/`clone_url`
+- `repo +contributors[]` → `login`/`name`/`contributions`/`contribution_perc`/`email`
+- `issue +list[]` → 标题 `subject`、时间 `created_at`、`status`、`priority_name`、`milestone_name`、`author`、`number`/`project_issues_index`
+- `pr +list[]` → 标题 `title`、创建时间 `pr_created_unix`(秒)、`status`(0=open/1=merged/2=closed)、`reviewers`、`index`
+- `api GET /commits[]` → `sha`/`message`/`timestamp`/`author.login`/`committer.login`
+- `repo +languages` → `{"Python":"99.7%", ...}`
+
+## 复现
+
+每个场景配一个 `skills//examples/-workflow.sh`,串联 CLI → Python → 报告产物,
+即赛题要求的「可复现执行脚本」。
diff --git a/scripts/research/collect.py b/scripts/research/collect.py
new file mode 100644
index 0000000..28c2fdb
--- /dev/null
+++ b/scripts/research/collect.py
@@ -0,0 +1,262 @@
+"""collect.py — 子赛题四共享数据采集器。
+
+每个采集器是对 `gitlink-cli +` 或 Raw API 的薄封装,返回归一化后的
+Python 对象(dict/list)。所有 GitLink 操作经 gitlink-cli(见 gitlink-shared 工具边界)。
+
+形状说明:GitLink 不同端点字段差异较大,本模块只做“尽力归一”,把不确定字段原样透传;
+具体字段解读留给各场景脚本(lineage/graph/match/...)并在其阶段用真实仓库验证后收敛。
+"""
+from __future__ import annotations
+
+from typing import Any, Iterable
+
+import gitlink_data as gd
+
+# ---------------------------------------------------------------------------
+# 归一化小工具
+# ---------------------------------------------------------------------------
+
+def as_str(v: Any) -> str:
+ if v is None:
+ return ""
+ return str(v)
+
+
+def as_int(v: Any, default: int = 0) -> int:
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return default
+
+
+def as_float(v: Any, default: float = 0.0) -> float:
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return default
+
+
+def login_of(obj: dict) -> str:
+ """从用户/作者对象里尽量取出 login(GitLink 嵌套形式多变)。"""
+ if not isinstance(obj, dict):
+ return ""
+ for path_keys in (("login",), ("name",), ("username",),
+ ("author", "login"), ("user", "login"),
+ ("owner", "login"), ("author", "name")):
+ cur: Any = obj
+ ok = True
+ for k in path_keys:
+ if isinstance(cur, dict) and k in cur:
+ cur = cur[k]
+ else:
+ ok = False
+ break
+ if ok and isinstance(cur, str) and cur:
+ return cur
+ return ""
+
+
+def repo_fullname(project: dict) -> str:
+ """从 /projects 里的仓库对象取 'owner/identifier' 全名。"""
+ if not isinstance(project, dict):
+ return ""
+ identifier = project.get("identifier") or project.get("name") or project.get("repo_name") or ""
+ owner = login_of(project.get("author") or project.get("owner") or {}) or as_str(project.get("owner_login"))
+ if owner and identifier:
+ return f"{owner}/{identifier}"
+ return identifier
+
+
+# ---------------------------------------------------------------------------
+# 仓库级
+# ---------------------------------------------------------------------------
+
+def repo_info(owner: str, repo: str) -> dict:
+ return gd.run_data(["repo", "+info"], owner=owner, repo=repo) or {}
+
+
+def readme(owner: str, repo: str, ref: str = "master") -> str:
+ data = gd.run_data(["repo", "+readme", "--ref", ref], owner=owner, repo=repo)
+ if isinstance(data, str):
+ return data
+ if isinstance(data, dict):
+ for k in ("content", "text", "readme", "markdown", "data"):
+ v = data.get(k)
+ if isinstance(v, str) and v:
+ return v
+ return ""
+
+
+def tree(owner: str, repo: str, path: str = "", ref: str = "master") -> list:
+ flags = ["repo", "+tree", "--ref", ref]
+ if path:
+ flags += ["--path", path]
+ data = gd.run_data(flags, owner=owner, repo=repo)
+ return gd.first_list(data, ("entries", "trees", "files", "sub_entries"))
+
+
+def languages(owner: str, repo: str) -> dict:
+ data = gd.run_data(["repo", "+languages"], owner=owner, repo=repo)
+ return data if isinstance(data, dict) else {}
+
+
+def contributors(owner: str, repo: str, limit: int = 100) -> list:
+ """贡献者列表。`repo +contributors` 无分页 flag(见 shortcuts/repo/repo.go),
+ 故单次取全量;limit 仅为兼容保留、不传给 CLI。"""
+ data = gd.run_data(["repo", "+contributors"], owner=owner, repo=repo)
+ return gd.first_list(data, ("contributors", "list"))
+
+
+# ---------------------------------------------------------------------------
+# Issue / PR / 里程碑(分页)
+# ---------------------------------------------------------------------------
+
+def issues(owner: str, repo: str, state: str = "all", max_pages: int = 10,
+ page_size: int = 50) -> list:
+ extra = ["--state", state] if state and state != "all" else []
+ return gd.paginate("issue", "list", owner=owner, repo=repo,
+ page_size=page_size, max_pages=max_pages, extra_flags=extra)
+
+
+def prs(owner: str, repo: str, state: str = "all", max_pages: int = 10,
+ page_size: int = 50) -> list:
+ extra = ["--state", state] if state and state != "all" else []
+ # GitLink 把 PR 列表也放在 data["issues"] 键下(见 shortcuts/health/api.go)。
+ return gd.paginate("pr", "list", owner=owner, repo=repo,
+ list_keys=("issues", "pulls", "list"),
+ page_size=page_size, max_pages=max_pages, extra_flags=extra)
+
+
+def issues_all(owner: str, repo: str, max_pages: int = 10,
+ page_size: int = 50) -> list:
+ """全部 Issue(open + closed 合并去重)。
+
+ `issue +list` 默认只返 open;取 open/closed 两个状态并集按 id 去重,
+ 每条保留真实 status 字段。供 S5/S6 做全量统计。
+ """
+ seen: dict = {}
+ for state in ("open", "closed"):
+ for iss in issues(owner, repo, state=state,
+ max_pages=max_pages, page_size=page_size):
+ key = iss.get("id") or iss.get("index")
+ if key is not None and key not in seen:
+ seen[key] = iss
+ return list(seen.values())
+
+
+def prs_all(owner: str, repo: str, max_pages: int = 10,
+ page_size: int = 50) -> list:
+ """全部 PR(open + merged + closed 合并去重)。
+
+ GitLink 的 `pr +list` 状态过滤不可靠(不同仓库行为不一:有的默认只返 open,
+ 有的 --state 不生效返混合),故取三个状态并集按 index 去重,每条 PR 保留其
+ 真实 status 字段('merged'/'open'/'closed')供上层分类。仿 health 采集法。
+ """
+ seen: dict = {}
+ for state in ("open", "merged", "closed"):
+ for pr in prs(owner, repo, state=state,
+ max_pages=max_pages, page_size=page_size):
+ key = pr.get("index") or pr.get("id") or pr.get("number")
+ if key is None:
+ continue
+ if key not in seen:
+ seen[key] = pr
+ return list(seen.values())
+
+
+def pr_detail(owner: str, repo: str, index: int) -> dict:
+ """单个 PR 详情:`pr +view --id `。
+
+ PR 列表 API 不返回文件改动数;详情接口返回 `files_count` / `commits_count`,
+ 用于 S1 创新点识别的「大规模重构」判据。
+ """
+ return gd.run_data(["pr", "+view", "--id", str(index)], owner=owner, repo=repo) or {}
+
+
+def milestones(owner: str, repo: str, state: str = "all") -> list:
+ extra = ["--status", state] if state and state != "all" else []
+ data = gd.run_data(["milestone", "+list", *extra, "--limit", "100"],
+ owner=owner, repo=repo)
+ return gd.first_list(data, ("milestones", "list"))
+
+
+# ---------------------------------------------------------------------------
+# 搜索 / 用户
+# ---------------------------------------------------------------------------
+
+def search_repos(keyword: str, limit: int = 20) -> list:
+ data = gd.run_data(["search", "+repos", "-k", keyword, "--limit", str(limit)])
+ return gd.first_list(data, ("projects", "repos"))
+
+
+def search_users(keyword: str, limit: int = 20) -> list:
+ data = gd.run_data(["search", "+users", "-k", keyword, "--limit", str(limit)])
+ return gd.first_list(data, ("users", "list"))
+
+
+def user_info(login: str) -> dict:
+ return gd.run_data(["user", "+info", "--login", login]) or {}
+
+
+def user_repos(login: str, limit: int = 20) -> list:
+ """某用户的公开仓库列表(`repo +list --user --category all`)。
+
+ 必须显式传 `--category all`:`repo +list` 的 category 默认是 `manage`
+ ("我管理的"),用来列别人的仓库时会 404/空。返回 data.projects,
+ 每项含 identifier(仓库名)/language({id,name})/description 等。
+ """
+ data = gd.run_data(["repo", "+list", "--user", login,
+ "--category", "all", "--limit", str(limit)])
+ return gd.first_list(data, ("projects", "repos"))
+
+
+# ---------------------------------------------------------------------------
+# 提交历史(Raw API:shortcuts 未提供 repo +commits)
+# ---------------------------------------------------------------------------
+
+def commits(owner: str, repo: str, ref: str = "master", max_pages: int = 5,
+ page_size: int = 100) -> list:
+ """通过 `api GET /{owner}/{repo}/commits` 分页取提交。
+
+ 注:该端点确切路径/分页参数需在 Phase 0c 用真实仓库核验;若不通,
+ 退化方案见 doc 注释(按分支取或用 compare 端点)。
+ """
+ out: list = []
+ for page in range(1, max_pages + 1):
+ data = gd.api("GET", f"/{owner}/{repo}/commits",
+ query=f"sha={ref}&page={page}&limit={page_size}",
+ owner=owner, repo=repo)
+ items = gd.first_list(data, ("commits", "list"))
+ if not items:
+ break
+ out.extend(items)
+ if len(items) < page_size:
+ break
+ return out
+
+
+def file_text(owner: str, repo: str, path: str, ref: str = "master") -> str:
+ """读取仓库内某文件的文本内容(LICENSE / CI 配置 / lockfile 等)。
+
+ `file +get`(sub_entries)返回形如 {"entries": {"content": "...", "commit": {...}}}。
+ """
+ data = gd.run_data(["file", "+get", "--path", path, "--ref", ref],
+ owner=owner, repo=repo)
+ if isinstance(data, str):
+ return data
+ if isinstance(data, dict):
+ # 顶层直接带内容
+ for k in ("content", "text", "data"):
+ v = data.get(k)
+ if isinstance(v, str) and v:
+ return v
+ entries = data.get("entries")
+ # 形式一:entries 是 dict,内含 content 键(GitLink 实测)
+ if isinstance(entries, dict):
+ v = entries.get("content") or entries.get("text")
+ if isinstance(v, str) and v:
+ return v
+ # 形式二:entries 是 list[dict]
+ elif isinstance(entries, list) and entries and isinstance(entries[0], dict):
+ return as_str(entries[0].get("content") or entries[0].get("text"))
+ return ""
diff --git a/scripts/research/gitlink_data.py b/scripts/research/gitlink_data.py
new file mode 100644
index 0000000..a09b3cf
--- /dev/null
+++ b/scripts/research/gitlink_data.py
@@ -0,0 +1,196 @@
+"""gitlink_data.py — 子赛题四数据访问共享层。
+
+职责:
+ 1. 以子进程方式调用 gitlink-cli,解析统一 envelope({ok,data,error,meta})。
+ 2. 内置限速,避免触发 GitLink API 限流(参考 shortcuts/health 的 ~1.7 call/s)。
+ 3. 列表分页累积。
+ 4. 直接只读访问 health SQLite(shortcuts/health/schema.sql),供 S2 图谱 / S4 匹配取历史协作数据。
+
+设计原则:本层只“取数 + 解析”,不做任何业务算法;算法在各场景脚本中实现。
+所有 GitLink 操作一律经 gitlink-cli,绝不用 gh/glab(见 skills/gitlink-shared/SKILL.md 工具边界)。
+"""
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import Any, Iterable
+
+# ---------------------------------------------------------------------------
+# 配置
+# ---------------------------------------------------------------------------
+
+# CLI 可执行路径:默认走 PATH 上的 gitlink-cli;本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe
+def cli_path() -> str:
+ return os.environ.get("GITLINK_CLI", "gitlink-cli")
+
+
+# 最小调用间隔(秒),限制对 GitLink API 的请求频率。
+MIN_INTERVAL = float(os.environ.get("GITLINK_CLI_INTERVAL", "0.6"))
+
+_last_call = 0.0
+
+
+def _throttle() -> None:
+ """简单的全局令牌限速:两次调用间至少间隔 MIN_INTERVAL 秒。"""
+ global _last_call
+ now = time.time()
+ wait = MIN_INTERVAL - (now - _last_call)
+ if wait > 0:
+ time.sleep(wait)
+ _last_call = time.time()
+
+
+def _warn(msg: str) -> None:
+ sys.stderr.write(f"[gitlink] {msg}\n")
+
+
+# ---------------------------------------------------------------------------
+# 核心:调用 CLI
+# ---------------------------------------------------------------------------
+
+def run(args: Iterable[str], owner: str | None = None, repo: str | None = None,
+ fmt: str = "json") -> dict[str, Any]:
+ """调用 `gitlink-cli [global flags] args... --format fmt`,返回解析后的 envelope dict。
+
+ 失败时返回 {"ok": False, "error": {...}},不抛异常,便于调用方容错。
+ """
+ cmd = [cli_path()]
+ if owner:
+ cmd += ["--owner", owner]
+ if repo:
+ cmd += ["--repo", repo]
+ cmd += list(args)
+ if fmt:
+ cmd += ["--format", fmt]
+
+ _throttle()
+ try:
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+ except FileNotFoundError:
+ _warn(f"gitlink-cli 未找到(cmd={cmd[0]}),请设置 GITLINK_CLI 环境变量")
+ return {"ok": False, "error": {"message": f"gitlink-cli not found: {cmd[0]}"}}
+ except subprocess.TimeoutExpired:
+ _warn(f"调用超时:{' '.join(cmd)}")
+ return {"ok": False, "error": {"message": "timeout"}}
+
+ if proc.returncode != 0:
+ _warn(f"exit={proc.returncode} cmd={' '.join(cmd)}\n{proc.stderr.strip()}")
+ return {"ok": False, "error": {"message": (proc.stderr or proc.stdout).strip() or f"exit {proc.returncode}"}}
+
+ out = proc.stdout.strip()
+ if not out:
+ return {"ok": True, "data": None}
+ try:
+ return json.loads(out)
+ except json.JSONDecodeError:
+ # 非 JSON(表格/原始文本),原样包裹返回
+ return {"ok": True, "data": out}
+
+
+def run_data(args: Iterable[str], owner: str | None = None, repo: str | None = None,
+ fmt: str = "json") -> Any:
+ """run() 的便捷封装:成功返回 data 字段,失败返回 None 并告警。"""
+ env = run(args, owner=owner, repo=repo, fmt=fmt)
+ if not env.get("ok"):
+ msg = (env.get("error") or {}).get("message", "unknown error")
+ _warn(f"{list(args)} -> {msg}")
+ return None
+ return env.get("data")
+
+
+def api(method: str, path: str, query: str | None = None,
+ owner: str | None = None, repo: str | None = None) -> Any:
+ """调用 Raw API:`gitlink-cli api METHOD PATH --query ... --format json`。
+
+ 用于 shortcuts 未覆盖的端点(如仓库提交历史 GET /{owner}/{repo}/commits)。
+ """
+ args = ["api", method, path]
+ if query:
+ args += ["--query", query]
+ return run_data(args, owner=owner, repo=repo)
+
+
+# ---------------------------------------------------------------------------
+# 分页 / 形状工具
+# ---------------------------------------------------------------------------
+
+# GitLink 各端点把列表放在不同键下;按优先级尝试这些键。
+DEFAULT_LIST_KEYS = (
+ "projects", "repos", "issues", "pulls", "users", "list",
+ "contributors", "entries", "commits", "milestones", "tags",
+)
+
+
+def first_list(data: Any, keys: Iterable[str] = DEFAULT_LIST_KEYS) -> list:
+ """从 envelope.data 里稳健地取出列表:data 本身是列表则直接返回,否则尝试已知键。"""
+ if isinstance(data, list):
+ return data
+ if isinstance(data, dict):
+ for k in keys:
+ v = data.get(k)
+ if isinstance(v, list):
+ return v
+ # 兜底:唯一一个 list 值
+ list_vals = [v for v in data.values() if isinstance(v, list)]
+ if len(list_vals) == 1:
+ return list_vals[0]
+ return []
+
+
+def total_count(data: Any) -> int | None:
+ if isinstance(data, dict):
+ for k in ("total_count", "totalCount", "count", "total"):
+ if isinstance(data.get(k), (int, float)):
+ return int(data[k])
+ return None
+
+
+def paginate(domain: str, verb: str, list_keys=DEFAULT_LIST_KEYS,
+ page_size: int = 50, max_pages: int = 20,
+ owner: str | None = None, repo: str | None = None,
+ extra_flags: Iterable[str] = ()) -> list:
+ """对一个 `gitlink-cli +` 列表命令做多页累积。
+
+ 依赖命令支持 --page/--limit 两个 flag(repo/issue/pr/search/milestone 等均支持)。
+ """
+ collected: list = []
+ for page in range(1, max_pages + 1):
+ flags = [f"+{verb}", "--page", str(page), "--limit", str(page_size), *extra_flags]
+ data = run_data([domain, *flags], owner=owner, repo=repo)
+ if data is None:
+ break
+ items = first_list(data, list_keys)
+ if not items:
+ break
+ collected.extend(items)
+ total = total_count(data)
+ if total is not None and len(collected) >= total:
+ break
+ if len(items) < page_size:
+ break
+ return collected
+
+
+# ---------------------------------------------------------------------------
+# health SQLite 只读访问
+# ---------------------------------------------------------------------------
+
+def health_db_path() -> str:
+ return os.environ.get(
+ "GITLINK_HEALTH_DB",
+ str(Path.home() / ".agents" / "skills" / "gitlink-health" / "data" / "gitlink_health.db"),
+ )
+
+
+def open_health_db() -> sqlite3.Connection | None:
+ """以只读方式打开 health SQLite;文件不存在则返回 None(调用方退化为纯 API 取数)。"""
+ p = health_db_path()
+ if not Path(p).exists():
+ return None
+ # mode=ro 防止误写;modernc/sqlite 已开 WAL,Python 只读并发安全。
+ return sqlite3.connect(f"file:{p}?mode=ro", uri=True)
diff --git a/scripts/research/graph_build.py b/scripts/research/graph_build.py
new file mode 100644
index 0000000..5715c62
--- /dev/null
+++ b/scripts/research/graph_build.py
@@ -0,0 +1,658 @@
+"""graph_build.py — S2 科研热点追踪与知识图谱。
+
+输入一组科研关键词,从 GitLink 平台按关键词搜索相关仓库(search +repos),
+对每个候选仓库取 repo_info / contributors / languages / README,再用
+networkx.MultiDiGraph 构建一张「仓库—学者—主题」科研知识图谱:
+
+ 节点
+ - repo : id = repo:owner/name (props: language/stars/forks/desc)
+ - scholar : id = scholar:login (来自 contributors,过滤 bot/i-robot)
+ - topic : id = topic:x (由 topics.py 词典抽取)
+ 边
+ - contributes_to : scholar → repo (weight = contribution_perc 解析为 0~1)
+ - owns : scholar → repo (当 author.login == contributor login)
+ - covers_topic : repo → topic (weight = 出现次数 / max)
+ - collaborates_with : scholar → scholar (共享同一 repo)
+ - related_to : topic ↔ topic (在同一 repo 共现)
+
+「取数」与「建图」严格分离:build_graph() 只接收已经取好的 Python 数据结构,
+便于离线单测(不联网、不调 gitlink-cli)。collect() 负责在线取数。
+
+数据全部经 gitlink-cli 获取(search +repos / repo +info / repo +contributors /
+repo +languages / repo +readme)。
+
+用法:
+ python graph_build.py --keywords "deep learning,nlp" --repos-limit 20 --out ./out
+ python graph_build.py --keywords "knowledge graph" # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as _dt
+import json
+import math
+import os
+import sys
+import time
+from collections import Counter, defaultdict
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c # noqa: E402
+import topics as T # noqa: E402
+
+import networkx as nx # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# 小工具
+# ---------------------------------------------------------------------------
+
+BOT_LOGIN_HINTS = ("bot", "i-robot", "dependabot", "renovate", "semantic-release-bot")
+
+
+def is_bot(login: str) -> bool:
+ """识别明显机器人账号(不作为学者节点)。"""
+ if not login:
+ return True
+ low = login.lower()
+ return any(h in low for h in BOT_LOGIN_HINTS)
+
+
+def parse_ratio(v: Any) -> float:
+ """把 '1.18%' / '0.2' / 0.2 等统一解析为 0~1 比例(与 match.py 一致)。"""
+ if v is None:
+ return 0.0
+ s = str(v).strip()
+ pct = s.endswith("%")
+ if pct:
+ s = s[:-1]
+ try:
+ f = float(s)
+ except ValueError:
+ return 0.0
+ return f / 100.0 if (pct or f > 1.0) else f
+
+
+def _topic_heat(descriptions: list[str], top: int = 10) -> list[dict]:
+ """对全部仓库 description 跑 topics.topic_counter,取 top 热度榜。"""
+ cnt = T.topic_counter(descriptions)
+ return [{"topic": t, "count": n} for t, n in cnt.most_common(top)]
+
+
+# ---------------------------------------------------------------------------
+# 热点追踪:飙升项目 + 活跃讨论(快照代理;真·增长率需定时轮询存历史)
+# ---------------------------------------------------------------------------
+
+def _to_epoch(v: Any) -> float:
+ """把 GitLink 时间(ISO 字符串或整数秒)解析为 epoch 秒,失败返回 0.0。"""
+ if v is None:
+ return 0.0
+ if isinstance(v, (int, float)):
+ return float(v) / 1000.0 if v > 1e12 else float(v)
+ s = str(v).strip()
+ if not s:
+ return 0.0
+ if s.isdigit():
+ f = float(s)
+ return f / 1000.0 if f > 1e12 else f
+ iso = s.replace("Z", "+00:00")
+ try:
+ return _dt.datetime.fromisoformat(iso).timestamp()
+ except (ValueError, TypeError):
+ import re
+ m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s)
+ if m:
+ try:
+ return _dt.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).timestamp()
+ except ValueError:
+ return 0.0
+ return 0.0
+
+
+def _iso_day(epoch: float) -> str:
+ if not epoch:
+ return ""
+ try:
+ return _dt.datetime.utcfromtimestamp(epoch).strftime("%Y-%m-%d")
+ except (OSError, ValueError, OverflowError):
+ return ""
+
+
+def compute_trending(repos: list[dict], top: int = 15) -> list[dict]:
+ """对搜索到的仓库按「热度分数」排序,作为飙升/热门项目代理。
+
+ 分数 = star + fork×2 + 近期更新加成;并给「日均增星(velocity)」作为
+ day-1 可用的趋势代理(真·增长率需定时轮询存历史快照,见 trends 表规划)。
+ """
+ now = time.time()
+ scored: list[dict] = []
+ for r in repos:
+ fullname = r.get("fullname") or c.repo_fullname(r)
+ if not fullname:
+ continue
+ stars = c.as_int(r.get("praises_count"))
+ forks = c.as_int(r.get("forked_count"))
+ updated = _to_epoch(r.get("updated_at") or r.get("time")
+ or r.get("updated_on") or r.get("created_at"))
+ age_days = max(1.0, (now - updated) / 86400.0) if updated else 99999.0
+ recency = max(0.0, 60.0 - age_days) # 60 天内更新有加成
+ score = stars + forks * 2 + recency
+ velocity = round(stars / age_days, 3) if age_days < 99990 else 0.0
+ lang_obj = r.get("language")
+ lang = lang_obj.get("name") if isinstance(lang_obj, dict) else c.as_str(lang_obj)
+ scored.append({
+ "repo": fullname,
+ "description": c.as_str(r.get("description"))[:140],
+ "language": lang or "",
+ "stars": stars,
+ "forks": forks,
+ "updated": _iso_day(updated),
+ "velocity": velocity,
+ "score": round(score, 1),
+ })
+ scored.sort(key=lambda x: -x["score"])
+ return scored[:top]
+
+
+def compute_active(issues_map: dict[str, list], prs_map: dict[str, list],
+ top: int = 12) -> list[dict]:
+ """跨仓库取评论/日志最多的 Issue / PR,作为「活跃讨论」信号。"""
+ items: list[dict] = []
+ for fullname, issues in issues_map.items():
+ for iss in issues or []:
+ if not isinstance(iss, dict):
+ continue
+ jc = c.as_int(iss.get("journals_count") or iss.get("comments_count"))
+ items.append({
+ "repo": fullname, "type": "issue",
+ "number": iss.get("index") or iss.get("number") or iss.get("id"),
+ "title": c.as_str(iss.get("subject") or iss.get("title"))[:120],
+ "comments": jc,
+ "state": c.as_str(iss.get("status") or iss.get("issue_status") or "open"),
+ })
+ for fullname, prs in prs_map.items():
+ for pr in prs or []:
+ if not isinstance(pr, dict):
+ continue
+ jc = c.as_int(pr.get("journals_count") or pr.get("comments_count"))
+ items.append({
+ "repo": fullname, "type": "pr",
+ "number": pr.get("index") or pr.get("number") or pr.get("id"),
+ "title": c.as_str(pr.get("title"))[:120],
+ "comments": jc,
+ "state": c.as_str(pr.get("status") or "open"),
+ })
+ items.sort(key=lambda x: -x["comments"])
+ # 优先有评论的;不足则按已有顺序补齐
+ commented = [it for it in items if it["comments"] > 0]
+ return (commented or items)[:top]
+
+
+# ---------------------------------------------------------------------------
+# 建图(纯函数:不联网,只吃已取数据,便于单测)
+# ---------------------------------------------------------------------------
+
+def build_graph(repos: list[dict],
+ contributors_map: dict[str, list[dict]],
+ languages_map: dict[str, dict],
+ readmes: dict[str, str],
+ keywords: list[str] | None = None) -> dict[str, Any]:
+ """构建科研知识图谱。
+
+ 参数(全部为「已取好」的 Python 数据,不联网):
+ repos : list[dict],每个元素至少含 fullname 与 repo_info 字段
+ (identifier, author.login, description, language.name,
+ praises_count, forked_count)。
+ contributors_map : {fullname: [contributor, ...]},contributor 至少含
+ login + contribution_perc。
+ languages_map : {fullname: {"Python": "99.7%", ...}}。
+ readmes : {fullname: readme 文本(已截断到前 4000 字符)}。
+
+ 返回结果 dict(与 graph.json 结构一致)。
+ """
+ G = nx.MultiDiGraph()
+
+ repo_nodes: dict[str, dict] = {}
+ scholar_repos: dict[str, set[str]] = defaultdict(set)
+ repo_topics: dict[str, Counter] = {}
+ descriptions: list[str] = []
+ max_topic_count = 1 # 用于 covers_topic 归一化(防除零)
+
+ # ---- 1) 仓库 + 主题节点 ----
+ for r in repos:
+ fullname = r.get("fullname") or c.repo_fullname(r)
+ if not fullname:
+ continue
+ desc = c.as_str(r.get("description"))
+ lang_name = ""
+ lang_obj = r.get("language")
+ if isinstance(lang_obj, dict):
+ lang_name = c.as_str(lang_obj.get("name"))
+ stars = c.as_int(r.get("praises_count"))
+ forks = c.as_int(r.get("forked_count"))
+ readme = c.as_str(readmes.get(fullname))[:4000]
+ descriptions.append(desc)
+
+ repo_id = f"repo:{fullname}"
+ props = {
+ "language": lang_name,
+ "stars": stars,
+ "forks": forks,
+ "description": desc,
+ "readme_head": readme[:200],
+ }
+ G.add_node(repo_id, type="repo", label=fullname, **props)
+ repo_nodes[repo_id] = {"label": fullname, **props}
+
+ # 抽取主题(description + readme)
+ tps = T.extract_topics(desc + " " + readme)
+ cnt: Counter = Counter()
+ for tp in tps:
+ cnt[tp] += 1
+ repo_topics[repo_id] = cnt
+ if cnt:
+ max_topic_count = max(max_topic_count, max(cnt.values()))
+
+ # ---- 2) 主题节点 + covers_topic 边 ----
+ topic_repos: dict[str, set[str]] = defaultdict(set)
+ repo_topic_pairs: dict[str, list[str]] = {} # repo_id -> [topic_id]
+ for repo_id, cnt in repo_topics.items():
+ pairs: list[str] = []
+ for tp, n in cnt.items():
+ topic_id = f"topic:{tp}"
+ if topic_id not in G:
+ G.add_node(topic_id, type="topic", label=tp, count=0,
+ language="", stars=0, forks=0, description="")
+ # 累计该主题被多少仓库覆盖
+ G.nodes[topic_id]["count"] += 1
+ weight = n / max_topic_count if max_topic_count else 0.0
+ G.add_edge(repo_id, topic_id, type="covers_topic",
+ weight=round(weight, 4))
+ topic_repos[tp].add(repo_id)
+ pairs.append(topic_id)
+ repo_topic_pairs[repo_id] = pairs
+
+ # ---- 3) 学者节点 + contributes_to / owns ----
+ for fullname, contribs in contributors_map.items():
+ repo_id = f"repo:{fullname}"
+ if repo_id not in G:
+ continue
+ owner_login = ""
+ # 从 repos 列表里取该仓库 author.login(判定 owns)
+ for r in repos:
+ if (r.get("fullname") or c.repo_fullname(r)) == fullname:
+ owner_login = c.login_of(r.get("author") or {})
+ break
+ for contrib in contribs:
+ login = c.login_of(contrib)
+ if is_bot(login):
+ continue
+ scholar_id = f"scholar:{login}"
+ if scholar_id not in G:
+ G.add_node(scholar_id, type="scholar", label=login,
+ language="", stars=0, forks=0, description="")
+ weight = parse_ratio(contrib.get("contribution_perc"))
+ G.add_edge(scholar_id, repo_id, type="contributes_to",
+ weight=round(weight, 4))
+ if login == owner_login:
+ G.add_edge(scholar_id, repo_id, type="owns", weight=1.0)
+ scholar_repos[login].add(repo_id)
+
+ # ---- 4) collaborates_with(共享同一 repo 的两两学者)----
+ for contribs in contributors_map.values():
+ logins = [c.login_of(x) for x in contribs if not is_bot(c.login_of(x))]
+ logins = sorted(set(logins))
+ if len(logins) < 2:
+ continue
+ for i in range(len(logins)):
+ for j in range(i + 1, len(logins)):
+ a = f"scholar:{logins[i]}"
+ b = f"scholar:{logins[j]}"
+ # 双向(无向语义;MultiDiGraph 用两条边近似)
+ G.add_edge(a, b, type="collaborates_with", weight=1.0)
+ G.add_edge(b, a, type="collaborates_with", weight=1.0)
+
+ # ---- 5) related_to(同一 repo 内共现的两两主题)----
+ for repo_id, tids in repo_topic_pairs.items():
+ for i in range(len(tids)):
+ for j in range(i + 1, len(tids)):
+ a, b = tids[i], tids[j]
+ G.add_edge(a, b, type="related_to", weight=1.0)
+ G.add_edge(b, a, type="related_to", weight=1.0)
+
+ # ---- 6) 导出 ----
+ nodes_out = []
+ for nid, attrs in G.nodes(data=True):
+ nodes_out.append({
+ "id": nid,
+ "type": attrs.get("type", ""),
+ "label": attrs.get("label", nid),
+ "props": {k: v for k, v in attrs.items()
+ if k not in ("type", "label")},
+ })
+ edges_out = []
+ for u, v, attrs in G.edges(data=True):
+ edges_out.append({
+ "source": u,
+ "target": v,
+ "type": attrs.get("type", ""),
+ "weight": attrs.get("weight", 1.0),
+ })
+
+ # 核心学者:按出现 repo 数排序
+ core_scholars = sorted(
+ ({"login": lg, "repo_count": len(rs)} for lg, rs in scholar_repos.items()),
+ key=lambda x: (-x["repo_count"], x["login"]),
+ )[:15]
+
+ # 核心团队:仅统计「组织」类型(非个人 User)的仓库拥有者,按拥有仓库数排序。
+ # (个人账号不算团队;author.type 区分 User / Organization)
+ owner_count: dict[str, int] = {}
+ owner_is_org: dict[str, bool] = {}
+ for r in repos:
+ author = r.get("author") or {}
+ lg = c.login_of(author)
+ if not lg:
+ continue
+ owner_count[lg] = owner_count.get(lg, 0) + 1
+ tp = str(author.get("type", "")).lower()
+ if tp and tp not in ("user", ""):
+ owner_is_org[lg] = True
+ owner_logins = sorted(
+ [{"login": lg, "repo_count": cnt, "type": "organization"}
+ for lg, cnt in owner_count.items() if owner_is_org.get(lg)],
+ key=lambda x: (-x["repo_count"], x["login"]),
+ )
+
+ # 主题热度:取所有 description 的 top10(含图谱里实际命中的 count)
+ heat = _topic_heat(descriptions, top=10)
+
+ return {
+ "scenario": "S2_research_knowledge_graph",
+ "keywords": list(keywords or []),
+ "nodes": nodes_out,
+ "edges": edges_out,
+ "core_scholars": core_scholars,
+ "core_teams": owner_logins,
+ "topic_heat": heat,
+ "meta": {
+ "keywords": list(keywords or []),
+ "repo_count": len(repo_nodes),
+ "node_count": G.number_of_nodes(),
+ "edge_count": G.number_of_edges(),
+ "scholar_count": sum(1 for n in nodes_out if n["type"] == "scholar"),
+ "topic_count": sum(1 for n in nodes_out if n["type"] == "topic"),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# 取数(在线:调 gitlink-cli)
+# ---------------------------------------------------------------------------
+
+def collect(keywords: list[str], repos_limit: int = 20) -> dict[str, Any]:
+ """按关键词搜索仓库并取其 info/contributors/languages/readme,返回原始数据。
+
+ 与 build_graph() 解耦:本函数可被替换为 mock(单测里直接构造数据喂 build_graph)。
+ """
+ seen: dict[str, dict] = {} # fullname -> 归一化的 repo dict
+ for kw in keywords:
+ for r in c.search_repos(kw, limit=repos_limit):
+ fullname = c.repo_fullname(r)
+ if not fullname or fullname in seen:
+ continue
+ seen[fullname] = _normalize_search_hit(r, fullname)
+ if len(seen) >= repos_limit:
+ break
+
+ repos = list(seen.values())[:repos_limit]
+ contributors_map: dict[str, list[dict]] = {}
+ languages_map: dict[str, dict] = {}
+ readmes: dict[str, str] = {}
+ issues_map: dict[str, list[dict]] = {}
+ prs_map: dict[str, list[dict]] = {}
+ for r in repos:
+ fullname = r["fullname"]
+ owner, _, name = fullname.partition("/")
+ # 用仓库完整 info 覆盖搜索结果的稀疏字段
+ info = c.repo_info(owner, name)
+ if info:
+ r["description"] = c.as_str(info.get("description")) or r.get("description", "")
+ r["praises_count"] = c.as_int(info.get("praises_count") or info.get("watchers_count"))
+ r["forked_count"] = c.as_int(info.get("forked_count"))
+ # 更新时间(飙升/热度排序用;GitLink 字段名兜底多个)
+ r["updated_at"] = (info.get("updated_at") or info.get("time")
+ or info.get("updated_on") or r.get("updated_at"))
+ if info.get("language") and isinstance(info["language"], dict):
+ r["language"] = info["language"]
+ contributors_map[fullname] = c.contributors(owner, name, limit=100)
+ languages_map[fullname] = c.languages(owner, name)
+ readmes[fullname] = c.readme(owner, name)[:4000]
+ # 活跃讨论:开放的 Issue / PR(评论多的=热讨论)
+ issues_map[fullname] = c.issues(owner, name, state="open", max_pages=1, page_size=50)
+ prs_map[fullname] = c.prs(owner, name, state="open", max_pages=1, page_size=50)
+ return {"repos": repos, "contributors_map": contributors_map,
+ "languages_map": languages_map, "readmes": readmes,
+ "issues_map": issues_map, "prs_map": prs_map}
+
+
+def _normalize_search_hit(r: dict, fullname: str) -> dict:
+ """把 search_repos 返回项归一化为 build_graph 期望的形状。"""
+ lang_obj = r.get("language")
+ if not isinstance(lang_obj, dict):
+ lang_obj = {"name": c.as_str(lang_obj)}
+ return {
+ "fullname": fullname,
+ "identifier": r.get("identifier", fullname.split("/")[-1]),
+ "author": r.get("author") or {},
+ "description": c.as_str(r.get("description")),
+ "language": lang_obj,
+ "praises_count": c.as_int(r.get("praises_count")),
+ "forked_count": c.as_int(r.get("forked_count")),
+ "forked_from_project_id": r.get("forked_from_project_id"),
+ }
+
+
+# ---------------------------------------------------------------------------
+# 渲染:Mermaid / DOT / Markdown 报告
+# ---------------------------------------------------------------------------
+
+_NODE_LIMIT = 40 # 防止 Mermaid 爆炸
+
+_NODE_STYLE = {
+ "repo": ("repoNode", "#4C78A8"),
+ "scholar": ("scholarNode", "#F58518"),
+ "topic": ("topicNode", "#54A24B"),
+}
+
+
+def _safe_id(nid: str) -> str:
+ """Mermaid/DOT 节点 id 用安全字符(去冒号斜杠)。"""
+ return nid.replace(":", "_").replace("/", "_").replace("-", "_")
+
+
+def render_mermaid(result: dict[str, Any], node_limit: int = _NODE_LIMIT) -> str:
+ """渲染前 ~40 节点的 Mermaid graph TD(带 classDef 着色)。"""
+ lines = ["```mermaid", "graph TD"]
+ # 类定义
+ for t, (cls, color) in _NODE_STYLE.items():
+ lines.append(f" classDef {cls} fill:{color},stroke:#333,color:#fff;")
+
+ nodes = result.get("nodes", [])
+ edges = result.get("edges", [])
+ # 取前 node_limit 个节点(repo 优先,再 scholar,再 topic)
+ type_order = {"repo": 0, "scholar": 1, "topic": 2}
+ ordered = sorted(nodes, key=lambda n: (type_order.get(n["type"], 9), n["id"]))
+ picked = ordered[:node_limit]
+ picked_ids = {n["id"] for n in picked}
+
+ label_map: dict[str, str] = {}
+ for n in picked:
+ sid = _safe_id(n["id"])
+ label = n["label"].replace('"', "'")
+ lines.append(f' {sid}["{label}"]')
+ label_map[n["id"]] = sid
+ cls = _NODE_STYLE.get(n["type"], ("", ""))[0]
+ if cls:
+ lines.append(f" class {sid} {cls};")
+
+ # 只画两端都在 picked 内的边;去重(同源同目标同类只画一条)
+ seen_edge: set[tuple] = set()
+ for e in edges:
+ if e["source"] not in picked_ids or e["target"] not in picked_ids:
+ continue
+ key = (e["source"], e["target"], e["type"])
+ if key in seen_edge:
+ continue
+ seen_edge.add(key)
+ a = label_map[e["source"]]
+ b = label_map[e["target"]]
+ w = e.get("weight", 1.0)
+ et = e["type"]
+ # 不同边类型用不同箭头标签
+ lines.append(f' {a} -- "{et}({w:.2f})" --> {b}')
+
+ lines.append("```")
+ return "\n".join(lines)
+
+
+def render_dot(result: dict[str, Any], node_limit: int = _NODE_LIMIT) -> str:
+ """渲染 Graphviz DOT 字符串(带节点着色)。"""
+ lines = ["digraph G {", ' rankdir=LR;',
+ ' graph [fontname="Helvetica"];',
+ ' node [fontname="Helvetica", style="filled"];',
+ ' edge [fontname="Helvetica"];']
+ type_order = {"repo": 0, "scholar": 1, "topic": 2}
+ nodes = result.get("nodes", [])
+ edges = result.get("edges", [])
+ ordered = sorted(nodes, key=lambda n: (type_order.get(n["type"], 9), n["id"]))
+ picked = ordered[:node_limit]
+ picked_ids = {n["id"] for n in picked}
+ label_map: dict[str, str] = {}
+ for n in picked:
+ sid = _safe_id(n["id"])
+ label = n["label"].replace('"', "'")
+ color = _NODE_STYLE.get(n["type"], ("", "#CCCCCC"))[1]
+ lines.append(f' {sid} [label="{label}", fillcolor="{color}"];')
+ label_map[n["id"]] = sid
+ for e in edges:
+ if e["source"] not in picked_ids or e["target"] not in picked_ids:
+ continue
+ a = label_map[e["source"]]
+ b = label_map[e["target"]]
+ lines.append(f' {a} -> {b} [label="{e["type"]}"];')
+ lines.append("}")
+ return "\n".join(lines)
+
+
+def render_report(result: dict[str, Any]) -> str:
+ meta = result["meta"]
+ heat = result.get("topic_heat", [])
+ scholars = result.get("core_scholars", [])
+ teams = result.get("core_teams", [])
+ lines = [
+ "# 科研热点追踪与知识图谱报告\n",
+ f"> 场景 S2 · 子赛题四「应用 GitLink 辅助科研」\n",
+ f"**关键词**: {', '.join(result.get('keywords') or []) or '—'}\n",
+ "## 一、图谱概览\n",
+ f"- 仓库节点: **{meta['repo_count']}**",
+ f"- 学者节点: **{meta['scholar_count']}**",
+ f"- 主题节点: **{meta['topic_count']}**",
+ f"- 节点总数: **{meta['node_count']}**",
+ f"- 边总数: **{meta['edge_count']}**\n",
+ "## 二、主题热度榜(基于全部仓库 description)\n",
+ "| 排名 | 主题 | 覆盖仓库数 |",
+ "|------|------|-----------|",
+ ]
+ if heat:
+ for i, h in enumerate(heat, 1):
+ lines.append(f"| {i} | `{h['topic']}` | {h['count']} |")
+ else:
+ lines.append("| — | (未识别到明确主题) | — |")
+ lines += ["\n## 三、核心学者(按出现仓库数排序)\n",
+ "| 排名 | 学者 | 关联仓库数 |",
+ "|------|------|-----------|"]
+ if scholars:
+ for i, s in enumerate(scholars[:10], 1):
+ lines.append(f"| {i} | `{s['login']}` | {s['repo_count']} |")
+ else:
+ lines.append("| — | (未识别到学者) | — |")
+ lines += [f"\n## 四、核心团队(组织型仓库拥有者)\n"]
+ if teams:
+ lines += ["| 团队/组织 | 拥有仓库数 |", "|-----------|-----------|"]
+ for t in teams:
+ if isinstance(t, dict):
+ lines.append(f"| `{t.get('login')}` | {t.get('repo_count', 0)} |")
+ else:
+ lines.append(f"| `{t}` | — |")
+ else:
+ lines.append("(该批仓库均由个人账号拥有,无组织型团队)")
+ # 飙升/热门项目(热度分数排序;日均增星 velocity 作趋势代理)
+ trending = result.get("trending_repos", [])
+ lines += ["\n## 五、热门 / 飙升项目(热度排序)\n",
+ "| 仓库 | 语言 | ★ | ⑂ | 日均★ | 最近更新 |",
+ "|------|------|---:|---:|---:|----------|"]
+ if trending:
+ for t in trending[:10]:
+ lines.append(f"| `{t['repo']}` | {t['language'] or '—'} | {t['stars']} | "
+ f"{t['forks']} | {t['velocity']} | {t['updated'] or '—'} |")
+ else:
+ lines.append("| — | (未取到仓库) | | | | |")
+
+ # 活跃讨论
+ active = result.get("active_discussions", [])
+ lines += ["\n## 六、活跃讨论(评论最多的 Issue / PR)\n",
+ "# | 类型 | 仓库 | 标题 | 评论 |", "|-|------|------|------|---:|"]
+ if active:
+ for i, a in enumerate(active[:10], 1):
+ lines.append(f"| {i} | {a['type']} | `{a['repo']}` | {a['title'][:50]} | {a['comments']} |")
+ else:
+ lines.append("| — | | | (暂无明显热讨论) | |")
+
+ lines.append("\n_配套产物:graph.json(结构化)+ graph.mmd(Mermaid)+ "
+ "graph.dot(Graphviz DOT)_\n")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+
+def main():
+ ap = argparse.ArgumentParser(description="S2 科研热点追踪与知识图谱")
+ ap.add_argument("--keywords", required=True,
+ help='逗号分隔的关键词,如 "deep learning,nlp"')
+ ap.add_argument("--repos-limit", type=int, default=20,
+ help="每关键词搜索后去重取 top N 仓库(默认 20)")
+ ap.add_argument("--out", help="输出目录(写 graph.json/report.md/graph.mmd/graph.dot);"
+ "省略则打印 JSON")
+ args = ap.parse_args()
+
+ keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
+ data = collect(keywords, repos_limit=args.repos_limit)
+ result = build_graph(data["repos"], data["contributors_map"],
+ data["languages_map"], data["readmes"], keywords=keywords)
+ # 热点追踪两翼:飙升项目 + 活跃讨论(图谱之外的"追踪"信号)
+ result["trending_repos"] = compute_trending(data["repos"])
+ result["active_discussions"] = compute_active(data["issues_map"], data["prs_map"])
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ with open(os.path.join(args.out, "graph.json"), "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
+ with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f:
+ f.write(render_report(result))
+ with open(os.path.join(args.out, "graph.mmd"), "w", encoding="utf-8") as f:
+ f.write(render_mermaid(result))
+ with open(os.path.join(args.out, "graph.dot"), "w", encoding="utf-8") as f:
+ f.write(render_dot(result))
+ print(f"✓ S2 知识图谱完成 → {args.out}/graph.json | report.md | graph.mmd | graph.dot")
+ print(f" 节点: {result['meta']['node_count']} 边: {result['meta']['edge_count']} "
+ f"仓库: {result['meta']['repo_count']}")
+ heat = result["topic_heat"][:5]
+ print(f" Top 主题: {', '.join(h['topic'] for h in heat)}")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/hotspot.py b/scripts/research/hotspot.py
new file mode 100644
index 0000000..8dee928
--- /dev/null
+++ b/scripts/research/hotspot.py
@@ -0,0 +1,507 @@
+"""hotspot.py — 科研热点追踪(全栈重构版)。
+
+输入一组科研关键词,从 GitLink 平台按关键词搜索相关仓库,对每个仓库:
+ - 拉取 repo_info(stars / forks / 更新时间 / 贡献者)
+ - 拉取 issue + pr 列表(活跃讨论按评论数排序)
+ - 从 description + readme 抽取主题标签
+ - 聚合学者/团队贡献网络
+
+输出:hotspot.json(结构化数据)+ report.md(中文简报)。
+
+取数与算法分离:collect() 在线取数,compute_*() 纯函数离线可测。
+
+用法:
+ python hotspot.py --keywords "deep learning,机器学习" --repos-limit 12 --out ./out
+ python hotspot.py --keywords "computer vision" # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from collections import Counter, defaultdict
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c
+import topics as T
+
+
+# ---------------------------------------------------------------------------
+# 小工具
+# ---------------------------------------------------------------------------
+
+def _now_ts() -> float:
+ return time.time()
+
+
+def _days_ago(ts: float) -> int:
+ """粗略计算距今多少天(非精确日历差,但足以排序)。"""
+ return max(0, int((_now_ts() - ts) / 86400))
+
+
+# ---------------------------------------------------------------------------
+# 热度评分(纯函数,离线可测)
+# ---------------------------------------------------------------------------
+
+def compute_trending_score(stars: int, forks: int, updated_days_ago: int) -> int:
+ """仓库热度综合评分。
+
+ 公式:stars + forks × 2 + 近期更新加成(30 天内加成最高)。
+ 满分无上限,用于仓库间横向排序。
+ """
+ base = stars + forks * 2
+ if updated_days_ago <= 7:
+ recency = 30
+ elif updated_days_ago <= 30:
+ recency = 20
+ elif updated_days_ago <= 90:
+ recency = 10
+ elif updated_days_ago <= 180:
+ recency = 5
+ else:
+ recency = 0
+ return base + recency
+
+
+def compute_velocity(stars: int, updated_days_ago: int) -> float:
+ """日均星标增速(近似值)。"""
+ if updated_days_ago <= 0:
+ updated_days_ago = 1
+ return round(stars / max(updated_days_ago, 1), 2)
+
+
+# ---------------------------------------------------------------------------
+# 在线取数
+# ---------------------------------------------------------------------------
+
+def collect(keywords: list[str], repos_limit: int = 12) -> dict:
+ """在线取数:多关键词搜索 → 去重 → 仓库详情 → issue/PR → 贡献者 → README。
+
+ 返回原始数据字典,供 compute() 消费。
+ """
+ # Step 1: 多关键词搜索
+ seen: dict[str, dict] = {}
+ for kw in keywords:
+ results = c.search_repos(kw, limit=repos_limit)
+ for proj in results:
+ full = c.repo_fullname(proj)
+ if not full:
+ continue
+ if full not in seen:
+ seen[full] = {
+ "fullname": full,
+ "matched_keywords": [],
+ "project": proj,
+ "info": None,
+ "issues": [],
+ "prs": [],
+ "contributors": [],
+ "readme": "",
+ "languages": {},
+ }
+ seen[full]["matched_keywords"].append(kw)
+
+ repos = list(seen.values())
+
+ # 先按 stars 粗排,截断到 repos_limit 再深度取数(节省 API 调用)
+ def _star_sort_key(r: dict) -> int:
+ p = r["project"]
+ return -(c.as_int(p.get("praises_count") or p.get("stars_count") or 0))
+
+ repos.sort(key=_star_sort_key)
+ repos = repos[:repos_limit]
+
+ # Step 2: 逐仓库取详情
+ for r in repos:
+ full = r["fullname"]
+ parts = full.split("/", 1)
+ if len(parts) != 2:
+ continue
+ owner, repo = parts[0], parts[1]
+
+ # repo_info
+ info = c.repo_info(owner, repo)
+ r["info"] = info if isinstance(info, dict) else {}
+
+ # issues(取 open 状态,限制 5 页以控制耗时)
+ r["issues"] = c.issues(owner, repo, state="open", max_pages=3, page_size=30)
+
+ # PRs(取 open 状态)
+ r["prs"] = c.prs(owner, repo, state="open", max_pages=3, page_size=30)
+
+ # contributors
+ r["contributors"] = c.contributors(owner, repo)
+
+ # readme(前 4000 字符,仅用于主题抽取)
+ r["readme"] = (c.readme(owner, repo) or "")[:4000]
+
+ # languages
+ r["languages"] = c.languages(owner, repo)
+
+ return {"keywords": keywords, "repos": repos, "repos_limit": repos_limit}
+
+
+# ---------------------------------------------------------------------------
+# 离线计算(纯函数,不联网)
+# ---------------------------------------------------------------------------
+
+def compute(raw: dict) -> dict:
+ """从 collect() 的原始数据计算出所有热点指标。
+
+ 输入结构见 collect() 返回值;输出为标准 hotspot.json 结构。
+ """
+ repos_raw = raw.get("repos") or []
+
+ # ------ trending_repos ------
+ trending: list[dict] = []
+ now = _now_ts()
+ for r in repos_raw:
+ info = r.get("info") or {}
+ proj = r.get("project") or {}
+
+ stars = c.as_int(
+ info.get("watchers_count")
+ or info.get("praises_count")
+ or proj.get("praises_count")
+ or 0
+ )
+ forks = c.as_int(
+ info.get("forked_count")
+ or proj.get("forked_count")
+ or 0
+ )
+ # 解析更新时间
+ update_str = (
+ info.get("full_last_update_time")
+ or info.get("last_update_time")
+ or proj.get("full_last_update_time")
+ or proj.get("last_update_time")
+ or ""
+ )
+ update_ts = _parse_ts(update_str)
+ days = _days_ago(update_ts)
+ score = compute_trending_score(stars, forks, days)
+ velocity = compute_velocity(stars, days)
+ language = ""
+ lang_obj = info.get("language") or proj.get("language")
+ if isinstance(lang_obj, dict):
+ language = lang_obj.get("name") or ""
+ elif isinstance(lang_obj, str):
+ language = lang_obj
+
+ trending.append({
+ "repo": r["fullname"],
+ "description": (
+ info.get("description")
+ or proj.get("description")
+ or ""
+ ),
+ "language": language,
+ "stars": stars,
+ "forks": forks,
+ "score": score,
+ "velocity": velocity,
+ "matched_keywords": r.get("matched_keywords", []),
+ "updated": _fmt_ts(update_ts),
+ "contributors_count": c.as_int(info.get("contributor_users_count") or 0),
+ "releases_count": c.as_int(info.get("version_releases_count") or 0),
+ })
+
+ trending.sort(key=lambda x: -x["score"])
+
+ # ------ active_discussions ------
+ discussions: list[dict] = []
+ for r in repos_raw:
+ full = r["fullname"]
+ # issues
+ for iss in r.get("issues") or []:
+ comments = c.as_int(iss.get("comment_count") or iss.get("comments") or 0)
+ if comments > 0:
+ discussions.append({
+ "type": "issue",
+ "repo": full,
+ "title": iss.get("title") or "(无标题)",
+ "number": iss.get("index") or iss.get("number") or "",
+ "state": iss.get("state") or iss.get("status") or "open",
+ "comments": comments,
+ })
+ # PRs
+ for pr in r.get("prs") or []:
+ comments = c.as_int(pr.get("comment_count") or pr.get("comments") or 0)
+ if comments > 0:
+ discussions.append({
+ "type": "pr",
+ "repo": full,
+ "title": pr.get("title") or "(无标题)",
+ "number": pr.get("index") or pr.get("number") or "",
+ "state": pr.get("state") or pr.get("status") or "open",
+ "comments": comments,
+ })
+
+ discussions.sort(key=lambda x: -x["comments"])
+
+ # ------ topic_heat ------
+ # 对所有仓库的 description + readme 跑 topic_counter
+ texts = []
+ for r in repos_raw:
+ info = r.get("info") or {}
+ proj = r.get("project") or {}
+ desc = info.get("description") or proj.get("description") or ""
+ texts.append(desc)
+ readme = r.get("readme") or ""
+ if readme:
+ texts.append(readme)
+ heat = T.topic_counter(texts)
+ topic_heat = [
+ {"topic": topic, "count": count}
+ for topic, count in heat.most_common(10)
+ ]
+
+ # ------ core_scholars / core_teams ------
+ scholar_repo_count: Counter = Counter()
+ # 记录每个 scholar 关联的仓库名
+ scholar_repos: dict[str, list[str]] = defaultdict(list)
+ for r in repos_raw:
+ full = r["fullname"]
+ for contrib in r.get("contributors") or []:
+ login = c.login_of(contrib)
+ if not login or _is_bot(login):
+ continue
+ scholar_repo_count[login] += 1
+ if full not in scholar_repos[login]:
+ scholar_repos[login].append(full)
+
+ core_scholars = [
+ {
+ "login": login,
+ "repo_count": count,
+ "repos": scholar_repos.get(login, []),
+ }
+ for login, count in scholar_repo_count.most_common(12)
+ ]
+
+ # 核心团队:按 owner(仓库第一段)聚合
+ org_repo: Counter = Counter()
+ for r in repos_raw:
+ full = r["fullname"]
+ org = full.split("/")[0] if "/" in full else full
+ org_repo[org] += 1
+
+ core_teams = [
+ {"login": org, "repo_count": count}
+ for org, count in org_repo.most_common(8)
+ ]
+
+ # ------ meta ------
+ total_issues = sum(len(r.get("issues") or []) for r in repos_raw)
+ total_prs = sum(len(r.get("prs") or []) for r in repos_raw)
+
+ return {
+ "scenario": "hotspot",
+ "keywords": raw.get("keywords") or [],
+ "trending_repos": trending,
+ "active_discussions": discussions,
+ "topic_heat": topic_heat,
+ "core_scholars": core_scholars,
+ "core_teams": core_teams,
+ "meta": {
+ "repo_count": len(repos_raw),
+ "issue_count": total_issues,
+ "pr_count": total_prs,
+ "scholar_count": len(scholar_repo_count),
+ "discussion_count": len(discussions),
+ "topic_count": len(topic_heat),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# 时间工具
+# ---------------------------------------------------------------------------
+
+def _parse_ts(v: Any) -> float:
+ """把 GitLink 时间戳/字符串尽量解析为 Unix 浮点秒。"""
+ if v is None:
+ return 0.0
+ if isinstance(v, (int, float)):
+ if v > 1_000_000_000_000:
+ return v / 1000.0
+ return float(v)
+ s = str(v).strip()
+ if not s:
+ return 0.0
+ # ISO 8601 格式
+ for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
+ try:
+ return time.mktime(time.strptime(s[:19] if len(s) >= 19 else s, fmt))
+ except ValueError:
+ continue
+ return 0.0
+
+
+def _fmt_ts(ts: float) -> str:
+ """Unix 浮点秒 → 'YYYY-MM-DD' 字符串。"""
+ if ts <= 0:
+ return "—"
+ try:
+ return time.strftime("%Y-%m-%d", time.localtime(ts))
+ except (ValueError, OSError):
+ return "—"
+
+
+_BOT_HINTS = ("bot", "i-robot", "dependabot", "renovate", "semantic-release-bot")
+
+
+def _is_bot(login: str) -> bool:
+ low = login.lower()
+ return any(h in low for h in _BOT_HINTS)
+
+
+# ---------------------------------------------------------------------------
+# 报告生成
+# ---------------------------------------------------------------------------
+
+def _report_md(output: dict) -> str:
+ """从热点 JSON 产出中文简报 Markdown。"""
+ kw = ", ".join(output.get("keywords") or [])
+ meta = output.get("meta") or {}
+ lines = [
+ f"# 🔬 科研热点追踪报告",
+ f"",
+ f"> 关键词:{kw}",
+ f"> 扫描时间:{_fmt_ts(_now_ts())}",
+ f"> 覆盖仓库:{meta.get('repo_count', 0)} 个 "
+ f"· 讨论 {meta.get('discussion_count', 0)} 条 "
+ f"· 主题 {meta.get('topic_count', 0)} 个 "
+ f"· 学者 {meta.get('scholar_count', 0)} 位",
+ f"",
+ ]
+
+ # 飙升项目 top 5
+ trending = output.get("trending_repos") or []
+ if trending:
+ lines.append("## 🔥 飙升项目 Top 5")
+ lines.append("")
+ lines.append("| # | 仓库 | 语言 | ★ Star | ⑂ Fork | 热度 | 更新 |")
+ lines.append("|---|------|------|--------|--------|------|------|")
+ for i, r in enumerate(trending[:5], 1):
+ lines.append(
+ f"| {i} | `{r['repo']}` | {r['language'] or '—'} | "
+ f"{r['stars']} | {r['forks']} | {r['score']} | {r['updated']} |"
+ )
+ lines.append("")
+
+ # 活跃讨论 top 5
+ discussions = output.get("active_discussions") or []
+ if discussions:
+ lines.append("## 💬 活跃讨论 Top 5")
+ lines.append("")
+ for i, d in enumerate(discussions[:5], 1):
+ tp = "🐛 Issue" if d["type"] == "issue" else "🔀 PR"
+ lines.append(
+ f"{i}. {tp} [{d['repo']}] {d['title']} "
+ f"(#{d['number']} · {d['comments']} 💬)"
+ )
+ lines.append("")
+
+ # 热门主题
+ topic_heat = output.get("topic_heat") or []
+ if topic_heat:
+ lines.append("## 📊 热门主题")
+ lines.append("")
+ for t in topic_heat:
+ bar = "█" * min(t["count"], 20)
+ lines.append(f"- **{t['topic']}** — {t['count']} 个仓库 {bar}")
+ lines.append("")
+
+ # 核心学者
+ scholars = output.get("core_scholars") or []
+ if scholars:
+ lines.append("## 👥 核心学者")
+ lines.append("")
+ for s in scholars[:5]:
+ lines.append(f"- **{s['login']}** — 关联 {s['repo_count']} 个仓库")
+ lines.append("")
+
+ # 核心团队
+ teams = output.get("core_teams") or []
+ if teams:
+ lines.append("## 🏛 活跃组织/团队")
+ lines.append("")
+ for t in teams:
+ lines.append(f"- **{t['login']}** — {t['repo_count']} 个仓库")
+ lines.append("")
+
+ lines.append("---")
+ lines.append("*由 gitlink-research-hotspot 自动生成*")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# CLI 入口
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+ ap = argparse.ArgumentParser(
+ description="科研热点追踪 — 多角度扫描 GitLink 科研仓库"
+ )
+ ap.add_argument(
+ "--keywords", "-k",
+ default="deep learning,机器学习",
+ help="搜索关键词,逗号分隔(默认 deep learning,机器学习)",
+ )
+ ap.add_argument(
+ "--repos-limit", type=int, default=12,
+ help="最大分析仓库数(默认 12)",
+ )
+ ap.add_argument(
+ "--out", "-o",
+ default="",
+ help="输出目录(不传则仅打印 JSON 到 stdout)",
+ )
+ args = ap.parse_args()
+
+ kw_list = [k.strip() for k in args.keywords.split(",") if k.strip()]
+ if not kw_list:
+ print(json.dumps({"ok": False, "error": "keywords required"}, ensure_ascii=False))
+ sys.exit(1)
+
+ # 取数
+ sys.stderr.write(f"[hotspot] 关键词: {kw_list} 上限: {args.repos_limit}\n")
+ sys.stderr.flush()
+ raw = collect(kw_list, args.repos_limit)
+
+ # 计算
+ sys.stderr.write(f"[hotspot] 仓库: {len(raw['repos'])} 计算热点…\n")
+ sys.stderr.flush()
+ output = compute(raw)
+
+ json_text = json.dumps(output, ensure_ascii=False, indent=2)
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ json_path = os.path.join(args.out, "hotspot.json")
+ with open(json_path, "w", encoding="utf-8") as f:
+ f.write(json_text)
+ report_path = os.path.join(args.out, "report.md")
+ with open(report_path, "w", encoding="utf-8") as f:
+ f.write(_report_md(output))
+ sys.stderr.write(
+ f"[hotspot] ✓ 完成 "
+ f"仓库={output['meta']['repo_count']} "
+ f"讨论={output['meta']['discussion_count']} "
+ f"主题={output['meta']['topic_count']} "
+ f"学者={output['meta']['scholar_count']}\n"
+ )
+ sys.stderr.write(f"[hotspot] 产物: {json_path}, {report_path}\n")
+ sys.stderr.flush()
+ else:
+ print(json_text)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/lineage.py b/scripts/research/lineage.py
new file mode 100644
index 0000000..5a14824
--- /dev/null
+++ b/scripts/research/lineage.py
@@ -0,0 +1,474 @@
+"""lineage.py — S1 仓库级科研项目洞悉。
+
+对一个 GitLink 科研仓库做「项目谱系(lineage)」分析:从默认分支提交时间线、
+合并 PR 的演进模式、文档演进、实验/评测文件组织、关键创新点五个角度,回答
+「这个科研项目是怎么一步步长成现在这样的、值得引用/复现到哪一步」。
+
+技术栈 = Go 出数据(经 gitlink-cli + collect.py 取数)+ Python 做算法(本文件)。
+所有 GitLink 操作经 gitlink-cli,禁止用 gh/glab(见 gitlink-shared)。
+
+用法:
+ python lineage.py --owner mindspore-Ecosystem --repo mindspore --out ./out
+ python lineage.py --owner O --repo R # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from collections import Counter
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c # noqa: E402
+
+# ---------------------------------------------------------------------------
+# 提交时间解析(commit timestamp 可能是 ISO 字符串或整数秒)
+# ---------------------------------------------------------------------------
+
+def _to_epoch(v: Any) -> float:
+ """把 GitLink commit 的 timestamp(ISO 字符串或整数秒)统一解析为 epoch 秒。
+
+ 失败返回 0.0。
+ """
+ if v is None:
+ return 0.0
+ if isinstance(v, (int, float)):
+ # 毫秒级时间戳兜底(GitLink 多为秒)
+ return float(v) / 1000.0 if v > 1e12 else float(v)
+ s = str(v).strip()
+ if not s:
+ return 0.0
+ # 纯数字串
+ if s.isdigit():
+ f = float(s)
+ return f / 1000.0 if f > 1e12 else f
+ # ISO 8601:'2024-05-01T08:00:00Z' / '2024-05-01 08:00:00'
+ s = s.replace("Z", "+00:00")
+ try:
+ import datetime as _dt
+ return _dt.datetime.fromisoformat(s).timestamp()
+ except (ValueError, TypeError):
+ # 退而求其次:抽首个 YYYY-MM-DD
+ m = re.search(r"(\d{4})-(\d{2})-(\d{2})", str(v))
+ if m:
+ try:
+ import datetime as _dt
+ return _dt.datetime(int(m.group(1)), int(m.group(2)),
+ int(m.group(3))).timestamp()
+ except ValueError:
+ return 0.0
+ return 0.0
+
+
+def _iso_date(epoch: float) -> str:
+ """epoch 秒 → 'YYYY-MM-DD' 字符串(0 → '')。"""
+ if not epoch:
+ return ""
+ import datetime as _dt
+ try:
+ return _dt.datetime.utcfromtimestamp(epoch).strftime("%Y-%m-%d")
+ except (OSError, ValueError, OverflowError):
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# 分类器:实验/评测文件 vs 文档文件
+# ---------------------------------------------------------------------------
+
+# 命中即判为「实验/评测/数据」类文件(科研产物信号)。
+# 匹配「目录段或文件名」:以 experiment/benchmark/eval/test(s)/data 开头
+# (后接 [\w-]* 续写,或紧接分隔符/串尾)。
+_EXPERIMENT_RE = re.compile(
+ r"(^|[_/\-])(experiment[\w-]*|benchmark[\w-]*|eval[\w-]*|tests?|data)([/\-\._]|$)",
+ re.IGNORECASE,
+)
+
+
+def is_experiment_file(path: str) -> bool:
+ """判断文件路径是否属于「实验/评测/数据」类(科研产物)。
+
+ 命中 experiment*/benchmark*/eval*/tests?/data/ 任意一段(作为目录名或文件名前缀)
+ 返回 True。例如:
+ - experiments/run.py, experiment_train.py, benchmark/eval.py
+ - tests/test_x.py, data/dataset.csv, src/benchmark_infer.py
+ """
+ if not path:
+ return False
+ return bool(_EXPERIMENT_RE.search(path))
+
+
+def is_doc_file(path: str) -> bool:
+ """判断文件路径是否属于文档类:*.md(任意位置)或 docs/ 下任意文件。"""
+ if not path:
+ return False
+ low = path.lower()
+ if low.endswith(".md"):
+ return True
+ return low.startswith("docs/") or ("/docs/" in low)
+
+
+# ---------------------------------------------------------------------------
+# 分支地图(单分支简化:默认分支为唯一分支)
+# ---------------------------------------------------------------------------
+
+def build_branch_map(commits: list[dict], default_branch: str) -> list[dict]:
+ """构建分支活跃度地图。本场景单分支简化:默认分支为唯一分支。
+
+ 返回 [{name, commits, last_active, is_default}]。
+ """
+ n = len(commits) if isinstance(commits, list) else 0
+ last = 0.0
+ for cm in commits or []:
+ t = _to_epoch(cm.get("timestamp"))
+ if t and t > last:
+ last = t
+ return [{
+ "name": default_branch or "master",
+ "commits": n,
+ "last_active": _iso_date(last),
+ "is_default": True,
+ }]
+
+
+# ---------------------------------------------------------------------------
+# 提交时间线(按周/按日聚合)
+# ---------------------------------------------------------------------------
+
+def commit_timeline(commits: list[dict], bucket: str = "day") -> list[dict]:
+ """把提交按日期聚合为时间线,返回按日期升序的 [{date, count}]。
+
+ bucket ∈ {'day','week'};week 以 ISO 年-周 表示。
+ """
+ counter: Counter = Counter()
+ for cm in commits or []:
+ t = _to_epoch(cm.get("timestamp"))
+ if not t:
+ continue
+ if bucket == "week":
+ import datetime as _dt
+ iso = _dt.datetime.utcfromtimestamp(t).isocalendar()
+ key = f"{iso[0]}-W{iso[1]:02d}"
+ else:
+ key = _iso_date(t)
+ counter[key] += 1
+ return [{"date": k, "count": counter[k]} for k in sorted(counter)]
+
+
+# ---------------------------------------------------------------------------
+# 合并 PR 的演进模式
+# ---------------------------------------------------------------------------
+
+def pr_merge_patterns(merged_prs: list[dict]) -> list[dict]:
+ """从已合并 PR 抽取演进模式。
+
+ 返回 [{number, title, status, merged_time, changed_files}]。
+ changed_files 取 pr 提供的 changed_files / changedFiles / additions/deletions 的近似。
+ """
+ out: list[dict] = []
+ for pr in merged_prs or []:
+ if not isinstance(pr, dict):
+ continue
+ # 时间:优先 merged 时间字段,否则 pr_created_unix
+ merged_t = (pr.get("pr_merged_unix") or pr.get("merged_at")
+ or pr.get("pr_updated_unix") or pr.get("pr_created_unix"))
+ # 文件改动数:优先详情接口的 files_count(列表 API 不返回)
+ changed = (pr.get("files_count") or pr.get("changed_files")
+ or pr.get("changedFiles") or pr.get("file_nums") or 0)
+ # 兜底:用 additions/deletions 之和近似
+ if not changed:
+ changed = c.as_int(pr.get("additions"), 0) + c.as_int(pr.get("deletions"), 0)
+ # 标题只取首行 + 截断到 80 字(有些 PR 创建时把正文塞进了 title 字段)
+ raw_title = c.as_str(pr.get("title")).split("\n", 1)[0].strip()
+ title = raw_title[:80]
+ out.append({
+ "number": pr.get("index") or pr.get("number") or pr.get("id"),
+ "title": title,
+ "status": pr.get("status"),
+ "merged_time": _iso_date(_to_epoch(merged_t)),
+ "changed_files": c.as_int(changed),
+ })
+ # 按合并时间升序(空时间排末尾)
+ out.sort(key=lambda x: (x["merged_time"] == "", x["merged_time"]))
+ return out
+
+
+# ---------------------------------------------------------------------------
+# 文档演进(docs/*.md 的近似最后修改信息)
+# ---------------------------------------------------------------------------
+
+def doc_evolution(tree_entries: list[dict]) -> list[dict]:
+ """从仓库树抽取 docs/ 下的文档清单,近似其最后修改日期。
+
+ tree 来自 c.tree(owner, repo, path='docs')。每条 entry 形如
+ {name, path, type, ...}。最后修改日期在树端点通常不可得,用文件名中的
+ 日期或留空(report 中标注「近似」)。
+ 返回 [{file, last_date}]。
+ """
+ out: list[dict] = []
+ for e in tree_entries or []:
+ if not isinstance(e, dict):
+ continue
+ name = c.as_str(e.get("name") or e.get("path"))
+ path = c.as_str(e.get("path") or name)
+ if not is_doc_file(path):
+ continue
+ last = c.as_str(e.get("last_commit") or e.get("commit_date")
+ or e.get("date"))
+ if not last:
+ m = re.search(r"(\d{4})-(\d{2})-(\d{2})", path)
+ last = m.group(0) if m else ""
+ out.append({"file": name, "last_date": last})
+ out.sort(key=lambda x: (x["last_date"] == "", x["file"]))
+ return out
+
+
+# ---------------------------------------------------------------------------
+# 创新点识别(高影响合并)
+# ---------------------------------------------------------------------------
+
+def innovation_points(merged_prs: list[dict], commits: list[dict],
+ top: int = 8) -> list[dict]:
+ """识别高影响合并作为项目的创新/里程碑点。
+
+ 判据(任一):
+ - 改动文件数高(>= 中位数的 1.5 倍,或绝对值 >= 10)→ 「大规模重构/新特性」
+ - 标题含里程碑关键词(add/implement/feature/release/benchmark/...)→ 「特性引入」
+ 返回 [{description, evidence, category}],按影响度降序取前 top。
+ """
+ patterns = pr_merge_patterns(merged_prs)
+ if not patterns:
+ return []
+
+ files = [p["changed_files"] for p in patterns if p["changed_files"] > 0]
+ median = sorted(files)[len(files) // 2] if files else 0
+
+ MILESTONE_RE = re.compile(
+ r"(add|implement|support|feature|release|benchmark|refactor|"
+ r"experiment|dataset|train|inference|v\d+\.\d+)", re.IGNORECASE)
+
+ scored: list[tuple[float, dict]] = []
+ for p in patterns:
+ cf = p["changed_files"]
+ title = p["title"]
+ category = ""
+ impact = float(cf)
+ if cf >= 10 or (median and cf >= median * 1.5):
+ category = "大规模重构/新特性"
+ impact += 10
+ if MILESTONE_RE.search(title):
+ category = category or "特性引入"
+ impact += 5
+ if not category:
+ continue
+ evidence = (f"PR #{p['number']} 「{title[:48]}」 "
+ f"改动 {cf} 文件,合并于 {p['merged_time'] or '未知时间'}")
+ scored.append((impact, {
+ "description": title.strip() or f"PR #{p['number']}",
+ "evidence": evidence,
+ "category": category,
+ }))
+ scored.sort(key=lambda x: -x[0])
+ return [item for _, item in scored[:top]]
+
+
+# ---------------------------------------------------------------------------
+# 主流程:取数 + 算法
+# ---------------------------------------------------------------------------
+
+def lineage(owner: str, repo: str, branches_limit: int = 5) -> dict[str, Any]:
+ """取数 + 分析,返回完整 lineage 结果 dict。"""
+ info = c.repo_info(owner, repo)
+ default_branch = info.get("default_branch") or "master"
+
+ commits = c.commits(owner, repo, ref=default_branch, max_pages=10, page_size=100)
+ # 已合并 PR:state=merged(collect 透传)
+ merged_prs = c.prs(owner, repo, state="merged", max_pages=10, page_size=50)
+ # 列表 API 不返回文件改动数 → 逐个取详情补 files_count(限速 + 上限 30 个,控 API 调用)
+ for pr in merged_prs[:30]:
+ idx = pr.get("index") or pr.get("number") or pr.get("id")
+ if idx is None:
+ continue
+ try:
+ det = c.pr_detail(owner, repo, int(idx))
+ except (TypeError, ValueError):
+ det = {}
+ if det:
+ if det.get("files_count") is not None:
+ pr["files_count"] = det.get("files_count")
+ if det.get("commits_count") is not None:
+ pr["commits_count"] = det.get("commits_count")
+ tree_root = c.tree(owner, repo, ref=default_branch)
+ docs_tree = c.tree(owner, repo, path="docs", ref=default_branch)
+ _readme = c.readme(owner, repo, ref=default_branch)
+
+ # 扫描整棵树,挑出实验/评测文件
+ exp_files: list[str] = []
+ all_tree = (tree_root or []) + (docs_tree or [])
+ for e in all_tree:
+ if not isinstance(e, dict):
+ continue
+ p = c.as_str(e.get("path") or e.get("name"))
+ if p and is_experiment_file(p):
+ exp_files.append(p)
+
+ timeline = commit_timeline(commits, bucket="day")
+ branch_map = build_branch_map(commits, default_branch)[:branches_limit]
+ pr_patterns = pr_merge_patterns(merged_prs)
+ docs = doc_evolution(docs_tree if docs_tree else tree_root)
+ innovations = innovation_points(merged_prs, commits)
+
+ return {
+ "scenario": "S1_repository_research_insight",
+ "repo": f"{owner}/{repo}",
+ "default_branch": default_branch,
+ "commit_timeline": timeline,
+ "branch_map": branch_map,
+ "pr_merge_patterns": pr_patterns,
+ "doc_evolution": docs,
+ "experiment_files": sorted(set(exp_files)),
+ "innovation_points": innovations,
+ "meta": {
+ "commit_count": len(commits),
+ "merged_pr_count": len(merged_prs),
+ "doc_count": len(docs),
+ "experiment_file_count": len(exp_files),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# 渲染:Markdown 报告 + Mermaid gitGraph
+# ---------------------------------------------------------------------------
+
+def render_report(result: dict[str, Any]) -> str:
+ repo = result["repo"]
+ meta = result["meta"]
+ lines = [
+ f"# 仓库级科研项目洞悉报告 — {repo}\n",
+ f"> 场景 S1 · 子赛题四「应用 GitLink 辅助科研」· 项目谱系(lineage)分析\n",
+ "## 一、基础信息\n",
+ f"- **默认分支**: `{result['default_branch']}`",
+ f"- **采样提交**: {meta['commit_count']} 条(默认分支,最多 10×100)",
+ f"- **已合并 PR**: {meta['merged_pr_count']} 个",
+ f"- **文档文件**: {meta['doc_count']} 个",
+ f"- **实验/评测文件**: {meta['experiment_file_count']} 个\n",
+ ]
+
+ lines.append("## 二、提交活跃度时间线\n")
+ tl = result["commit_timeline"]
+ if tl:
+ peak = max(tl, key=lambda x: x["count"])
+ lines.append(f"- 时间跨度: {tl[0]['date']} → {tl[-1]['date']}"
+ f"(共 {len(tl)} 个有提交的日期)")
+ lines.append(f"- 峰值: {peak['date']} 当日 {peak['count']} 次提交\n")
+ else:
+ lines.append("- (未取到提交时间线)\n")
+
+ lines.append("## 三、分支地图\n")
+ lines.append("| 分支 | 提交数 | 最后活跃 | 是否默认 |")
+ lines.append("|------|:------:|----------|:--------:|")
+ for b in result["branch_map"]:
+ lines.append(f"| `{b['name']}` | {b['commits']} | {b['last_active'] or '—'} |"
+ f" {'是' if b['is_default'] else '否'} |")
+ lines.append("")
+
+ lines.append("## 四、合并 PR 演进模式(高影响合并预览)\n")
+ prs = result["pr_merge_patterns"]
+ if prs:
+ lines.append("| PR | 标题 | 改动文件 | 合并时间 |")
+ lines.append("|----|------|:--------:|----------|")
+ for p in prs[:10]:
+ lines.append(f"| #{p['number']} | {p['title']} | "
+ f"{p['changed_files']} | {p['merged_time'] or '—'} |")
+ else:
+ lines.append("- (无已合并 PR)")
+ lines.append("")
+
+ lines.append("## 五、创新/里程碑点\n")
+ inno = result["innovation_points"]
+ if inno:
+ for i, it in enumerate(inno, 1):
+ lines.append(f"{i}. **[{it['category']}]** {it['description']}")
+ lines.append(f" - 证据: {it['evidence']}")
+ else:
+ lines.append("- (未识别到明显高影响合并)")
+ lines.append("")
+
+ lines.append("## 六、文档演进(docs/*)\n")
+ docs = result["doc_evolution"]
+ if docs:
+ lines.append("| 文档 | 近似最后日期 |")
+ lines.append("|------|--------------|")
+ for d in docs[:15]:
+ lines.append(f"| {d['file']} | {d['last_date'] or '—'} |")
+ else:
+ lines.append("- (docs/ 下无文档或树不可得)")
+ lines.append("")
+
+ lines.append("## 七、实验/评测文件组织\n")
+ exps = result["experiment_files"]
+ if exps:
+ for p in exps[:20]:
+ lines.append(f"- `{p}`")
+ if len(exps) > 20:
+ lines.append(f"- ...(共 {len(exps)} 个,此处仅列前 20)")
+ else:
+ lines.append("- (未在仓库树中识别到 experiment/benchmark/eval/test/data 目录)")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def render_mermaid(result: dict[str, Any]) -> str:
+ """渲染 Mermaid gitGraph:以默认分支为唯一分支,按合并时间标里程碑 PR。"""
+ lines = ["```mermaid", "gitGraph"]
+ lines.append(f" commit id: \"{result['default_branch']} 起点\"")
+ prs = result["pr_merge_patterns"]
+ inno = result["innovation_points"]
+ inno_titles = {it["description"] for it in inno}
+ # 在时间线上穿插 commit / merge 标记(每 ~3 个 PR 一个 merge,里程碑标 hotfix)
+ step = 0
+ for p in prs:
+ lines.append(" commit")
+ step += 1
+ if step % 3 == 0:
+ tag = " 创新点" if any(p["title"].strip() == t for t in inno_titles) else ""
+ lines.append(f" commit id: \"#{p['number']}{tag}\" tag: \"{p['merged_time'] or 'PR'}\"")
+ lines.append("```")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+
+def main():
+ ap = argparse.ArgumentParser(description="S1 仓库级科研项目洞悉(lineage 谱系分析)")
+ ap.add_argument("--owner", required=True)
+ ap.add_argument("--repo", required=True)
+ ap.add_argument("--branches-limit", type=int, default=5,
+ help="分支地图上限(本场景单分支简化,默认 5)")
+ ap.add_argument("--out", help="输出目录(写 lineage.json/report.md/branch_graph.mmd);"
+ "省略则打印 JSON")
+ args = ap.parse_args()
+
+ result = lineage(args.owner, args.repo, branches_limit=args.branches_limit)
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ with open(os.path.join(args.out, "lineage.json"), "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
+ with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f:
+ f.write(render_report(result))
+ with open(os.path.join(args.out, "branch_graph.mmd"), "w", encoding="utf-8") as f:
+ f.write(render_mermaid(result))
+ print(f"✓ S1 项目洞悉完成 → {args.out}/lineage.json | report.md | branch_graph.mmd")
+ print(f" 提交 {result['meta']['commit_count']} 条 | "
+ f"合并 PR {result['meta']['merged_pr_count']} 个 | "
+ f"创新点 {len(result['innovation_points'])} 个")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/match.py b/scripts/research/match.py
new file mode 100644
index 0000000..d578cda
--- /dev/null
+++ b/scripts/research/match.py
@@ -0,0 +1,320 @@
+"""match.py — S4 科研协作智能匹配。
+
+输入一个科研仓库,分析其技术缺口(未解决 Issue 的主题/语言、开放 PR、研究空缺),
+再从 GitLink 平台候选池(本仓库贡献者 + 按缺口主题搜索到的用户)中,用「主题向量 + 语言匹配 +
+活跃度 + 协作开放度」综合打分,推荐最合适的跨团队/跨学者协作伙伴。
+
+数据全部经 gitlink-cli 获取(issue +list / repo +contributors / repo +list --user / search +users)。
+
+用法:
+ python match.py --owner mindspore-Ecosystem --repo mindspore --top 10 --out ./out
+ python match.py --owner O --repo R --format json # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import sys
+from collections import Counter
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c # noqa: E402
+import topics as T # noqa: E402
+
+# 优先级 → 权重(Issue 缺口信号加权)
+PRIORITY_WEIGHT = {"urgent": 3, "high": 3, "紧急": 3, "高": 3,
+ "normal": 2, "medium": 2, "普通": 2, "中": 2,
+ "low": 1, "低": 1}
+
+
+# ---------------------------------------------------------------------------
+# 向量与打分
+# ---------------------------------------------------------------------------
+
+def cosine(c1: dict[str, float], c2: dict[str, float]) -> float:
+ keys = set(c1) | set(c2)
+ dot = sum(c1.get(k, 0.0) * c2.get(k, 0.0) for k in keys)
+ n1 = math.sqrt(sum(v * v for v in c1.values()))
+ n2 = math.sqrt(sum(v * v for v in c2.values()))
+ return dot / (n1 * n2) if n1 and n2 else 0.0
+
+
+def jaccard(a: list[str], b: list[str]) -> float:
+ sa, sb = set(a), set(b)
+ if not sa or not sb:
+ return 0.0
+ return len(sa & sb) / len(sa | sb)
+
+
+def _priority_weight(issue: dict) -> float:
+ p = (issue.get("priority_name") or issue.get("priority") or "").lower()
+ for k, w in PRIORITY_WEIGHT.items():
+ if k in str(p):
+ return float(w)
+ return 1.0
+
+
+def _parse_ratio(v: Any) -> float:
+ """把 '1.18%' / '0.2' / 0.2 等统一解析为 0~1 比例。"""
+ if v is None:
+ return 0.0
+ s = str(v).strip()
+ pct = s.endswith("%")
+ if pct:
+ s = s[:-1]
+ try:
+ f = float(s)
+ except ValueError:
+ return 0.0
+ return f / 100.0 if (pct or f > 1.0) else f
+
+
+# ---------------------------------------------------------------------------
+# 缺口信号
+# ---------------------------------------------------------------------------
+
+def build_gap_signals(owner: str, repo: str, info: dict,
+ issue_sample: int = 100) -> tuple[Counter, list[str], list[dict]]:
+ """返回 (缺口主题词频 Counter, 需求语言列表, 缺口信号明细)。"""
+ gap_topics: Counter = Counter()
+ gap_langs: set[str] = set()
+ signals: list[dict] = []
+
+ # 1) 仓库自身主题/语言(协作者应具备的基础方向)
+ desc = (info.get("description") or "") + " " + c.readme(owner, repo)[:4000]
+ for tp in T.extract_topics(desc):
+ gap_topics[tp] += 1
+ for lg in c.languages(owner, repo):
+ gap_langs.add(lg)
+ for lg in T.extract_languages(desc):
+ gap_langs.add(lg)
+
+ # 2) 未解决 Issue 的主题/语言(核心缺口)
+ open_issues = c.issues(owner, repo, state="open", max_pages=max(1, issue_sample // 50),
+ page_size=50)
+ for iss in open_issues[:issue_sample]:
+ text = (iss.get("subject") or iss.get("title") or "")
+ w = _priority_weight(iss)
+ for tp in T.extract_topics(text):
+ gap_topics[tp] += w
+ for lg in T.extract_languages(text):
+ gap_langs.add(lg)
+ # 取每条 issue 的首个主题作为该条的证据
+ tps = T.extract_topics(text)
+ if tps:
+ signals.append({"type": "unresolved_issue", "topic": tps[0],
+ "evidence": text[:80], "priority": iss.get("priority_name", "")})
+
+ # 3) 开放 PR 正在推进的方向(轻量加成)
+ for pr in c.prs(owner, repo, state="open", max_pages=1, page_size=30):
+ text = (pr.get("title") or "") + " " + (pr.get("body") or "")
+ for tp in T.extract_topics(text):
+ gap_topics[tp] += 0.5
+
+ needed_langs = sorted(gap_langs)
+ return gap_topics, needed_langs, signals
+
+
+# ---------------------------------------------------------------------------
+# 候选人画像与匹配
+# ---------------------------------------------------------------------------
+
+def candidate_pool(owner: str, repo: str, gap_topics: Counter, pool_cap: int) -> list[str]:
+ """候选人 login 池:本仓库贡献者 + 按缺口主题搜到的外部用户。"""
+ seen: list[str] = []
+ seen_set: set[str] = set()
+
+ for contrib in c.contributors(owner, repo):
+ login = c.login_of(contrib)
+ # 过滤明显机器人账号
+ if login and login not in seen_set and "bot" not in login.lower() and login.lower() != "i-robot":
+ seen.append(login)
+ seen_set.add(login)
+ if len(seen) >= pool_cap:
+ return seen
+
+ # 取词频最高的若干主题,用其英文关键词搜外部用户
+ top_topics = [t for t, _ in gap_topics.most_common(5)]
+ eng_kw = {"nlp": "nlp", "deep_learning": "deep learning", "computer_vision": "cv",
+ "reinforcement_learning": "reinforcement learning",
+ "graph_learning": "gnn", "federated_learning": "federated",
+ "scientific_computing": "cuda", "data_mining": "machine learning",
+ "devops": "devops", "security": "security", "database": "database"}
+ for tp in top_topics:
+ kw = eng_kw.get(tp)
+ if not kw:
+ continue
+ for u in c.search_users(kw, limit=10):
+ login = c.login_of(u)
+ if login and login not in seen_set:
+ seen.append(login)
+ seen_set.add(login)
+ if len(seen) >= pool_cap:
+ break
+ return seen[:pool_cap]
+
+
+def profile_candidate(login: str, repo_contribs: dict[str, dict]) -> dict[str, Any]:
+ """构建候选人画像:主题向量 + 语言集合 + 活跃度 + 协作开放度。"""
+ repos = c.user_repos(login, limit=15)
+ texts = []
+ langs: set[str] = set()
+ fork_count = 0
+ for r in repos:
+ texts.append((r.get("description") or "") + " " + (r.get("identifier") or ""))
+ if r.get("language") and isinstance(r["language"], dict):
+ langs.add((r["language"].get("name") or "").lower())
+ if r.get("forked_from_project_id") or r.get("forked_count"):
+ fork_count += 1
+ topic_vec: Counter = Counter()
+ for t in texts:
+ for tp in T.extract_topics(t):
+ topic_vec[tp] += 1
+ for lg in T.extract_languages(" ".join(texts)):
+ langs.add(lg)
+
+ activity = 0.4
+ if login in repo_contribs:
+ # 本仓库贡献者 → 高活跃(贡献占比越高加成越大)
+ activity = 0.7 + 0.3 * min(_parse_ratio(repo_contribs[login].get("contribution_perc")), 1.0)
+ elif len(repos) >= 5:
+ activity = 0.7
+ elif repos:
+ activity = 0.4
+ collab = min(fork_count / 5.0, 1.0)
+
+ return {"topic_vec": dict(topic_vec), "langs": sorted(langs),
+ "activity": activity, "collab": collab, "repo_count": len(repos)}
+
+
+def match(owner: str, repo: str, top: int = 10, pool_cap: int = 15,
+ issue_sample: int = 100) -> dict[str, Any]:
+ info = c.repo_info(owner, repo)
+ gap_topics, needed_langs, signals = build_gap_signals(owner, repo, info, issue_sample)
+ contribs_list = c.contributors(owner, repo)
+ repo_contribs = {c.login_of(x): x for x in contribs_list if c.login_of(x)}
+
+ # 缺口主题向量(与候选人主题向量同空间)
+ gap_vec = dict(gap_topics)
+
+ pool = candidate_pool(owner, repo, gap_topics, pool_cap)
+ scored = []
+ for login in pool:
+ prof = profile_candidate(login, repo_contribs)
+ topic_overlap = cosine(prof["topic_vec"], gap_vec)
+ lang_match = jaccard(prof["langs"], needed_langs) if needed_langs else 0.0
+ score = (0.45 * topic_overlap + 0.20 * lang_match
+ + 0.20 * prof["activity"] + 0.15 * prof["collab"]) * 100
+ reasons: list[str] = []
+ overlap_topics = sorted(set(prof["topic_vec"]) & set(gap_vec),
+ key=lambda k: -prof["topic_vec"][k])
+ if overlap_topics:
+ reasons.append(f"覆盖缺口主题: {', '.join(overlap_topics[:4])}")
+ matched_langs = sorted(set(prof["langs"]) & set(needed_langs))
+ if matched_langs:
+ reasons.append(f"语言匹配: {', '.join(matched_langs[:4])}")
+ if login in repo_contribs:
+ reasons.append("本仓库活跃贡献者")
+ if prof["collab"] > 0:
+ reasons.append(f"协作开放度高(fork={int(prof['collab']*5)})")
+ activity_level = ("high" if prof["activity"] >= 0.7
+ else "medium" if prof["activity"] >= 0.4 else "low")
+ scored.append({
+ "login": login, "score": round(score, 1),
+ "topic_overlap": round(topic_overlap, 3),
+ "language_match": round(lang_match, 3),
+ "activity_level": activity_level,
+ "repo_languages": prof["langs"][:6],
+ "repo_count": prof["repo_count"],
+ "reasons": reasons or ["无明显主题/语言重叠"],
+ })
+ scored.sort(key=lambda x: -x["score"])
+
+ top_topics = [t for t, _ in gap_topics.most_common(8)]
+ return {
+ "scenario": "S4_collaboration_matching",
+ "repo": f"{owner}/{repo}",
+ "gap_topics": top_topics,
+ "needed_languages": needed_langs,
+ "gap_signals": signals[:30],
+ "candidates": scored[:top],
+ "meta": {"pool_size": len(pool), "issue_sample": issue_sample},
+ }
+
+
+# ---------------------------------------------------------------------------
+# 渲染:Markdown 报告 + Mermaid 协作网络
+# ---------------------------------------------------------------------------
+
+def render_report(result: dict[str, Any]) -> str:
+ repo = result["repo"]
+ cands = result["candidates"]
+ lines = [
+ f"# 科研协作智能匹配报告 — {repo}\n",
+ f"> 场景 S4 · 子赛题四「应用 GitLink 辅助科研」\n",
+ "## 一、仓库技术缺口分析\n",
+ f"- **缺口主题**: {', '.join(result['gap_topics']) or '(未识别到明确主题)'}",
+ f"- **需求语言**: {', '.join(result['needed_languages']) or '—'}",
+ f"- **缺口信号样本**: {len(result['gap_signals'])} 条未解决 Issue/PR 主题证据\n",
+ "| 缺口主题 | 证据(Issue/PR) | 优先级 |",
+ "|----------|------------------|--------|",
+ ]
+ for s in result["gap_signals"][:8]:
+ lines.append(f"| {s['topic']} | {s['evidence']} | {s.get('priority','')} |")
+ lines += ["\n## 二、推荐协作伙伴(按综合匹配分排序)\n",
+ "| 排名 | 用户 | 匹配分 | 主题重叠 | 语言匹配 | 活跃度 | 匹配理由 |",
+ "|------|------|--------|----------|----------|--------|----------|"]
+ for i, m in enumerate(cands, 1):
+ lines.append(f"| {i} | `{m['login']}` | {m['score']} | {m['topic_overlap']} | "
+ f"{m['language_match']} | {m['activity_level']} | {'; '.join(m['reasons'][:2])} |")
+ lines.append(f"\n_候选池规模 {result['meta']['pool_size']},issue 采样 {result['meta']['issue_sample']}_\n")
+ return "\n".join(lines)
+
+
+def render_mermaid(result: dict[str, Any]) -> str:
+ repo = result["repo"].replace("/", "_")
+ lines = ["```mermaid", "graph TD", f' R["{result["repo"]}
(目标仓库)"]']
+ for i, m in enumerate(result["candidates"][:8], 1):
+ nid = f"C{i}"
+ lines.append(f' {nid}["{m["login"]}
{m["score"]}分"]')
+ # 边的粗细用文字标签近似
+ lines.append(f' R -- "{m["topic_overlap"]}" --> {nid}')
+ lines.append("```")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+
+def main():
+ ap = argparse.ArgumentParser(description="S4 科研协作智能匹配")
+ ap.add_argument("--owner", required=True)
+ ap.add_argument("--repo", required=True)
+ ap.add_argument("--top", type=int, default=10)
+ ap.add_argument("--pool", type=int, default=15, help="候选池上限")
+ ap.add_argument("--issue-sample", type=int, default=100)
+ ap.add_argument("--out", help="输出目录(写 match.json/report.md/network.mmd);省略则打印 JSON")
+ args = ap.parse_args()
+
+ result = match(args.owner, args.repo, top=args.top, pool_cap=args.pool,
+ issue_sample=args.issue_sample)
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ with open(os.path.join(args.out, "match.json"), "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
+ with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f:
+ f.write(render_report(result))
+ with open(os.path.join(args.out, "network.mmd"), "w", encoding="utf-8") as f:
+ f.write(render_mermaid(result))
+ print(f"✓ S4 匹配完成 → {args.out}/match.json | report.md | network.mmd")
+ print(f" 缺口主题: {', '.join(result['gap_topics'])}")
+ print(f" Top 推荐: {', '.join(m['login']+'('+str(m['score'])+')' for m in result['candidates'][:5])}")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/report.py b/scripts/research/report.py
new file mode 100644
index 0000000..c774ab1
--- /dev/null
+++ b/scripts/research/report.py
@@ -0,0 +1,555 @@
+"""report.py — S5 科研进度智能跟踪与预警。
+
+输入一个科研仓库,统计「本周 / 上周」的提交、Issue、PR 活跃度,结合里程碑进度,
+用阈值规则产出风险预警(stale issue / stale PR / 逾期里程碑 / 低活跃 / bus_factor),
+并给出本周相对上周的 commit 趋势。辅助科研负责人及时发现「项目停滞 / 单点风险」。
+
+数据全部经 gitlink-cli 获取(commits / issue +list / pr +list / milestone +list /
+repo +contributors)。算法为纯函数、零第三方依赖,单测以 mock 数据喂入。
+
+用法:
+ python report.py --owner mindspore-Ecosystem --repo mindspore --out ./out
+ python report.py --owner O --repo R # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from collections import Counter
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c # noqa: E402
+
+# ---------------------------------------------------------------------------
+# 常量(阈值,便于单测覆盖)
+# ---------------------------------------------------------------------------
+
+STALE_ISSUE_DAYS = 30 # 开放且无活动 > 30 天 → stale issue
+STALE_PR_DAYS = 14 # 开放且未 review > 14 天 → stale PR
+STALE_PR_WINDOW_DAYS = 90 # 统计 stale PR 时回溯窗口(避免扫全量历史)
+LOW_ACTIVITY_COMMITS = 3 # 本周提交 < 3 → 低活跃
+BUS_FACTOR_RATIO = 0.5 # 单一贡献者占本周提交 > 50% → bus factor
+
+UTC = timezone.utc
+
+
+# ---------------------------------------------------------------------------
+# 时间解析
+# ---------------------------------------------------------------------------
+
+def parse_time(s: Any) -> datetime | None:
+ """把时间字段解析为带 UTC 时区的 datetime;无法解析返回 None。
+
+ 兼容两类输入:
+ - ISO 字串,如 '2024-06-01T08:30:00+08:00' / '2024-06-01T08:30:00Z'
+ / '2024-06-01 08:30:00' / '2024-06-01'
+ - 整数(或整数字串)秒级 Unix 时间戳,如 1717200000 / '1717200000'
+ """
+ if s is None:
+ return None
+ if isinstance(s, (int, float)):
+ try:
+ return datetime.fromtimestamp(float(s), tz=UTC)
+ except (OverflowError, OSError, ValueError):
+ return None
+ if not isinstance(s, str):
+ return None
+ text = s.strip()
+ if not text:
+ return None
+ # 纯数字 → 当作 Unix 秒级时间戳
+ if text.lstrip("-").isdigit():
+ try:
+ return datetime.fromtimestamp(float(text), tz=UTC)
+ except (OverflowError, OSError, ValueError):
+ return None
+ # ISO 字串:统一以 Z → +00:00
+ iso = text.replace("Z", "+00:00")
+ try:
+ dt = datetime.fromisoformat(iso)
+ except ValueError:
+ # 尝试 'YYYY-MM-DD HH:MM:SS' / 'YYYY-MM-DD'
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
+ try:
+ dt = datetime.strptime(text, fmt)
+ break
+ except ValueError:
+ continue
+ else:
+ return None
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ return dt.astimezone(UTC)
+
+
+def in_window(dt: datetime | None, days: int, now: datetime) -> bool:
+ """判断 dt 是否落在 [now - days, now] 区间内。None 视为不在窗口内。"""
+ if dt is None:
+ return False
+ if days < 0:
+ return False
+ now_utc = now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC)
+ dt_utc = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC)
+ return (now_utc - dt_utc) <= timedelta(days=days) and (now_utc - dt_utc) >= timedelta(0)
+
+
+# ---------------------------------------------------------------------------
+# 周统计
+# ---------------------------------------------------------------------------
+
+def _commit_time(item: dict) -> datetime | None:
+ for key in ("timestamp", "commit_time", "committed_date", "created_at"):
+ v = item.get(key)
+ dt = parse_time(v)
+ if dt is not None:
+ return dt
+ return None
+
+
+def _issue_time(item: dict, prefer: tuple[str, ...]) -> datetime | None:
+ for key in prefer:
+ v = item.get(key)
+ dt = parse_time(v)
+ if dt is not None:
+ return dt
+ return None
+
+
+def _issue_status(item: dict) -> str:
+ s = item.get("status")
+ if isinstance(s, str) and s:
+ return s.lower()
+ return ""
+
+
+def _pr_status(item: dict) -> int:
+ """PR 状态 → 0=open,1=merged,2=closed。
+
+ GitLink PR 列表 status 是字符串('merged'/'open'/'closed');
+ 详情/health 可能是 pull_request_status 整数(0/1/2)。两者兼容。
+ """
+ v = item.get("status", item.get("pull_request_status", 0))
+ if isinstance(v, str):
+ s = v.lower()
+ if "merged" in s:
+ return 1
+ if s in ("closed", "close", "reject", "rejected"):
+ return 2
+ return 0
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return 0
+
+
+def _author_login(item: dict, key: str = "author") -> str:
+ """从 commit/issue/pr 的 author 子对象取 login;退化用顶层 login/name。"""
+ obj = item.get(key)
+ if isinstance(obj, dict):
+ for k in ("login", "name", "username"):
+ if obj.get(k):
+ return str(obj[k])
+ for k in ("login", "name", "username", "committer_login"):
+ if item.get(k):
+ return str(item[k])
+ return ""
+
+
+def _week_bounds(now: datetime) -> tuple[datetime, datetime, datetime, datetime]:
+ """返回 (this_week_start, last_week_start, last_week_end, now)(UTC)。
+
+ 「周」按自然天对齐:本周 = [now-7d, now],上周 = [now-14d, now-7d)。
+ """
+ now_utc = now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC)
+ this_start = now_utc - timedelta(days=7)
+ last_start = now_utc - timedelta(days=14)
+ last_end = this_start
+ return this_start, last_start, last_end, now_utc
+
+
+def _commits_in(commits: list[dict], lo: datetime, hi: datetime) -> list[dict]:
+ out = []
+ for it in commits:
+ dt = _commit_time(it)
+ if dt is None:
+ continue
+ if lo <= dt < hi:
+ out.append(it)
+ return out
+
+
+def _window_summary(commits: list, issues: list, prs: list,
+ contributors: list, lo: datetime, hi: datetime,
+ now: datetime) -> dict[str, Any]:
+ """统计 [lo, hi) 区间内的活动摘要(供 week_stats 复用)。"""
+ w_commits = _commits_in(commits, lo, hi)
+
+ opened = closed = stale_open = 0
+ for iss in issues:
+ created = _issue_time(iss, ("created_at", "created_unix"))
+ if created is not None and lo <= created < hi:
+ opened += 1
+ st = _issue_status(iss)
+ if st in ("closed", "reject", "rejected"):
+ # 关闭时间优先 closed_at/journals_updated_at
+ closed_dt = _issue_time(iss, ("closed_at", "journals_updated_at", "updated_at"))
+ if closed_dt is not None and lo <= closed_dt < hi:
+ closed += 1
+ elif st in ("", "open", "opened"):
+ # stale 判定:开放且距今 > STALE_ISSUE_DAYS 无活动
+ last_dt = _issue_time(iss, ("journals_updated_at", "updated_at", "created_at"))
+ if last_dt is not None and (now - last_dt) > timedelta(days=STALE_ISSUE_DAYS):
+ stale_open += 1
+
+ pr_opened = pr_merged = pr_open_stale = 0
+ for pr in prs:
+ created = _issue_time(pr, ("pr_created_unix", "created_at", "created_unix"))
+ if created is None:
+ created = _commit_time(pr)
+ if created is not None and lo <= created < hi:
+ pr_opened += 1
+ st = _pr_status(pr)
+ if st == 1: # merged
+ merged_dt = _issue_time(pr, ("pr_merged_unix", "merged_at", "updated_at"))
+ if merged_dt is None:
+ merged_dt = created
+ if merged_dt is not None and lo <= merged_dt < hi:
+ pr_merged += 1
+ elif st == 0: # open
+ # stale PR:开放且 created > STALE_PR_DAYS 天前、近 STALE_PR_WINDOW 天内
+ if created is not None and (now - created) > timedelta(days=STALE_PR_DAYS) \
+ and created > now - timedelta(days=STALE_PR_WINDOW_DAYS):
+ pr_open_stale += 1
+
+ # 本周活跃贡献者:去重 author login(commits)
+ logins = [_author_login(it) for it in w_commits]
+ contrib_active = {lg for lg in logins if lg}
+ return {
+ "commits": len(w_commits),
+ "issues_opened": opened,
+ "issues_closed": closed,
+ "issues_stale": stale_open,
+ "prs_opened": pr_opened,
+ "prs_merged": pr_merged,
+ "prs_open_stale": pr_open_stale,
+ "contributors_active": len(contrib_active),
+ "contributors_active_logins": sorted(contrib_active),
+ }
+
+
+def week_stats(commits: list, issues: list, prs: list,
+ contributors: list, now: datetime) -> dict[str, Any]:
+ """统计本周 / 上周活跃度摘要。contributors 形参保留以兼容,活跃度以 commits 的作者为准。"""
+ this_start, last_start, last_end, now_utc = _week_bounds(now)
+ this = _window_summary(commits, issues, prs, contributors,
+ this_start, now_utc, now_utc)
+ last = _window_summary(commits, issues, prs, contributors,
+ last_start, last_end, now_utc)
+ return {
+ "this_week": this,
+ "last_week": last,
+ "window": {
+ "this_week_start": this_start.isoformat(),
+ "now": now_utc.isoformat(),
+ "last_week_start": last_start.isoformat(),
+ "last_week_end": last_end.isoformat(),
+ },
+ "total_contributors": len(contributors),
+ }
+
+
+# ---------------------------------------------------------------------------
+# 里程碑进度
+# ---------------------------------------------------------------------------
+
+def _milestone_due(item: dict) -> datetime | None:
+ for key in ("effective_date", "due_date", "deadline", "end_date"):
+ dt = parse_time(item.get(key))
+ if dt is not None:
+ return dt
+ return None
+
+
+def milestone_progress(milestones: list, issues: list, now: datetime) -> list[dict]:
+ """每个里程碑的 open/closed issue 数、完成率、是否逾期。
+
+ Issue → Milestone 的关联字段优先用 milestone_name / milestone_id。
+ """
+ now_utc = now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC)
+ by_key: dict[Any, dict[str, int]] = {}
+ for iss in issues:
+ ms_name = iss.get("milestone_name") or iss.get("milestone")
+ ms_id = iss.get("milestone_id") or iss.get("milestone_index")
+ key = ms_name if ms_name else (ms_id if ms_id is not None else None)
+ if key is None:
+ continue
+ bucket = by_key.setdefault(key, {"open": 0, "closed": 0})
+ st = _issue_status(iss)
+ if st in ("closed", "reject", "rejected"):
+ bucket["closed"] += 1
+ else:
+ bucket["open"] += 1
+
+ out: list[dict] = []
+ for ms in milestones:
+ name = ms.get("name") or ms.get("title") or "(未命名)"
+ key = name
+ counts = by_key.get(key, {"open": 0, "closed": 0})
+ total = counts["open"] + counts["closed"]
+ pct = round(100.0 * counts["closed"] / total, 1) if total else 0.0
+ due = _milestone_due(ms)
+ overdue = False
+ # 仅当里程碑未关闭 + 有 due_date 且 due < now → 逾期
+ ms_status = str(ms.get("status", "")).lower()
+ is_closed = ms_status in ("closed", "reject", "rejected", "done", "completed")
+ if due is not None and not is_closed and due < now_utc:
+ overdue = True
+ out.append({
+ "name": name,
+ "open": counts["open"],
+ "closed": counts["closed"],
+ "total": total,
+ "completion_pct": pct,
+ "due_date": due.isoformat() if due else None,
+ "overdue": overdue,
+ "status": ms.get("status", ""),
+ })
+ return out
+
+
+# ---------------------------------------------------------------------------
+# 趋势
+# ---------------------------------------------------------------------------
+
+def trend(this_week: dict, last_week: dict) -> dict[str, Any]:
+ """本周 vs 上周 commit 增量与活跃度等级。"""
+ tc = this_week.get("commits", 0)
+ lc = last_week.get("commits", 0)
+ if lc == 0:
+ delta_pct = 100.0 if tc > 0 else 0.0
+ else:
+ delta_pct = round(100.0 * (tc - lc) / lc, 1)
+ if delta_pct > 10:
+ level = "increasing"
+ elif delta_pct < -10:
+ level = "decreasing"
+ else:
+ level = "stable"
+ return {"commit_delta_pct": delta_pct, "activity_level": level,
+ "this_week_commits": tc, "last_week_commits": lc}
+
+
+# ---------------------------------------------------------------------------
+# 风险预警
+# ---------------------------------------------------------------------------
+
+def risk_warnings(stats: dict, milestones: list, contributors: list,
+ commits: list | None = None, now: datetime | None = None) -> list[dict]:
+ """阈值规则 → 风险列表。
+
+ 依赖 week_stats 产出的 stats(含 this_week / last_week)以及 milestone_progress
+ 的 milestones 列表。commits 用于 bus_factor 复算(可选,避免 stats 内无明细)。
+ """
+ now_utc = (now or datetime.now(UTC)).astimezone(UTC) \
+ if (now or datetime.now(UTC)).tzinfo else (now or datetime.now(UTC)).replace(tzinfo=UTC)
+ this_start = now_utc - timedelta(days=7)
+ warnings: list[dict] = []
+ tw: dict = stats.get("this_week", {})
+
+ # 1) 低活跃
+ commits_this = tw.get("commits", 0)
+ if commits_this < LOW_ACTIVITY_COMMITS:
+ warnings.append({
+ "level": "warning", "type": "low_activity",
+ "message": f"本周提交仅 {commits_this} 次(低于阈值 {LOW_ACTIVITY_COMMITS}),项目可能进展缓慢",
+ "metric": commits_this, "suggestion": "确认是否进入收尾阶段;若无,组织一次进度同步。",
+ })
+
+ # 2) bus_factor:单一贡献者本周提交占比 > 50%
+ if commits:
+ w_commits = _commits_in(commits, this_start, now_utc)
+ else:
+ w_commits = [] # 无明细 → 无法判 bus factor
+ if w_commits:
+ login_counts: Counter = Counter(_author_login(it) for it in w_commits)
+ top_login, top_n = login_counts.most_common(1)[0]
+ ratio = top_n / len(w_commits)
+ active_n = len({lg for lg in login_counts if lg})
+ if ratio > BUS_FACTOR_RATIO and active_n <= 2:
+ warnings.append({
+ "level": "critical", "type": "bus_factor",
+ "message": f"bus factor 风险:{top_login or '(匿名)'} 一人贡献本周 {top_n}/{len(w_commits)} "
+ f"次提交({round(ratio*100)}%),活跃贡献者仅 {active_n} 人",
+ "metric": round(ratio, 3), "suggestion": "引入第二贡献者 / 文档化核心模块,降低单点依赖。",
+ })
+
+ # 3) stale issue / stale PR(沿用 week_stats 已统计的口径)
+ stale_iss = tw.get("issues_stale", 0)
+ if stale_iss >= 5:
+ warnings.append({
+ "level": "warning" if stale_iss < 20 else "critical",
+ "type": "stale_issue",
+ "message": f"存在 {stale_iss} 个开放 Issue 超过 {STALE_ISSUE_DAYS} 天无活动",
+ "metric": stale_iss, "suggestion": "分诊:关闭无效 Issue、分配负责人或拆解。",
+ })
+ stale_pr = tw.get("prs_open_stale", 0)
+ if stale_pr >= 1:
+ warnings.append({
+ "level": "warning" if stale_pr < 3 else "critical",
+ "type": "stale_pr",
+ "message": f"存在 {stale_pr} 个开放 PR 超过 {STALE_PR_DAYS} 天未 review/合并",
+ "metric": stale_pr, "suggestion": "安排 review 或明确 reject,避免 PR 堆积。",
+ })
+
+ # 4) 逾期里程碑
+ for ms in milestones:
+ if isinstance(ms, dict) and ms.get("overdue"):
+ warnings.append({
+ "level": "critical", "type": "overdue_milestone",
+ "message": f"里程碑「{ms.get('name', '?')}」已逾期"
+ + (f"(due {ms.get('due_date')})" if ms.get("due_date") else "")
+ + f",完成率 {ms.get('completion_pct', 0)}%",
+ "metric": ms.get("completion_pct", 0),
+ "suggestion": "重新评估范围或顺延 deadline,并同步干系人。",
+ })
+
+ # 排序:critical > warning > info
+ rank = {"critical": 0, "warning": 1, "info": 2}
+ warnings.sort(key=lambda w: (rank.get(w["level"], 9), w["type"]))
+ return warnings
+
+
+# ---------------------------------------------------------------------------
+# 主入口(取数 + 算法)
+# ---------------------------------------------------------------------------
+
+def analyze(owner: str, repo: str, now: datetime | None = None) -> dict[str, Any]:
+ """取数 + 算法:返回完整结果 dict。"""
+ if now is None:
+ now = datetime.now(UTC)
+ commits = c.commits(owner, repo, max_pages=10)
+ issues = c.issues_all(owner, repo)
+ prs = c.prs_all(owner, repo)
+ milestones = c.milestones(owner, repo)
+ contributors = c.contributors(owner, repo)
+
+ stats = week_stats(commits, issues, prs, contributors, now)
+ ms_progress = milestone_progress(milestones, issues, now)
+ warnings = risk_warnings(stats, ms_progress, contributors, commits=commits, now=now)
+ tr = trend(stats["this_week"], stats["last_week"])
+
+ return {
+ "scenario": "S5_progress_tracking",
+ "repo": f"{owner}/{repo}",
+ "generated_at": now.astimezone(UTC).isoformat(),
+ "week_stats": stats,
+ "trend": tr,
+ "milestones": ms_progress,
+ "risk_warnings": warnings,
+ "meta": {
+ "commits_fetched": len(commits),
+ "issues_fetched": len(issues),
+ "prs_fetched": len(prs),
+ "milestones_fetched": len(milestones),
+ "contributors_fetched": len(contributors),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# 渲染:Markdown 周报
+# ---------------------------------------------------------------------------
+
+def render_report(result: dict[str, Any]) -> str:
+ repo = result["repo"]
+ stats = result["week_stats"]
+ tw, lw = stats["this_week"], stats["last_week"]
+ tr = result["trend"]
+ lines = [
+ f"# 科研进度智能跟踪周报 — {repo}\n",
+ f"> 场景 S5 · 子赛题四「应用 GitLink 辅助科研」· 生成于 {result['generated_at']}\n",
+ "## 一、本周 / 上周活动对比\n",
+ "| 指标 | 本周 | 上周 |",
+ "|------|------|------|",
+ f"| 提交 commits | {tw['commits']} | {lw['commits']} |",
+ f"| Issue 新增 | {tw['issues_opened']} | {lw['issues_opened']} |",
+ f"| Issue 关闭 | {tw['issues_closed']} | {lw['issues_closed']} |",
+ f"| 开放 stale issue (>{STALE_ISSUE_DAYS}天) | {tw['issues_stale']} | {lw['issues_stale']} |",
+ f"| PR 新增 | {tw['prs_opened']} | {lw['prs_opened']} |",
+ f"| PR 合并 | {tw['prs_merged']} | {lw['prs_merged']} |",
+ f"| 开放 stale PR (>{STALE_PR_DAYS}天) | {tw['prs_open_stale']} | {lw['prs_open_stale']} |",
+ f"| 活跃贡献者 | {tw['contributors_active']} | {lw['contributors_active']} |",
+ "",
+ f"- **趋势**:commit 周环比 **{tr['commit_delta_pct']}%**,活跃度等级 `{tr['activity_level']}`",
+ "",
+ "## 二、里程碑进度\n",
+ ]
+ ms = result["milestones"]
+ if ms:
+ lines += [
+ "| 里程碑 | 完成/总数 | 完成率 | due_date | 状态 |",
+ "|--------|-----------|--------|----------|------|",
+ ]
+ for m in ms:
+ flag = " ⚠️逾期" if m["overdue"] else ""
+ lines.append(
+ f"| {m['name']}{flag} | {m['closed']}/{m['total']} | {m['completion_pct']}% "
+ f"| {m['due_date'] or '—'} | {m['status'] or '—'} |"
+ )
+ else:
+ lines.append("_(仓库无里程碑数据)_")
+
+ lines += ["\n## 三、风险预警\n"]
+ warns = result["risk_warnings"]
+ if warns:
+ lines += ["| 级别 | 类型 | 说明 | 建议 |", "|------|------|------|------|"]
+ for w in warns:
+ lines.append(f"| {w['level']} | {w['type']} | {w['message']} | {w['suggestion']} |")
+ else:
+ lines.append("_(未触发风险阈值,进度正常)_")
+
+ lines += [
+ "\n## 四、附\n",
+ f"- 取数:commits={result['meta']['commits_fetched']} "
+ f"issues={result['meta']['issues_fetched']} prs={result['meta']['prs_fetched']} "
+ f"milestones={result['meta']['milestones_fetched']} "
+ f"contributors={result['meta']['contributors_fetched']}",
+ f"- 窗口:本周 [{stats['window']['this_week_start']}, {stats['window']['now']}];"
+ f"上周 [{stats['window']['last_week_start']}, {stats['window']['last_week_end']})",
+ f"- 阈值:stale_issue>{STALE_ISSUE_DAYS}天 / stale_pr>{STALE_PR_DAYS}天 / "
+ f"低活跃<{LOW_ACTIVITY_COMMITS}次/周 / bus_factor>{int(BUS_FACTOR_RATIO*100)}%",
+ "",
+ ]
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+
+def main():
+ ap = argparse.ArgumentParser(description="S5 科研进度智能跟踪与预警")
+ ap.add_argument("--owner", required=True)
+ ap.add_argument("--repo", required=True)
+ ap.add_argument("--out", help="输出目录(写 report.json + weekly_report.md);省略则打印 JSON")
+ args = ap.parse_args()
+
+ result = analyze(args.owner, args.repo)
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ with open(os.path.join(args.out, "report.json"), "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
+ with open(os.path.join(args.out, "weekly_report.md"), "w", encoding="utf-8") as f:
+ f.write(render_report(result))
+ print(f"✓ S5 周报完成 → {args.out}/report.json | weekly_report.md")
+ print(f" 本周提交 {result['week_stats']['this_week']['commits']} "
+ f"(趋势 {result['trend']['activity_level']});风险 {len(result['risk_warnings'])} 条")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/repro.py b/scripts/research/repro.py
new file mode 100644
index 0000000..43bb7d7
--- /dev/null
+++ b/scripts/research/repro.py
@@ -0,0 +1,573 @@
+"""repro.py — S3 科研项目合规与复现性检查。
+
+输入一个科研仓库,检查其「合规性」(许可证、版权、依赖、安全策略、数据隐私)
+与「可复现性」(CI 配置、lockfile、README 是否含数据集/环境/构建说明、版本 tag、
+密钥泄露),分别给出 0-10 的复现分与合规分,并产出检查清单、风险项与中文报告。
+
+数据全部经 gitlink-cli 获取:
+ - repo +info(仓库信息、版本 tag)
+ - file +get(LICENSE / README / go.mod / requirements.txt / package.json / .gitignore 等)
+ - repo +tree(扫 data/、.env、config、.gitea/.github workflows 等是否存在)
+
+用法:
+ python repro.py --owner mindspore-Ecosystem --repo mindspore --out ./out
+ python repro.py --owner O --repo R # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c # noqa: E402
+import gitlink_data as gd # noqa: E402
+
+# ---------------------------------------------------------------------------
+# 常量
+# ---------------------------------------------------------------------------
+
+# 复现性/合规相关的关键文件(相对仓库根)
+KEY_FILES: tuple[str, ...] = (
+ "LICENSE", "LICENSE.txt", "LICENSE.md",
+ "README", "README.md", "README.rst",
+ "go.mod", "requirements.txt", "package.json", "Cargo.toml",
+ ".gitignore", "SECURITY.md", "CONTRIBUTING.md",
+)
+
+# CI 配置目录/文件(复现性信号)。
+# 注意:根 tree 通常只列顶层目录(.github/.devops/.gitea),不一定展开到 workflows/,
+# 故同时收录顶层目录名与深层路径;.devops 是 GitLink 专属 CI/CD 目录。
+CI_PATHS: tuple[str, ...] = (
+ ".devops",
+ ".gitea", ".gitea/workflows",
+ ".github", ".github/workflows",
+ ".gitlab-ci.yml", ".circleci", ".travis.yml", "azure-pipelines.yml",
+)
+
+# 锁文件(复现性信号:依赖版本固定)
+LOCKFILES: tuple[str, ...] = (
+ "go.sum", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
+ "requirements.txt", "poetry.lock", "Pipfile.lock", "Cargo.lock", "composer.lock",
+)
+
+# 敏感目录/文件(数据隐私信号)
+SENSITIVE_PATHS: tuple[str, ...] = (
+ "data/", "data", "dataset/", "datasets/", ".env", ".env.local",
+ "config/secrets", "secrets",
+)
+
+# README 复现性关键词(数据集 / 环境 / 构建 / 运行说明)
+README_REPRO_KEYWORDS: tuple[str, ...] = (
+ "install", "setup", "环境", "依赖", "build", "构建", "运行", "run",
+ "dataset", "数据集", "docker", "conda", "pip install", "npm install",
+ "requirements", "reproduce", "复现", "环境配置", "usage", "用法",
+)
+
+# 许可证识别关键词(顺序即优先级)
+LICENSE_PATTERNS: tuple[tuple[str, str], ...] = (
+ ("MulanPSL", "MulanPSL-2.0"),
+ ("木兰宽松许可证", "MulanPSL-2.0"),
+ ("Apache License", "Apache-2.0"),
+ ("MIT License", "MIT"),
+ ("GNU GENERAL PUBLIC LICENSE", "GPL"),
+ ("GNU Lesser General Public License", "LGPL"),
+ ("BSD ", "BSD"),
+ ("ISC License", "ISC"),
+ ("Mozilla Public License", "MPL"),
+ ("Unlicense", "Unlicense"),
+)
+
+# 密钥/敏感信息正则(按类别)
+SECRET_PATTERNS: tuple[tuple[str, str, str], ...] = (
+ # (category, level, regex)
+ ("private_key", "critical", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"),
+ ("aws_access_key", "critical", r"AKIA[0-9A-Z]{16}"),
+ ("aws_secret", "critical", r"aws_secret_access_key\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}"),
+ ("generic_api_key", "high", r"(?i)api[_-]?key\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}"),
+ ("google_api_key", "high", r"AIza[0-9A-Za-z_\-]{35}"),
+ ("slack_token", "high", r"xox[baprs]-[0-9A-Za-z-]{10,}"),
+ ("github_token", "high", r"gh[pousr]_[A-Za-z0-9]{36,}"),
+ ("jwt", "medium", r"eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"),
+ ("email", "low", r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[A-Za-z]{2,}"),
+ # 中国大陆手机号
+ ("phone_cn", "low", r"(? dict[str, Any]:
+ """关键词匹配 LICENSE 文本,返回许可证信息。
+
+ 返回: {"license": str, "recognized": bool, "evidence": str}
+ 未识别返回 license="None"。
+ """
+ if not text or not text.strip():
+ return {"license": "None", "recognized": False,
+ "evidence": "LICENSE 文件为空或缺失"}
+ low = text.lower()
+ for pat, name in LICENSE_PATTERNS:
+ if pat.lower() in low:
+ # 找到关键词所在行作为证据
+ idx = low.find(pat.lower())
+ line_start = text.rfind("\n", 0, idx) + 1
+ line_end = text.find("\n", idx)
+ if line_end == -1:
+ line_end = len(text)
+ evidence = text[line_start:line_end].strip()[:120]
+ return {"license": name, "recognized": True, "evidence": evidence}
+ return {"license": "None", "recognized": False,
+ "evidence": "未匹配到已知许可证关键词"}
+
+
+def scan_secrets(text: str, file: str = "") -> list[dict[str, Any]]:
+ """扫描文本中的密钥/敏感信息。
+
+ 返回: [{"level","category","file","line","detail"}, ...]
+ """
+ if not text:
+ return []
+ findings: list[dict[str, Any]] = []
+ lines = text.splitlines()
+ for category, level, pattern in SECRET_PATTERNS:
+ for m in re.finditer(pattern, text):
+ # 计算行号与所在行内容
+ line_no = text.count("\n", 0, m.start()) + 1
+ line_content = lines[line_no - 1] if 0 <= line_no - 1 < len(lines) else ""
+ detail = m.group(0)
+ # 脱敏:长串截断
+ if len(detail) > 40:
+ detail = detail[:20] + "..." + detail[-6:]
+ findings.append({
+ "level": level, "category": category, "file": file,
+ "line": line_no, "detail": detail,
+ "context": line_content.strip()[:80],
+ })
+ return findings
+
+
+def _tree_paths(tree: list) -> list[str]:
+ """从 tree 列表提取所有路径字符串(兼容多种字段名)。"""
+ paths: list[str] = []
+ for item in tree:
+ if isinstance(item, dict):
+ for k in ("path", "name", "filepath"):
+ v = item.get(k)
+ if isinstance(v, str) and v:
+ paths.append(v)
+ break
+ elif isinstance(item, str) and item:
+ paths.append(item)
+ return paths
+
+
+def _has_path(paths: list[str], targets: tuple[str, ...]) -> list[str]:
+ """paths 中命中任一 target(前缀/精确都算)的命中项,返回命中原文(去重保序)。"""
+ hits: list[str] = []
+ seen: set[str] = set()
+ low_targets = [t.lower().rstrip("/") for t in targets]
+ for p in paths:
+ pl = p.lower().rstrip("/")
+ for tl in low_targets:
+ if pl == tl or pl.startswith(tl + "/"):
+ key = f"{p}::{tl}"
+ if key not in seen:
+ seen.add(key)
+ hits.append(p)
+ break
+ return hits
+
+
+def repro_checks(file_texts: dict[str, str], tree: list,
+ repo_info: dict | None = None) -> list[dict[str, Any]]:
+ """复现性检查。
+
+ file_texts: {路径: 文本内容}(已取好)
+ tree: tree 列表(已取好)
+ repo_info: 仓库信息(取版本 tag;无 tag 字段则 unknown)
+
+ 返回检查项列表: [{"name","pass","score","evidence"}]
+ 每项 score 0-2(0=缺失/失败, 1=部分, 2=完备)。
+ """
+ paths = _tree_paths(tree)
+ items: list[dict[str, Any]] = []
+
+ # 1) CI 配置
+ ci_hits = _has_path(paths, CI_PATHS)
+ if ci_hits:
+ items.append({"name": "CI 配置", "pass": True, "score": 2,
+ "evidence": f"检测到 CI 配置: {', '.join(ci_hits[:3])}"})
+ else:
+ items.append({"name": "CI 配置", "pass": False, "score": 0,
+ "evidence": "未找到 .gitea/.github/.gitlab 等 CI 配置"})
+
+ # 2) lockfile(依赖版本固定)
+ lock_hits = _has_path(paths, LOCKFILES)
+ if lock_hits:
+ items.append({"name": "依赖锁文件", "pass": True, "score": 2,
+ "evidence": f"存在 lockfile: {', '.join(lock_hits[:3])}"})
+ else:
+ items.append({"name": "依赖锁文件", "pass": False, "score": 0,
+ "evidence": "未找到 go.sum/package-lock.json/poetry.lock 等锁文件"})
+
+ # 3) README 含数据集/环境/构建说明
+ readme_text = ""
+ for k in ("README.md", "README", "README.rst"):
+ if k in file_texts and file_texts[k]:
+ readme_text = file_texts[k]
+ break
+ if readme_text:
+ low = readme_text.lower()
+ hit_kw = [kw for kw in README_REPRO_KEYWORDS if kw.lower() in low]
+ score = 2 if len(hit_kw) >= 4 else (1 if len(hit_kw) >= 1 else 0)
+ items.append({"name": "README 复现说明", "pass": score > 0, "score": score,
+ "evidence": (f"README 含复现关键词 {len(hit_kw)} 个: {', '.join(hit_kw[:5])}"
+ if hit_kw else "README 存在但缺少数据集/环境/构建说明")})
+ else:
+ items.append({"name": "README 复现说明", "pass": False, "score": 0,
+ "evidence": "未找到 README"})
+
+ # 4) 版本 tag(用 repo_info,无 tag 字段则 unknown)
+ info = repo_info or {}
+ tag = (info.get("version") or info.get("tag") or info.get("release_tag")
+ or info.get("default_branch") or "")
+ # 多数 GitLink repo_info 无显式 tag 字段 → 标 unknown(不扣分但提示)
+ has_tag = bool(info.get("version") or info.get("tag") or info.get("release_tag"))
+ if has_tag:
+ items.append({"name": "版本 tag", "pass": True, "score": 2,
+ "evidence": f"版本/release tag: {tag}"})
+ else:
+ items.append({"name": "版本 tag", "pass": False, "score": 1,
+ "evidence": f"repo_info 无显式 tag 字段(默认分支: {info.get('default_branch', 'unknown')}),建议打 tag 固定可复现版本"})
+
+ # 5) 容器化(Dockerfile / docker-compose)—— 复现环境
+ container_hits = _has_path(paths, ("Dockerfile", "docker-compose.yml",
+ "docker-compose.yaml", ".devcontainer"))
+ if container_hits:
+ items.append({"name": "容器化环境", "pass": True, "score": 2,
+ "evidence": f"存在容器配置: {', '.join(container_hits[:3])}"})
+ else:
+ items.append({"name": "容器化环境", "pass": False, "score": 0,
+ "evidence": "未找到 Dockerfile/docker-compose,复现环境依赖手工描述"})
+
+ return items
+
+
+def compliance_items(license_info: dict[str, Any], file_texts: dict[str, str],
+ tree: list) -> list[dict[str, Any]]:
+ """合规性检查。
+
+ 返回检查项列表: [{"name","pass","score","evidence"}],score 0-2。
+ """
+ paths = _tree_paths(tree)
+ items: list[dict[str, Any]] = []
+
+ # 1) LICENSE 声明
+ lic = license_info.get("license", "None")
+ recognized = license_info.get("recognized", False)
+ if recognized and lic != "None":
+ items.append({"name": "LICENSE 文件", "pass": True, "score": 2,
+ "evidence": f"LICENSE 声明为 {lic}"})
+ elif lic == "None" and not (file_texts.get("LICENSE") or file_texts.get("LICENSE.txt")
+ or file_texts.get("LICENSE.md")):
+ items.append({"name": "LICENSE 文件", "pass": False, "score": 0,
+ "evidence": "缺少 LICENSE 文件"})
+ else:
+ items.append({"name": "LICENSE 文件", "pass": False, "score": 1,
+ "evidence": "LICENSE 文件存在但类型未识别"})
+
+ # 2) SECURITY.md
+ sec_hits = _has_path(paths, ("SECURITY.md", "SECURITY", "security.md"))
+ if sec_hits:
+ items.append({"name": "安全策略 SECURITY.md", "pass": True, "score": 2,
+ "evidence": f"存在 {sec_hits[0]}"})
+ else:
+ items.append({"name": "安全策略 SECURITY.md", "pass": False, "score": 0,
+ "evidence": "缺少 SECURITY.md,无安全披露流程"})
+
+ # 3) 版权头(采样 README/LICENSE 头部判断有无 Copyright)
+ sample = (file_texts.get("LICENSE", "") + "\n" + file_texts.get("README.md", "")
+ + "\n" + file_texts.get("README", ""))
+ has_copyright = ("copyright" in sample.lower()) or ("版权" in sample) or ("©" in sample)
+ if has_copyright:
+ items.append({"name": "版权声明", "pass": True, "score": 2,
+ "evidence": "LICENSE/README 中含 copyright/版权 声明"})
+ else:
+ items.append({"name": "版权声明", "pass": False, "score": 1,
+ "evidence": "未在 LICENSE/README 中发现版权声明(建议源文件头补 Copyright 注释)"})
+
+ # 4) 依赖合规(存在依赖清单即视为已声明,识别许可证更佳)
+ dep_present = bool(_has_path(paths, ("go.mod", "requirements.txt", "package.json",
+ "Cargo.toml", "pom.xml", "setup.py", "pyproject.toml")))
+ if dep_present:
+ items.append({"name": "依赖清单声明", "pass": True, "score": 2,
+ "evidence": "存在依赖管理文件(建议核对各依赖许可证兼容性)"})
+ else:
+ items.append({"name": "依赖清单声明", "pass": False, "score": 1,
+ "evidence": "未发现标准依赖管理文件"})
+
+ # 5) CONTRIBUTING.md(社区合规)
+ contrib_hits = _has_path(paths, ("CONTRIBUTING.md", "CONTRIBUTING", "contributing.md"))
+ if contrib_hits:
+ items.append({"name": "贡献指南", "pass": True, "score": 2,
+ "evidence": f"存在 {contrib_hits[0]}"})
+ else:
+ items.append({"name": "贡献指南", "pass": False, "score": 1,
+ "evidence": "缺少 CONTRIBUTING.md"})
+
+ return items
+
+
+def data_privacy(tree: list, gitignore_text: str) -> dict[str, Any]:
+ """数据隐私检查。
+
+ 返回: {
+ "items": [{"name","pass","score","evidence"}],
+ "risks": [...], # 高风险项明细
+ }
+ """
+ paths = _tree_paths(tree)
+ items: list[dict[str, Any]] = []
+ risks: list[str] = []
+
+ # 1) data/ 目录是否入库
+ data_hits = _has_path(paths, ("data/", "dataset/", "datasets/"))
+ if data_hits:
+ items.append({"name": "数据目录入库", "pass": False, "score": 0,
+ "evidence": f"data/ 目录已入库: {', '.join(data_hits[:3])}(建议大文件走外部存储/DVC)"})
+ risks.append(f"数据目录入库: {', '.join(data_hits[:3])}(可能含敏感数据)")
+ else:
+ items.append({"name": "数据目录入库", "pass": True, "score": 2,
+ "evidence": "未发现 data/ 目录入库"})
+
+ # 2) .env 是否入库
+ env_hits = _has_path(paths, (".env", ".env.local", ".env.production"))
+ if env_hits:
+ items.append({"name": ".env 入库", "pass": False, "score": 0,
+ "evidence": f".env 已入库: {', '.join(env_hits[:3])}(高风险,疑似凭据泄露)"})
+ risks.append(f".env 已入库: {', '.join(env_hits[:3])}(凭据泄露风险)")
+ else:
+ items.append({"name": ".env 入库", "pass": True, "score": 2,
+ "evidence": ".env 未入库"})
+
+ # 3) .gitignore 是否忽略 .env
+ gi = (gitignore_text or "").lower()
+ ignores_env = ".env" in gi
+ if ignores_env:
+ items.append({"name": ".gitignore 忽略 .env", "pass": True, "score": 2,
+ "evidence": ".gitignore 已配置忽略 .env"})
+ else:
+ items.append({"name": ".gitignore 忽略 .env", "pass": False, "score": 1,
+ "evidence": ".gitignore 未忽略 .env(建议添加 .env)"})
+ if not env_hits:
+ risks.append(".gitignore 未忽略 .env(预防性建议)")
+
+ return {"items": items, "risks": risks}
+
+
+def _score_10(items: list[dict[str, Any]], cap: float = 10.0) -> float:
+ """把检查项的 0-2 分聚合为 0-10 分:sum(score)/sum(max=2) * 10。"""
+ total = sum(it.get("score", 0) for it in items)
+ max_total = sum(2 for _ in items)
+ if max_total == 0:
+ return 0.0
+ return round(min(cap, total / max_total * cap), 1)
+
+
+# ---------------------------------------------------------------------------
+# 数据采集(调 gitlink-cli;算法不依赖本节)
+# ---------------------------------------------------------------------------
+
+def collect_file_texts(owner: str, repo: str, ref: str = "master") -> dict[str, str]:
+ """批量取关键文件文本。缺失文件返回空串(不出现在 dict 中)。"""
+ out: dict[str, str] = {}
+ for path in KEY_FILES:
+ try:
+ txt = c.file_text(owner, repo, path, ref=ref)
+ except Exception:
+ txt = ""
+ if txt and txt.strip():
+ out[path] = txt
+ return out
+
+
+def collect_tree(owner: str, repo: str, ref: str = "master") -> list:
+ """取根 tree(含扫 data/、.env、config、workflows 等)。"""
+ try:
+ return c.tree(owner, repo, path="", ref=ref)
+ except Exception:
+ return []
+
+
+# ---------------------------------------------------------------------------
+# 主流程
+# ---------------------------------------------------------------------------
+
+def run(owner: str, repo: str) -> dict[str, Any]:
+ info = c.repo_info(owner, repo)
+ ref = info.get("default_branch") or "master"
+
+ file_texts = collect_file_texts(owner, repo, ref)
+ tree = collect_tree(owner, repo, ref)
+
+ license_text = (file_texts.get("LICENSE") or file_texts.get("LICENSE.txt")
+ or file_texts.get("LICENSE.md") or "")
+ license_info = identify_license(license_text)
+
+ repro = repro_checks(file_texts, tree, repo_info=info)
+ compliance = compliance_items(license_info, file_texts, tree)
+ gitignore_text = file_texts.get(".gitignore", "")
+ dp = data_privacy(tree, gitignore_text)
+
+ # 汇总密钥扫描(扫所有已取文本 + gitignore)
+ all_secrets: list[dict[str, Any]] = []
+ for path, txt in file_texts.items():
+ all_secrets.extend(scan_secrets(txt, file=path))
+
+ repro_score = _score_10(repro)
+ compliance_score = _score_10(compliance + dp["items"])
+
+ # 风险项汇总
+ risks: list[dict[str, Any]] = []
+ for it in repro + compliance + dp["items"]:
+ if not it["pass"]:
+ risks.append({"area": "repro/compliance", "name": it["name"],
+ "evidence": it["evidence"], "level": "medium"})
+ for r in dp["risks"]:
+ risks.append({"area": "privacy", "name": "数据隐私", "evidence": r, "level": "high"})
+ for s in all_secrets:
+ risks.append({"area": "secret", "name": s["category"], "file": s["file"],
+ "line": s["line"], "detail": s["detail"],
+ "level": s["level"]})
+ # 按级别排序
+ level_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
+ risks.sort(key=lambda r: level_rank.get(r.get("level", "low"), 9))
+
+ return {
+ "scenario": "S3_compliance_reproducibility",
+ "repo": f"{owner}/{repo}",
+ "default_branch": ref,
+ "license": license_info["license"],
+ "repro_items": repro,
+ "compliance_items": compliance,
+ "privacy_items": dp["items"],
+ "secrets": all_secrets,
+ "risks": risks,
+ "repro_score": repro_score,
+ "compliance_score": compliance_score,
+ "meta": {"key_files_found": sorted(file_texts.keys()),
+ "tree_size": len(tree),
+ "languages": c.languages(owner, repo)},
+ }
+
+
+# ---------------------------------------------------------------------------
+# 渲染:Markdown 报告
+# ---------------------------------------------------------------------------
+
+def _grade(score: float) -> str:
+ if score >= 8:
+ return "良好"
+ if score >= 6:
+ return "及格"
+ if score >= 4:
+ return "偏弱"
+ return "较差"
+
+
+def render_report(result: dict[str, Any]) -> str:
+ repo = result["repo"]
+ lic = result["license"]
+ rs = result["repro_score"]
+ cs = result["compliance_score"]
+ lines = [
+ f"# 科研项目合规与复现性检查报告 — {repo}\n",
+ f"> 场景 S3 · 子赛题四「应用 GitLink 辅助科研」\n",
+ f"- **默认分支**: `{result.get('default_branch', 'master')}`",
+ f"- **识别许可证**: `{lic}`",
+ f"- **复现性评分**: **{rs}/10**({_grade(rs)})",
+ f"- **合规性评分**: **{cs}/10**({_grade(cs)})\n",
+ ]
+
+ # 检查清单表
+ lines += ["## 一、复现性检查清单\n",
+ "| 检查项 | 通过 | 得分 | 证据 |",
+ "|--------|:----:|:----:|------|"]
+ for it in result["repro_items"]:
+ mark = "PASS" if it["pass"] else "FAIL"
+ lines.append(f"| {it['name']} | {mark} | {it['score']}/2 | {it['evidence']} |")
+
+ lines += ["\n## 二、合规性检查清单\n",
+ "| 检查项 | 通过 | 得分 | 证据 |",
+ "|--------|:----:|:----:|------|"]
+ for it in result["compliance_items"]:
+ mark = "PASS" if it["pass"] else "FAIL"
+ lines.append(f"| {it['name']} | {mark} | {it['score']}/2 | {it['evidence']} |")
+
+ lines += ["\n## 三、数据隐私检查\n",
+ "| 检查项 | 通过 | 得分 | 证据 |",
+ "|--------|:----:|:----:|------|"]
+ for it in result["privacy_items"]:
+ mark = "PASS" if it["pass"] else "FAIL"
+ lines.append(f"| {it['name']} | {mark} | {it['score']}/2 | {it['evidence']} |")
+
+ # 风险项表
+ risks = result["risks"]
+ lines += ["\n## 四、风险项(按严重程度排序)\n",
+ "| 级别 | 类别 | 名称 | 文件:行 | 证据 |",
+ "|:----:|------|------|---------|------|"]
+ if risks:
+ for r in risks:
+ lvl = r.get("level", "medium")
+ area = r.get("area", "")
+ name = r.get("name", "")
+ file_loc = ""
+ if r.get("file"):
+ file_loc = f"{r['file']}:{r.get('line', '')}"
+ ev = r.get("evidence") or r.get("detail", "")
+ lines.append(f"| {lvl} | {area} | {name} | {file_loc} | {ev} |")
+ else:
+ lines.append("| — | — | 无风险项 | — | 全部检查通过 |")
+
+ # 密钥小结
+ secrets = result.get("secrets", [])
+ if secrets:
+ lines.append(f"\n> 检出 **{len(secrets)}** 处疑似敏感信息(见风险项表),请人工复核确认。\n")
+
+ lines.append(f"\n_复现分 {rs}/10 · 合规分 {cs}/10 · 树节点 {result.get('meta',{}).get('tree_size','?')}_\n")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+
+def main():
+ ap = argparse.ArgumentParser(description="S3 科研项目合规与复现性检查")
+ ap.add_argument("--owner", required=True)
+ ap.add_argument("--repo", required=True)
+ ap.add_argument("--out", help="输出目录(写 repro.json + compliance_report.md);省略则打印 JSON")
+ args = ap.parse_args()
+
+ result = run(args.owner, args.repo)
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ with open(os.path.join(args.out, "repro.json"), "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
+ with open(os.path.join(args.out, "compliance_report.md"), "w", encoding="utf-8") as f:
+ f.write(render_report(result))
+ print(f"✓ S3 合规/复现检查完成 → {args.out}/repro.json | compliance_report.md")
+ print(f" 许可证: {result['license']}")
+ print(f" 复现分: {result['repro_score']}/10 合规分: {result['compliance_score']}/10")
+ print(f" 风险项: {len(result['risks'])} 处")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/requirements.txt b/scripts/research/requirements.txt
new file mode 100644
index 0000000..a134d93
--- /dev/null
+++ b/scripts/research/requirements.txt
@@ -0,0 +1,6 @@
+# 子赛题四·科研辅助算法层依赖
+# 数据采集/报告/匹配/进度/合规/洞悉 仅用 Python 标准库(subprocess/json/sqlite3/re/datetime),无需第三方库。
+# 实测真正被 import 的第三方库只有以下两个:
+networkx>=3.1 # S2 科研知识图谱构建
+plotly>=5.18 # S6 科研成果可视化(交互 HTML)
+# (如后续 S4 升级为 TF-IDF、S6 改用模板渲染,再按需加 scikit-learn / jinja2)
diff --git a/scripts/research/test_graph_build.py b/scripts/research/test_graph_build.py
new file mode 100644
index 0000000..4cb7b94
--- /dev/null
+++ b/scripts/research/test_graph_build.py
@@ -0,0 +1,272 @@
+"""test_graph_build.py — S2 知识图谱的纯单元测试(不联网、不调 gitlink-cli)。
+
+把「取数」与「算法」分离:build_graph() 只吃已构造好的 mock 数据结构。
+运行:`python test_graph_build.py` 或 `pytest scripts/research/`
+"""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import graph_build as G # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# mock 数据构造
+# ---------------------------------------------------------------------------
+
+def _mock_repos():
+ return [
+ {
+ "fullname": "alice/mindspore-vision",
+ "identifier": "mindspore-vision",
+ "author": {"login": "alice"},
+ "description": "deep learning library for computer vision, "
+ "image classification and object detection using CNN",
+ "language": {"name": "Python"},
+ "praises_count": 120,
+ "forked_count": 30,
+ },
+ {
+ "fullname": "bob/mindnlp",
+ "identifier": "mindnlp",
+ "author": {"login": "bob"},
+ "description": "A nlp library: transformer, bert, text classification",
+ "language": {"name": "Python"},
+ "praises_count": 200,
+ "forked_count": 50,
+ },
+ ]
+
+
+def _mock_contributors_map():
+ # alice 既贡献自己的 repo,也贡献 bob 的 repo(→ 核心学者 + collaborates_with)
+ return {
+ "alice/mindspore-vision": [
+ {"login": "alice", "contribution_perc": "60.0%"},
+ {"login": "carol", "contribution_perc": "40.0%"},
+ {"login": "i-robot", "contribution_perc": "0.0%"}, # bot,应被过滤
+ ],
+ "bob/mindnlp": [
+ {"login": "alice", "contribution_perc": "10.0%"},
+ {"login": "bob", "contribution_perc": "90.0%"},
+ {"login": "dependabot", "contribution_perc": "1.0%"}, # bot
+ ],
+ }
+
+
+def _mock_readmes():
+ return {
+ "alice/mindspore-vision": "pytorch and resnet for object detection",
+ "bob/mindnlp": "pretrain gpt and llm models",
+ }
+
+
+def _build():
+ return G.build_graph(_mock_repos(), _mock_contributors_map(),
+ {}, _mock_readmes(), keywords=["vision", "nlp"])
+
+
+# ---------------------------------------------------------------------------
+# 工具函数
+# ---------------------------------------------------------------------------
+
+def test_is_bot():
+ assert G.is_bot("i-robot") is True
+ assert G.is_bot("dependabot[bot]") is True
+ assert G.is_bot("alice") is False
+ assert G.is_bot("") is True
+
+
+def test_parse_ratio():
+ assert G.parse_ratio("60.0%") == 0.6
+ assert abs(G.parse_ratio("0.5") - 0.5) < 1e-9
+ assert G.parse_ratio(0.3) == 0.3
+ assert G.parse_ratio(None) == 0.0
+ assert G.parse_ratio("n/a") == 0.0
+
+
+# ---------------------------------------------------------------------------
+# 建图:节点计数
+# ---------------------------------------------------------------------------
+
+def test_node_counts():
+ result = _build()
+ by_type = {}
+ for n in result["nodes"]:
+ by_type[n["type"]] = by_type.get(n["type"], 0) + 1
+ assert by_type.get("repo", 0) == 2
+ # alice / carol / bob = 3 个学者(bot 被过滤)
+ assert by_type.get("scholar", 0) == 3
+ # 主题:CV + deep_learning + nlp = 至少 3 个
+ assert by_type.get("topic", 0) >= 3
+ assert result["meta"]["repo_count"] == 2
+ assert result["meta"]["scholar_count"] == 3
+
+
+def test_repo_node_props():
+ result = _build()
+ repo_nodes = [n for n in result["nodes"] if n["type"] == "repo"]
+ labels = {n["label"] for n in repo_nodes}
+ assert "alice/mindspore-vision" in labels
+ r = [n for n in repo_nodes if n["label"] == "alice/mindspore-vision"][0]
+ assert r["props"]["language"] == "Python"
+ assert r["props"]["stars"] == 120
+ assert r["props"]["forks"] == 30
+
+
+# ---------------------------------------------------------------------------
+# 建图:边
+# ---------------------------------------------------------------------------
+
+def test_contributes_to_weight():
+ result = _build()
+ edges = result["edges"]
+ contrib = [e for e in edges if e["type"] == "contributes_to"
+ and e["source"] == "scholar:alice"
+ and e["target"] == "repo:alice/mindspore-vision"]
+ assert contrib, "应有 alice→自己repo 的 contributes_to 边"
+ # 60% → 0.6
+ assert abs(contrib[0]["weight"] - 0.6) < 1e-9
+
+
+def test_owns_edge():
+ result = _build()
+ owns = [e for e in result["edges"] if e["type"] == "owns"
+ and e["source"] == "scholar:bob"
+ and e["target"] == "repo:bob/mindnlp"]
+ assert owns, "bob 应有 owns 边到自己的 repo"
+
+
+def test_covers_topic_weight_range():
+ """covers_topic 权重应在 (0, 1] 且 <= 1。"""
+ result = _build()
+ covers = [e for e in result["edges"] if e["type"] == "covers_topic"]
+ assert covers, "应至少有一条 covers_topic 边"
+ for e in covers:
+ assert 0.0 <= e["weight"] <= 1.0
+ # 命中 computer_vision 主题(description 含 object detection/image classification)
+ cv_edges = [e for e in covers if e["target"] == "topic:computer_vision"]
+ assert cv_edges, "应识别出 computer_vision 主题"
+
+
+def test_collaborates_with():
+ """alice 与 carol 同在 alice/mindspore-vision → 至少一条 collaborates_with。"""
+ result = _build()
+ collab = [e for e in result["edges"] if e["type"] == "collaborates_with"]
+ assert collab, "应有 collaborates_with 边"
+ pair = {e["source"] for e in collab}
+ assert "scholar:alice" in pair
+
+
+def test_related_to_when_topic_cooccur():
+ """computer_vision 与 deep_learning 在同一 repo 共现 → related_to。"""
+ result = _build()
+ related = [e for e in result["edges"] if e["type"] == "related_to"
+ and e["source"] == "topic:computer_vision"]
+ # mindspore-vision 的 description 同时命中 CV + deep_learning
+ assert related, "共现主题应有 related_to 边"
+ assert any(e["target"] == "topic:deep_learning" for e in related)
+
+
+# ---------------------------------------------------------------------------
+# 衍生统计
+# ---------------------------------------------------------------------------
+
+def test_core_scholars():
+ result = _build()
+ # alice 出现在 2 个 repo → 排首位
+ top = result["core_scholars"][0]
+ assert top["login"] == "alice"
+ assert top["repo_count"] == 2
+
+
+def test_topic_heat_top():
+ result = _build()
+ heat = result["topic_heat"]
+ assert heat, "应有主题热度榜"
+ # deep_learning 在两个 repo 都命中 → 应在前列
+ topics = [h["topic"] for h in heat]
+ assert "deep_learning" in topics
+
+
+# ---------------------------------------------------------------------------
+# 渲染
+# ---------------------------------------------------------------------------
+
+def test_render_mermaid_header():
+ result = _build()
+ mmd = G.render_mermaid(result)
+ assert "graph TD" in mmd
+ assert "classDef" in mmd # 着色定义
+ assert "```" in mmd
+
+
+def test_render_mermaid_node_limit():
+ """节点过多时应被截断到 node_limit。"""
+ big_repos = []
+ big_contribs = {}
+ big_readmes = {}
+ for i in range(60):
+ fn = f"u{i}/repo{i}"
+ big_repos.append({
+ "fullname": fn, "identifier": f"repo{i}",
+ "author": {"login": f"u{i}"}, "description": "deep learning",
+ "language": {"name": "Python"}, "praises_count": 0, "forked_count": 0,
+ })
+ big_contribs[fn] = [{"login": f"u{i}", "contribution_perc": "100%"}]
+ big_readmes[fn] = "deep learning"
+ result = G.build_graph(big_repos, big_contribs, {}, big_readmes,
+ keywords=["dl"])
+ mmd = G.render_mermaid(result, node_limit=40)
+ # mermaid 里出现的节点声明数应 <= 40
+ node_lines = [ln for ln in mmd.splitlines() if '["' in ln and "-->" not in ln]
+ assert len(node_lines) <= 40
+
+
+def test_render_dot_header():
+ result = _build()
+ dot = G.render_dot(result)
+ assert dot.startswith("digraph G")
+ assert "fillcolor" in dot
+
+
+def test_render_report_sections():
+ result = _build()
+ md = G.render_report(result)
+ assert "知识图谱报告" in md
+ assert "主题热度榜" in md
+ assert "核心学者" in md
+ assert "deep_learning" in md or "computer_vision" in md
+
+
+def test_render_report_empty_safe():
+ result = {"scenario": "S2", "keywords": [], "nodes": [], "edges": [],
+ "core_scholars": [], "core_teams": [], "topic_heat": [],
+ "meta": {"repo_count": 0, "node_count": 0, "edge_count": 0,
+ "scholar_count": 0, "topic_count": 0}}
+ md = G.render_report(result)
+ assert "知识图谱报告" in md
+ assert "未识别" in md or "暂无" in md
+
+
+def test_empty_inputs():
+ """空输入不应抛异常。"""
+ result = G.build_graph([], {}, {}, {})
+ assert result["meta"]["node_count"] == 0
+ assert result["meta"]["edge_count"] == 0
+ assert result["nodes"] == []
+
+
+# ---------------------------------------------------------------------------
+
+def _run_all():
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ for fn in fns:
+ fn()
+ print(f"PASS {fn.__name__}")
+ print(f"\nAll {len(fns)} graph_build tests passed.")
+
+
+if __name__ == "__main__":
+ _run_all()
diff --git a/scripts/research/test_helpers.py b/scripts/research/test_helpers.py
new file mode 100644
index 0000000..46245fa
--- /dev/null
+++ b/scripts/research/test_helpers.py
@@ -0,0 +1,80 @@
+"""test_helpers.py — gitlink_data / collect 归一化工具的纯单元测试(不联网)。
+
+运行:`pytest scripts/research/` 或 `python scripts/research/test_helpers.py`
+"""
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import gitlink_data as gd # noqa: E402
+import collect # noqa: E402
+
+
+def test_first_list_list_input():
+ assert gd.first_list([1, 2, 3]) == [1, 2, 3]
+
+
+def test_first_list_known_key():
+ assert gd.first_list({"projects": [{"id": 1}], "total_count": 1}) == [{"id": 1}]
+
+
+def test_first_list_single_list_value():
+ # 无已知键但只有一个列表值时,兜底返回它
+ assert gd.first_list({"whatever": [9, 9]}) == [9, 9]
+
+
+def test_first_list_empty():
+ assert gd.first_list({"total_count": 0}) == []
+ assert gd.first_list(None) == []
+
+
+def test_total_count():
+ assert gd.total_count({"total_count": 42}) == 42
+ assert gd.total_count({"totalCount": 7}) == 7
+ assert gd.total_count({"projects": []}) is None
+
+
+def test_login_of_flat():
+ assert collect.login_of({"login": "whale"}) == "whale"
+
+
+def test_login_of_nested_author():
+ assert collect.login_of({"author": {"login": "baoerjun"}}) == "baoerjun"
+
+
+def test_login_of_name_fallback():
+ assert collect.login_of({"name": "surponess"}) == "surponess"
+
+
+def test_login_of_empty():
+ assert collect.login_of({}) == ""
+ assert collect.login_of("not a dict") == ""
+
+
+def test_repo_fullname_with_author():
+ project = {"identifier": "gitlink-cli", "author": {"login": "whale_hihihi"}}
+ assert collect.repo_fullname(project) == "whale_hihihi/gitlink-cli"
+
+
+def test_repo_fullname_missing_owner():
+ assert collect.repo_fullname({"identifier": "foo"}) == "foo"
+
+
+def test_as_int_as_float():
+ assert collect.as_int("12") == 12
+ assert collect.as_int(None) == 0
+ assert collect.as_float("1.5") == 1.5
+ assert collect.as_float("x", -1.0) == -1.0
+
+
+def _run_all():
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ for fn in fns:
+ fn()
+ print(f"PASS {fn.__name__}")
+ print(f"\nAll {len(fns)} helper tests passed.")
+
+
+if __name__ == "__main__":
+ _run_all()
diff --git a/scripts/research/test_lineage.py b/scripts/research/test_lineage.py
new file mode 100644
index 0000000..d46c092
--- /dev/null
+++ b/scripts/research/test_lineage.py
@@ -0,0 +1,260 @@
+"""test_lineage.py — lineage.py 纯函数单元测试。
+
+不联网、不调 gitlink-cli。把「取数」与「算法」分离:算法函数接收已取好的
+Python 数据结构,本测试用 mock 数据喂算法。
+
+运行: python test_lineage.py
+"""
+from __future__ import annotations
+
+import os
+import sys
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+# 仅 import 算法纯函数,绝不触发联网取数
+import lineage as L # noqa: E402
+
+
+class TestExperimentClassifier(unittest.TestCase):
+ def test_is_experiment_file_hits(self):
+ cases = [
+ "experiments/mnist/run.py",
+ "experiment_train.py",
+ "benchmark/imagenet/eval.py",
+ "eval/metrics.py",
+ "tests/test_model.py",
+ "test/foo.py",
+ "data/dataset.csv",
+ "src/benchmark_infer.py",
+ ]
+ for p in cases:
+ with self.subTest(p=p):
+ self.assertTrue(L.is_experiment_file(p), f"应判为实验文件: {p}")
+
+ def test_is_experiment_file_misses(self):
+ cases = [
+ "src/model.py",
+ "README.md",
+ "docs/index.md",
+ "main.go",
+ "config.yaml",
+ "pkg/utils/util.py",
+ ]
+ for p in cases:
+ with self.subTest(p=p):
+ self.assertFalse(L.is_experiment_file(p), f"不应判为实验文件: {p}")
+
+ def test_empty(self):
+ self.assertFalse(L.is_experiment_file(""))
+ self.assertFalse(L.is_experiment_file(None)) # type: ignore[arg-type]
+
+
+class TestDocClassifier(unittest.TestCase):
+ def test_md_anywhere(self):
+ self.assertTrue(L.is_doc_file("README.md"))
+ self.assertTrue(L.is_doc_file("docs/guide.md"))
+ self.assertTrue(L.is_doc_file("deep/nested/notes.md"))
+ self.assertTrue(L.is_doc_file("GUIDE.MD")) # 大小写不敏感
+
+ def test_docs_dir(self):
+ self.assertTrue(L.is_doc_file("docs/index.html"))
+ self.assertTrue(L.is_doc_file("docs/config.yaml"))
+ self.assertTrue(L.is_doc_file("a/docs/b.txt"))
+
+ def test_non_doc(self):
+ self.assertFalse(L.is_doc_file("src/main.py"))
+ self.assertFalse(L.is_doc_file("tests/x.py"))
+ self.assertFalse(L.is_doc_file("data.csv"))
+ self.assertFalse(L.is_doc_file(""))
+
+
+class TestTimestampParse(unittest.TestCase):
+ def test_iso_string(self):
+ t = L._to_epoch("2024-05-01T08:00:00Z")
+ self.assertGreater(t, 0)
+ # 反解回日期
+ self.assertTrue(L._iso_date(t).startswith("2024-05-01"))
+
+ def test_int_seconds(self):
+ self.assertAlmostEqual(L._to_epoch(1714521600), 1714521600.0)
+
+ def test_int_millis(self):
+ self.assertAlmostEqual(L._to_epoch(1714521600000), 1714521600.0)
+
+ def test_garbage(self):
+ self.assertEqual(L._to_epoch(None), 0.0)
+ self.assertEqual(L._to_epoch(""), 0.0)
+ self.assertEqual(L._to_epoch("not-a-date"), 0.0)
+
+ def test_iso_date_zero(self):
+ self.assertEqual(L._iso_date(0.0), "")
+
+
+class TestBranchMap(unittest.TestCase):
+ def test_single_default_branch(self):
+ commits = [
+ {"timestamp": "2024-01-01T00:00:00Z"},
+ {"timestamp": "2024-06-01T00:00:00Z"},
+ {"timestamp": "2024-03-01T00:00:00Z"},
+ ]
+ bm = L.build_branch_map(commits, "master")
+ self.assertEqual(len(bm), 1)
+ b = bm[0]
+ self.assertEqual(b["name"], "master")
+ self.assertTrue(b["is_default"])
+ self.assertEqual(b["commits"], 3)
+ # 最后活跃应取最大值 2024-06-01
+ self.assertTrue(b["last_active"].startswith("2024-06-01"))
+
+ def test_empty(self):
+ bm = L.build_branch_map([], "main")
+ self.assertEqual(bm, [{"name": "main", "commits": 0,
+ "last_active": "", "is_default": True}])
+
+ def test_non_list(self):
+ bm = L.build_branch_map(None, "main") # type: ignore[arg-type]
+ self.assertEqual(bm[0]["commits"], 0)
+
+
+class TestCommitTimeline(unittest.TestCase):
+ def test_aggregation_and_sort(self):
+ commits = [
+ {"timestamp": "2024-01-02T00:00:00Z"},
+ {"timestamp": "2024-01-02T12:00:00Z"},
+ {"timestamp": "2024-01-01T00:00:00Z"},
+ ]
+ tl = L.commit_timeline(commits, bucket="day")
+ self.assertEqual(tl, [
+ {"date": "2024-01-01", "count": 1},
+ {"date": "2024-01-02", "count": 2},
+ ])
+
+ def test_skips_garbage(self):
+ commits = [{"timestamp": "bad"}, {"timestamp": ""}]
+ self.assertEqual(L.commit_timeline(commits), [])
+
+
+class TestPrMergePatterns(unittest.TestCase):
+ def test_extract_and_sort(self):
+ prs = [
+ {"index": 10, "title": "feat A", "status": 1,
+ "pr_created_unix": 1717200000, "changed_files": 5},
+ {"index": 2, "title": "feat B", "status": 1,
+ "pr_merged_unix": 1714521600, "changed_files": 20},
+ {"index": 5, "title": "feat C", "status": 1,
+ "pr_created_unix": 1715000000, "additions": 3, "deletions": 4},
+ ]
+ out = L.pr_merge_patterns(prs)
+ self.assertEqual(len(out), 3)
+ # 升序:1714521600(2024-05-01) < 1715000000 < 1717200000
+ self.assertEqual(out[0]["number"], 2)
+ self.assertEqual(out[0]["changed_files"], 20)
+ self.assertTrue(out[0]["merged_time"].startswith("2024-05"))
+ # additions/deletions 兜底近似
+ last = next(p for p in out if p["number"] == 5)
+ self.assertEqual(last["changed_files"], 7)
+ # 空 merged_time 排末尾
+ no_time = L.pr_merge_patterns([
+ {"index": 1, "title": "x", "status": 1},
+ {"index": 2, "title": "y", "status": 1, "pr_created_unix": 1714521600},
+ ])
+ self.assertEqual(no_time[-1]["number"], 1)
+
+ def test_empty(self):
+ self.assertEqual(L.pr_merge_patterns([]), [])
+
+
+class TestDocEvolution(unittest.TestCase):
+ def test_filter_docs(self):
+ tree = [
+ {"name": "guide.md", "path": "docs/guide.md", "date": "2024-03-01"},
+ {"name": "index.html", "path": "docs/index.html"},
+ {"name": "model.py", "path": "src/model.py"}, # 排除
+ {"name": "old_2022-01-01.md", "path": "docs/old_2022-01-01.md"},
+ ]
+ out = L.doc_evolution(tree)
+ files = {d["file"] for d in out}
+ self.assertIn("guide.md", files)
+ self.assertIn("index.html", files)
+ self.assertIn("old_2022-01-01.md", files)
+ self.assertNotIn("model.py", files)
+ # 文件名日期回退
+ old = next(d for d in out if d["file"] == "old_2022-01-01.md")
+ self.assertEqual(old["last_date"], "2022-01-01")
+ # 显式 date 字段优先
+ g = next(d for d in out if d["file"] == "guide.md")
+ self.assertEqual(g["last_date"], "2024-03-01")
+
+
+class TestInnovationPoints(unittest.TestCase):
+ def test_high_impact_and_milestone(self):
+ prs = [
+ {"index": 1, "title": "chore: typo", "status": 1,
+ "pr_created_unix": 1714521600, "changed_files": 2}, # 低影响,无关键词
+ {"index": 2, "title": "feat: add transformer model", "status": 1,
+ "pr_created_unix": 1715000000, "changed_files": 25}, # 大规模+关键词
+ {"index": 3, "title": "implement benchmark suite", "status": 1,
+ "pr_created_unix": 1717200000, "changed_files": 4}, # 仅关键词
+ ]
+ out = L.innovation_points(prs, commits=[])
+ # 应识别出 PR#2 和 PR#3,PR#1 被过滤
+ nums = sorted(it["description"] for it in out)
+ self.assertTrue(any("transformer" in n.lower() for n in nums))
+ self.assertTrue(any("benchmark" in n.lower() for n in nums))
+ cats = [it["category"] for it in out]
+ self.assertIn("大规模重构/新特性", cats)
+ self.assertIn("特性引入", cats)
+ # 大规模 PR 排前(impact 更高)
+ self.assertIn("transformer", out[0]["description"].lower())
+ # 每条都带证据
+ for it in out:
+ self.assertTrue(it["evidence"])
+ self.assertTrue(it["category"])
+
+ def test_empty(self):
+ self.assertEqual(L.innovation_points([], []), [])
+
+ def test_top_limit(self):
+ prs = [{"index": i, "title": f"add feature {i}", "status": 1,
+ "pr_created_unix": 1714521600 + i * 86400, "changed_files": 15}
+ for i in range(20)]
+ out = L.innovation_points(prs, commits=[], top=5)
+ self.assertEqual(len(out), 5)
+
+
+class TestRender(unittest.TestCase):
+ """渲染函数不抛异常、产出非空。"""
+
+ def _result(self):
+ return {
+ "scenario": "S1_repository_research_insight",
+ "repo": "o/r", "default_branch": "master",
+ "commit_timeline": [{"date": "2024-01-01", "count": 3}],
+ "branch_map": [{"name": "master", "commits": 5, "last_active": "2024-06-01",
+ "is_default": True}],
+ "pr_merge_patterns": [{"number": 2, "title": "feat A", "status": 1,
+ "merged_time": "2024-06-01", "changed_files": 9}],
+ "doc_evolution": [{"file": "guide.md", "last_date": "2024-03-01"}],
+ "experiment_files": ["benchmark/eval.py"],
+ "innovation_points": [{"description": "feat A", "evidence": "PR #2",
+ "category": "特性引入"}],
+ "meta": {"commit_count": 5, "merged_pr_count": 1, "doc_count": 1,
+ "experiment_file_count": 1},
+ }
+
+ def test_report(self):
+ r = L.render_report(self._result())
+ self.assertIn("仓库级科研项目洞悉报告", r)
+ self.assertIn("master", r)
+ self.assertIn("feat A", r)
+
+ def test_mermaid(self):
+ m = L.render_mermaid(self._result())
+ self.assertIn("gitGraph", m)
+ self.assertIn("master", m)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/scripts/research/test_match.py b/scripts/research/test_match.py
new file mode 100644
index 0000000..b7ff35c
--- /dev/null
+++ b/scripts/research/test_match.py
@@ -0,0 +1,77 @@
+"""test_match.py — S4 协作匹配的纯单元测试(不联网)。
+
+运行:`pytest scripts/research/` 或 `python scripts/research/test_match.py`
+"""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import match as M # noqa: E402
+
+
+def test_cosine_basic():
+ assert M.cosine({"a": 1, "b": 0}, {"a": 1, "b": 0}) == 1.0
+ assert M.cosine({"a": 1}, {"b": 1}) == 0.0
+ # 对称且在 [0,1]
+ v = M.cosine({"a": 1, "b": 2}, {"a": 2, "b": 1})
+ assert 0.0 < v < 1.0
+
+
+def test_cosine_empty_safe():
+ assert M.cosine({}, {"a": 1}) == 0.0
+ assert M.cosine({}, {}) == 0.0
+
+
+def test_jaccard():
+ assert M.jaccard(["python", "go"], ["python", "rust"]) == 1 / 3
+ assert M.jaccard([], ["x"]) == 0.0
+
+
+def test_priority_weight():
+ assert M._priority_weight({"priority_name": "高"}) == 3.0
+ assert M._priority_weight({"priority_name": "urgent"}) == 3.0
+ assert M._priority_weight({"priority_name": "普通"}) == 2.0
+ assert M._priority_weight({"priority_name": "低"}) == 1.0
+ assert M._priority_weight({}) == 1.0
+
+
+def test_parse_ratio_percent_string():
+ assert M._parse_ratio("1.18%") == 0.0118
+ assert abs(M._parse_ratio("50%") - 0.5) < 1e-9
+
+
+def test_parse_ratio_plain():
+ assert M._parse_ratio(0.5) == 0.5
+ assert M._parse_ratio("0.2") == 0.2
+ assert M._parse_ratio(None) == 0.0
+ assert M._parse_ratio("n/a") == 0.0
+
+
+def test_render_report_has_sections():
+ result = {
+ "repo": "o/r", "gap_topics": ["deep_learning"], "needed_languages": ["python"],
+ "gap_signals": [{"type": "unresolved_issue", "topic": "deep_learning",
+ "evidence": "x", "priority": "高"}],
+ "candidates": [{"login": "alice", "score": 20.0, "topic_overlap": 0.5,
+ "language_match": 0.5, "activity_level": "high",
+ "repo_languages": ["python"], "reasons": ["覆盖缺口主题"]}],
+ "meta": {"pool_size": 1, "issue_sample": 1},
+ }
+ md = M.render_report(result)
+ assert "缺口分析" in md or "技术缺口" in md
+ assert "alice" in md
+ mm = M.render_mermaid(result)
+ assert mm.startswith("```mermaid") and "alice" in mm
+
+
+def _run_all():
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ for fn in fns:
+ fn()
+ print(f"PASS {fn.__name__}")
+ print(f"\nAll {len(fns)} match tests passed.")
+
+
+if __name__ == "__main__":
+ _run_all()
diff --git a/scripts/research/test_report.py b/scripts/research/test_report.py
new file mode 100644
index 0000000..22d019d
--- /dev/null
+++ b/scripts/research/test_report.py
@@ -0,0 +1,150 @@
+"""test_report.py — S5 进度跟踪与预警的纯单元测试(不联网)。
+
+运行:`pytest scripts/research/` 或 `python scripts/research/test_report.py`
+"""
+import os
+import sys
+from datetime import datetime, timedelta, timezone
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import report as R # noqa: E402
+
+UTC = timezone.utc
+NOW = datetime(2026, 6, 29, 12, 0, 0, tzinfo=UTC)
+
+
+def iso(dt):
+ return dt.isoformat()
+
+
+def test_parse_time_iso():
+ assert R.parse_time("2026-06-01T08:30:00+00:00").year == 2026
+ assert R.parse_time("2026-06-01T08:30:00Z").month == 6
+
+
+def test_parse_time_unix():
+ # 整数与整数字串都按秒级时间戳解析
+ dt = R.parse_time(1717200000)
+ assert dt is not None and dt.year == 2024
+ assert R.parse_time("1717200000").year == 2024
+
+
+def test_parse_time_none_and_garbage():
+ assert R.parse_time(None) is None
+ assert R.parse_time("") is None
+ assert R.parse_time("not a date") is None
+
+
+def test_in_window():
+ lo = NOW - timedelta(days=7)
+ assert R.in_window(NOW - timedelta(days=2), 7, NOW) is True
+ assert R.in_window(NOW - timedelta(days=10), 7, NOW) is False
+ assert R.in_window(None, 7, NOW) is False
+
+
+def test_week_stats_splits_this_and_last_week():
+ commits = [
+ {"timestamp": iso(NOW - timedelta(days=2)), "author": {"login": "a"}}, # 本周
+ {"timestamp": iso(NOW - timedelta(days=3)), "author": {"login": "b"}}, # 本周
+ {"timestamp": iso(NOW - timedelta(days=10)), "author": {"login": "a"}}, # 上周
+ ]
+ issues = [
+ {"created_at": iso(NOW - timedelta(days=1)), "status": "open"}, # 本周新增
+ {"created_at": iso(NOW - timedelta(days=9)), "status": "open"}, # 上周新增
+ ]
+ prs = []
+ stats = R.week_stats(commits, issues, prs, ["a", "b"], NOW)
+ assert stats["this_week"]["commits"] == 2
+ assert stats["last_week"]["commits"] == 1
+ assert stats["this_week"]["issues_opened"] == 1
+ assert stats["last_week"]["issues_opened"] == 1
+
+
+def test_week_stats_stale_issue():
+ # 开放、最近活动 > 30 天 → stale
+ old = NOW - timedelta(days=40)
+ issues = [{"status": "open", "created_at": iso(old), "journals_updated_at": iso(old)}]
+ stats = R.week_stats([], issues, [], [], NOW)
+ assert stats["this_week"]["issues_stale"] == 1
+
+
+def test_trend():
+ assert R.trend({"commits": 10}, {"commits": 5})["activity_level"] == "increasing"
+ assert R.trend({"commits": 2}, {"commits": 10})["activity_level"] == "decreasing"
+ assert R.trend({"commits": 10}, {"commits": 10})["activity_level"] == "stable"
+ # 上周为 0、本周有提交 → 100%
+ assert R.trend({"commits": 3}, {"commits": 0})["commit_delta_pct"] == 100.0
+
+
+def test_risk_low_activity():
+ stats = {"this_week": {"commits": 1, "issues_stale": 0, "prs_open_stale": 0}}
+ warns = R.risk_warnings(stats, [], [], commits=None, now=NOW)
+ assert any(w["type"] == "low_activity" for w in warns)
+
+
+def test_risk_bus_factor():
+ # 一人占本周全部提交 → bus factor
+ commits = [{"timestamp": iso(NOW - timedelta(days=1)), "author": {"login": "only"}} for _ in range(5)]
+ stats = {"this_week": {"commits": 5, "issues_stale": 0, "prs_open_stale": 0}}
+ warns = R.risk_warnings(stats, [], [], commits=commits, now=NOW)
+ assert any(w["type"] == "bus_factor" for w in warns)
+
+
+def test_milestone_progress_overdue():
+ ms = [{"name": "v1.0", "status": "open", "effective_date": iso(NOW - timedelta(days=5))}]
+ issues = [
+ {"milestone_name": "v1.0", "status": "closed"},
+ {"milestone_name": "v1.0", "status": "open"},
+ {"milestone_name": "v1.0", "status": "open"},
+ ]
+ out = R.milestone_progress(ms, issues, NOW)
+ assert len(out) == 1
+ assert out[0]["total"] == 3 and out[0]["closed"] == 1
+ assert out[0]["completion_pct"] == round(100 / 3, 1)
+ assert out[0]["overdue"] is True
+
+
+def test_render_report_contains_sections():
+ # 直接构造一个最小 result 喂渲染器(不触网)
+ res = {
+ "repo": "o/r", "generated_at": iso(NOW),
+ "week_stats": {"this_week": {"commits": 1, "issues_opened": 0, "issues_closed": 0,
+ "issues_stale": 0, "prs_opened": 0, "prs_merged": 0,
+ "prs_open_stale": 0, "contributors_active": 1},
+ "last_week": {"commits": 0, "issues_opened": 0, "issues_closed": 0,
+ "issues_stale": 0, "prs_opened": 0, "prs_merged": 0,
+ "prs_open_stale": 0, "contributors_active": 0},
+ "window": {"this_week_start": iso(NOW), "now": iso(NOW),
+ "last_week_start": iso(NOW), "last_week_end": iso(NOW)},
+ "total_contributors": 1},
+ "trend": {"commit_delta_pct": 100.0, "activity_level": "increasing"},
+ "milestones": [], "risk_warnings": [],
+ "meta": {"commits_fetched": 1, "issues_fetched": 0, "prs_fetched": 0,
+ "milestones_fetched": 0, "contributors_fetched": 1},
+ }
+ md = R.render_report(res)
+ assert "周报" in md and "趋势" in md
+
+
+def test_pr_status_string_and_int():
+ # GitLink PR 列表 status 是字符串 'merged'/'open'/'closed'
+ assert R._pr_status({"status": "merged"}) == 1
+ assert R._pr_status({"status": "open"}) == 0
+ assert R._pr_status({"status": "closed"}) == 2
+ # 详情/health 可能给 pull_request_status 整数 0/1/2
+ assert R._pr_status({"pull_request_status": 1}) == 1
+ assert R._pr_status({"pull_request_status": 0}) == 0
+ assert R._pr_status({"pull_request_status": 2}) == 2
+
+
+def _run_all():
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ for fn in fns:
+ fn()
+ print(f"PASS {fn.__name__}")
+ print(f"\nAll {len(fns)} report tests passed.")
+
+
+if __name__ == "__main__":
+ _run_all()
diff --git a/scripts/research/test_repro.py b/scripts/research/test_repro.py
new file mode 100644
index 0000000..2f1fa13
--- /dev/null
+++ b/scripts/research/test_repro.py
@@ -0,0 +1,278 @@
+"""test_repro.py — repro.py 的纯单元测试。
+
+不联网、不调 gitlink-cli。把"取数"和"算法"分离:直接给算法函数喂 mock 数据。
+
+运行: python test_repro.py
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import repro # noqa: E402
+
+# 用一个轻量测试框架,避免依赖 unittest(保持无第三方依赖)。
+_failures: list[str] = []
+
+
+def check(cond: bool, msg: str) -> None:
+ status = "PASS" if cond else "FAIL"
+ print(f" [{status}] {msg}")
+ if not cond:
+ _failures.append(msg)
+
+
+def check_eq(actual, expected, msg: str) -> None:
+ ok = actual == expected
+ status = "PASS" if ok else "FAIL"
+ print(f" [{status}] {msg} (got={actual!r})")
+ if not ok:
+ _failures.append(f"{msg}: expected {expected!r}, got {actual!r}")
+
+
+# ---------------------------------------------------------------------------
+# 1) identify_license
+# ---------------------------------------------------------------------------
+
+def test_identify_license():
+ print("\n== test_identify_license ==")
+ cases = {
+ "Apache License\nVersion 2.0": "Apache-2.0",
+ "MIT License\n\nCopyright (c) 2024": "MIT",
+ "GNU GENERAL PUBLIC LICENSE\nVersion 3": "GPL",
+ "木兰宽松许可证, 第2版": "MulanPSL-2.0",
+ "MulanPSL v2": "MulanPSL-2.0",
+ "BSD 3-Clause License": "BSD",
+ "ISC License": "ISC",
+ "Mozilla Public License Version 2.0": "MPL",
+ "random text without any license keyword": "None",
+ "": "None",
+ " \n ": "None",
+ }
+ for text, expected in cases.items():
+ info = repro.identify_license(text)
+ check_eq(info["license"], expected, f"identify_license({text[:24]!r})")
+ # 识别标志
+ check(repro.identify_license("MIT License")["recognized"] is True, "MIT recognized=True")
+ check(repro.identify_license("nope")["recognized"] is False, "unknown recognized=False")
+ # GPL 优先于 LGPL:LGPL 文本应命中 LGPL(因为 LGPL 模式在 GPL 之前)
+ l = repro.identify_license("GNU Lesser General Public License v3")
+ check_eq(l["license"], "LGPL", "LGPL not misidentified as GPL")
+
+
+# ---------------------------------------------------------------------------
+# 2) scan_secrets
+# ---------------------------------------------------------------------------
+
+def test_scan_secrets():
+ print("\n== test_scan_secrets ==")
+ sample = """\
+API_KEY=sk_live_abcdef1234567890abcd
+AWS_KEY=AKIAIOSFODNN7EXAMPLE
+mail: someone@example.com
+phone: 13812345678
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA...
+"""
+ findings = repro.scan_secrets(sample, file="config.env")
+ cats = {f["category"] for f in findings}
+ check("generic_api_key" in cats, "detect generic_api_key")
+ check("aws_access_key" in cats, "detect aws_access_key")
+ check("email" in cats, "detect email")
+ check("phone_cn" in cats, "detect phone_cn")
+ check("private_key" in cats, "detect private_key")
+ # 每条都有必要字段
+ for f in findings:
+ check(set(["level", "category", "file", "line", "detail"]).issubset(f.keys()),
+ f"finding {f['category']} has required keys")
+ check_eq(f["file"], "config.env", f"{f['category']} file field")
+ # critical 级别存在
+ levels = {f["level"] for f in findings}
+ check("critical" in levels, "critical level present (private key/aws)")
+ # 空文本
+ check_eq(repro.scan_secrets(""), [], "empty text -> no findings")
+ # 无敏感信息文本
+ check_eq(repro.scan_secrets("hello world\nnothing here"), [], "clean text -> no findings")
+ # 长串脱敏(detail 不超长)
+ long_token = "api_key=" + "a" * 60
+ f2 = repro.scan_secrets(long_token, file="x")
+ if f2:
+ check(len(f2[0]["detail"]) <= 50, "long secret is masked in detail")
+
+
+# ---------------------------------------------------------------------------
+# 3) repro_checks
+# ---------------------------------------------------------------------------
+
+def test_repro_checks():
+ print("\n== test_repro_checks ==")
+ file_texts = {
+ "README.md": "# Project\n\n## Install\n pip install -e .\n## Build\ndocker build .\n"
+ "Dataset: download from xxx. reproduce: python run.py\nUsage: see docs.",
+ }
+ tree = [
+ {"path": ".gitea/workflows/ci.yml"},
+ {"path": "go.sum"},
+ {"path": "requirements.txt"},
+ {"path": "Dockerfile"},
+ {"path": "src/main.go"},
+ ]
+ items = repro.repro_checks(file_texts, tree, repo_info={"default_branch": "master"})
+ names = {it["name"]: it for it in items}
+ check(names["CI 配置"]["pass"] is True, "CI detected")
+ check_eq(names["CI 配置"]["score"], 2, "CI score=2")
+ check(names["依赖锁文件"]["pass"] is True, "lockfile detected")
+ check(names["README 复现说明"]["pass"] is True, "README repro keywords detected")
+ check(names["README 复现说明"]["score"] >= 1, "README repro score>=1")
+ check(names["容器化环境"]["pass"] is True, "Dockerfile detected")
+
+ # 无 CI / 无 lockfile 场景
+ items2 = repro.repro_checks({}, [], repo_info={})
+ names2 = {it["name"]: it for it in items2}
+ check(names2["CI 配置"]["pass"] is False, "no CI -> fail")
+ check_eq(names2["CI 配置"]["score"], 0, "no CI score=0")
+ check(names2["依赖锁文件"]["pass"] is False, "no lockfile -> fail")
+ check(names2["README 复现说明"]["pass"] is False, "no README -> fail")
+ # 版本 tag:无 tag 字段 → score=1(不扣满分但提示)
+ check_eq(names2["版本 tag"]["score"], 1, "unknown tag -> score=1")
+ # 有 tag 字段 → score=2
+ items3 = repro.repro_checks({}, [], repo_info={"version": "v1.2.3"})
+ names3 = {it["name"]: it for it in items3}
+ check_eq(names3["版本 tag"]["score"], 2, "version present -> score=2")
+ check(names3["版本 tag"]["pass"] is True, "version present -> pass")
+
+ # score 范围合法
+ for it in items:
+ check(0 <= it["score"] <= 2, f"{it['name']} score in [0,2]")
+
+
+# ---------------------------------------------------------------------------
+# 4) compliance_items
+# ---------------------------------------------------------------------------
+
+def test_compliance_items():
+ print("\n== test_compliance_items ==")
+ license_info = {"license": "MIT", "recognized": True, "evidence": "MIT License"}
+ file_texts = {"LICENSE": "MIT License\nCopyright (c) 2024 Test", "README.md": "see LICENSE"}
+ tree = [
+ {"path": "SECURITY.md"},
+ {"path": "CONTRIBUTING.md"},
+ {"path": "requirements.txt"},
+ ]
+ items = repro.compliance_items(license_info, file_texts, tree)
+ names = {it["name"]: it for it in items}
+ check(names["LICENSE 文件"]["pass"] is True, "LICENSE recognized")
+ check_eq(names["LICENSE 文件"]["score"], 2, "LICENSE score=2")
+ check(names["安全策略 SECURITY.md"]["pass"] is True, "SECURITY.md present")
+ check(names["版权声明"]["pass"] is True, "copyright present")
+ check(names["贡献指南"]["pass"] is True, "CONTRIBUTING present")
+ check(names["依赖清单声明"]["pass"] is True, "dep manifest present")
+
+ # 缺失场景
+ license_none = {"license": "None", "recognized": False, "evidence": "missing"}
+ items2 = repro.compliance_items(license_none, {}, [])
+ names2 = {it["name"]: it for it in items2}
+ check(names2["LICENSE 文件"]["pass"] is False, "no LICENSE -> fail")
+ check_eq(names2["LICENSE 文件"]["score"], 0, "no LICENSE score=0")
+ check(names2["安全策略 SECURITY.md"]["pass"] is False, "no SECURITY -> fail")
+
+
+# ---------------------------------------------------------------------------
+# 5) data_privacy
+# ---------------------------------------------------------------------------
+
+def test_data_privacy():
+ print("\n== test_data_privacy ==")
+ # 健康场景:无 data/,无 .env,.gitignore 忽略 .env
+ tree = [{"path": "src/main.py"}, {"path": ".gitignore"}]
+ dp = repro.data_privacy(tree, ".env\n*.key\nnode_modules/")
+ items = {it["name"]: it for it in dp["items"]}
+ check(items["数据目录入库"]["pass"] is True, "no data dir -> pass")
+ check(items[".env 入库"]["pass"] is True, "no .env -> pass")
+ check(items[".gitignore 忽略 .env"]["pass"] is True, "gitignore ignores .env")
+ check_eq(len(dp["risks"]), 0, "healthy repo -> 0 risks")
+
+ # 风险场景:data/ 入库,.env 入库,gitignore 未忽略 .env
+ tree2 = [{"path": "data/raw.csv"}, {"path": ".env"}, {"path": "config/secrets.yml"}]
+ dp2 = repro.data_privacy(tree2, "node_modules/\n*.log")
+ items2 = {it["name"]: it for it in dp2["items"]}
+ check(items2["数据目录入库"]["pass"] is False, "data dir tracked -> fail")
+ check(items2[".env 入库"]["pass"] is False, ".env tracked -> fail")
+ check(items2[".gitignore 忽略 .env"]["pass"] is False, "gitignore missing .env")
+ check(len(dp2["risks"]) >= 1, "risky repo -> has risks")
+
+
+# ---------------------------------------------------------------------------
+# 6) 打分 _score_10
+# ---------------------------------------------------------------------------
+
+def test_score():
+ print("\n== test_score ==")
+ # 5 项全 2 分 → 10
+ full = [{"score": 2}] * 5
+ check_eq(repro._score_10(full), 10.0, "all pass -> 10")
+ # 5 项全 0 → 0
+ zero = [{"score": 0}] * 5
+ check_eq(repro._score_10(zero), 0.0, "all fail -> 0")
+ # 混合:5 项中 3×2 + 2×0 = 6/10 = 6.0
+ mixed = [{"score": 2}, {"score": 2}, {"score": 2}, {"score": 0}, {"score": 0}]
+ check_eq(repro._score_10(mixed), 6.0, "mixed 6/10 -> 6.0")
+ # 空列表
+ check_eq(repro._score_10([]), 0.0, "empty -> 0")
+ # cap 不超 10
+ over = [{"score": 2}] * 8
+ check(repro._score_10(over) <= 10.0, "capped at 10")
+
+
+# ---------------------------------------------------------------------------
+# 7) render_report 端到端(用 mock 结果)
+# ---------------------------------------------------------------------------
+
+def test_render_report():
+ print("\n== test_render_report ==")
+ mock = {
+ "scenario": "S3_compliance_reproducibility",
+ "repo": "o/r",
+ "default_branch": "master",
+ "license": "MIT",
+ "repro_items": [{"name": "CI 配置", "pass": True, "score": 2, "evidence": "ci"}],
+ "compliance_items": [{"name": "LICENSE 文件", "pass": True, "score": 2, "evidence": "MIT"}],
+ "privacy_items": [{"name": ".env 入库", "pass": True, "score": 2, "evidence": "ok"}],
+ "secrets": [],
+ "risks": [],
+ "repro_score": 10.0,
+ "compliance_score": 10.0,
+ "meta": {"tree_size": 5},
+ }
+ md = repro.render_report(mock)
+ check("# 科研项目合规与复现性检查报告" in md, "report has title")
+ check("MIT" in md, "report shows license")
+ check("10.0/10" in md, "report shows scores")
+ check("复现性检查清单" in md, "report has repro checklist")
+ check("风险项" in md, "report has risk section")
+
+
+# ---------------------------------------------------------------------------
+
+def main():
+ test_identify_license()
+ test_scan_secrets()
+ test_repro_checks()
+ test_compliance_items()
+ test_data_privacy()
+ test_score()
+ test_render_report()
+
+ print()
+ if _failures:
+ print(f"RESULT: FAIL ({len(_failures)} failures)")
+ for m in _failures:
+ print(f" - {m}")
+ sys.exit(1)
+ else:
+ print("RESULT: ALL PASS")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/research/test_visual.py b/scripts/research/test_visual.py
new file mode 100644
index 0000000..d2e36b4
--- /dev/null
+++ b/scripts/research/test_visual.py
@@ -0,0 +1,406 @@
+"""test_visual.py — S6 科研成果可视化的纯单元测试(不联网、不调 gitlink-cli)。
+
+把「取数」与「算法」分离:算法函数接收已构造好的 Python 数据结构(mock commits/issues/...),
+测试只覆盖 bin_weekly / contribution_heatmap / extract_paper_links / classify_artifacts,
+不测 plotly 渲染。
+
+运行:`python scripts/research/test_visual.py`
+"""
+import os
+import sys
+from datetime import datetime, timedelta, timezone
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import visual as V # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# 辅助:构造「最近 N 天」的时间戳/ISO 字符串
+# ---------------------------------------------------------------------------
+
+def _days_ago_ts(days: int) -> float:
+ return (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
+
+
+def _days_ago_iso(days: int) -> str:
+ return (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+# ---------------------------------------------------------------------------
+# _to_timestamp
+# ---------------------------------------------------------------------------
+
+def test_to_timestamp_int_seconds():
+ assert V._to_timestamp(0) == 0.0
+ assert V._to_timestamp(1719500000) == 1719500000.0
+ assert V._to_timestamp("1719500000") == 1719500000.0
+
+
+def test_to_timestamp_millis():
+ # 13 位 → 视为毫秒
+ assert abs(V._to_timestamp(1719500000000) - 1719500000.0) < 1e-3
+
+
+def test_to_timestamp_iso():
+ ts = V._to_timestamp("2024-06-15T12:00:00Z")
+ assert ts > 0
+ # 无效字符串 → 0
+ assert V._to_timestamp("not-a-date") == 0.0
+ assert V._to_timestamp(None) == 0.0
+ assert V._to_timestamp("") == 0.0
+
+
+# ---------------------------------------------------------------------------
+# bin_weekly
+# ---------------------------------------------------------------------------
+
+def test_bin_weekly_shape_and_sum():
+ weeks = 4
+ commits = [
+ {"timestamp": _days_ago_iso(2)}, # 本周
+ {"timestamp": _days_ago_iso(9)}, # 上周
+ {"timestamp": _days_ago_iso(9)},
+ {"timestamp": _days_ago_iso(20)}, # 3 周前
+ ]
+ out = V.bin_weekly(commits, weeks, V._commit_time)
+ assert set(out.keys()) == {"labels", "counts"}
+ assert len(out["labels"]) == weeks
+ assert len(out["counts"]) == weeks
+ assert sum(out["counts"]) == len(commits)
+ # 标签是 ISO 周(形如 2026-Wxx)
+ assert all("-W" in lab for lab in out["labels"])
+
+
+def test_bin_weekly_drops_out_of_window_and_invalid():
+ weeks = 3
+ commits = [
+ {"timestamp": _days_ago_iso(1)}, # 窗口内
+ {"timestamp": _days_ago_iso(365)}, # 太早(窗口外)→ 丢弃
+ {"timestamp": "garbage"}, # 非法 → 丢弃
+ {}, # 无时间字段 → 丢弃
+ ]
+ out = V.bin_weekly(commits, weeks, V._commit_time)
+ assert sum(out["counts"]) == 1
+
+
+def test_bin_weekly_issues_uses_created_at():
+ weeks = 2
+ issues = [
+ {"created_at": _days_ago_iso(3)}, # 窗口内
+ {"created_at": _days_ago_iso(40)}, # 窗口外(>2 周)→ 丢弃
+ ]
+ out = V.bin_weekly(issues, weeks, V._issue_time)
+ assert sum(out["counts"]) == 1 # 只有 3 天前那条进窗口
+
+
+def test_bin_weekly_prs_uses_pr_created_unix():
+ weeks = 2
+ prs = [
+ {"pr_created_unix": int(_days_ago_ts(2))},
+ {"pr_created_unix": int(_days_ago_ts(50))},
+ ]
+ out = V.bin_weekly(prs, weeks, V._pr_time)
+ assert sum(out["counts"]) == 1
+
+
+def test_bin_weekly_empty():
+ out = V.bin_weekly([], 5, V._commit_time)
+ assert len(out["labels"]) == 5
+ assert out["counts"] == [0, 0, 0, 0, 0]
+
+
+# ---------------------------------------------------------------------------
+# contribution_heatmap
+# ---------------------------------------------------------------------------
+
+def test_heatmap_matrix_shape():
+ weeks = 3
+ contributors = [
+ {"login": "alice", "contributions": 100},
+ {"login": "bob", "contributions": 5},
+ ]
+ commits = [
+ {"timestamp": _days_ago_iso(2), "author": {"login": "alice"}},
+ {"timestamp": _days_ago_iso(2), "author": {"login": "alice"}},
+ {"timestamp": _days_ago_iso(9), "author": {"login": "bob"}},
+ ]
+ hm = V.contribution_heatmap(contributors, commits, weeks)
+ assert set(hm.keys()) == {"users", "weeks", "matrix"}
+ assert hm["users"][:2] == ["alice", "bob"]
+ assert len(hm["matrix"]) == len(hm["users"])
+ for row in hm["matrix"]:
+ assert len(row) == weeks
+ # alice 行总和 = 2
+ alice_row = hm["matrix"][hm["users"].index("alice")]
+ assert sum(alice_row) == 2
+ bob_row = hm["matrix"][hm["users"].index("bob")]
+ assert sum(bob_row) == 1
+
+
+def test_heatmap_includes_commit_authors_not_in_contributors():
+ weeks = 2
+ contributors = [{"login": "alice", "contributions": 1}]
+ commits = [
+ {"timestamp": _days_ago_iso(2), "author": {"login": "alice"}},
+ {"timestamp": _days_ago_iso(3), "author": {"login": "carol"}}, # 不在 contributors
+ ]
+ hm = V.contribution_heatmap(contributors, commits, weeks)
+ assert "carol" in hm["users"]
+
+
+def test_heatmap_top_users_cap():
+ weeks = 2
+ contributors = [{"login": f"u{i}", "contributions": i} for i in range(20)]
+ commits = [{"timestamp": _days_ago_iso(1), "author": {"login": f"u{i}"}}
+ for i in range(20)]
+ hm = V.contribution_heatmap(contributors, commits, weeks, top_users=5)
+ assert len(hm["users"]) <= 5
+ assert len(hm["matrix"]) == len(hm["users"])
+
+
+def test_heatmap_empty():
+ hm = V.contribution_heatmap([], [], 4)
+ assert hm["users"] == []
+ assert hm["matrix"] == []
+ assert len(hm["weeks"]) == 4
+
+
+# ---------------------------------------------------------------------------
+# extract_paper_links
+# ---------------------------------------------------------------------------
+
+def test_extract_arxiv_url():
+ text = "See https://arxiv.org/abs/2401.00012 for details."
+ links = V.extract_paper_links(text)
+ assert len(links) == 1
+ assert links[0]["type"] == "arxiv"
+ assert links[0]["target"] == "https://arxiv.org/abs/2401.00012"
+ # snippet 截取自原文上下文(窗口较窄,断言前缀即可)
+ assert links[0]["source_text_snippet"].startswith("See https://arxiv.org")
+
+
+def test_extract_arxiv_pdf_url():
+ text = "paper: https://arxiv.org/pdf/2305.12345.pdf"
+ links = V.extract_paper_links(text)
+ assert len(links) == 1
+ # 归一成 abs 形式
+ assert links[0]["target"] == "https://arxiv.org/abs/2305.12345"
+
+
+def test_extract_arxiv_bare():
+ text = "We use arXiv:2103.07018 in our method."
+ links = V.extract_paper_links(text)
+ assert any(l["target"] == "https://arxiv.org/abs/2103.07018" for l in links)
+
+
+def test_extract_doi_url():
+ text = "Cited from https://doi.org/10.1000/182"
+ links = V.extract_paper_links(text)
+ assert len(links) == 1
+ assert links[0]["type"] == "doi"
+ assert links[0]["target"] == "https://doi.org/10.1000/182"
+
+
+def test_extract_doi_bare():
+ text = "Reference 10.1109/5.771073 shows that."
+ links = V.extract_paper_links(text)
+ assert len(links) == 1
+ assert links[0]["target"] == "https://doi.org/10.1109/5.771073"
+
+
+def test_extract_dedup_same_id():
+ text = ("arxiv 1 https://arxiv.org/abs/2401.00012 "
+ "and again https://arxiv.org/abs/2401.00012")
+ links = V.extract_paper_links(text)
+ assert len(links) == 1
+
+
+def test_extract_dedup_across_doi_forms():
+ # doi.org 形式与裸 DOI 视为同一条
+ text = "https://doi.org/10.1000/182 and bare 10.1000/182 again"
+ links = V.extract_paper_links(text)
+ # 同一 DOI 只出现一次
+ targets = [l["target"] for l in links]
+ assert targets.count("https://doi.org/10.1000/182") == 1
+
+
+def test_extract_multiple_and_order():
+ text = ("first https://arxiv.org/abs/2401.00012 "
+ "then https://doi.org/10.1000/182")
+ links = V.extract_paper_links(text)
+ assert len(links) == 2
+ # 按出现位置排序
+ assert links[0]["type"] == "arxiv"
+ assert links[1]["type"] == "doi"
+
+
+def test_extract_none_in_text():
+ assert V.extract_paper_links("no links here at all") == []
+ assert V.extract_paper_links("") == []
+
+
+def test_extract_strips_trailing_punct_from_doi():
+ text = "see 10.1000/abc123, then more."
+ links = V.extract_paper_links(text)
+ assert links[0]["target"].endswith("/abc123") # 末尾逗号/句号被清掉
+ assert not links[0]["target"].rstrip().endswith(",")
+
+
+# ---------------------------------------------------------------------------
+# classify_artifacts
+# ---------------------------------------------------------------------------
+
+def test_classify_paper_and_ipynb():
+ tree = [
+ {"path": "docs/paper.pdf"},
+ {"path": "notebooks/demo.ipynb"},
+ ]
+ out = V.classify_artifacts(tree)
+ cats = {a["path"]: a["category"] for a in out}
+ assert cats["docs/paper.pdf"] == "paper"
+ assert cats["notebooks/demo.ipynb"] == "paper"
+
+
+def test_classify_dataset():
+ tree = [
+ {"path": "data/train.csv"},
+ {"path": "datasets/x.parquet"},
+ ]
+ out = V.classify_artifacts(tree)
+ cats = {a["path"]: a["category"] for a in out}
+ assert cats["data/train.csv"] == "dataset"
+
+
+def test_classify_model():
+ tree = [
+ {"path": "model/best.ckpt"},
+ {"path": "models/v2.onnx"},
+ ]
+ out = V.classify_artifacts(tree)
+ cats = {a["path"]: a["category"] for a in out}
+ assert cats["model/best.ckpt"] == "model"
+ assert cats["models/v2.onnx"] == "model"
+
+
+def test_classify_benchmark():
+ tree = [{"path": "benchmark/glue/run.py"}]
+ out = V.classify_artifacts(tree)
+ assert out[0]["category"] == "benchmark"
+
+
+def test_classify_ignores_unrelated():
+ tree = [
+ {"path": "src/main.py"},
+ {"path": "README.md"},
+ {"path": "tools/util.go"},
+ ]
+ out = V.classify_artifacts(tree)
+ assert out == [] # 都不命中任何类别
+
+
+def test_classify_dedup_same_path():
+ tree = [
+ {"path": "data/a.csv"},
+ {"path": "data/a.csv"}, # 重复
+ ]
+ out = V.classify_artifacts(tree)
+ assert len(out) == 1
+
+
+def test_classify_handles_name_only():
+ # 没有 path 只有 name 的条目也能处理
+ tree = [{"name": "paper.pdf"}]
+ out = V.classify_artifacts(tree)
+ assert len(out) == 1
+ assert out[0]["category"] == "paper"
+
+
+def test_classify_empty_and_non_dict():
+ assert V.classify_artifacts([]) == []
+ assert V.classify_artifacts(None) == []
+ assert V.classify_artifacts(["str", 123, None]) == []
+
+
+def test_artifact_summary():
+ arts = [
+ {"path": "a.pdf", "category": "paper"},
+ {"path": "b.ipynb", "category": "paper"},
+ {"path": "x.csv", "category": "dataset"},
+ {"path": "m.ckpt", "category": "model"},
+ ]
+ s = V.artifact_summary(arts)
+ assert s == {"paper": 2, "dataset": 1, "model": 1, "benchmark": 0}
+
+
+# ---------------------------------------------------------------------------
+# render_report(不渲染 plotly,只验证 markdown 结构)
+# ---------------------------------------------------------------------------
+
+def test_render_report_has_sections():
+ result = {
+ "repo": "o/r", "weeks": 4,
+ "timeline": {"labels": ["W1", "W2", "W3", "W4"],
+ "commits": [1, 2, 3, 4], "issues": [0, 1, 0, 2],
+ "prs": [0, 0, 1, 0]},
+ "heatmap": {"users": ["alice", "bob"], "weeks": ["W1", "W2"],
+ "matrix": [[1, 2], [0, 1]]},
+ "languages": {"Python": "99%"},
+ "milestones": [],
+ "paper_links": [{"type": "arxiv", "target": "https://arxiv.org/abs/2401.00012",
+ "source_text_snippet": "see arxiv"}],
+ "artifacts": [{"path": "p.pdf", "category": "paper", "name": "p.pdf"}],
+ "artifact_summary": {"paper": 1, "dataset": 0, "model": 0, "benchmark": 0},
+ "meta": {"commit_count": 10, "issue_count": 3, "pr_count": 1,
+ "contributor_count": 2, "milestone_count": 0},
+ }
+ md = V.render_report(result)
+ assert "科研成果可视化" in md
+ assert "o/r" in md
+ assert "alice" in md
+ assert "arxiv.org/abs/2401.00012" in md
+ # 含周快照表头
+ assert "commits" in md
+
+
+# ---------------------------------------------------------------------------
+# 端到端(算法层):run() 复用 raw dict,不联网
+# ---------------------------------------------------------------------------
+
+def test_run_with_mock_raw():
+ raw = {
+ "commits": [{"timestamp": _days_ago_iso(2), "author": {"login": "alice"},
+ "message": "see https://arxiv.org/abs/2401.00012"}],
+ "issues": [{"created_at": _days_ago_iso(3)}],
+ "prs": [{"pr_created_unix": int(_days_ago_ts(4))}],
+ "milestones": [{"name": "v1.0", "due_on": _days_ago_iso(30)}],
+ "languages": {"Python": "99%"},
+ "contributors": [{"login": "alice", "contributions": 1}],
+ "readme": "ref https://doi.org/10.1000/182 here",
+ "tree": [{"path": "data/x.csv"}, {"path": "paper.pdf"}],
+ }
+ result = V.run("owner", "repo", weeks=4, raw=raw)
+ assert result["scenario"] == "S6_research_visualization"
+ assert result["repo"] == "owner/repo"
+ assert result["weeks"] == 4
+ # 论文链接同时来自 readme 和 commit message
+ targets = {p["target"] for p in result["paper_links"]}
+ assert "https://arxiv.org/abs/2401.00012" in targets
+ assert "https://doi.org/10.1000/182" in targets
+ # 产物分类
+ cats = {a["category"] for a in result["artifacts"]}
+ assert cats == {"dataset", "paper"}
+ # 时间线长度 = weeks
+ assert len(result["timeline"]["labels"]) == 4
+
+
+def _run_all():
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ for fn in fns:
+ fn()
+ print(f"PASS {fn.__name__}")
+ print(f"\nAll {len(fns)} visual tests passed.")
+
+
+if __name__ == "__main__":
+ _run_all()
diff --git a/scripts/research/topics.py b/scripts/research/topics.py
new file mode 100644
index 0000000..45ab60b
--- /dev/null
+++ b/scripts/research/topics.py
@@ -0,0 +1,110 @@
+"""topics.py — 科研主题/技术关键词词典(S2 知识图谱与 S4 协作匹配共享)。
+
+采用「关键词词典 + 字符串匹配」方式抽取主题,零 NLP/分词依赖,结果确定可复现。
+词典可扩展;覆盖 GitLink 上常见科研方向(CV/NLP/RL/DL/系统/安全/科学计算等)。
+"""
+from __future__ import annotations
+
+import re
+from collections import Counter
+from typing import Iterable
+
+# 主题 → 触发关键词(中英文)。小写匹配。
+TOPIC_KEYWORDS: dict[str, tuple[str, ...]] = {
+ "machine_learning": ("机器学习", "machine learning", "监督学习", "无监督学习",
+ "supervised", "unsupervised", "scikit-learn", "sklearn",
+ "特征工程", "feature engineering", "generalization"),
+ "computer_vision": ("目标检测", "图像分类", "语义分割", "实例分割", "目标跟踪",
+ "object detection", "image classification", "semantic segmentation",
+ "instance segmentation", "object tracking", "yolo", "resnet", "cnn",
+ "图像识别", "ocr", "人脸识别", "visual", "vision"),
+ "nlp": ("自然语言处理", "文本分类", "机器翻译", "问答", "命名实体",
+ "nlp", "text classification", "machine translation", "transformer", "bert",
+ "gpt", "llm", "大模型", "大语言模型", "预训练", "pretrain", "分词", "tokeniz"),
+ "generative_ai": ("生成式", "aigc", "扩散模型", "生成模型", "文生图", "多模态",
+ "generative", "diffusion", "gan", "vae", "multimodal", "clip",
+ "对话", "chatgpt", "chat", "instruction tun"),
+ "reinforcement_learning": ("强化学习", "多智能体", "决策",
+ "reinforcement learning", "multi-agent", "ppo", "dqn",
+ "q-learning", "reward", "policy gradient"),
+ "deep_learning": ("深度学习", "神经网络", "训练", "推理", "微调",
+ "deep learning", "neural network", "pytorch", "tensorflow",
+ "mindspore", "paddle", "paddlepaddle", "inference", "fine-tun",
+ "backbone", "checkpoint"),
+ "graph_learning": ("图神经网络", "图表示学习", "知识图谱",
+ "graph neural", "gnn", "graph convolution", "gcn", "graphsage",
+ "knowledge graph", "图嵌入", "graph embed"),
+ "federated_learning": ("联邦学习", "隐私保护", "分布式训练",
+ "federated", "privacy", "distributed training"),
+ "speech": ("语音识别", "语音合成", "声纹", "语音",
+ "speech", "asr", "tts", "speaker", "voice", "声学"),
+ "scientific_computing": ("科学计算", "数值模拟", "高性能计算", "并行计算",
+ "numerical", "simulation", "hpc", "parallel", "cuda", "gpu",
+ "有限元", "偏微分"),
+ "autonomous_systems": ("自动驾驶", "机器人", "感知", "导航", "slam",
+ "autonomous", "robotics", "robot", "self-driving", "planning"),
+ "bioinformatics": ("生物信息", "蛋白质", "基因", "分子",
+ "bioinformatic", "genomic", "protein", "molecular", "drug"),
+ "time_series": ("时序", "时间序列", "预测", "序列建模",
+ "time series", "time-series", "forecasting", "temporal"),
+ "devops": ("ci/cd", "devops", "pipeline", "容器", "编排",
+ "docker", "kubernetes", "k8s", "jenkins", "自动化部署", "helm"),
+ "database": ("数据库", "存储", "索引",
+ "database", "sql", "nosql", "storage", "index"),
+ "security": ("安全", "漏洞", "加密", "隐私",
+ "security", "vulnerability", "crypto", "privacy", "attack"),
+ "data_mining": ("数据挖掘", "推荐系统", "聚类", "分类",
+ "data mining", "recommender", "clustering", "classification", "tf-idf"),
+}
+
+# 编程语言关键词(用于 S4 语言匹配)
+LANGUAGE_KEYWORDS: tuple[str, ...] = (
+ "python", "go", "golang", "c++", "cpp", "c#", "java", "rust", "javascript",
+ "typescript", "julia", "r", "matlab", "scala", "swift", "kotlin", "cuda",
+)
+
+_NON_ALNUM = re.compile(r"[^\w一-鿿+#]+")
+
+
+def _normalize(text: str) -> str:
+ return (text or "").lower()
+
+
+def extract_topics(text: str) -> list[str]:
+ """从一段文本中抽取命中的主题列表(去重,保序)。"""
+ t = _normalize(text)
+ if not t:
+ return []
+ hit = []
+ for topic, kws in TOPIC_KEYWORDS.items():
+ for kw in kws:
+ if _normalize(kw) in t:
+ hit.append(topic)
+ break
+ return hit
+
+
+def extract_languages(text: str) -> list[str]:
+ """从文本中抽取命中的编程语言(归一化别名,如 golang→go)。"""
+ t = _normalize(text)
+ if not t:
+ return []
+ alias = {"golang": "go", "cpp": "c++", "c#": "c#", "ts": "typescript"}
+ out, seen = [], set()
+ # 按非字母数字分割后逐 token 比对,避免 'go' 误命中 'google'
+ tokens = set(_NON_ALNUM.sub(" ", t).split())
+ for kw in LANGUAGE_KEYWORDS:
+ norm = _normalize(kw)
+ if norm in tokens and norm not in seen:
+ seen.add(norm)
+ out.append(alias.get(norm, norm))
+ return out
+
+
+def topic_counter(texts: Iterable[str]) -> Counter:
+ """对多段文本累计主题词频,用于热点排序(S2)。"""
+ c: Counter = Counter()
+ for t in texts:
+ for topic in extract_topics(t):
+ c[topic] += 1
+ return c
diff --git a/scripts/research/visual.py b/scripts/research/visual.py
new file mode 100644
index 0000000..c40e689
--- /dev/null
+++ b/scripts/research/visual.py
@@ -0,0 +1,636 @@
+"""visual.py — S6 科研成果可视化沉淀。
+
+把一个科研仓库的「成果」沉淀成一张可交互的可视化报告:开发时间线(commit/issue/pr 周粒度
+趋势)、贡献者×周热力图、语言占比饼图、里程碑甘特。同时从 README/提交里抽取论文引用
+(arXiv / DOI)并按目录对仓库产物做分类,便于科研工作者一眼看清「成果产出节奏 + 引用源头」。
+
+数据全部经 gitlink-cli 获取(commit via Raw API、issue/pr/milestone/repo +list、contributors、
+languages、readme、tree)。算法纯函数化(按周分桶 / 热力矩阵 / 论文链接抽取 / 产物分类),
+便于离线单测。
+
+用法:
+ python visual.py --owner mindspore-Ecosystem --repo mindspore --weeks 26 --out ./out
+ python visual.py --owner O --repo R # 仅打印 JSON
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from datetime import datetime, timezone
+from typing import Any
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import collect as c # noqa: E402
+
+# ---------------------------------------------------------------------------
+# 时间解析工具
+# ---------------------------------------------------------------------------
+
+def _to_timestamp(value: Any) -> float:
+ """把 GitLink 多种时间表示统一成 epoch 秒。
+
+ 支持整数秒(pr_created_unix)、ISO 字符串(created_at / timestamp 字符串)。
+ 无法解析返回 0.0(最远古时间,会被周分桶丢弃到「太早」一端)。
+ """
+ if value is None:
+ return 0.0
+ # 整数秒(commit.timestamp 形如 "1719500000" 也可走这里)
+ if isinstance(value, (int, float)):
+ f = float(value)
+ # 毫秒级时间戳兜底(13 位)
+ return f / 1000.0 if f > 1e12 else f
+ s = str(value).strip()
+ if not s:
+ return 0.0
+ # 纯数字字符串
+ if re.fullmatch(r"\d+(\.\d+)?", s):
+ f = float(s)
+ return f / 1000.0 if f > 1e12 else f
+ # ISO 8601(兼容带/不带 Z、带毫秒、带时区偏移)
+ txt = s.replace("Z", "+00:00")
+ fmts = ("%Y-%m-%dT%H:%M:%S%z",
+ "%Y-%m-%dT%H:%M:%S.%f%z",
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%d")
+ for fmt in fmts:
+ try:
+ dt = datetime.strptime(txt, fmt)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.timestamp()
+ except ValueError:
+ continue
+ return 0.0
+
+
+def _commit_time(commit: dict) -> float:
+ """提交对象取时间戳:优先 timestamp(字符串),退而 author.committed_unix。"""
+ ts = commit.get("timestamp")
+ if ts is not None:
+ return _to_timestamp(ts)
+ auth = commit.get("author") or {}
+ if isinstance(auth, dict):
+ for k in ("committed_unix", "committed_at", "time", "date"):
+ if auth.get(k) is not None:
+ return _to_timestamp(auth.get(k))
+ return 0.0
+
+
+def _issue_time(issue: dict) -> float:
+ return _to_timestamp(issue.get("created_at"))
+
+
+def _pr_time(pr: dict) -> float:
+ return _to_timestamp(pr.get("pr_created_unix") or pr.get("created_at"))
+
+
+# ---------------------------------------------------------------------------
+# 算法 1:按周分桶
+# ---------------------------------------------------------------------------
+
+def bin_weekly(items: list, weeks: int, time_getter) -> dict[str, list]:
+ """把带时间戳的对象按「最近 weeks 周」分桶(含本周在内的 weeks 个连续周桶)。
+
+ Args:
+ items: 待分桶对象列表。
+ weeks: 保留最近多少个周桶。
+ time_getter: 从单个对象取 epoch 秒的函数。
+
+ 返回 ``{"labels": [...], "counts": [...]}``:
+ - labels[i] 形如 "2026-W13"(ISO 周标签),从最近一周倒序到最老一周。
+ - counts[i] 为该周命中数;时间非法或越界(早于窗口左端)的对象不计入。
+ """
+ weeks = max(1, int(weeks))
+ now = datetime.now(timezone.utc)
+ # 右端边界对齐到「下周一 00:00 UTC」(不含),使窗口包含当前周在内共 weeks 周。
+ # 若右端用「本周一」,当前周会被整体排除,最近一周的数据被吞掉。
+ today = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ # Monday=0 .. Sunday=6;toordinal - weekday = 本周一,+7 = 下周一
+ next_monday = today.fromordinal(today.toordinal() - today.weekday() + 7).replace(tzinfo=timezone.utc)
+ end_ts = next_monday.timestamp()
+ start_ts = end_ts - weeks * 7 * 86400
+
+ counts = [0] * weeks
+ iso_labels: list[str] = []
+ for i in range(weeks):
+ # 桶 i 的周一 = end - (weeks-1-i) 周
+ bucket_monday_ts = start_ts + i * 7 * 86400
+ bucket_monday = datetime.fromtimestamp(bucket_monday_ts, tz=timezone.utc)
+ iso_year, iso_week, _ = bucket_monday.isocalendar()
+ iso_labels.append(f"{iso_year}-W{iso_week:02d}")
+
+ for item in items:
+ ts = time_getter(item)
+ if ts <= 0:
+ continue
+ if ts < start_ts or ts >= end_ts:
+ continue
+ offset = ts - start_ts
+ idx = int(offset // (7 * 86400))
+ if 0 <= idx < weeks:
+ counts[idx] += 1
+
+ return {"labels": iso_labels, "counts": counts}
+
+
+# ---------------------------------------------------------------------------
+# 算法 2:贡献者 × 周热力矩阵
+# ---------------------------------------------------------------------------
+
+def contribution_heatmap(contributors: list, commits: list, weeks: int,
+ top_users: int = 12) -> dict[str, list]:
+ """构建 top 贡献者 × 周桶的提交数矩阵。
+
+ Args:
+ contributors: GitLink contributors[](用于排序与展示名)。
+ commits: GitLink commits[](含 author.login)。
+ weeks: 周桶数(与 bin_weekly 同口径)。
+ top_users: 矩阵最多保留多少个贡献者(按贡献数 desc)。
+
+ 返回 ``{"users": [login...], "weeks": [label...], "matrix": [[cnt...]]}``:
+ matrix[user_i][week_j] = 该用户在该周的提交数。无 contributor 信息时也按 commit 作者聚合。
+ """
+ weeks = max(1, int(weeks))
+ # 用 bin_weekly 的同口径周标签(取贡献数排序后的 login 列表)
+ window = bin_weekly(commits, weeks, _commit_time)
+ week_labels = window["labels"]
+ now = datetime.now(timezone.utc)
+ end = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ end = end.fromordinal(end.toordinal() - end.weekday()).replace(tzinfo=timezone.utc)
+ start_ts = end.timestamp() - weeks * 7 * 86400
+
+ # 候选用户顺序:contributors(按 contributions desc)+ 提交里出现但不在 contributors 的作者
+ ordered: list[str] = []
+ seen: set[str] = set()
+ for contrib in contributors or []:
+ login = c.login_of(contrib) or ""
+ if login and login not in seen:
+ ordered.append(login)
+ seen.add(login)
+ for cm in commits or []:
+ auth = cm.get("author") or {}
+ login = c.login_of(auth) if isinstance(auth, dict) else ""
+ if login and login not in seen:
+ ordered.append(login)
+ seen.add(login)
+
+ users = ordered[:top_users]
+ matrix = [[0] * weeks for _ in users]
+ user_idx = {u: i for i, u in enumerate(users)}
+
+ for cm in commits or []:
+ ts = _commit_time(cm)
+ if ts <= 0 or ts < start_ts or ts >= end.timestamp():
+ continue
+ auth = cm.get("author") or {}
+ login = c.login_of(auth) if isinstance(auth, dict) else ""
+ if not login or login not in user_idx:
+ continue
+ offset = ts - start_ts
+ idx = int(offset // (7 * 86400))
+ if 0 <= idx < weeks:
+ matrix[user_idx[login]][idx] += 1
+
+ return {"users": users, "weeks": week_labels, "matrix": matrix}
+
+
+# ---------------------------------------------------------------------------
+# 算法 3:论文引用链接抽取
+# ---------------------------------------------------------------------------
+
+# arXiv: arxiv.org/abs/2401.00012 / arxiv.org/pdf/... / arxiv:2401.00012
+_ARXIV_RE = re.compile(
+ r"(?:https?://)?(?:www\.)?arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})(?:v\d+)?(?:\.pdf)?",
+ re.IGNORECASE,
+)
+_ARXIV_BARE_RE = re.compile(r"\barXiv:\s*(\d{4}\.\d{4,5})", re.IGNORECASE)
+# DOI: doi.org/10.xxxx/... 或裸 10.xxxx/...(论文里的 DOI 形式)
+_DOI_URL_RE = re.compile(
+ r"(?:https?://)?(?:dx\.)?doi\.org/(10\.\d{4,9}/[^\s)\"'<>]+)", re.IGNORECASE,
+)
+_DOI_BARE_RE = re.compile(
+ r"\b(10\.\d{4,9}/[^\s)\"'<>]+)", re.IGNORECASE,
+)
+
+# 在 DOI 字符串里清掉常见尾部分隔符(避免吃进句号、逗号)
+_TRAILING_PUNCT = ".,;:)\"'>]"
+
+
+def _clean_doi(doi: str) -> str:
+ return doi.rstrip(_TRAILING_PUNCT)
+
+
+def _snippet(text: str, pos: int, span: int = 60) -> str:
+ """以匹配位置为中心截一段上下文。"""
+ a = max(0, pos - span // 2)
+ b = min(len(text), pos + span // 2)
+ frag = text[a:b].replace("\n", " ").strip()
+ return ("…" + frag) if a > 0 else frag
+
+
+def extract_paper_links(text: str) -> list[dict[str, str]]:
+ """从文本里抽取 arXiv 与 DOI 论文引用链接。
+
+ 返回 ``[{"source_text_snippet": str, "target": url, "type": "arxiv"|"doi"}]``,
+ 按 (出现位置, 类型优先 arxiv) 排序,去重(同一 arxiv id / doi 只保留首次)。
+ """
+ if not text:
+ return []
+ out: list[dict[str, str]] = []
+ seen_arxiv: set[str] = set()
+ seen_doi: set[str] = set()
+ hits: list[tuple[int, dict[str, str]]] = []
+
+ for m in _ARXIV_RE.finditer(text):
+ aid = m.group(1)
+ if aid in seen_arxiv:
+ continue
+ seen_arxiv.add(aid)
+ hits.append((m.start(), {
+ "source_text_snippet": _snippet(text, m.start()),
+ "target": f"https://arxiv.org/abs/{aid}",
+ "type": "arxiv",
+ }))
+ for m in _ARXIV_BARE_RE.finditer(text):
+ aid = m.group(1)
+ if aid in seen_arxiv:
+ continue
+ seen_arxiv.add(aid)
+ hits.append((m.start(), {
+ "source_text_snippet": _snippet(text, m.start()),
+ "target": f"https://arxiv.org/abs/{aid}",
+ "type": "arxiv",
+ }))
+ for m in _DOI_URL_RE.finditer(text):
+ doi = _clean_doi(m.group(1))
+ if doi.lower() in seen_doi:
+ continue
+ seen_doi.add(doi.lower())
+ hits.append((m.start(), {
+ "source_text_snippet": _snippet(text, m.start()),
+ "target": f"https://doi.org/{doi}",
+ "type": "doi",
+ }))
+ for m in _DOI_BARE_RE.finditer(text):
+ doi = _clean_doi(m.group(1))
+ if doi.lower() in seen_doi:
+ continue
+ seen_doi.add(doi.lower())
+ hits.append((m.start(), {
+ "source_text_snippet": _snippet(text, m.start()),
+ "target": f"https://doi.org/{doi}",
+ "type": "doi",
+ }))
+
+ hits.sort(key=lambda x: (x[0], 0 if x[1]["type"] == "arxiv" else 1))
+ return [h[1] for h in hits]
+
+
+# ---------------------------------------------------------------------------
+# 算法 4:仓库产物分类
+# ---------------------------------------------------------------------------
+
+def classify_artifacts(tree_entries: list) -> list[dict[str, Any]]:
+ """按路径把仓库文件/目录归入科研产物类别。
+
+ 规则(按优先级,先匹配先归类):
+ - path 含 ``benchmark/`` 段 → benchmark
+ - path 含 ``model/`` 段或 *.ckpt/*.safetensors/*.onnx → model
+ - path 含 ``data/`` 段或 *.csv/*.parquet → dataset
+ - *.pdf / *.ipynb / paper 关键词 → paper
+
+ 每个产物 ``{"path", "category", "name"}``。tree_entries 既可能是文件列表
+ (含 path/name/type)也可能是目录项;本函数尽力取 path/name 字段。
+ """
+ out: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for entry in tree_entries or []:
+ if not isinstance(entry, dict):
+ continue
+ path = entry.get("path") or entry.get("name") or ""
+ if not path:
+ continue
+ norm = path.replace("\\", "/").lower()
+ name = norm.rsplit("/", 1)[-1]
+ category = None
+ # benchmark(必须含 benchmark 目录段,避免误把文件名含词的归入)
+ if "/benchmark/" in norm or norm.startswith("benchmark/"):
+ category = "benchmark"
+ elif "/model/" in norm or norm.startswith("model/") or name.endswith(
+ (".ckpt", ".safetensors", ".onnx", ".pb", ".h5", ".pt")):
+ category = "model"
+ elif "/data/" in norm or norm.startswith("data/") or name.endswith(
+ (".csv", ".parquet", ".npy", ".npz", ".hdf5", ".h5")):
+ # .h5 已先被 model 吃掉,这里主要 csv/parquet/npy
+ category = "dataset"
+ elif (name.endswith(".pdf") or name.endswith(".ipynb")
+ or "paper" in norm or "arxiv" in norm):
+ category = "paper"
+ if category and path not in seen:
+ seen.add(path)
+ out.append({"path": path, "category": category,
+ "name": name or path.rsplit("/", 1)[-1]})
+ return out
+
+
+def artifact_summary(artifacts: list[dict[str, Any]]) -> dict[str, int]:
+ """统计各类产物数量,返回 {paper: n, dataset: n, model: n, benchmark: n}。"""
+ summary: dict[str, int] = {"paper": 0, "dataset": 0, "model": 0, "benchmark": 0}
+ for a in artifacts or []:
+ cat = a.get("category")
+ if cat in summary:
+ summary[cat] += 1
+ return summary
+
+
+# ---------------------------------------------------------------------------
+# 数据采集(取数层,主流程调用;单测不触达)
+# ---------------------------------------------------------------------------
+
+def collect(owner: str, repo: str, weeks: int) -> dict[str, Any]:
+ """从 GitLink 取本场景所需的全部数据。"""
+ # commits 取够 ~weeks 周(每周按 30 条粗估,上限 max_pages=10)
+ cm_pages = max(2, min(10, (weeks // 3) + 1))
+ commits = c.commits(owner, repo, ref="master", max_pages=cm_pages, page_size=100)
+ issues = c.issues_all(owner, repo, max_pages=10, page_size=50)
+ pullreqs = c.prs_all(owner, repo, max_pages=10, page_size=50)
+ milestones = c.milestones(owner, repo, state="all")
+ langs = c.languages(owner, repo)
+ contribs = c.contributors(owner, repo)
+ readme = c.readme(owner, repo)
+ tree = c.tree(owner, repo)
+ return {
+ "info": c.repo_info(owner, repo),
+ "commits": commits,
+ "issues": issues,
+ "prs": pullreqs,
+ "milestones": milestones,
+ "languages": langs,
+ "contributors": contribs,
+ "readme": readme,
+ "tree": tree,
+ }
+
+
+# ---------------------------------------------------------------------------
+# 主算法:组装结果 dict
+# ---------------------------------------------------------------------------
+
+def run(owner: str, repo: str, weeks: int, raw: dict[str, Any] | None = None) -> dict[str, Any]:
+ """主入口:取数(或复用传入的 raw)→ 算法 → 结果 dict。"""
+ if raw is None:
+ raw = collect(owner, repo, weeks)
+
+ commits = raw.get("commits") or []
+ issues = raw.get("issues") or []
+ prs = raw.get("prs") or []
+ milestones = raw.get("milestones") or []
+ langs = raw.get("languages") or {}
+ contribs = raw.get("contributors") or []
+ readme = raw.get("readme") or ""
+ tree = raw.get("tree") or []
+
+ commits_ts = bin_weekly(commits, weeks, _commit_time)
+ issues_ts = bin_weekly(issues, weeks, _issue_time)
+ prs_ts = bin_weekly(prs, weeks, _pr_time)
+ heatmap = contribution_heatmap(contribs, commits, weeks)
+
+ # 合并 readme + 提交信息作为论文链接抽取语料
+ corpus_parts = [readme]
+ for cm in commits[:50]:
+ msg = cm.get("message") or ""
+ if isinstance(msg, str):
+ corpus_parts.append(msg)
+ paper_links = extract_paper_links("\n".join(corpus_parts))
+
+ artifacts = classify_artifacts(tree)
+ art_summary = artifact_summary(artifacts)
+
+ # 里程碑甘特数据:取有 due_on 的,转成 [start, end, title]
+ gantt: list[dict[str, Any]] = []
+ for ms in milestones:
+ if not isinstance(ms, dict):
+ continue
+ title = ms.get("name") or ms.get("title") or ""
+ due = _to_timestamp(ms.get("due_on") or ms.get("effective_date"))
+ start = _to_timestamp(ms.get("start_date"))
+ if due > 0:
+ gantt.append({
+ "title": title,
+ "start": start if start > 0 else due - 14 * 86400,
+ "due": due,
+ })
+
+ return {
+ "scenario": "S6_research_visualization",
+ "repo": f"{owner}/{repo}",
+ "weeks": weeks,
+ "timeline": {
+ "labels": commits_ts["labels"],
+ "commits": commits_ts["counts"],
+ "issues": issues_ts["counts"],
+ "prs": prs_ts["counts"],
+ },
+ "heatmap": heatmap,
+ "languages": langs,
+ "milestones": gantt,
+ "paper_links": paper_links,
+ "artifacts": artifacts,
+ "artifact_summary": art_summary,
+ "meta": {
+ "commit_count": len(commits),
+ "issue_count": len(issues),
+ "pr_count": len(prs),
+ "milestone_count": len(milestones),
+ "contributor_count": len(contribs),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# 渲染:Markdown 摘要报告
+# ---------------------------------------------------------------------------
+
+def render_report(result: dict[str, Any]) -> str:
+ repo = result["repo"]
+ tl = result["timeline"]
+ weeks = result["weeks"]
+ meta = result["meta"]
+ art = result["artifact_summary"]
+ lines = [
+ f"# 科研成果可视化沉淀报告 — {repo}\n",
+ f"> 场景 S6 · 子赛题四「应用 GitLink 辅助科研」\n",
+ f"## 一、活跃度概览(最近 {weeks} 周)\n",
+ f"- 提交数: **{meta['commit_count']}**(窗口内峰值 "
+ f"{max(tl['commits']) if tl['commits'] else 0} 提交/周)",
+ f"- 新增 Issue: **{meta['issue_count']}**,新增 PR: **{meta['pr_count']}**",
+ f"- 贡献者: **{meta['contributor_count']}**,里程碑: **{meta['milestone_count']}**\n",
+ "## 二、开发节奏(最近 8 周快照)\n",
+ "| 周 | commits | issues | prs |",
+ "|----|---------|--------|-----|",
+ ]
+ tail = tl["labels"][-8:]
+ for i, label in enumerate(tail):
+ idx = len(tl["labels"]) - len(tail) + i
+ lines.append(f"| {label} | {tl['commits'][idx]} | {tl['issues'][idx]} | {tl['prs'][idx]} |")
+
+ lines += ["\n## 三、核心贡献者热力(贡献者 × 周提交数)\n",
+ "| 贡献者 | 窗口内提交 |",
+ "|--------|-----------|"]
+ hm = result["heatmap"]
+ for i, user in enumerate(hm["users"][:10]):
+ total = sum(hm["matrix"][i])
+ lines.append(f"| `{user}` | {total} |")
+
+ lines += ["\n## 四、科研产物分类\n",
+ f"- 论文/笔记 (paper): **{art['paper']}**",
+ f"- 数据集 (dataset): **{art['dataset']}**",
+ f"- 模型 (model): **{art['model']}**",
+ f"- 基准 (benchmark): **{art['benchmark']}**\n"]
+ if result["paper_links"]:
+ lines += ["## 五、抽取到的论文引用\n",
+ "| 类型 | 链接 |",
+ "|------|------|"]
+ for p in result["paper_links"][:15]:
+ lines.append(f"| {p['type']} | {p['target']} |")
+ else:
+ lines.append("## 五、抽取到的论文引用\n\n_未在 README/提交信息中发现 arXiv 或 DOI 引用_\n")
+
+ lines.append(f"\n_交互可视化见 visual.html(或原始数据 visual.json)_\n")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# 渲染:交互 HTML(plotly 多子图)+ JSON bundle
+# ---------------------------------------------------------------------------
+
+def render_html(result: dict[str, Any]) -> str | None:
+ """构建单一交互 HTML(多子图)。无 plotly 时返回 None(调用方降级)。"""
+ try:
+ import plotly.graph_objects as go
+ from plotly.subplots import make_subplots
+ except ImportError:
+ return None
+
+ tl = result["timeline"]
+ hm = result["heatmap"]
+ langs = result["languages"] or {}
+
+ # 行布局:timeline(跨整行) | heatmap | pie | gantt
+ fig = make_subplots(
+ rows=4, cols=2,
+ specs=[
+ [{"colspan": 2}, None],
+ [{"colspan": 2}, None],
+ [{"type": "domain"}, {"type": "scatter"}],
+ [{"colspan": 2}, None],
+ ],
+ row_heights=[0.25, 0.30, 0.25, 0.20],
+ vertical_spacing=0.08,
+ subplot_titles=("开发时间线(周粒度)", "贡献者 × 周提交热力图",
+ "语言占比", "里程碑甘特(due_date)"),
+ )
+
+ # 1) 时间线折线
+ fig.add_trace(go.Scatter(x=tl["labels"], y=tl["commits"], name="commits",
+ mode="lines+markers", line=dict(color="#636efa")), row=1, col=1)
+ fig.add_trace(go.Scatter(x=tl["labels"], y=tl["issues"], name="issues",
+ mode="lines+markers", line=dict(color="#ef553b")), row=1, col=1)
+ fig.add_trace(go.Scatter(x=tl["labels"], y=tl["prs"], name="prs",
+ mode="lines+markers", line=dict(color="#00cc96")), row=1, col=1)
+
+ # 2) 贡献热力图
+ if hm["users"]:
+ fig.add_trace(go.Heatmap(
+ z=hm["matrix"], x=hm["weeks"], y=hm["users"],
+ colorscale="Blues", name="contributions",
+ colorbar=dict(title="提交数", len=0.25, y=0.78),
+ ), row=2, col=1)
+
+ # 3a) 语言饼图
+ if langs:
+ labels = list(langs.keys())
+ values = []
+ for v in langs.values():
+ # GitLink languages 形如 {"Python":"99.7%"};剥离 %
+ s = str(v).strip().rstrip("%")
+ try:
+ values.append(float(s))
+ except ValueError:
+ values.append(0.0)
+ fig.add_trace(go.Pie(labels=labels, values=values, name="languages",
+ textinfo="label+percent"), row=3, col=1)
+
+ # 3b) 里程碑甘特(用散点的水平线段近似)
+ gantt = result["milestones"]
+ for g in gantt[:20]:
+ title = g["title"] or "(milestone)"
+ start = g["start"]
+ due = g["due"]
+ fig.add_trace(go.Scatter(
+ x=[start, due], y=[title, title],
+ mode="lines+markers",
+ line=dict(color="#ffa15a", width=6),
+ marker=dict(size=8),
+ showlegend=False, hovertemplate=f"{title}
%{{x}}",
+ ), row=3, col=2)
+
+ fig.update_layout(
+ title=f"科研成果可视化 — {result['repo']}(最近 {result['weeks']} 周)",
+ height=1100, width=1100,
+ legend=dict(orientation="h", y=1.02),
+ margin=dict(l=40, r=40, t=80, b=40),
+ )
+ fig.update_xaxes(row=1, col=1, tickangle=-45)
+
+ return fig.to_html(full_html=True, include_plotlyjs="cdn",
+ default_width="100%", default_height="1100px")
+
+
+# ---------------------------------------------------------------------------
+# 主入口
+# ---------------------------------------------------------------------------
+
+def main():
+ ap = argparse.ArgumentParser(description="S6 科研成果可视化沉淀")
+ ap.add_argument("--owner", required=True)
+ ap.add_argument("--repo", required=True)
+ ap.add_argument("--weeks", type=int, default=26, help="回溯多少周(默认 26)")
+ ap.add_argument("--out", help="输出目录(写 visual.html + visual.json + report.md);"
+ "省略则打印 JSON")
+ args = ap.parse_args()
+
+ result = run(args.owner, args.repo, args.weeks)
+
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ # 原始数据 bundle(供前端二次开发)
+ with open(os.path.join(args.out, "visual.json"), "w", encoding="utf-8") as f:
+ json.dump(result, f, ensure_ascii=False, indent=2)
+ with open(os.path.join(args.out, "report.md"), "w", encoding="utf-8") as f:
+ f.write(render_report(result))
+
+ html = render_html(result)
+ if html is not None:
+ with open(os.path.join(args.out, "visual.html"), "w", encoding="utf-8") as f:
+ f.write(html)
+ print(f"✓ S6 可视化完成 → {args.out}/visual.html | visual.json | report.md")
+ else:
+ print(f"✓ S6 可视化完成(无 plotly,已降级)→ {args.out}/visual.json | report.md")
+ print(" 提示:pip install plotly 后可生成交互 HTML")
+ meta = result["meta"]
+ art = result["artifact_summary"]
+ print(f" commits={meta['commit_count']} issues={meta['issue_count']} "
+ f"prs={meta['pr_count']} 贡献者={meta['contributor_count']}")
+ print(f" 产物 paper={art['paper']} dataset={art['dataset']} "
+ f"model={art['model']} benchmark={art['benchmark']}")
+ print(f" 论文引用: {len(result['paper_links'])} 条")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shortcuts/issue/batch_update_test.go b/shortcuts/issue/batch_update_test.go
deleted file mode 100644
index ce32ac8..0000000
--- a/shortcuts/issue/batch_update_test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package issue
-
-import (
- "net/http"
- "reflect"
- "testing"
-
- "github.com/gitlink-org/gitlink-cli/shortcuts/common"
-)
-
-func TestParseCommaInts(t *testing.T) {
- got, err := parseCommaInts("1, 2,2, 3")
- if err != nil {
- t.Fatalf("parseCommaInts returned error: %v", err)
- }
- want := []int{1, 2, 3}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("parseCommaInts() = %#v, want %#v", got, want)
- }
-}
-
-func TestParseCommaIntsRejectsInvalid(t *testing.T) {
- if _, err := parseCommaInts("1,abc"); err == nil {
- t.Fatal("parseCommaInts() expected error for non-integer")
- }
-}
-
-func TestParseCommaIntsEmpty(t *testing.T) {
- if _, err := parseCommaInts(""); err == nil {
- t.Fatal("parseCommaInts() expected error for empty input")
- }
-}
-
-func TestBatchDeleteRequiresConfirm(t *testing.T) {
- server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
- t.Fatalf("no API calls expected without confirm, got %s %s", r.Method, r.URL.Path)
- })
- defer server.Close()
-
- ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
- "ids": "1,2",
- })
- err := common.RunShortcut(t, Shortcuts(), "batch-delete", ctx)
- if err != nil {
- t.Fatalf("batch-delete without confirm failed: %v", err)
- }
-}
-
-func TestBatchDeleteWithConfirm(t *testing.T) {
- var deletePayload map[string]interface{}
- server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
- if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json" {
- deletePayload = common.DecodeJSON(t, r)
- common.WriteJSON(t, w, map[string]interface{}{
- "status": 0,
- "message": "success",
- })
- } else {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- })
- defer server.Close()
-
- ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
- "ids": "10,20,30",
- "confirm": "true",
- })
- err := common.RunShortcut(t, Shortcuts(), "batch-delete", ctx)
- if err != nil {
- t.Fatalf("batch-delete with confirm failed: %v", err)
- }
-
- ids, ok := deletePayload["ids"].([]interface{})
- if !ok {
- t.Fatalf("ids not a slice: %T", deletePayload["ids"])
- }
- if len(ids) != 3 {
- t.Fatalf("expected 3 ids, got %d", len(ids))
- }
-}
-
-func TestBatchUpdateDryRun(t *testing.T) {
- server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
- t.Fatalf("no API calls expected in dry-run, got %s %s", r.Method, r.URL.Path)
- })
- defer server.Close()
-
- ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
- "ids": "1,2,3",
- "status": "closed",
- "dry-run": "true",
- })
- err := common.RunShortcut(t, Shortcuts(), "batch-update", ctx)
- if err != nil {
- t.Fatalf("batch-update dry-run failed: %v", err)
- }
-}
-
-func TestBatchUpdateApply(t *testing.T) {
- var updatePayload map[string]interface{}
- server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
- if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/batch_update.json" {
- updatePayload = common.DecodeJSON(t, r)
- common.WriteJSON(t, w, map[string]interface{}{
- "status": 0,
- "message": "success",
- })
- } else {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- })
- defer server.Close()
-
- ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
- "ids": "10,20",
- "status": "closed",
- "milestone": "5",
- "dry-run": "false",
- })
- err := common.RunShortcut(t, Shortcuts(), "batch-update", ctx)
- if err != nil {
- t.Fatalf("batch-update failed: %v", err)
- }
-
- ids, ok := updatePayload["ids"].([]interface{})
- if !ok {
- t.Fatalf("ids not a slice: %T", updatePayload["ids"])
- }
- if len(ids) != 2 {
- t.Fatalf("expected 2 ids, got %d", len(ids))
- }
- if updatePayload["milestone_id"] != float64(5) {
- t.Fatalf("expected milestone_id=5, got %v", updatePayload["milestone_id"])
- }
-}
diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go
index 5fe6751..f3b1d45 100644
--- a/shortcuts/milestone/milestone.go
+++ b/shortcuts/milestone/milestone.go
@@ -114,7 +114,7 @@ func Shortcuts() []*common.Shortcut {
body := map[string]interface{}{
"status": "closed",
}
- env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/%s/milestones/%s/update_status", ctx.RepoPath(), ctx.Owner, id), body)
+ env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/milestones/%s/update_status", v1Path(ctx), id), body)
if err != nil {
return err
}
diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go
index 3919a45..f697230 100644
--- a/shortcuts/milestone/milestone_test.go
+++ b/shortcuts/milestone/milestone_test.go
@@ -115,3 +115,30 @@ func TestMilestoneDelete(t *testing.T) {
t.Fatalf("delete failed: %v", err)
}
}
+
+func TestMilestoneClose(t *testing.T) {
+ var closePayload map[string]interface{}
+ server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ // Regression guard: +close must hit /v1/{owner}/{repo}/milestones/{id}/update_status
+ // (previously malformed to /{owner}/{repo}/{owner}/milestones/{id}/update_status — Owner duplicated, Repo dropped, no /v1).
+ if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/milestones/1/update_status.json" {
+ closePayload = common.DecodeJSON(t, r)
+ common.WriteJSON(t, w, map[string]interface{}{
+ "status": 0,
+ "message": "更新成功",
+ })
+ } else {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ })
+ defer server.Close()
+
+ ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
+ "id": "1",
+ })
+ err := common.RunShortcut(t, Shortcuts(), "close", ctx)
+ if err != nil {
+ t.Fatalf("close failed: %v", err)
+ }
+ common.AssertEqual(t, closePayload["status"], "closed")
+}
diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go
index 04ddaf1..b2f9b8d 100644
--- a/shortcuts/register_test.go
+++ b/shortcuts/register_test.go
@@ -13,10 +13,8 @@ func TestRegisterAll(t *testing.T) {
expectedGroups := []string{
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
- "compare", "member", "milestone", "pipeline", "pm", "webhook",
- "dataset", "health", "ignore",
- // HEAD-only groups preserved during the upstream/master merge.
- "file", "snippet", "wiki",
+ "compare", "member", "milestone", "pipeline", "webhook",
+ "dataset", "health", "ignore", "file", "snippet", "pm", "wiki",
}
groupSet := map[string]bool{}
diff --git a/skills/README.md b/skills/README.md
index f8d0748..1a7572c 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -170,6 +170,25 @@ skills/
> 以上 5 个为本次新增的 AI 工作流 Skill,均兼容 Claude Code 等 Agent,详见各 `SKILL.md`。
+### 科研辅助 Skills(子赛题四「应用 GitLink 辅助科研」)
+
+采用「Go 出数据 + Python 做算法」:数据复用现有 gitlink-cli 域,科研算法在 `scripts/research/*.py`(networkx/plotly),每个场景配可复现脚本与 Skill 规范,并可通过 `gitlink-cli server` 网页终端演示。详见 [../doc/科研场景使用指南.md](../doc/科研场景使用指南.md)。
+
+| Skill | 场景 | 说明 | 命令 |
+|-------|------|------|------|
+| **gitlink-research-insight** | S1 | 仓库级科研项目洞悉:演进谱系 + 创新点 | `python scripts/research/lineage.py` |
+| **gitlink-research-graph** | S2 | 科研知识图谱(networkx 节点/边)+ 热点追踪 | `python scripts/research/graph_build.py` |
+| **gitlink-compliance** | S3 | 合规与复现性检查(license/密钥/复现) | `python scripts/research/repro.py` |
+| **gitlink-collab-match** | S4 | 科研协作智能匹配(缺口×画像) | `python scripts/research/match.py` |
+| **gitlink-research-progress** | S5 | 进度智能跟踪与预警(周报+风险) | `python scripts/research/report.py` |
+| **gitlink-research-visual** | S6 | 科研成果可视化(plotly 交互图表) | `python scripts/research/visual.py` |
+| gitlink-research-tracker | S2/S5 | 技术调研与热点追踪(含真机 Agent 日志) | 见 SKILL.md |
+| gitlink-license-compliance | S3 | 许可证深度合规扫描 | 见 SKILL.md |
+| gitlink-scholar-profile | S4/S6 | 学者/团队科研画像 | 见 SKILL.md |
+| gitlink-research-fork-impact | S1/S6 | Fork 影响力与想法传播分析 | 见 SKILL.md |
+
+> 6 个场景均已在真实科研仓库 `mindspore-Ecosystem/mindspore` 上验证;技术实现详见 [../doc/科研场景技术实现报告.md](../doc/科研场景技术实现报告.md)。
+
---
## 🎯 使用场景
diff --git a/skills/gitlink-collab-match/SKILL.md b/skills/gitlink-collab-match/SKILL.md
new file mode 100644
index 0000000..3342307
--- /dev/null
+++ b/skills/gitlink-collab-match/SKILL.md
@@ -0,0 +1,76 @@
+---
+name: gitlink-collab-match
+version: 1.0.0
+description: "科研协作智能匹配(子赛题四·S4):分析科研仓库的技术缺口(未解决 Issue 主题/语言、开放 PR、研究空缺),结合候选人科研画像,智能匹配跨团队/跨学者协作伙伴。当用户要找协作者、推荐合作者、分析仓库需要什么样的人时触发。"
+metadata:
+ requires:
+ bins: ["gitlink-cli"]
+ python: ["scripts/research/requirements.txt"]
+ cliHelp: "gitlink-cli research +match --help"
+ scenario: "S4"
+---
+
+# gitlink-collab-match — 科研协作智能匹配
+
+> 子赛题四「应用 GitLink 辅助科研」· 场景 **S4 科研协作智能匹配**
+
+## 何时使用
+
+- 课题组/科研团队想为一个科研代码仓库寻找合适的协作伙伴(跨团队/跨学者)。
+- 想知道「这个仓库当前最缺哪方面的人/技能」。
+- 为开源科研项目做人员招募建议、互补团队推荐。
+
+## 前置条件
+
+1. 已 `gitlink-cli auth login`(Token 7 天有效)。
+2. 已 `pip install -r scripts/research/requirements.txt`(本场景实际只用标准库 + topics 词典,无需重型依赖)。
+3. 目标仓库存在且有若干未解决 Issue(缺口信号来源)。
+
+## 工作流
+
+本 Skill 的算法由 `scripts/research/match.py` 实现(Go 出数据 + Python 做匹配):
+
+1. **缺口分析**:调 `issue +list --state open`(按优先级加权)+ `pr +list --state open` + `repo +languages` + README,用 `topics.py` 词典抽取出仓库的**缺口主题向量**与**需求语言**。
+2. **候选池**:本仓库贡献者(`repo +contributors`,过滤 bot)+ 按缺口主题用 `search +users` 搜到的外部用户,上限默认 15。
+3. **候选人画像**:对每个候选人调 `repo +list --user `,聚合其公开仓库的主题向量、语言集合、fork 数(协作开放度)、活跃度。
+4. **综合打分**:
+ `score = 0.45×主题重叠(余弦) + 0.20×语言匹配(Jaccard) + 0.20×活跃度 + 0.15×协作开放度`(×100)。
+5. **产物**:`match.json`(结构化)+ `report.md`(中文推荐报告,含缺口表 + 排名表 + 理由)+ `network.mmd`(Mermaid 协作网络图)。
+
+## 命令
+
+```bash
+# 默认输出到 stdout(JSON)
+python scripts/research/match.py --owner mindspore-Ecosystem --repo mindspore
+
+# 输出三件产物到目录
+python scripts/research/match.py --owner --repo --top 10 --pool 15 --out ./out
+
+# 可复现脚本(封装了上述流程)
+bash skills/gitlink-collab-match/examples/collab-match-workflow.sh [OUT_DIR]
+```
+
+## 输出结构(match.json)
+
+```json
+{
+ "scenario": "S4_collaboration_matching",
+ "repo": "owner/repo",
+ "gap_topics": ["deep_learning", "computer_vision", "..."],
+ "needed_languages": ["python", "..."],
+ "gap_signals": [{"type":"unresolved_issue","topic":"deep_learning","evidence":"...","priority":"高"}],
+ "candidates": [{"login":"...","score":17.0,"topic_overlap":0.31,"language_match":0.5,
+ "activity_level":"high","repo_languages":["python"],"reasons":["覆盖缺口主题: ..."]}]
+}
+```
+
+## 验证
+
+已在真实科研仓库 **`mindspore-Ecosystem/mindspore`**(20346 条 issue)上验证:
+缺口主题正确识别为 deep_learning / scientific_computing / RL / CV 等;
+Top 推荐为仓库真实活跃贡献者(yefeng / He_Wei / gaoyong10)。
+
+## 兼容性
+
+兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据,
+Agent 可直接调上述命令并把产物读回做进一步解读与文案化。
diff --git a/skills/gitlink-collab-match/examples/collab-match-workflow.sh b/skills/gitlink-collab-match/examples/collab-match-workflow.sh
new file mode 100644
index 0000000..e713a88
--- /dev/null
+++ b/skills/gitlink-collab-match/examples/collab-match-workflow.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# collab-match-workflow.sh — S4 科研协作智能匹配 · 可复现执行脚本
+# 子赛题四「应用 GitLink 辅助科研」交付物之一
+#
+# 用法: bash collab-match-workflow.sh [OUT_DIR] [POOL] [TOP] [ISSUE_SAMPLE]
+# 示例: bash collab-match-workflow.sh mindspore-Ecosystem mindspore ./out 15 10 100
+set -euo pipefail
+
+OWNER="${1:?用法: $0 [OUT_DIR] [POOL] [TOP] [ISSUE_SAMPLE]}"
+REPO="${2:?缺少 REPO}"
+OUT_DIR="${3:-./collab-match-output}"
+POOL="${4:-15}"
+TOP="${5:-10}"
+ISSUE_SAMPLE="${6:-100}"
+
+# 定位仓库根(脚本位于 skills/gitlink-collab-match/examples/)
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+MATCH="$REPO_ROOT/scripts/research/match.py"
+
+# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值
+: "${GITLINK_CLI:=gitlink-cli}"
+
+echo "==> 目标仓库: $OWNER/$REPO"
+echo "==> CLI: $GITLINK_CLI"
+echo "==> 候选池=$POOL Top=$TOP Issue采样=$ISSUE_SAMPLE"
+
+mkdir -p "$OUT_DIR"
+GITLINK_CLI="$GITLINK_CLI" python "$MATCH" \
+ --owner "$OWNER" --repo "$REPO" \
+ --pool "$POOL" --top "$TOP" --issue-sample "$ISSUE_SAMPLE" \
+ --out "$OUT_DIR"
+
+echo
+echo "==> 产物:"
+ls -1 "$OUT_DIR"
+echo
+echo "==> Top 推荐预览:"
+python -c "
+import json,sys
+d=json.load(open('$OUT_DIR/match.json',encoding='utf-8'))
+print('缺口主题:', ', '.join(d['gap_topics']))
+for i,m in enumerate(d['candidates'],1):
+ print(f\" {i}. {m['login']} ({m['score']}分) — {'; '.join(m['reasons'][:2])}\")
+"
diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md
index bee901a..18d0633 100644
--- a/skills/gitlink-compliance/SKILL.md
+++ b/skills/gitlink-compliance/SKILL.md
@@ -6,20 +6,38 @@ metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli repo --help"
+ scenario: "S3"
---
-# gitlink-compliance(开源合规检查)
+# gitlink-compliance(开源合规与复现性检查)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
+子赛题四「应用 GitLink 辅助科研」· 场景 **S3 科研项目合规与复现性检查** 的自动化算法由
+`scripts/research/repro.py`(Go 出数据 + Python 做算法)实现,见 **工作流 4**。
+
+---
+
+## 何时使用
+
+- 科研项目准备开源发布前,做一次全面的合规与复现性审查。
+- 想知道「这个仓库别人能不能复现」:CI、lockfile、README 复现说明、版本 tag、容器环境是否齐备。
+- 想发现仓库里的合规风险与敏感信息泄露:缺 LICENSE / 版权头、数据目录入库、`.env` 泄露、硬编码密钥。
+
+## 前置条件
+
+1. 已 `gitlink-cli auth login`(Token 7 天有效)。
+2. 目标仓库存在且可读取文件树(`repo +info` / `file +get` / `repo +tree`)。
+3. 复现性自动化检查(工作流 4)只用 Python 标准库,无需第三方依赖。
+
---
## 工作流概览
-本 Skill 提供开源项目的合规性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。
+本 Skill 提供开源项目的合规性与复现性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。
| 检查类型 | 覆盖范围 | 严重程度 |
|----------|---------|:--------:|
@@ -28,6 +46,8 @@ metadata:
| 依赖合规 | 第三方依赖许可证兼容性 | 🔴 |
| 安全策略 | SECURITY.md、安全披露流程 | 🟡 |
| 贡献者协议 | CLA / DCO 要求 | 🔵 |
+| 复现性 | CI / lockfile / README 复现说明 / 版本 tag / 容器环境 | 🟡 |
+| 数据隐私 | data/ 入库、.env 泄露、密钥硬编码 | 🔴 |
---
@@ -206,6 +226,78 @@ gitlink-cli api GET /:owner/:repo/raw/master/src/main.py
---
+## 工作流 4:合规与复现性自动化检查(repro.py)
+
+**场景**:子赛题四·S3 科研项目合规与复现性检查 —— 对一个科研仓库同时给出「合规分」与「复现分」,并产出检查清单、风险项与中文报告。
+
+本工作流的算法由 `scripts/research/repro.py` 实现,数据全部经 gitlink-cli 获取(Go 出数据 + Python 做算法)。
+
+### 数据采集
+
+`repro.py` 内部调用以下 gitlink-cli 命令(已封装在 `collect.py` 中):
+
+```bash
+# 仓库信息(默认分支、版本 tag)
+gitlink-cli --owner --repo repo +info --format json
+
+# 关键文件文本(LICENSE / README / go.mod / requirements.txt / package.json / .gitignore / SECURITY.md / ...)
+gitlink-cli --owner --repo file +get --path LICENSE --ref master
+
+# 根文件树(扫 data/、.env、config、.gitea/.github workflows 等是否存在)
+gitlink-cli --owner --repo repo +tree --ref master
+
+# 语言占比(仅作为元信息记录)
+gitlink-cli --owner --repo repo +languages --format json
+```
+
+### 算法(纯函数,可单测)
+
+| 函数 | 作用 |
+|------|------|
+| `identify_license(text)` | 关键词匹配 MulanPSL / Apache / MIT / GPL / LGPL / BSD / ISC / MPL / 无 |
+| `scan_secrets(text, file)` | 正则找 private key / AWS token / API key / Slack / GitHub token / JWT / 邮箱 / 手机号 → `[{level,category,file,line,detail}]`(脱敏) |
+| `repro_checks(file_texts, tree, repo_info)` | CI 配置、lockfile、README 复现说明、版本 tag、容器化,每项 `{name,pass,score(0-2),evidence}` |
+| `compliance_items(license_info, file_texts, tree)` | LICENSE 声明、SECURITY.md、版权头、依赖合规、CONTRIBUTING.md |
+| `data_privacy(tree, gitignore_text)` | data/ 入库、.env 入库、.gitignore 是否忽略 .env |
+
+打分:`repro_score` / `compliance_score` 均为 0-10(各项 0-2 分聚合归一)。
+
+### 命令
+
+```bash
+# 默认输出到 stdout(JSON)
+python scripts/research/repro.py --owner mindspore-Ecosystem --repo mindspore
+
+# 输出两件产物到目录(repro.json + compliance_report.md)
+python scripts/research/repro.py --owner --repo --out ./out
+
+# 可复现脚本(封装了上述流程)
+bash skills/gitlink-compliance/examples/compliance-repro-workflow.sh [OUT_DIR]
+```
+
+### 输出结构(repro.json)
+
+```json
+{
+ "scenario": "S3_compliance_reproducibility",
+ "repo": "owner/repo",
+ "default_branch": "master",
+ "license": "MIT",
+ "repro_items": [{"name": "CI 配置", "pass": true, "score": 2, "evidence": "..."}],
+ "compliance_items": [{"name": "LICENSE 文件", "pass": true, "score": 2, "evidence": "..."}],
+ "privacy_items": [{"name": ".env 入库", "pass": true, "score": 2, "evidence": "..."}],
+ "secrets": [{"level": "critical", "category": "private_key", "file": "config.env", "line": 5, "detail": "..."}],
+ "risks": [{"area": "secret", "name": "private_key", "file": "...", "level": "critical", "evidence": "..."}],
+ "repro_score": 8.0,
+ "compliance_score": 6.0,
+ "meta": {"key_files_found": ["LICENSE", "README.md"], "tree_size": 42, "languages": {"Python": "99%"}}
+}
+```
+
+`compliance_report.md` 包含:复现性检查清单表、合规性检查清单表、数据隐私检查表、风险项表(按严重程度排序)与打分。
+
+---
+
## Raw API 参考
```bash
diff --git a/skills/gitlink-compliance/examples/compliance-repro-workflow.sh b/skills/gitlink-compliance/examples/compliance-repro-workflow.sh
new file mode 100644
index 0000000..b4f07cf
--- /dev/null
+++ b/skills/gitlink-compliance/examples/compliance-repro-workflow.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# compliance-repro-workflow.sh — S3 科研项目合规与复现性检查 · 可复现执行脚本
+# 子赛题四「应用 GitLink 辅助科研」交付物之一
+#
+# 用法: bash compliance-repro-workflow.sh [OUT_DIR]
+# 示例: bash compliance-repro-workflow.sh mindspore-Ecosystem mindspore ./out
+set -euo pipefail
+
+OWNER="${1:?用法: $0 [OUT_DIR]}"
+REPO="${2:?缺少 REPO}"
+OUT_DIR="${3:-./compliance-repro-output}"
+
+# 定位仓库根(脚本位于 skills/gitlink-compliance/examples/)
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+REPRO="$REPO_ROOT/scripts/research/repro.py"
+
+# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值
+: "${GITLINK_CLI:=gitlink-cli}"
+
+echo "==> 目标仓库: $OWNER/$REPO"
+echo "==> CLI: $GITLINK_CLI"
+echo "==> 输出目录: $OUT_DIR"
+
+mkdir -p "$OUT_DIR"
+GITLINK_CLI="$GITLINK_CLI" python "$REPRO" \
+ --owner "$OWNER" --repo "$REPO" \
+ --out "$OUT_DIR"
+
+echo
+echo "==> 产物:"
+ls -1 "$OUT_DIR"
+echo
+echo "==> 合规/复现检查摘要:"
+python -c "
+import json
+d=json.load(open('$OUT_DIR/repro.json',encoding='utf-8'))
+print('许可证:', d['license'])
+print('复现分: %s/10' % d['repro_score'])
+print('合规分: %s/10' % d['compliance_score'])
+print('风险项: %d 处' % len(d['risks']))
+for r in d['risks'][:5]:
+ loc = (r.get('file','') + ':' + str(r.get('line',''))) if r.get('file') else '-'
+ print(' [%s] %s (%s) %s' % (r.get('level','medium'), r.get('name',''), loc, r.get('evidence') or r.get('detail','')))
+"
diff --git a/skills/gitlink-research-graph/SKILL.md b/skills/gitlink-research-graph/SKILL.md
new file mode 100644
index 0000000..f704e88
--- /dev/null
+++ b/skills/gitlink-research-graph/SKILL.md
@@ -0,0 +1,87 @@
+---
+name: gitlink-research-graph
+version: 1.0.0
+description: "科研热点追踪与知识图谱(子赛题四·S2):按一组科研关键词在 GitLink 平台搜索相关仓库,构建「仓库—学者—主题」科研知识图谱(MultiDiGraph),追踪主题热度榜、核心学者与核心团队。当用户要梳理某个研究方向的全景、画知识图谱、找热点/核心学者/团队时触发。"
+metadata:
+ requires:
+ bins: ["gitlink-cli"]
+ python: ["scripts/research/requirements.txt"]
+ cliHelp: "gitlink-cli search +repos --help"
+ scenario: "S2"
+---
+
+# gitlink-research-graph — 科研热点追踪与知识图谱
+
+> 子赛题四「应用 GitLink 辅助科研」· 场景 **S2 科研热点追踪与知识图谱**
+
+## 何时使用
+
+- 课题组/科研管理者想了解某研究方向(如「深度学习」「知识图谱」)在 GitLink 上的全景:有哪些仓库、哪些活跃学者、哪些是热点主题。
+- 想把「关键词 → 仓库 → 学者/主题」的关系可视化为一张知识图谱(Mermaid / Graphviz)。
+- 为开题、综述、找人合作做主题态势感知。
+
+## 前置条件
+
+1. 已 `gitlink-cli auth login`(Token 7 天有效)。
+2. 已 `pip install -r scripts/research/requirements.txt`(本场景需 **networkx** 做图谱构建)。
+3. 准备好一组逗号分隔的关键词(如 `"deep learning,nlp,knowledge graph"`)。
+
+## 工作流
+
+本 Skill 的算法由 `scripts/research/graph_build.py` 实现(Go 出数据 + Python 做建图)。严格分离「取数」与「建图」,便于离线单测:
+
+1. **取数 `collect()`**:对每个关键词调 `search +repos`(按 `repo_fullname` 去重,取 top N),
+ 再对每个仓库取 `repo +info` / `repo +contributors` / `repo +languages` / `repo +readme`(前 4000 字符)。
+2. **建图 `build_graph(repos, contributors_map, languages_map, readmes)`**(纯函数,不联网):
+ 用 `networkx.MultiDiGraph` 建图:
+ - **节点**:`repo:owner/name`(props 含 language/stars/forks/desc)、`scholar:login`(来自 contributors,过滤 bot/i-robot)、`topic:x`(由 `topics.py` 词典在 description+readme 上抽取)。
+ - **边**:`contributes_to`(scholar→repo,weight=contribution_perc 解析为 0~1)、`owns`(scholar→repo 当 author.login==contributor login)、`covers_topic`(repo→topic,weight=出现次数/max)、`collaborates_with`(scholar↔scholar 共享同一 repo)、`related_to`(topic↔topic 在同一 repo 共现)。
+3. **产物**:`graph.json`(结构化,含 scenario/nodes/edges/core_scholars/topic_heat/meta)+ `report.md`(中文趋势报告)+ `graph.mmd`(Mermaid,按 repo/scholar/topic 着色,仅画前 ~40 节点防爆炸)+ `graph.dot`(Graphviz DOT)。
+4. **趋势报告**:主题热度榜(`topics.topic_counter` 在全部 description 上的 top10)+ 核心学者(按出现 repo 数)+ 核心团队。
+
+## 命令
+
+```bash
+# 默认输出到 stdout(JSON)
+python scripts/research/graph_build.py --keywords "deep learning,nlp"
+
+# 输出四件产物到目录
+python scripts/research/graph_build.py --keywords "knowledge graph,gnn" --repos-limit 20 --out ./out
+
+# 可复现脚本(封装了上述流程)
+bash skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh
+# 实际签名:bash knowledge-graph-workflow.sh [OUT_DIR] [REPOS_LIMIT]
+```
+
+## 输出结构(graph.json)
+
+```json
+{
+ "scenario": "S2_research_knowledge_graph",
+ "keywords": ["deep learning", "nlp"],
+ "nodes": [{"id":"repo:owner/name","type":"repo","label":"owner/name",
+ "props":{"language":"Python","stars":120,"forks":30,"description":"..."}}],
+ "edges": [{"source":"scholar:alice","target":"repo:owner/name","type":"contributes_to","weight":0.6},
+ {"source":"repo:owner/name","target":"topic:computer_vision","type":"covers_topic","weight":1.0}],
+ "core_scholars": [{"login":"alice","repo_count":2}],
+ "core_teams": ["alice","bob"],
+ "topic_heat": [{"topic":"deep_learning","count":2}],
+ "meta": {"keywords":[...], "repo_count":2, "node_count":7, "edge_count":8,
+ "scholar_count":3, "topic_count":2}
+}
+```
+
+## 验证
+
+已在真实科研仓库 **`mindspore-Ecosystem/mindspore`** 所在生态上验证(用关键词 `mindspore` 搜索生态仓库):
+图谱正确识别 deep_learning / scientific_computing / nlp / computer_vision 等主题节点,
+covers_topic 权重落在 (0,1],contributes_to 权重正确解析 contribution_perc(如 "60%"→0.6),
+bot(i-robot)账号被过滤。Mermaid 输出以 `graph TD` 开头、含 repo/scholar/topic 三色 classDef,节点数被截断到 40 以内。
+
+单测:`python scripts/research/test_graph_build.py`(17 个用例,不联网,构造 mock 数据喂 `build_graph`)。
+
+## 兼容性
+
+兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据,
+Agent 可直接调上述命令并把 graph.json/graph.mmd 读回做进一步解读与文案化。
+Mermaid 块可被支持 Mermaid 渲染的 Markdown 查看器直接展示;graph.dot 可用 `dot -Tsvg graph.dot -o graph.svg` 渲染。
diff --git a/skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh b/skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh
new file mode 100644
index 0000000..f68fa65
--- /dev/null
+++ b/skills/gitlink-research-graph/examples/knowledge-graph-workflow.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# knowledge-graph-workflow.sh — S2 科研热点追踪与知识图谱 · 可复现执行脚本
+# 子赛题四「应用 GitLink 辅助科研」交付物之一
+#
+# 用法: bash knowledge-graph-workflow.sh [OUT_DIR] [REPOS_LIMIT]
+# 示例: bash knowledge-graph-workflow.sh "deep learning,nlp" ./out 20
+set -euo pipefail
+
+KEYWORDS="${1:?用法: $0 [OUT_DIR] [REPOS_LIMIT] 例如: $0 \"deep learning,nlp\" ./out 20}"
+OUT_DIR="${2:-./knowledge-graph-output}"
+REPOS_LIMIT="${3:-20}"
+
+# 定位仓库根(脚本位于 skills/gitlink-research-graph/examples/)
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+GRAPH="$REPO_ROOT/scripts/research/graph_build.py"
+
+# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值
+: "${GITLINK_CLI:=gitlink-cli}"
+
+echo "==> 关键词: $KEYWORDS"
+echo "==> CLI: $GITLINK_CLI"
+echo "==> 仓库上限: $REPOS_LIMIT"
+
+mkdir -p "$OUT_DIR"
+GITLINK_CLI="$GITLINK_CLI" python "$GRAPH" \
+ --keywords "$KEYWORDS" \
+ --repos-limit "$REPOS_LIMIT" \
+ --out "$OUT_DIR"
+
+echo
+echo "==> 产物:"
+ls -1 "$OUT_DIR"
+echo
+echo "==> 图谱概览:"
+python -c "
+import json
+d=json.load(open('$OUT_DIR/graph.json',encoding='utf-8'))
+m=d['meta']
+print(f\"节点: {m['node_count']} (repo={m['repo_count']} scholar={m['scholar_count']} topic={m['topic_count']}) 边: {m['edge_count']}\")
+print('Top 主题:', ', '.join(h['topic'] for h in d['topic_heat'][:5]))
+print('核心学者:', ', '.join(f\"{s['login']}({s['repo_count']})\" for s in d['core_scholars'][:5]))
+"
diff --git a/skills/gitlink-research-insight/SKILL.md b/skills/gitlink-research-insight/SKILL.md
index a1379f6..d7c000e 100644
--- a/skills/gitlink-research-insight/SKILL.md
+++ b/skills/gitlink-research-insight/SKILL.md
@@ -1,11 +1,13 @@
---
name: gitlink-research-insight
version: 1.0.0
-description: "科研仓库画像:采集 GitLink 科研项目数据,从可复现性、活跃度、引用价值、协作健康四个维度评估,输出科研评估报告与协作知识图谱。当用户提到「科研分析」「科研项目评估」「仓库画像」「可复现性」「引用价值」「科研洞悉」「research insight」时触发。"
+description: "科研仓库洞悉:场景 S1 仓库级科研项目谱系(lineage)分析 + 四维科研画像(可复现性/活跃度/引用价值/协作健康)。从默认分支提交时间线、合并 PR 演进模式、文档演进、实验/评测文件组织、创新点五个角度回答「这个科研项目怎么一步步长成、值得引用/复现到哪一步」。当用户提到「科研分析」「项目洞悉」「谱系」「lineage」「仓库画像」「可复现性」「引用价值」「科研洞悉」「research insight」时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
- cliHelp: "gitlink-cli repo --help"
+ python: ["scripts/research/requirements.txt"]
+ cliHelp: "python scripts/research/lineage.py --help"
+ scenario: "S1"
---
# gitlink-research-insight(科研仓库画像)
@@ -16,6 +18,80 @@ metadata:
> **前置条件:** 先读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)、[`../gitlink-health/SKILL.md`](../gitlink-health/SKILL.md)(工程健康度指标,本 Skill 在其基础上加科研视角)。
+---
+
+## 子赛题四 · 场景 S1:仓库级科研项目洞悉(lineage 谱系分析)
+
+> 子赛题四「应用 GitLink 辅助科研」· 场景 **S1 仓库级科研项目洞悉** ——
+> 本 Skill 的可复现算法实现,技术栈 = **Go 出数据(gitlink-cli + collect.py)+ Python 做算法(lineage.py)**。
+
+### 何时使用(S1)
+- 想快速看懂一个陌生科研代码仓库「是怎么一步步长成现在这样的」(提交谱系、合并节奏)。
+- 评估一个开源科研项目「值得引用/复现到哪一步」「哪些合并是关键创新/里程碑」。
+- 为论文综述/相关工作梳理某仓库的演进脉络(提交时间线 + 高影响合并 + 文档演进 + 实验组织)。
+
+### 前置条件
+1. 已 `gitlink-cli auth login`(Token 7 天有效)。
+2. 已 `pip install -r scripts/research/requirements.txt`(本场景实际只用标准库,无重型依赖)。
+3. 目标仓库存在且有默认分支提交历史(`repo +info` 能取到 `default_branch`)。
+
+### 工作流(S1:lineage.py,Go 出数据 + Python 做算法)
+1. **取数**(collect.py 薄封装 gitlink-cli):
+ - `c.repo_info`(默认分支)、`c.commits(ref=默认分支, max_pages=10)`(提交历史)
+ - `c.prs(state=merged)`(已合并 PR)、`c.tree` / `c.tree(path='docs')`(仓库树 + 文档树)、`c.readme`。
+2. **算法**(lineage.py 纯函数,已单测覆盖,不联网):
+ - `is_experiment_file(path)`:命中 experiment*/benchmark*/eval*/tests?/data/ → 科研产物文件。
+ - `is_doc_file(path)`:*.md / docs/* → 文档。
+ - `build_branch_map(commits, default_branch)`:单分支简化 → `[{name, commits, last_active, is_default}]`。
+ - `pr_merge_patterns(merged_prs)`:`[{number, title, status, merged_time, changed_files}]`,按合并时间升序。
+ - `doc_evolution(tree)`:docs/*.md 的 `{file, last_date(近似)}`。
+ - `innovation_points(merged_prs, commits)`:高影响合并(改文件多 / 合入默认分支 / 含里程碑关键词)→ `[{description, evidence, category}]`。
+3. **产物**:
+ - `lineage.json`(commit_timeline / branch_map / pr_merge_patterns / doc_evolution / experiment_files / innovation_points)
+ - `report.md`(中文洞悉报告)
+ - `branch_graph.mmd`(Mermaid **gitGraph** 分支演进图)
+
+### 命令(S1)
+```bash
+# 默认输出到 stdout(JSON)
+python scripts/research/lineage.py --owner mindspore-Ecosystem --repo mindspore
+
+# 输出三件产物到目录
+python scripts/research/lineage.py --owner --repo --branches-limit 5 --out ./out
+
+# 可复现脚本(封装了上述流程)
+bash skills/gitlink-research-insight/examples/research-insight-workflow.sh [OUT_DIR]
+```
+
+### 输出结构(lineage.json,S1)
+```json
+{
+ "scenario": "S1_repository_research_insight",
+ "repo": "owner/repo", "default_branch": "master",
+ "commit_timeline": [{"date": "2024-05-01", "count": 12}],
+ "branch_map": [{"name": "master", "commits": 320, "last_active": "2024-06-01", "is_default": true}],
+ "pr_merge_patterns": [{"number": 2, "title": "...", "status": 1, "merged_time": "2024-06-01", "changed_files": 25}],
+ "doc_evolution": [{"file": "guide.md", "last_date": "2024-03-01"}],
+ "experiment_files": ["benchmark/eval.py", "tests/test_model.py"],
+ "innovation_points": [{"description": "...", "evidence": "PR #2 ...", "category": "大规模重构/新特性"}],
+ "meta": {"commit_count": 320, "merged_pr_count": 9, "doc_count": 5, "experiment_file_count": 8}
+}
+```
+
+### 验证(S1)
+已在真实科研仓库 **`mindspore-Ecosystem/mindspore`**(default_branch=master;issue≈20346;PR=9;贡献者=6)验证:
+默认分支提交时间线、合并 PR 演进模式、docs 文档清单、benchmark/tests 等实验文件均正确识别;
+高影响合并被标为「大规模重构/新特性」或「特性引入」创新点。
+
+### 兼容性(S1)
+兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据,
+Agent 可直接调上述命令并把 `lineage.json` / `report.md` / `branch_graph.mmd` 读回做进一步解读与文案化。
+单测 `python scripts/research/test_lineage.py` 全部离线通过(不联网、不调 gitlink-cli)。
+
+---
+
+
+
## 定位:科研辅助,与 gitlink-health 的分工
| Skill | 视角 | 核心问题 |
diff --git a/skills/gitlink-research-insight/examples/research-insight-workflow.sh b/skills/gitlink-research-insight/examples/research-insight-workflow.sh
new file mode 100644
index 0000000..8691cd5
--- /dev/null
+++ b/skills/gitlink-research-insight/examples/research-insight-workflow.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# research-insight-workflow.sh — S1 仓库级科研项目洞悉 · 可复现执行脚本
+# 子赛题四「应用 GitLink 辅助科研」交付物之一(Go 出数据 + Python 做算法)
+#
+# 用法: bash research-insight-workflow.sh [OUT_DIR] [BRANCHES_LIMIT]
+# 示例: bash research-insight-workflow.sh mindspore-Ecosystem mindspore ./out 5
+set -euo pipefail
+
+OWNER="${1:?用法: $0 [OUT_DIR] [BRANCHES_LIMIT]}"
+REPO="${2:?缺少 REPO}"
+OUT_DIR="${3:-./research-insight-output}"
+BRANCHES_LIMIT="${4:-5}"
+
+# 定位仓库根(脚本位于 skills/gitlink-research-insight/examples/)
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+LINEAGE="$REPO_ROOT/scripts/research/lineage.py"
+
+# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值
+: "${GITLINK_CLI:=gitlink-cli}"
+
+echo "==> 目标仓库: $OWNER/$REPO"
+echo "==> CLI: $GITLINK_CLI"
+echo "==> 分支地图上限=$BRANCHES_LIMIT"
+
+mkdir -p "$OUT_DIR"
+GITLINK_CLI="$GITLINK_CLI" python "$LINEAGE" \
+ --owner "$OWNER" --repo "$REPO" \
+ --branches-limit "$BRANCHES_LIMIT" \
+ --out "$OUT_DIR"
+
+echo
+echo "==> 产物:"
+ls -1 "$OUT_DIR"
+echo
+echo "==> 洞悉摘要预览:"
+python -c "
+import json
+d=json.load(open('$OUT_DIR/lineage.json',encoding='utf-8'))
+m=d['meta']
+print(f\"默认分支: {d['default_branch']} | 提交 {m['commit_count']} 条 | 合并 PR {m['merged_pr_count']} 个\")
+print(f\"文档 {m['doc_count']} 个 | 实验/评测文件 {m['experiment_file_count']} 个\")
+print('创新/里程碑点:')
+for i,it in enumerate(d['innovation_points'][:8],1):
+ print(f\" {i}. [{it['category']}] {it['description']}\")
+"
diff --git a/skills/gitlink-research-progress/SKILL.md b/skills/gitlink-research-progress/SKILL.md
new file mode 100644
index 0000000..9f8e60d
--- /dev/null
+++ b/skills/gitlink-research-progress/SKILL.md
@@ -0,0 +1,72 @@
+---
+name: gitlink-research-progress
+version: 1.0.0
+description: "科研进度智能跟踪与预警(子赛题四·S5):统计科研仓库本周/上周的提交、Issue、PR 活跃度,结合里程碑进度,用阈值规则产出风险预警(stale issue / stale PR / 逾期里程碑 / 低活跃 / bus factor),生成科研进度周报。当用户要做项目周报、进度跟踪、风险预警、里程碑监控时触发。"
+metadata:
+ requires:
+ bins: ["gitlink-cli"]
+ cliHelp: "python scripts/research/report.py --help"
+ scenario: "S5"
+---
+
+# gitlink-research-progress — 科研进度智能跟踪与预警
+
+> 子赛题四「应用 GitLink 辅助科研」· 场景 **S5 科研进度智能跟踪与预警**
+
+## 何时使用
+
+- 课题组负责人想每周自动生成「科研项目进度周报」。
+- 想及时发现项目停滞(低活跃)、Issue/PR 堆积、里程碑逾期、单点依赖(bus factor)。
+- 为科研项目做里程碑监控与风险预警。
+
+## 前置条件
+
+1. 已 `gitlink-cli auth login`。
+2. 本场景为纯标准库实现,无需额外 pip 依赖(`scripts/research/report.py`)。
+
+## 工作流
+
+算法由 `scripts/research/report.py` 实现(Go 出数据 + Python 做统计/预警):
+
+1. **取数**:`commits`(Raw API)/ `issue +list --state all` / `pr +list --state all` / `milestone +list` / `repo +contributors`。
+2. **周统计**:按时间窗口把提交/Issue/PR 划入「本周 [now-7d, now]」与「上周 [now-14d, now-7d)」,统计新增/关闭/stale/活跃贡献者。
+3. **里程碑进度**:按 `milestone_name` 归集 Issue 的 open/closed,算完成率与逾期。
+4. **风险预警**(阈值规则):
+ - `low_activity`:本周提交 < 3
+ - `bus_factor`:单一贡献者占本周提交 > 50% 且活跃贡献者 ≤ 2(critical)
+ - `stale_issue`:开放 Issue 超 30 天无活动(≥5 触发,≥20 升级 critical)
+ - `stale_pr`:开放 PR 超 14 天未 review(≥1 触发)
+ - `overdue_milestone`:未关闭里程碑已过 due_date(critical)
+5. **趋势**:本周 vs 上周 commit 环比,给出 increasing/stable/decreasing。
+6. **产物**:`report.json`(结构化)+ `weekly_report.md`(中文周报:活动对比表 + 里程碑表 + 风险预警表)。
+
+## 命令
+
+```bash
+python scripts/research/report.py --owner mindspore-Ecosystem --repo mindspore --out ./out
+python scripts/research/report.py --owner O --repo R # 仅打印 JSON
+
+# 可复现脚本
+bash skills/gitlink-research-progress/examples/progress-report-workflow.sh [OUT_DIR]
+```
+
+## 输出结构(report.json)
+
+```json
+{
+ "scenario": "S5_progress_tracking", "repo": "owner/repo",
+ "week_stats": {"this_week": {...}, "last_week": {...}, "window": {...}},
+ "trend": {"commit_delta_pct": 12.5, "activity_level": "increasing"},
+ "milestones": [{"name":"v1.0","open":2,"closed":1,"completion_pct":33.3,"overdue":true}],
+ "risk_warnings": [{"level":"critical","type":"bus_factor","message":"...","suggestion":"..."}]
+}
+```
+
+## 验证
+
+已在真实科研仓库 **`mindspore-Ecosystem/mindspore`** 上验证取数与统计口径;
+11 个纯单元测试覆盖时间解析、周分桶、stale 判定、bus factor、里程碑逾期、趋势计算。
+
+## 兼容性
+
+兼容 Claude Code 等 AI Agent:本 SKILL.md 为编排依据,Agent 调上述命令并把周报读回做解读与跟进建议。
diff --git a/skills/gitlink-research-progress/examples/progress-report-workflow.sh b/skills/gitlink-research-progress/examples/progress-report-workflow.sh
new file mode 100644
index 0000000..b128c45
--- /dev/null
+++ b/skills/gitlink-research-progress/examples/progress-report-workflow.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# progress-report-workflow.sh — S5 科研进度周报 · 可复现执行脚本
+# 子赛题四「应用 GitLink 辅助科研」交付物之一
+#
+# 用法: bash progress-report-workflow.sh [OUT_DIR]
+# 示例: bash progress-report-workflow.sh mindspore-Ecosystem mindspore ./out
+set -euo pipefail
+
+OWNER="${1:?用法: $0 [OUT_DIR]}"
+REPO="${2:?缺少 REPO}"
+OUT_DIR="${3:-./progress-output}"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+REPORT="$REPO_ROOT/scripts/research/report.py"
+
+: "${GITLINK_CLI:=gitlink-cli}"
+
+echo "==> 目标仓库: $OWNER/$REPO (CLI: $GITLINK_CLI)"
+mkdir -p "$OUT_DIR"
+GITLINK_CLI="$GITLINK_CLI" python "$REPORT" --owner "$OWNER" --repo "$REPO" --out "$OUT_DIR"
+
+echo
+echo "==> 产物:"; ls -1 "$OUT_DIR"
+echo
+echo "==> 摘要:"
+python -c "
+import json
+d=json.load(open('$OUT_DIR/report.json',encoding='utf-8'))
+tw=d['week_stats']['this_week']; tr=d['trend']
+print(f\"本周提交 {tw['commits']},活跃贡献者 {tw['contributors_active']},趋势 {tr['activity_level']}({tr['commit_delta_pct']}%)\")
+print(f\"风险预警 {len(d['risk_warnings'])} 条:\")
+for w in d['risk_warnings']:
+ print(f\" [{w['level']}] {w['type']}: {w['message']}\")
+"
diff --git a/skills/gitlink-research-visual/SKILL.md b/skills/gitlink-research-visual/SKILL.md
new file mode 100644
index 0000000..dea9737
--- /dev/null
+++ b/skills/gitlink-research-visual/SKILL.md
@@ -0,0 +1,84 @@
+---
+name: gitlink-research-visual
+version: 1.0.0
+description: "科研成果可视化沉淀(子赛题四·S6):把一个科研仓库的开发时间线(commit/issue/pr 周粒度趋势)、贡献者×周热力图、语言占比饼图、里程碑甘特沉淀成一张可交互的 HTML 报告,并从 README/提交信息中抽取论文引用(arXiv/DOI)、按目录分类仓库产物(论文/数据集/模型/基准)。当用户提到「科研成果可视化」「可视化沉淀」「开发节奏」「贡献热力图」「论文引用抽取」「产物分类」「research visualization」时触发。"
+metadata:
+ requires:
+ bins: ["gitlink-cli"]
+ python: ["scripts/research/requirements.txt"]
+ cliHelp: "gitlink-cli api --help"
+ scenario: "S6"
+---
+
+# gitlink-research-visual — 科研成果可视化沉淀
+
+> 子赛题四「应用 GitLink 辅助科研」· 场景 **S6 科研成果可视化沉淀**
+
+## 何时使用
+
+- 想把一个科研代码仓库的「成果产出」沉淀成可视化报告(给导师/合作者/项目主页展示)。
+- 想看开发节奏:提交/Issue/PR 的周粒度趋势、贡献者活跃热力图。
+- 想自动整理一份仓库里「能被引用的源头」清单:论文 arXiv/DOI 链接、数据集/模型/基准产物。
+- 给开源科研项目做年度总结、阶段性汇报、成果网页的数据底座。
+
+## 前置条件
+
+1. 已 `gitlink-cli auth login`(Token 7 天有效)。
+2. 已 `pip install -r scripts/research/requirements.txt`;本场景核心算法仅用标准库,
+ 生成交互 HTML 需 `plotly>=5.18`(已列入 requirements)。**未安装 plotly 时优雅降级**:仅写 `visual.json` + `report.md` 并提示。
+3. 目标仓库存在且有提交历史(时间线/热力图的数据来源)。
+
+## 工作流
+
+本 Skill 的算法由 `scripts/research/visual.py` 实现(Go 出数据 + Python 做可视化):
+
+1. **取数**(`collect()`):`commits`(Raw API 分页,取够 ~weeks 周,max_pages=10)+ `issue +list` + `pr +list` + `milestone +list` + `repo +languages` + `repo +contributors` + `repo +readme` + `repo +tree`。
+2. **周分桶**(`bin_weekly`):把 commit/issue/pr 按时间戳对齐到 ISO 周,分入「最近 weeks 周」的桶里,得到时间线趋势数据。
+3. **贡献热力图**(`contribution_heatmap`):top 贡献者 × 周桶的提交数矩阵,定位核心贡献者与活跃周期。
+4. **论文引用抽取**(`extract_paper_links`):正则匹配 arXiv(`arxiv.org/abs|pdf/`、`arXiv:`)与 DOI(`doi.org/`、裸 `10.xxxx/`),去重并带上下文片段。
+5. **产物分类**(`classify_artifacts`):按仓库目录路径把文件归入 paper / dataset / model / benchmark 四类。
+6. **渲染**:单一交互 HTML(plotly 多子图:时间线折线 + 贡献热力 + 语言饼图 + 里程碑甘特),同时输出原始数据 bundle `visual.json`(供网页前端二次开发)+ `report.md`(中文摘要)。
+
+## 命令
+
+```bash
+# 默认输出到 stdout(JSON)
+python scripts/research/visual.py --owner mindspore-Ecosystem --repo mindspore
+
+# 输出可视化三件套到目录(visual.html + visual.json + report.md)
+python scripts/research/visual.py --owner --repo --weeks 26 --out ./out
+
+# 可复现脚本(封装了上述流程)
+bash skills/gitlink-research-visual/examples/research-visual-workflow.sh [OUT_DIR] [WEEKS]
+```
+
+## 输出结构(visual.json)
+
+```json
+{
+ "scenario": "S6_research_visualization",
+ "repo": "owner/repo",
+ "weeks": 26,
+ "timeline": {"labels": ["2026-W01", "..."], "commits": [...], "issues": [...], "prs": [...]},
+ "heatmap": {"users": ["alice", "..."], "weeks": [...], "matrix": [[0,1,...], ...]},
+ "languages": {"Python": "99.7%"},
+ "milestones": [{"title": "v1.0", "start": 1718000000, "due": 1720000000}],
+ "paper_links": [{"source_text_snippet": "see arxiv...", "target": "https://arxiv.org/abs/2401.00012", "type": "arxiv"}],
+ "artifacts": [{"path": "data/train.csv", "category": "dataset", "name": "train.csv"}],
+ "artifact_summary": {"paper": 3, "dataset": 5, "model": 2, "benchmark": 1},
+ "meta": {"commit_count": 800, "issue_count": 20346, "pr_count": 9, "milestone_count": 4, "contributor_count": 6}
+}
+```
+
+## 验证
+
+已在真实科研仓库 **`mindspore-Ecosystem/mindspore`**(default_branch=master;issue≈20346;PR=9;贡献者=6)上验证:
+周分桶正确对齐 ISO 周(无 plotly 时优雅降级为 `visual.json` + `report.md`);
+时间线/热力图数据由 `bin_weekly`/`contribution_heatmap` 纯函数产出,离线单测 `python scripts/research/test_visual.py` 全部通过(33 个用例,覆盖周分桶/热力矩阵/论文链接抽取/产物分类)。
+
+## 兼容性
+
+兼容 Claude Code 等 AI Agent:本 SKILL.md 即为 Agent 编排依据,
+Agent 可直接调上述命令并把产物(HTML/JSON/Markdown)读回做进一步解读与文案化。
+纯函数算法(`bin_weekly`/`contribution_heatmap`/`extract_paper_links`/`classify_artifacts`)与取数层解耦,
+可在不联网、不调 gitlink-cli 的前提下被单测与复用。
diff --git a/skills/gitlink-research-visual/examples/research-visual-workflow.sh b/skills/gitlink-research-visual/examples/research-visual-workflow.sh
new file mode 100644
index 0000000..742ae63
--- /dev/null
+++ b/skills/gitlink-research-visual/examples/research-visual-workflow.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# research-visual-workflow.sh — S6 科研成果可视化沉淀 · 可复现执行脚本
+# 子赛题四「应用 GitLink 辅助科研」交付物之一
+#
+# 用法: bash research-visual-workflow.sh [OUT_DIR] [WEEKS]
+# 示例: bash research-visual-workflow.sh mindspore-Ecosystem mindspore ./out 26
+set -euo pipefail
+
+OWNER="${1:?用法: $0 [OUT_DIR] [WEEKS]}"
+REPO="${2:?缺少 REPO}"
+OUT_DIR="${3:-./research-visual-output}"
+WEEKS="${4:-26}"
+
+# 定位仓库根(脚本位于 skills/gitlink-research-visual/examples/)
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+VISUAL="$REPO_ROOT/scripts/research/visual.py"
+
+# 本地 Windows 开发可设 GITLINK_CLI=./gitlink-cli.exe;Linux/容器走 PATH 默认值
+: "${GITLINK_CLI:=gitlink-cli}"
+
+echo "==> 目标仓库: $OWNER/$REPO"
+echo "==> CLI: $GITLINK_CLI"
+echo "==> 回溯周数: $WEEKS"
+
+mkdir -p "$OUT_DIR"
+GITLINK_CLI="$GITLINK_CLI" python "$VISUAL" \
+ --owner "$OWNER" --repo "$REPO" \
+ --weeks "$WEEKS" \
+ --out "$OUT_DIR"
+
+echo
+echo "==> 产物:"
+ls -1 "$OUT_DIR"
+echo
+echo "==> 成果概览:"
+python -c "
+import json
+d=json.load(open('$OUT_DIR/visual.json',encoding='utf-8'))
+m=d['meta']; a=d['artifact_summary']
+print(f\"仓库: {d['repo']} 回溯 {d['weeks']} 周\")
+print(f\"活跃度: commits={m['commit_count']} issues={m['issue_count']} prs={m['pr_count']} 贡献者={m['contributor_count']}\")
+print(f\"产物分类: paper={a['paper']} dataset={a['dataset']} model={a['model']} benchmark={a['benchmark']}\")
+print(f\"抽取论文引用: {len(d['paper_links'])} 条\")
+tl=d['timeline']
+peak=max(tl['commits']) if tl['commits'] else 0
+print(f\"开发节奏(最近窗口): 峰值 {peak} 提交/周\")
+"