Lume DSL 驱动的本地 GitHub 人才看板

作者:

在

🇬🇧 English

引言

做招聘或人脉运营时,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、Lume env() 对含 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 分层、数据模型、数据流、安全边界

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号