Coding

本地Web应用交付·Windows环境坑

Try it

在 WorkBuddy(Windows)环境里交付零依赖本地 Web 应用,并绕开官方测试技能完全不覆盖的本机专属坑。当用户要在本机跑起来一个本地 Web 应用 / 单文件工具 / 案例库 / 看板 / 小工具,或要验证刚写完的 HTML/JS/Python 交付物真的能工作时使用。独家覆盖:Bash 缺 coreutils、bash 是 WSL 启动桩、rm 是坏 shim、PowerShell 不回显 stdout、cwd 漂移、预览面板靠 HEAD 探活——这些在干净 Linux 环境里遇不到。

What it does

零依赖本地 Web 应用 · 交付与验证 **定位**:官方 等技能覆盖「用浏览器自动化测 Web 应用」,但**不覆盖在 WorkBuddy/Windows 环境里把应用做出来并跑通**时那堆本机专属坑( 是 WSL 桩、 是坏 shim、PowerShell 不回显 stdout、cwd 漂移、预览面板靠 HEAD 探活)。本技能专补这块,且只依赖标准库、零框架。

The skill document

零依赖本地 Web 应用 · 交付与验证

定位:官方 webapp-testing 等技能覆盖「用浏览器自动化测 Web 应用」,但不覆盖在 WorkBuddy/Windows 环境里把应用做出来并跑通时那堆本机专属坑(bash 是 WSL 桩、rm 是坏 shim、PowerShell 不回显 stdout、cwd 漂移、预览面板靠 HEAD 探活)。本技能专补这块,且只依赖标准库、零框架。

WorkBuddy/Windows 环境的硬约束(先看这条,能省半小时)

Bash 缺 coreutils —— 但一行 PATH 就能修

裸调 head / tail / wc / grep / sed / awk / sleep / dirname / which / cat / tr / cut 都会 command not found(ls、find、sort、echo 是好的)。 报错长这样,且很容易误导成「文件不存在」:

bash.exe: line 1: head: command not found

二进制其实都在 /usr/bin/ 下,只是不在 PATH 里。开头加一行即可恢复(实测可行):

PATH="/usr/bin:$PATH"          # 或 export PATH="/usr/bin:/bin:$PATH"
printf 'a\nb\nc\n' | head -2   # 现在正常了
  • 也可以用绝对路径:/usr/bin/sleep 1、/usr/bin/head -2。
  • 但并非 100% 稳定(同一写法偶尔仍报 not found)。所以关键流程仍留一条保底路径:
python test.py > out.txt 2>&1; echo "exit=$?"   # 再用 Read 工具读 out.txt

先试 PATH 修复,不行就退回重定向 + Read。别一开始就放弃管道。

rm 也是坏 shim;删文件用 Python

rm 不属于「PATH 缺失」那一类 —— 它被换成了一个 safe-delete 桩,而桩自己是坏的:

bash.exe: .../shim/safe-bin/rm: line 13: dirname: command not found
bash.exe: .../shim/safe-bin/rm: line 15: /safe-delete-common.sh: No such file or directory
bash.exe: .../shim/safe-bin/rm: line 17: safe_delete_main: command not found
# exit 127,文件纹丝不动

PowerShell 的 Remove-Item -Force -Confirm:$false 对同一批文件静默无效 (exit 1、无输出、文件还在)—— 别以为删掉了。

删自己产生的临时文件,用 Python 最可靠:

python -c "import glob,os; [os.remove(f) for f in glob.glob(r'DIR/_*.log')]; print('cleaned')"

(清理自己的临时文件可以这么干;删用户的真实文件仍要走 trash 或让用户确认。)

本机两个 Python 各有所长,别装错

解释器情况
托管 ~/.workbuddy/binaries/python/versions/3.13.12干净,没有 pyyaml;要装包就走上面的隔离 venv
系统 C:\Python313\python.exe已有 pyyaml 6.0.3 → 跑 check_ci.py(校验 workflow YAML)直接用它,省掉建 venv;但它的 pytest 是坏的(缺 pygments,import 就炸)

推论:这类项目的 scripts/test_*.py 通常是自包含脚本(自带 chk() / main() / sys.exit), 直接用解释器跑就行,不要走 pytest(跑不起来,会误判成「测试全挂」):

python scripts/test_xxx.py > out.log 2>&1; echo "exit=$?"   # 再 Read out.log

接手一个已有项目:先建基线,再动手

要往别人的项目里加东西前,先把现有测试全跑一遍拿到基线,否则改坏了分不清是自己弄的 还是本来就坏的。一条命令串起来,全部重定向到文件再读:

for f in scripts/test_*.py; do python "$f" > t.log 2>&1; echo "$f exit=$?"; done
python selftest.py        > s.log 2>&1; echo "selftest=$?"
node uitest.js            > u1.log 2>&1; echo "ui=$?"
node uitest.js --static   > u2.log 2>&1; echo "ui_static=$?"

基线里如果有失败项,先判断是环境问题还是代码问题(本次就遇到 check_ci.py 只因缺 pyyaml 而失败 —— 换系统 Python 立刻全过,不是代码坏了)。改完再跑一遍,逐项对比。

bash 在 PATH 里是 WSL 启动桩,不是真 bash

这条最阴。从 Python 里 shutil.which('bash') 或 subprocess 直接调 bash,解析到的是

C:\WINDOWS\system32\bash.EXE

那是 WSL 的启动桩:它不解析脚本,对任何输入都打印「请安装 Linux 发行版」并返回 1。 于是 subprocess.run(['bash','-n',script]) 对每一个文件都返回 rc=1、stderr 为空, 看起来像「所有脚本都有语法错」。我为此白排查了一轮。

必须显式用 Git 自带的真 bash,并且先拿一个故意写错的脚本自检,确认它真会报错:

BASH = r"C:\Program Files\Git\bin\bash.exe"          # 真 bash
subprocess.run([BASH, "--noprofile", "--norc", "-n", path])   # 合法 rc=0 / 非法 rc=2
# 自检:如果这个 bash 对 `if then fi` 都不报错,那它就是个桩,校验结果不可信
open(tmp, 'w').write("if then fi\n")
assert subprocess.run([BASH, "-n", tmp]).returncode != 0

PowerShell 工具不返回 stdout

命令执行成功(exit 0)但输出为空,拿不到任何结果。不要靠它做验证。

读文件的正确姿势

node 和 python 本身可用,用绝对路径调用托管版本最稳:

  • ~/.workbuddy/binaries/python/versions/3.13.12/python.exe
  • ~/.workbuddy/binaries/node/versions/22.22.2-3/node.exe

要装包就建这个隔离 venv(已建过,可直接复用):

"~/.workbuddy/binaries/python/versions/3.13.12/python.exe" -m venv "~/.workbuddy/binaries/python/envs/default"
"~/.workbuddy/binaries/python/envs/default/Scripts/python.exe" -m pip install pyyaml

(venv 里的 pip 装包很慢,实测 pyyaml 花了近 60 秒,别以为卡死了。)

编辑同一文件:不要并行 Edit

同一条消息里对同一个文件发多个 Edit 会竞态:工具全部返回 "Successfully edited", 但实际落盘的只有一部分(实测 6 个 Edit 只存活 2 个),文件停在中间状态,且不报错—— 极难发现,症状是 Grep 关键标记时发现「明明说改了却没有」。

  • 同一文件的多个位置:串行(一轮一个 Edit),或者直接 Write 整体重写。
  • 改动在 3 处以上 → 全量 Write 更省事也更可靠。
  • 不同文件之间并行是安全的。
  • 改完必须 Grep 关键标记确认真的落盘了,不要相信「编辑成功」的提示。

定时采集类需求:给采集脚本配容量上限

「每天自动采集」的需求一定要配容量上限 + 归档,否则队列文件无限膨胀:

# 队列超上限时,最旧的移入归档文件——留底,永不删除
def prune_inbox(items, cap, archive_name="inbox_archive"):
    existing = load_json(archive_name, []) or []
    if cap <= 0 or len(items) <= cap:
        return items, 0, len(existing)
    keep, spill = items[:cap], items[cap:]
    archive = existing + spill
    save_json(archive_name, archive)
    return keep, len(spill), len(archive)

另外两个坑:

  • 让脚本自己写一份机器可读的简报(last_harvest.json:新增数、来源分布、队列总量、 Top N)。定时任务的 prompt 直接读这个文件汇报,比解析 stdout 稳定得多。
  • 过滤信号词要用词边界匹配。裸子串匹配会误伤:bootstrapped 命中了技术术语 source-bootstrapped,把编译器帖子当成了创业项目。用 re.compile(r"(?<![a-z0-9])%s(?![a-z0-9])" % re.escape(k), re.I)。

定时任务跑完自动提交推送(daily_harvest.py 模式)

「每天采集,让远程仓库跟着长」这种诉求,做成一个脚本、而不是在定时任务的 prompt 里 拼 git 命令 —— 脚本可测、可复跑、边界写死。四段式:

  1. 跑采集子进程(sys.executable + 采集脚本),失败就退出、不提交
  2. git status --porcelain -- data/ 检测有没有真变化 —— 无变化直接 return 0,不制造空提交
  3. git add -- data/(只加这一个目录)→ commit
  4. git push origin ;失败要说清「本地提交还在,网络恢复后手动 push」

无人值守才会遇到的四个坑,都要处理:

坑处理
没有提交身份(新 clone / CI)开跑前检查,缺失就报错并打印 git config --global user.name/email
.git/index.lock 残留超 10 分钟视为陈旧锁自动清理;太新的则跳过本次(说明真有 git 在跑)
游离 HEADrev-parse --abbrev-ref HEAD 返回 HEAD 时拒绝提交
origin/ 落不了盘推送成功后直接写 ref 文件兜底(见上文「坑二」)

两条铁律:

  • 只 git add 数据目录,永远不要 git add -A —— 否则会把开发中的半成品脚本一起提交。
  • 自动提交绝不碰「人工核实」那一层数据(cases.json)。自动化只搬运机器采回来的原料, 核实留给人 —— 这是这类库存在的全部意义。

子进程要显式设 GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never,否则凭证缺失时 git 卡在交互提示上,定时任务就这么无声地挂着。

测这个脚本不要联网:把脚本拷进临时 git 仓库,用 importlib 从那个路径加载模块 (它的 ROOT 由 __file__ 推出,自然指向临时仓库),逐个测 changed_paths / commit_message / handle_index_lock / heal_remote_ref; 再用子进程跑 CLI 验「非 git 目录」「无身份」两条失败路径。 真实链路单独用 git clone 到临时目录 + --no-push 跑一次。

测完删临时克隆要先清只读属性,否则 git 对象文件会 WinError 5 拒绝访问: os.chmod(f, stat.S_IWRITE) 走一遍再 shutil.rmtree(p, onerror=...)。 另外命令里 cd 进临时目录后,下一次命令的 cwd 还在那里,会锁住目录删不掉 —— 先 cd 出来。

本地代理会炸 TLS

本机有代理环境变量时,Python 的 urllib 抓外网会 TLS 失败。所有出网请求显式绕开:

PROXY_ENV_KEYS = ("HTTP_PROXY","HTTPS_PROXY","http_proxy","https_proxy","ALL_PROXY","all_proxy")
proxies = {k: os.environ.get(k) for k in PROXY_ENV_KEYS if os.environ.get(k)}
opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) if proxies \
         else urllib.request.build_opener()

交付形态(用户偏好)

  • 零依赖:Python 只用标准库(http.server + json),前端 Vanilla JS,不上框架、不装包。
  • 能双击就双击:附一个 ASCII-only 的 start.bat(bat 里写中文会因代码页出乱码)。
  • 端口:常用 5052,避开已被占用的常见端口(如 5000/8000)。
  • 深色主题优先(用户 IDE 是 dark)。

server.py 骨架要点

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# 1) 写操作必须加锁,避免并发写坏 JSON
_LOCK = threading.Lock()

# 2) 写 JSON 用「临时文件 + os.replace」原子替换,别直接 open('w')
tmp = path + ".tmp"; json.dump(...); os.replace(tmp, path)

# 3) 静态文件服务要挡目录穿越
full = os.path.normpath(os.path.join(STATIC_DIR, rel))
if not full.startswith(os.path.normpath(STATIC_DIR)):
    return self._send(403, "forbidden")

# 4) 日志必须行缓冲。python server.py > srv.log 2>&1 时 Python 默认块缓冲(8KB),
#    除 flush=True 之外的全卡在内存里 —— 现象是日志文件长期 0 字节,
#    服务端明明出错却查不到任何痕迹,极易被误判成「服务不输出日志」。
for _s in (sys.stdout, sys.stderr):
    try: _s.reconfigure(line_buffering=True)
    except Exception: pass
# 启动时再带 -u 双保险:python -u server.py > srv.log 2>&1

# 5) 路由外层包总异常捕获:任何抛错都回 JSON 500,绝不返回空 body。
#    异常冒泡出 BaseHTTPRequestHandler 会直接关连接,前端只收到 0 字节,
#    表现成一句毫无线索的 "Unexpected end of JSON input"。
def do_GET(self):
    try: self._route_get()
    except (BrokenPipeError, ConnectionResetError): pass     # 客户端自己断开
    except Exception as e:
        log("GET %s 异常: %r" % (self.path, e))
        self._json({"ok": False, "error": "服务内部错误: %s" % e}, 500)

# 6) 默认静音日志;但要记下非 200,否则出错时无从查证
def log_message(self, fmt, *args):
    if len(args) > 1 and str(args[1]) not in ("200", "304"):
        log("HTTP %s" % (fmt % args))

# 7) JSON 序列化兜底:某个字段混进 set/datetime 也不该炸掉整个响应
body = json.dumps(obj, ensure_ascii=False, default=str)

# 8) 开浏览器用 Timer,并留一个关掉的口子(自测时不能弹窗)
if not os.environ.get("NO_BROWSER"):
    threading.Timer(0.8, lambda: webbrowser.open(url)).start()

# 9) **必须实现 do_HEAD**。BaseHTTPRequestHandler 默认对 HEAD 回 501,
#    预览面板 / 监控 / curl -I / k8s liveness 都用 HEAD 探活 —— 拿到 501 会
#    误判服务不可用 → fallback 到内置静态服务托管 index.html → 页面里
#    所有 /api/* 全 404。表现:报错「后端返回了空响应(HTTP 404)」。
#    简化:do_HEAD 复用 do_GET 但不写 body(Content-Length 仍给真实长度)。
def do_HEAD(self):
    self._head_only = True            # _send 里看到这个就跳过 wfile.write
    if urllib.parse.urlparse(self.path).path == "/api/search":
        return self._send(200, b"")   # 探测不该真触发搜索(打 5 个上游)
    self._route_get()

验证套路(必做,两层)

后端:selftest.py

自测脚本必须自己起服务、跑完自己关,用独立端口不干扰用户正在跑的实例。

# 关键:开始前备份 data/,结束后无论成败都还原,否则测试会污染用户数据
shutil.copytree(DATA, BACKUP)          # 测试前
try:
    ... 跑测试 ...
finally:
    proc.terminate()
    for name in os.listdir(BACKUP):    # 还原
        shutil.copy2(os.path.join(BACKUP, name), os.path.join(DATA, name))
    shutil.rmtree(BACKUP)

踩过的坑:先写完测试再补备份很容易写成「备份在变更之后」,等于没备份。备份一定是第一步。

urllib 遇到 4xx 会抛 HTTPError 而不是返回状态码,测试里要包起来:

try:
    with opener.open(r, timeout=10) as resp: return resp.status, resp.read()
except urllib.error.HTTPError as e: return e.code, e.read()

前端:uitest.js(不用浏览器)

agent-browser 未必装了,配合上面 shell 的问题不值得走浏览器自动化。 用 Node 的 vm + 最小 DOM 桩跑一遍渲染函数,覆盖率比手动点高。

// 桩要覆盖:innerHTML setter / querySelectorAll 返回 [] / classList / dataset /
//          style / textContent / hidden / insertAdjacentHTML
// 注意:vm 里用 let 声明的顶层变量不会挂到 sandbox 上!
//      要改内部状态,必须用 vm.runInContext(`DATA.cases.push(...)`) 在 context 内部执行。
//      用 var 声明才会挂到 sandbox 上(可用来把结果取出来)。

更省事的做法:从被测脚本里 return 一个测试 API

不用纠结 let / var,把脚本包进函数、末尾 return 出要操作的函数和一个兜住内部状态的 shallow object 即可(state 是对象,暴露引用后内部改动可见):

const api = new Function('document', 'fetch', 'localStorage', /* … */
    js + '\n;return {doSearch, renderSuggest, renderStatus, state};'
)(documentStub, fetchStub, localStorageStub);
await api.doSearch(false);                  // 直接调用被测函数
api.state.items.length;                     // 直接断言内部状态

id 必须写成访问器属性,否则「移除旧节点」这类逻辑测不出来

querySelector 桩通常是「按选择器记忆化一个元素」。但代码里动态插入的节点是 createElement 出来后再赋 id 的,桩如果不登记,$('#suggest') 就会返回另一个空对象, 于是 old.remove() 作用在错的节点上 —— 测试假失败(或假通过),排查很久。

Object.defineProperty(el, 'id', {
  get() { return el._id || ''; },
  set(v) { el._id = v; registry.set('#' + v, el); },   // 赋值时登记进选择器表
});

appendChild 要同步更新 _html,否则 innerHTML getter 读不到追加内容

showError() 用 document.createElement 造错误块再 appendChild 进去, 然后测试断言 box.innerHTML.indexOf('panradar.py') >= 0 —— 桩的 appendChild 如果只 push 到 children[]、不动 _html,innerHTML getter 就读不到追加内容, 断言全挂。

appendChild(c) {
  this.children.push(c);
  // 同步 innerHTML:否则 innerHTML getter 读不到追加的内容,测试看不到错误块
  const tag = c.className ? '' : '';
  this._html += tag + (c.textContent || '') + '';
  return c;
},

同理 insertAdjacentElement / insertAdjacentHTML / appendChild 桩里要留引用 (如 this._adj = node),否则断言不到「插到哪儿了」。

桩掉 fetch + 执行 doSearch(),把「点击 → 换词 → 重新请求」整条链路测掉

只断言「渲染出了 HTML」覆盖率太低。给 fetch 喂真实形状的响应(照抄一次线上 JSON), 然后真的调 doSearch()、真的调按钮的 onclick(),断言可观测副作用:

  • 收集请求 URL(SEARCH_CALLS.push(url)),断言点击后确实发出了新请求、且新请求带了新关键词
  • 断言输入框的值变了、旧节点被移除、状态对象更新了
  • 断言「无数据时不渲染空框」这类边界

这比手动点强得多,且不需要浏览器。

前端绝对不要裸用 (await fetch()).json()

响应体为空(服务重启 / 连接被重置)时它抛的是 Failed to execute 'json' on 'Response': Unexpected end of JSON input —— 用户完全无法据此判断发生了什么,这是本地应用最常见的"天书报错"。

封一层 apiGet(url, timeoutMs):AbortController 控超时 → 先 text() → 判空 → 判 resp.ok/status → 再 JSON.parse,每种情况给一句能照着做的话。 瞬时断连(服务刚重启)静默重试一次再报错。错误块里放「重试」按钮,别让用户重输关键词。

长搜索还要每秒更新「已等待 N 秒…」—— 几十秒毫无变化的界面会被当成卡死, 用户去刷新反而更容易踩到断连。

桩要跟着升级:只提供 json() 的 fetch 桩跑不了新的请求层,必须同时给 text() 和 ok/status。再给桩加一个 FETCH_MODE(ok/empty/badjson/http500/throw/abort/failOnce/hang), 一次把所有故障分支跑完 —— 这类分支手点几乎测不到。

单测过了也要用真实数据回归

这次单测 7/7 全绿,但一接真实上游就冒出 095.马老师的 这种脏建议 —— 单测里的样例标题 是自己编的、太干净。修完必须把真实抓到的脏数据补成回归用例,再跑一遍。

必测项:

  1. 每个 UI 区块是否产出 HTML(长度 > 40 且含关键子串)
  2. 卡片数量是否与 data/*.json 一致
  3. 逐条渲染详情抽屉,确认无一条抛异常
  4. XSS 转义:注入 / / & / 单双引号,确认无裸标签
  5. 缺字段(metrics: {}、全 null)不抛异常

计数卡片别用 class="card(会匹配到 .card-top 等子元素),用 class="card(?:\s+read)?"。

状态同步类改动:下沉到「唯一出口」,不要逐点打补丁

同一个坑今天踩了两次(手牌排序),值得单列一节。

需求是「手牌任何时候都要按花色+大小排好」。第一轮我在 drawTile(上层包装)、doPeng、 doChi 等各个调用点分别加 sortHand,结果:

  • 漏了 drawTo(真正 push 手牌的最底层函数,drawTile 只是它的包装)→ 摸到的牌插在末尾,立刻乱序
  • 漏了 doMingGang / doAnGang / doBuGang → 杠/补杠后乱序
  • 用户只报了「发牌时是乱的」,其余路径的乱序一直存在却没人发现

判据:如果一条不变量(排序 / 去重 / 计数 / 时间戳 / 脏标记)需要在多处维护, 它一定会漏。 找那个「所有路径都必经的唯一出口」:

// 反例:每个调用点各自处理,必然漏
drawTile(p){ …; this.sortHand(p); }
doPeng(p,t){ for(…){/*删除*/} this.sortHand(p); }
doChi(p,opt,t){ for(…){/*删除*/} this.sortHand(p); }

// 正例:抽成一个出口,删除即排序
_removeFromHand(p,tile,n){
  for (let k=0;k h.every((t,i,a) => i===0 || T.sortKey(a[i-1]) <= T.sortKey(t));
g.hands[1] = ['s9','m1','p5', /* …乱序 */];
g.drawTile(1);              ok(isSorted(g.hands[1]), '摸牌后应有序');
g.doPeng(2,'p3');           ok(isSorted(g.hands[2]), '碰后应有序');
g.doChi(3,['m3','m4'],'m5'); ok(isSorted(g.hands[3]), '吃后应有序');

顺带教训:doChi(p, opt, tile) 的参数顺序是把 tile 放最后(handleDiscard 的签名也如此)。 写测试或调用时先 Grep 一下真实签名,别按直觉传。

交付收尾

  1. 起服务(run_in_background: true),用 Python 脚本打一次 /api/ping 确认活着。
  2. present_files:第一项放 http://127.0.0.1:5052/(会打开内置浏览器预览面板),再跟上 README 和数据文件。
  3. 写工作区日志到 .workbuddy/memory/YYYY-MM-DD.md。

推送到 GitHub(本机实测路径)

gh CLI 装了但未登录(gh auth status 报 not logged into any GitHub hosts), 不过本机 ~/.git-credentials 里存着 github.com 的 classic token(40 字符, scope gist, repo, workflow),身份即你的 GitHub 用户名),照样能建仓库和推送。 先解析出来验证,再动手:

m = re.search(r'https?://([^:]+):([^@]+)@github\.com', open(os.path.expanduser('~/.git-credentials')).read())
user, tok = m.group(1), m.group(2)
# 用 token 调 API 验证身份 + 权限 + 是否已有同名仓库,再决定建不建

坑一:通用 credential.helper=store 会被 URL-specific 空值覆盖

global 配置里有这两行:

credential.https://github.com.helper=                     # 空值:清空该 URL 的 helper 列表
credential.https://github.com.helper=!gh.exe auth git-credential

URL-specific 配置会完全替代通用 helper 列表,所以下面这条无效(实测仍报 could not read Username):

git -c credential.helper=store ls-remote ...

必须写成 URL-specific 的形式:

git -c credential.https://github.com.helper=store ls-remote https://github.com/USER/REPO.git   # ✓

想长期生效就写进仓库本地配置(只存 helper 名,不存 token,安全):

git config --local credential.https://github.com.helper store

同时设 GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never,否则凭证缺失时 子进程会卡在交互提示上(外部表现为「命令没输出、一直挂着」)。

坑二:refs/remotes/ 写入静默失败

git fetch 会打印 * [new branch] main -> origin/main 且返回 rc=0,但 .git/refs/remotes/origin/ 目录和 ref 文件根本没创建,于是 git status -sb 一直显示 ## main...origin/main [gone]——看起来像远程分支被删了,其实远程完全正常 (git ls-remote --heads origin 和本地 SHA 一致)。走 dangerouslyDisableSandbox 重试也一样,说明不是沙箱拦的。

修法:用 Python 直接落盘 ref 文件(内容 = 40 位 SHA + \n):

os.makedirs('.git/refs/remotes/origin', exist_ok=True)
open('.git/refs/remotes/origin/main', 'w', encoding='ascii').write(sha + '\n')

写完 git 立刻识别,branch -vv 不再显示 gone。refs/heads/* 不受影响。

坑三:推送前必扫本机绝对路径

用户本机脚本常带硬编码路径(实测 start.bat 里写死了 C:\Users\<用户名>\.workbuddy\binaries\python\...),公开仓库里既不可复用又暴露用户名。 推之前用正则扫全项目:

pat = re.compile(r'(C:[\\/]Users[\\/][^\\/"\'\s]+|/c/Users/[^/"\'\s]+|\.workbuddy)', re.I)

改法:start.bat 用 where py → where python 逐级探测,都找不到才报错并给安装地址; 不要写死任何用户目录。改完必须实测(py server.py 起服务 + /api/ping 返回 200), 否则可能把用户自己的双击启动改坏。

建仓库 + 推送

api('/user/repos', 'POST', {'name': 'xxx', 'description': '…', 'private': False,
                            'auto_init': False,      # 关键:别自动初始化,会和本地 README 冲突
                            'has_wiki': False})
api('/repos/USER/REPO/topics', 'PUT', {'names': ['ai', 'case-study', 'python']})
git init -b main
git config --local credential.https://github.com.helper store
git remote add origin https://github.com/USER/REPO.git
git push -u origin main

其它必备

  • .gitattributes 统一换行,否则 Windows/Linux 双环境每次 git add 都刷一屏 CRLF 警告:* text=auto eol=lf + *.bat text eol=crlf。
  • .gitignore 至少排除 __pycache__/、自测备份目录、*.log。
  • 提交信息用中文、多段 -m(标题 + 正文 + 细节)。
  • 用 git -c core.pager=cat log 避免打开分页器卡住。

加 GitHub Actions 前先在本地验一遍 workflow

workflow 写错,GitHub 只会丢一句「Invalid workflow file」,来回一轮很浪费时间。 用 pyyaml 解析 + 把每段 run: 抽出来做 bash -n(用真 bash,见上文 WSL 桩那条), 本地就能全验完。YAML 的 run: | 块会去掉公共缩进,所以 heredoc 结束符(PY / EOF) 在解析后应当是顶格的 —— 这一点要专门断言,否则 shell 会把后续行一起吞掉。

CI 里跑「会改数据」的自测时,多加一条数据未被污染的断言,价值很高:

- name: 断言数据未被自测污染
  run: |
    if [ -n "$(git status --porcelain data/)" ]; then
      echo "::error::自测改动了 data/,备份/还原机制失效"; exit 1
    fi

这把「自测的备份/还原真的有效」变成了持续验证,而不是靠人记得。 matrix 一定配 fail-fast: false,否则只能看到第一个版本的结果。

仓库规范化:协议跟已有仓库保持一致,徽章锚点必须存在

若你的项目使用 MIT 协议,版权行统一 Copyright (c) 2026 <你的名字>。新仓库直接复用同一份原文 (先拉一个已有仓库的 LICENSE 逐行比对,除版权行外应完全一致)。

README 顶部加徽章时锚点必须真的存在:写了 [![PRs Welcome](#贡献)] 就得有 ## 贡献 一节。 写完用脚本把 ](#xxx) 全部提出来对一遍标题。

版本下限别猜:先用正则扫代码里有没有版本专有语法 (match 语句 / PEP604 X | None / list[str] / walrus / tomllib / removeprefix), 扫不出来再上 CI matrix 实测(实测 Python 3.9 / 3.11 / 3.13 + Node 18 / 20 / 22 全绿), 拿到绿灯再在文档里写「支持 3.9」。

数据驱动应用的通用结构(做「库 / 看板 / 案例集」类需求时)

三级漏斗 + 三层文件,用户反馈很好:

原始采集 (机器)  →  人工候选      →  已核实成品
inbox.json          candidates.json   cases.json

铁律:采集脚本只写最上游那一层,绝不直接写成品层。 UI 画出漏斗比例,让「素材多、成品少」变成可解释的正常状态,而不是看起来没做完。

SPA 也要能被爬:预渲染独立页

纯客户端渲染的 SPA(多视图靠 hidden 切换、详情是抽屉、没有路由)对爬虫、社交平台 预览、以及任何直达链接都是空壳——它们读不到正文。要让内容被收录 / 被分享预览 / 能当 「阅读原文」落地页,就得把每条数据预渲染成一个独立页面。

做法:/.html + /index.html 静态总目录 + sitemap.xml + robots.txt

  • 在主 SPA 首页注入 `` 链接清单(标准降级手段,比把正文藏起来安全)。 独立页不要引入 app.js,正文全部内联。

四个容易漏的点:

  1. 复用主样式表的类名,先确认这些选择器没有作用域前缀。若详情样式写成 .drawer .d-title {…},独立页就套不上;必须是裸的 .d-title {…}。另外查一遍 body / html 有没有全局规则(如 overflow:hidden、height:100vh), 它们会把独立页的滚动弄坏。补的那点页面样式页内内联即可,别去改主样式表。 但主样式表本身必须内联进每个页面,理由见下一节 —— 外链会静默失效。
  2. 站点元信息外置成配置文件(title / url / repo / 社群链接 / 备案号),缺字段用默认值 兜底 —— 这样配置文件不存在也能构建,只是引流位是空的。sitemap / canonical / og:url 都需要绝对域名,没有域名就干净地跳过,不要留空 ``。
  3. 接进构建脚本时改三处:① 清理输出目录的"白名单"常量要加上新目录(否则每次构建都 把上次产物报成"非构建文件");② 自检要断言页数 = 数据条数、且每页正文够厚 (去标签后 ≥ 600 字);③ 部署脚本的缓存规则如果是按 basename 匹配的,认不出 子目录里的页面,会错给短缓存(或反之),要加前缀规则。
  4. `` 控长度:名称 — 一句话 里的那句话要截断(20 字上下),否则搜索结果页 全被截掉。og:title 反而应带上站名,分享卡片才认得出是谁发的。

sitemap.xml 的 lastmod 用数据自带的 updated_at / verified_at,别一律写今天。

测试:多副本常量漂移检测

同一份标签文案(枚举中译名、维度名、象限文案)经常在 JS / Python / 配置 里各存一份。 漂移后不会报错,只会让同一个字段在两个页面显示成两个词 —— 属最难查的一类。

写测试时从源文件里正则抠出来比对,别手工维护第二份清单:

# 注意 JS 里常量有「一格一行」和「挤在一行」两种写法,正则两种都要认
def js_const(name, kind="obj"):
    src = open("static/app.js", encoding="utf-8").read()
    if kind == "obj":
        m = re.search(r"const\s+%s\s*=\s*\{([\s\S]*?)\n\};" % name, src)
        block = m.group(1)
        # 带嵌套对象的只取顶层键
        return re.findall(r"^\s*(\w+)\s*:\s*\{", block, re.M) if "{" in block \
            else re.findall(r"(\w+)\s*:", block)
    return re.findall(r"'([^']+)'",
                      re.search(r"const\s+%s\s*=\s*\[([^\]]*)\]" % name, src).group(1))

同理要断言「预渲染页正文里去标签后不含可执行标签」「URL 里的 & 被转义成 &amp;」 「引用了 app.js 的独立页为 0 个」——这三条一旦回归就是事故。

独立页必须内联样式表(外链会静默失效)

踩过的坑:预渲染页写 ``,在设计上完全正确 (子目录深一层,往上一级正好是站点根),本地起 HTTP 服务也正常。但真正打开时整页退化成 白底黑字的裸 HTML —— 样式表根本没被加载。高发场景:file:// 直接双击打开、 预览器/内嵌面板做了目录隔离或把单文件抽走渲染、CDN 缓存与回源路径不一致。

为什么后果很重:主样式表里的 :root { --bg: … --text: … } 是配色的地基,一缺全部 var(--x) 失效;但页面看起来"能读",不报错、控制台也不一定有声,很容易被当成"样式 没做美"而不是"样式没加载",排查方向一开始就是错的。

做法:构建时读 static/style.css 全文,塞进每个页面的 `` 再截。 产物是自包含的(样式已内联、无外部资源)时这招最省事。 2. 断点两端各截一次:响应式页面有断点(如 @media (max-width:900px)),800 宽会把 桌面端布局误判成"堆叠错了"。别看到一列就以为标记写错 —— 先确认当前视口在断点哪一侧。 3. 截图是唯一能发现"掉了样式"的手段:DOM 断言只能证明"CSS 文本在 HTML 里", 证明不了它生效。交付前截一张,比读十遍代码有用。 4. ⚠ --window-size 不等于视口,别用它模拟手机(Windows 无头实测钳到最小宽 ~504 / 最大高 ~760): 请求 --window-size=390,844 实得视口 504×748,截图右侧被裁 —— 现象是"右侧那一栏没了",很容易当成真实布局 bug 排查半天。 要精确设备尺寸就在页面里放一个精确尺寸的 iframe,外面用 transform:scale() 等比缩放:


  

然后照常截图这个 harness 页(scale = min(1, 500/w, 740/h))。 iframe 的视口就等于设备视口,100vw/媒体查询全部按真实尺寸生效。 5. 两个本机小坑:

  • 不要在 node 里 spawn Chrome(execFileSync / execSync 一律 EBUSY,连 cmd.exe 都拉不起来),截图脚本写成 .sh 从 bash 调。
  • Git Bash 的 pwd 给的是 /c/...,直接拼 file:/// 会 ERR_FILE_NOT_FOUND; 给 Chrome 的路径要用 pwd -W(C:/...),文件操作仍用 pwd。

要验「点击/交互」时:playwright-cli(已全局安装)

截图证明不了"点了会不会弹"。需要真实点按时用 @playwright/cli(通常 npm i -g @playwright/cli;没装就 npm install -g @playwright/cli@latest,约 6 秒)。 四条必知:

# ① file:// 被拦(Error: Access to "file:" protocol is blocked)→ 必须先起本地 http 服务
cd <项目目录> && "~/.workbuddy/binaries/python/versions/3.13.12/python.exe" \
  -m http.server 8137 --bind 127.0.0.1 >/dev/null 2>&1 &
# ② ⚠ 浏览器会话在两次 Bash 调用之间会被回收 —— 所有 playwright 命令必须 && 串在一条里
playwright-cli open "http://127.0.0.1:8137/index.html" >/dev/null 2>&1; \
  playwright-cli resize 400 948 >/dev/null 2>&1; sleep 3; \
  playwright-cli mousemove 380 400 >/dev/null 2>&1; \
  playwright-cli mousedown >/dev/null 2>&1; playwright-cli mouseup >/dev/null 2>&1; \
  playwright-cli eval "document.getElementById('x').className" 2>&1 | head -12; \
  playwright-cli close >/dev/null 2>&1
  • 读结果别用 tail -3:eval 的返回值在 ### Result 段,很容易被截掉,用 head -12 或 grep -m1 '"'。前几行常是 ### Result + "值"。
  • eval 里的 () => ... 与裸表达式都可以,但结论只在 ### Result 里,别漏。
  • 截图默认落在进程 cwd(不是 .playwright-cli/),写 --filename=xxx.png 后去哪找先 find。
  • eval 点 el.click() 验证的是"监听器挂上了",验证不了命中测试(有没有东西盖住它)。 怀疑"点了没反应"时一定要用 mousemove/mousedown/mouseup 真点一次。

用测量代替眼力:截图看"观感",出问题时读真实几何定位,别对着截图猜——

playwright-cli eval "()=>{const e=document.getElementById('discard-west'); \
  const c=e.children[0]; const rh=c?c.offsetHeight:0; \
  return 'h='+e.clientHeight+' rowH='+rh+' 整除='+(((e.clientHeight+2)%(rh+2))===0?'是':'否')}"

验「节奏 / 时长」类改动:在页面内就地改回旧实现做 A/B

调动画时长、AI 出牌间隔、轮询周期这类"只改数字"的改动,改前改后各测一轮才有说服力。 不必来回改文件——在同一浏览器会话里把被测函数就地换成旧实现再测一轮即可:

// ① 注入探针:patch window.setTimeout 记录延迟(脚本里的裸 setTimeout 会落到 window 上)
//    ()=> { const L=[]; const o=window.setTimeout;
//           window.setTimeout=function(fn,ms){ if(typeof ms==="number"&&ms>=300&&ms<=8000)
//             L.push(Math.round(ms)); return o.apply(this,arguments); };
//           window.__pr={ reset(){ L.length=0; this.t0=Date.now(); this.d0=读当前计数值; },
//                         read(){ 返回 {秒, 次数, 秒/次, min, max, 直方图} } }; }
// ② 自动替"人"操作:用 setInterval 驱动页面暴露的交互入口,否则轮不到 AI 动
//    先找有没有现成钩子(有的项目把 ui/onTileClick getter 暴露出来了,直接调即可)
// ③ 测完一轮 → 就地换成旧实现 + reset → 再测一轮
//    ()=> { const p=Controller.pace; p.for=function(k){ return 旧公式 }; window.__pr.reset(); }

三个要点:

  • 别只报"延迟数组的均值":showToast、动画、防抖也用 setTimeout,样本会被污染成 一堆 700/1200。可信指标是 墙钟秒数 ÷ 发生次数(如"秒/张牌"),数组只留着看分布和 max。
  • 先确认被测函数在页面里按名字可达:顶层 const X = (function(){...})() 在后续脚本和 eval 里都能按名访问,只是不在 window 上(别去 window.X 找)。
  • A/B 两轮必须在同一会话内,否则环境差异(缓存、并发、GC)会盖过你要测的差异—— 而会话又在两次 Bash 调用之间被回收(见上),所以 A/B 也得分步但必须同一条命令。

⚠ 不要拿用户截图的像素当 CSS 像素:用户发来的截图常被浏览器缩放/DPR 放大过。 从内容反推("牌河每行 7~8 张、每张约多少 px" → 估计视口宽)比量图片像素靠谱得多; 最省事的是直接问/看他 DevTools 上的 Dimensions: 400 × 948(他给过这个截图就别再猜)。

布局"被裁一半"类问题的三个通用坑(实测踩过)

内容被容器裁切时,按这个顺序查:

  1. flex 子项会等比收缩:容器不够高时所有子项一起缩,牌/卡片被压成半张。 给不该缩的固定内容加 flex-shrink:0,只让"可以裁"的那块(如列表)承担收缩。
  2. align-content:flex-end 对 grid 是非法值:从 flex 改 grid 时忘了改这条, 会静默退化成 normal(=start) —— 表现是"对齐方向反了、裁掉的是最新的而不是最旧的"。 改 grid 就把 justify-content/align-content 一起显式改成 center/end。
  3. CSS 没法表达"取整行数",且溢出到容器上方的部分 scrollHeight 数不到: 固定行高的 grid 遇到"容器高不整除行高"仍会在顶端留半行残牌; align-content:end 把多余行推到容器上方(负方向),scrollHeight 不计入 → scrollHeight > clientHeight 会误判成"装得下"。 正确做法:JS 量 children[0].offsetHeight 得行高、按 getComputedStyle(el).gridTemplateColumns 的列数算自然高度,再把容器高度写成整行数(写死高度要配合 flex:0 0 auto + margin-top:auto, 否则 flex-basis:0 会让 height 失效;每次重算前先清空 height/类还原 flex:1 才量得准)。 这套逻辑挂两处:每次渲染后 + window.resize(转屏/拖窗口不会触发重渲染)。
  4. 被压扁的弹性区里若有固定尺寸的装饰(如中央的大牌),也要按高度收起 (clientHeight < 需要值 就 display:none),否则它从格子顶部漏出半张牌残影。

手机端布局:先把 CSS 数字算一遍,再截图

纯 CSS 的响应式布局,最省时间的顺序是「建模 → 试算 → 截图」,不是「改一版截一版」:

  1. 把 :root 尺寸变量 + 媒体查询的覆盖顺序 + Grid 轨道规则,翻译成一个 node 试算脚本 (--tile-ar 这类 calc 要按真实公式展开;媒体查询按源码顺序后者覆盖前者,别漏)。
  2. 对一组视口(360/375/390/412/430 竖、844×390/667×375 横、768/1280)逐项校核: 关键行能否放下、内容块是否装得下、留白多少、被裁时裁掉的是不是最不重要的那部分。
  3. 全绿了再去截图。截图只用来确认"观感",不用来发现"放不下"。

配套两条 CSS 经验:

  • 变量要锁宽高比:--tile-ar:1.381 + height:calc(var(--w)*var(--tile-ar))。 宽高分别写死,一旦被 flex 挤压宽高比就崩(实测 0.71 → 0.52,图案糊成一团)。
  • ⚠ 自定义属性是"在哪儿声明就在哪儿算完"::root{--h:calc(var(--w)*2)} 的值在 :root 上就算好了,后代继承到的是算完的值。在某个后代上覆盖 --w 不会改变 --h。 要么连 --h 一起重定义,要么把换算写在使用处(width:calc(var(--w)*var(--scale,1)))。 这个坑的症状是"改了变量毫无反应",特别容易误判成选择器权重问题。

新增的断言,要亲手验证"它真的会失败"

加完断言立刻构造一次回归:复制产物到临时目录 → 人为改坏一处(留 `` 换成外链、把配置文件置空)→ 跑自检函数,确认它报出对应的 问题行。永远通过的新断言等于没有断言,还会给出虚假的安全感。

⚠ 假绿陷阱:uncaughtException 处理器会把"崩在半路"变成"pass"

new Function 跑被测代码时,顶层抛错会直接中断整个自测脚本。如果脚本里挂了 process.on('uncaughtException', ...) 来收集异步异常(很常见),Node 就不再崩进程 —— 于是现象是:一行 PASS/FAIL 都不打印,exit=0。不看输出内容只 && echo OK 的话, 这就是一次彻头彻尾的假绿(实测踩过:被测代码里一句裸 location.hash, Node 环境没有 location,整套断言根本没跑)。

两道防线,都要有:

// ① 被测代码里做 typeof 守卫(Node 里没有 location / navigator 之类)
const dbg = ((typeof location !== 'undefined' && location.hash) || '').replace(/^#/,'');

// ② 自测脚本退出时强制兜底:挂了 uncaughtException 就必须自己报
process.on('exit', () => {
  if (asyncErrors.length) console.log('!! 未捕获异常:', asyncErrors.join(' | '));
});

另外:断言别依赖"跑完整局之后"的状态。对局/流程可能已经结束并把字段清空, 断言就会随机翻车(一会儿过一会儿不过)。要在事件发生的那一刻断言 —— 例如在循环里捕获到"这一次真的出牌了",就在那一次立刻校验相关 DOM。

base = m.check_pages(tmp, cases, verbose=False)   # 基线:应为 0
open(p, "w", encoding="utf-8").write(broken)      # 构造回归
assert [x for x in m.check_pages(tmp, cases, verbose=False) if cid in x]

Related skills

Generate and edit Draw.io, Mermaid, and Excalidraw diagrams from natural language using a structured JSON spec.

by nssa.io1.0k installs47 stars

Find why your productivity system keeps failing, then apply the smallest fix — capacity math, bottleneck routing, durable local notes.

by Iván855 installs69 stars

Join a video meeting as an AI bot with voice, avatar, and screenshare across four operating modes.

by johnpatternai22 installs8 stars

Fetch raw ad creative, app, ranking, and revenue data from AdMapix as structured JSON.

by fly0pants4.3k installs296 stars

Stores durable facts in a categorized, plain-markdown vault on disk, alongside your agent's built-in memory.

by Iván559 installs18 stars

More from oracis

Browse all skills

把本地 Skill 打包并发布到 SkillHub(腾讯 skillhub.cn)与 ClawHub,覆盖预检、发布、状态回读与竞品对标全链路,并在上传被拒时用二分/ddmin 脚本把「服务端 WAF 拦内容(566)」与「包结构/字段问题」精确区分开。当用户说「发布技能到市场」「上架 skill」「SkillHub 提交失败」「Failed to fetch」「566」「上传 zip 报错」「技能审核状态」「版本号被拒」时使用。也讲清「入库成功 ≠ 已公开」——用 `mine` 的 `reviewStatus`、重发探测 `409 VERSION_EXISTS`、`/versions/<v>/signature` 三个探针判断版本到底走到哪一步。

by oracis1 installs1 stars

平台只有网页后台、没有上传 API 时,用本机已装的 Chrome/Edge + CDP 自动化走完上传/发布/填表流程——复用真实登录态、零安装(不下载 500MB Chromium)、能给文件选择框塞文件;并实测富文本编辑器(公众号 / CMS / 邮件模板)里到底哪些 CSS 能活下来,以及在决定是否接入平台自带的「一键排版 / AI 美化」前先抓它输出做结构化 diff。当用户说「只能网页上传」「后台发布」「网页填表自动提交」「发布到技能市场/各平台控制台」「复用已登录的浏览器」「公众号排版样式不生效/保存后样式没了」「一键排版要不要用」「点了按钮没反应/提交没成功也没报错」时使用。

by oracis2 installs

一键安装并实测验证已验证可用的免费 LLM 到 WorkBuddy/Claude Code,无需注册、无需 API Key。当用户说「装免费模型」「加免费模型」「免费 LLM」「免费模型一键装」「验证免费模型可用吗」「免费模型怎么接入」「帮我配几个免费模型」「监控免费模型」「免费模型挂了吗」「kilo 网关」「kilo-auto」「nemotron 免费」「space-bunny」「智谱 glm 免费」「zhipu 免费」「glm-4-flash」「glm-4.7-flash」「glm-z1-flash」「glm-4v-flash」「探测国外免费模型」「国外平台免费 llm」「优先用免费模型」「读图桥接」「vision bridge」「给 glm-4-flash 加读图」「不读图的模型怎么看图」「阶跃星辰」「stepfun」「step 5」「step-5-preview」「限时免费模型」「ccproxy 读图转发」「给 claude code 加读图」时使用。也用于验证已有免费模型配置是否真的可用(tool calling 是否支持、是否限流、是否支持读图),以及定期 watch 监控免费模型是否下线/限流/失去工具调用能力。

by oracis1 installs

在中国大陆网络环境下用 yt-dlp 下载视频(含 Windows GUI 封装)。当用户要下载 YouTube、Bilibili、SoundCloud 等 yt-dlp 支持的站点内容,或遇到「yt-dlp 装不上 / 下载报 502 / Tunnel connection failed / ffmpeg not found / 缺 JS runtime / TLS fingerprint / watch?v=&list= 把整个列表都下了 / Sign in to confirm you're not a bot / The page needs to be reloaded / 下到一半突然下不动 / 下回来的视频糊、有拖影残影 / 下载速度特别慢」等问题时使用。

by oracis2 installs

聚合搜索百度网盘/夸克网盘/阿里云盘等网盘分享链接。当用户想按作者名、课程名、剧名、书名、软件名找网盘下载路径,或提到「网盘搜索」「PanSou」「盘搜」「找到下载链接」「夸克/百度/阿里网盘资源」时使用。包含已验证的 PanSou 协议端点、绕过系统代理的必要写法、以及可直接运行的本地 Web 应用 panradar.py。

by oracis2 installs

把B站横屏中长视频(读书解读/知识类)自动改造为微信视频号9:16竖屏短视频切片。当用户说"B站视频转视频号""横屏转竖屏切片""读书视频切片""视频号短视频制作""横屏改竖屏"时使用。覆盖下载→转写→选段→横转竖→烧字幕→封面全流程, 含本机环境坑与画质/字幕优化。

by oracis1 installs