引言
做招聘或人脉运营时,GitHub 公开数据是最便宜的"人才信号"来源——但不是直接能用的。一份原始 /users/<owner>/repos 响应有上百字段、单页就近 250KB,且浏览器直连 api.github.com 还要过 CORS。lume-talentlens 想做的事很具体:把一个人的 GitHub 足迹,整理成"值不值得关注 / 值不值得雇"的决策素材(求职状态、工程产出、技术焦点、人脉质量)。
它最反直觉的一点是:服务端不是 Node/Python,而是一份自创 DSL(Lume)编译出的单二进制。这篇文章不带你从零搭建,而是沿真实代码逐层拆开——看它怎么用 Lume 的路由 / SSR / 智能体工具,把"读 GitHub 快照、算人才信号、跑聊天智能体"这三件事塞进一个二进制里,同时把隐私和响应体上限这两道坑提前堵死。
速览(TL;DR)
- 服务端是 Lume 单二进制(prefork×4),
app/github.lume只做组装:import各lib/*.lume模块即完成路由/工具注册,再server {}+run()起服务。 - 数据两条来源、一条服务路径:默认读本地离线快照(
data/github/<owner>/,零网络);未缓存 owner 才走服务端/api/live/github代拉 GitHub API,绕开浏览器 CORS。 - 项目起步时 Lume 还没有
lower/contains内建,因此shared.lume自带了这两个助手(基于replace);lume-core 已于 2026-10-08 把它们纳入语言内建,talentlens 保留本地 helpers 仅为向后兼容。import跨模块不传播突变,所以analyze.lume全是显式传(repos, snap)的纯函数。 - 响应体有硬上限(历史 release 版 64KB 会塌陷),全项目统一三招:聚合(
/api/overview只回统计)、分页(/api/repos单页 ≤50)、投影(live 代理把原始 JSON 瘦身到消费字段)。 - 安全闸靠约定而非运气:只绑
127.0.0.1、.env全 gitignored、Lumeenv()对含TOKEN/SECRET的变量遮蔽(应用读别名GH_ANALYZER_PAT)、写操作只接受application/json拦 CSRF。
整体架构:浏览器只信本地服务
拓扑很扁——浏览器只跟本地 Lume 服务说话,出站只有两处:GitHub REST API、LLM 端点。
┌─────────────── 浏览器(127.0.0.1:8091)───────────────┐
│ React SPA (/、/chat) SSR 页 (/overview 等) │
└───────────────┬───────────────────────┬───────────────┘
│ 同源 JSON / SSE │ 同源 HTML
┌───────────────▼───────────────────────▼───────────────┐
│ Lume server(单二进制,prefork×4) │
│ 入口 github.lume → lib/*(路由/工具/渲染) │
│ bind 127.0.0.1:8091 │
└───┬───────────────┬───────────────────┬───────────────┘
│ 读本地文件 │ 出站 http_get() │ LLM 桥(智能体)
▼ ▼ ▼
data/github/<owner>/ api.github.com OpenAI 兼容端点
关键设计:服务端默认只读本地文件。快照由 scripts/fetch-github.sh 离线预取到 data/github/<owner>/,常驻服务不主动联网。只有用户查一个还没缓存的 owner 时,才由服务端经 /api/live/github 代拉——这一步同时绕开了浏览器对 api.github.com 的出口/CORS 限制。
服务端:用 Lume 单二进制串起一切
入口 app/github.lume 刻意做薄,路由和工具都住在 lib/ 里,靠顶层注册生效。server_port() / server_bind() 从环境变量取端口和绑定地址,缺省 8091 和 127.0.0.1:
import "lib/shared.lume" as sh;
import "lib/api.lume" as api;
import "lib/live.lume" as live;
import "lib/ssr.lume" as ssr;
import "lib/tools.lume" as tools;
import "lib/actions.lume" as actions;
server {
port = server_port();
workers = 4;
bind = server_bind();
docroot = "./www";
views = "github";
}
run();
Lume 的模块机制是这套拆分能成立的基石:get / tool 写在模块顶层,入口 import 时即完成路由/工具注册,无需显式调用;嵌套 import 相对模块自身目录(lib/shared.lume 里写 import "analyze.lume" 而非 lib/...);export func 跨模块可调。但导入方对入参 map/array 的修改不会传回导出方——所以纯计算层必须显式接收并返回数据,而不是改传入的引用。
依赖方向单向:github.lume → 各 lib/*,analyze.lume 谁都不依赖,ui.lume 只做 SSR 渲染。
数据模型与纯函数层
快照 data/github/<owner>/snapshot.json 是管道产物(服务端只读)。原始 API 的 ~100 字段被 fetch-github.sh 投影成 ~24 字段/仓库,且 created_year、recency 分桶、push_month 等派生字段在管道里预计算——服务端只聚合不重算,这是接口毫秒级返回的原因。
lib/analyze.lume 是纯函数层,所有函数 (repos, snap) 进、JSON 出,无 IO、无 env、无路由。注意 analyze 会构建完整 all 数组(含全量仓库),而对外暴露的是 analyze_lite——把 all 砍掉,只留聚合与 top-10,专门为了压在响应体上限下:
export func analyze_lite(repos, snap) {
let a = analyze(repos, snap);
return {
owner: a.owner,
fetched_at: a.fetched_at,
profile: a.profile,
count: a.count,
non_fork_count: a.non_fork_count,
totals: a.totals,
languages: a.languages,
recency: a.recency,
years: a.years,
top_by_stars: a.top_by_stars,
top_by_activity: a.top_by_activity,
talent: a.talent,
push_trend: a.push_trend,
};
}
insight() 在 analyze 之上再算出招聘视角的人才信号:active_within_90d 比例、with_desc/with_license/with_topics 占比、top2_language_share 等——这些正是仪表盘和智能体工具共用的同一套口径。
owner 解析与快照读取:安全边界从参数开始
所有路由/工具第一步都是解析 owner。lib/shared.lume 里 sanitize_owner 把 owner 当成单路径段来守:长度 ≤39、且禁止 . / % 空格 # ? 等字符,目的很直接——data/github/<owner>/ 的目录查找永远逃不出 data/github/:
export func sanitize_owner(s) {
if (s == null) { return ""; }
s = str(s);
if (s == "") { return ""; }
if (len(s) > 39) { return ""; }
if (contains(s, ".") or contains(s, "/") or contains(s, "%")
or contains(s, " ") or contains(s, "#") or contains(s, "?")
or contains(s, "\n")) {
return "";
}
return s;
}
export func load_snap(owner) {
let raw = read_file("data/github/" + owner + "/snapshot.json");
if (raw == null) { return null; }
let s = json(raw);
if (s == null) { return null; }
return s;
}
项目起步时 Lume 还没有 lower / contains 内建,所以 shared.lume 自带这两个助手,都拿 replace 现搓(contains 用"替换后长度变短"判断包含);lume-core 已于 2026-10-08 把 lower/contains(连同 upper/capitalize/trim/split/join/substr)纳入语言内建,talentlens 保留本地 helpers 仅为向后兼容。多 owner 解析优先级是:?owner= > OWNER 环境变量 > data/github/last_owner > 默认(查不到时返回空串,JSON 路由因此走 no_snapshot);缺快照时 JSON 路由统一回 {status:404, error:"no_snapshot", owner:<owner>, hint:"run: OWNER=<owner> make fetch (or make fetch OWNER=<owner>)"}。
实时代理与响应体上限
lib/live.lume 的 /api/live/github 是服务端代拉 GitHub 的出口。它先过 live_safe_path 这道 SSRF 守卫——固定 https://api.github.com 前缀 + 路径白名单,让 p 永远没法夹带别的 host 或 scheme:
func live_safe_path(p) {
if (p == "" or len(p) > 512) { return false; }
if (not sh.contains(p, "/users/")) { return false; }
if (sh.contains(p, ":") or sh.contains(p, "//") or sh.contains(p, " ") or sh.contains(p, "..")) { return false; }
return true;
}
原始 100 仓库页 ~250KB,远超框架单响应体上限,所以 slim_body 必须先投影再返回(/repos → 16 字段/条,/followers|/following → 5 字段,profile → 15 字段)。代理还做了两层缓存:内存 TTL(LIVE_CACHE_TTL_SEC,默认 300s)和落盘 data/github/live/(跨重启共享,默认 24h)——只缓存成功响应,瞬时错误不会被钉死。gh_get 本身带 3 次重试(针对大陆网络偶发的 TLS 握手重置),但 403/429 直接返回、不重试、不烧配额。
前端:React + esbuild 的轻量 SPA
前端是 React 18 + TypeScript,用 esbuild 打包、没有 webpack/vite,pnpm 管依赖。scripts/build-ui.sh 把 frontend/src/main.tsx 打进 www/github/app.js。api.ts 统一封装 fetch,用 j<T>() 包一层:404/非 ok 时尝试解析 no_snapshot 体,解析不出才抛错——因为"没快照"是正常业务态,不该当异常:
async function j<T>(url: string): Promise<T> {
const r = await window.fetch(url);
if (r.status === 404 || !r.ok) {
try {
return (await r.json()) as T;
} catch {
throw new Error(url + " -> " + r.status);
}
}
return (await r.json()) as T;
}
组件树按 HR 视角组织:Dashboard.tsx 组装 panels(概览/人才信号/对比)、lists(粉丝/关注/水号/值得关注 + 雷达人像卡)、bars(语言/活跃/年份柱状图);Agent.tsx 走 SSE 接 /chat;LangSwitch + i18n.ts 做中英切换,所有文案走字典、切换持久化。列表按服务端 scores.json 的影响力排序,高分打徽标,水号 chip 的 tooltip 直接显示判定依据。
智能体工具:10 个 repo_*/github_*
lib/tools.lume 注册了 10 个智能体工具(repo_insights / repo_search / repo_language / repo_recency / repo_year / repo_stats / github_story / github_owners / github_radar / github_people)。它们跑在 fork 出的 worker 上,每次调用现读快照,所以无共享可变状态。github_people 还会在工具层就把 suspects.json 的水号标记合并进返回,让智能体看到和 UI 一样的 chip。/chat 走 Lume 的 LLM 桥,未配 LLM_* 时有内置离线兜底引擎,可回答但无模型依赖。
隐私与安全的几道闸
这不是"上线才补安全",而是把约定写进架构:
- 网络暴露面:只绑
127.0.0.1,无外网监听;容器才设0.0.0.0并只在宿主 loopback 发布。 - 凭据不进代码:
.env、data/github/、logs/全 gitignored;scripts/env.sh加载时抑制bash -x防回显。 - 凭据不进应用:Lume
env()遮蔽名字含TOKEN/API_KEY/SECRET/PASSWORD的变量;live 代理读的是 Makefile 由GH_TOKEN映射出的别名GH_ANALYZER_PAT。 - 响应不泄密:
/api/github_auth只回{authed:bool};live/refresh 响应不含 token。 - 写操作 CSRF:follow/unfollow 只接受
Content-Type: application/json;跨站 JSON 因无 CORS 头 + preflight 被拦,HTML form / text-plain 载体在解析 body 前就被 403 拒。 - 数据隔离:快照/人脉/雷达全在 gitignored 的
data/github/;代码无硬编码 owner。
一个值得记的坑:Lume 的公开 release 二进制(agent-httpd 1.0)不内置 http_get/put/delete,跑这个应用会在运行时失败——必须用在 work/lume/lume 下 make 出的完整构建。
常见问题(FAQ)
lume-talentlens 的服务端为什么用自创的 Lume DSL 而不是 Node/Python?
因为它要塞进一个单二进制:原生 HTTP 服务器 + SSR + 智能体工具 + LLM 桥一站式解决,且离线只读本地快照、零第三方 Python 依赖。Lume 的顶层注册(import 即生效)和 server{} 配置让路由/工具/渲染的拆分很轻。代价是项目起步时 Lume 还没有 lower/contains 内建(已于 2026-10-08 合入语言,talentlens 保留 helpers 兼容),以及公开 release 二进制缺出站 HTTP 内建这两条约束。
响应体上限具体怎么影响接口设计?
Lume 框架对单响应体有硬上限(历史 release 版 64KB 会塌陷)。全项目三招应对:/api/overview 只返回聚合统计不返回仓库列表;/api/repos 分页、单页 ≤50 条;/api/live/github 在代理层把原始 GitHub JSON 投影到应用消费字段(/repos → 16 字段/条)再返回。不投影的 100 仓库页 ~250KB 会直接撑爆。
浏览器怎么绕开 GitHub 的 CORS?
不在浏览器直连 api.github.com。未缓存的 owner 由服务端经 /api/live/github 代拉,服务端出站没有浏览器那层 CORS/出口限制;live_safe_path 固定 host + 路径白名单防 SSRF,返回前 slim_body 投影瘦身。
local-only 的招聘看板,数据隐私怎么保证?
快照由离线脚本拉到本地 data/github/<owner>/,常驻服务只读本地、默认零网络;.env 和 data/ 都 gitignored,公开克隆不带任何个人数据;Lume env() 遮蔽含 TOKEN/SECRET 的变量,应用读别名 GH_ANALYZER_PAT;/api/github_auth 只回布尔、不回 token;写操作只接受 JSON 载体拦 CSRF。
智能体(/chat)的 10 个 repo_*/github_* 工具读的是实时数据吗?
不是实时。它们跑在 fork 出的 worker 上、每次调用现读本地快照(data/github/<owner>/snapshot.json),所以无共享可变状态、可离线。要刷新数据得先 OWNER=x make fetch 或 /api/refresh(每 owner 60 秒限流),再查才会看到新快照。
直接 make dev 起不来,报 http 相关 undefined 怎么办?
说明用的是公开 release 二进制(agent-httpd 1.0),它不内置 http_get/put/delete。换用 work/lume/lume 下 make 出的完整 Lume 构建(LUME= 指定路径)即可。make dev 启动探针会用 printf 'print(http_get);' | $LUME - 校验出站 HTTP 内建,缺失即拒绝启动。
项目地址
| 模块 | 文件 | 说明 |
|---|---|---|
| 入口与装配 | app/github.lume | import 各模块 + server{} + run() |
| 共享层 | app/lib/shared.lume | owner 解析、快照读取、字符串助手、gh_get 重试 |
| 纯分析层 | app/lib/analyze.lume | repo_view / analyze / insight / 各直方图(无 IO) |
| JSON 路由 | app/lib/api.lume | owners/overview/repos/people/radar/refresh 等 16+ 端点 |
| 实时代理 | app/lib/live.lume | /api/live/github 代理 + SSRF 守卫 + 双层缓存 |
| 智能体工具 | app/lib/tools.lume | 10 个 repo_*/github_* agent 工具 |
| 前端 API 客户端 | frontend/src/api.ts | 统一 fetch 封装 + no_snapshot 处理 |
| 架构文档 | ARCHITECTURE.md | 分层、数据模型、数据流、安全边界 |
发表回复