[{"content":"之前写过一版 DSH 的 macOS 启动器：一个 shell 脚本包装成的 .app，双击后自动拉起 dsh web 服务再开浏览器。能用，但打开的还是浏览器标签页，混在一堆网页里不够\u0026quot;应用\u0026quot;。\n这次升级成原生应用方案，并且把整套方法整理成跨平台教程：\nmacOS：Swift + WKWebView 编译成原生 .app，独立窗口、Dock 图标、断线自动重连 Windows：Edge/Chrome --app 模式快捷方式（零依赖），或 WebView2 原生窗口（进阶） 图标：不再手绘，直接从 DSH 源码里提取官方鲸鱼 SVG 渲染，配深蓝渐变圆角背景 最终效果（图标）：\n本文自包含：所有源码完整附上，照抄即可复现；也可以直接把本文链接发给任何 AI agent，让它照着做。\n一、原理 DSH 的 Web 界面跑在 http://127.0.0.1:3080。所谓\u0026quot;桌面应用\u0026quot;，本质就是一个固定加载这个地址的独立窗口：\n平台 方案 窗口技术 macOS 编译原生 .app WKWebView Windows（推荐） 快捷方式 + --app= 参数 Edge/Chrome 应用模式 Windows（进阶） .NET WinForms WebView2 三种方案都不需要打包网页资源，服务还是那个服务，只是入口变成了双击图标。\n二、通用部分：制作图标 2.1 提取官方鲸鱼 SVG DSH 前端的产物目录里自带 favicon.svg，就是官方鲸鱼 Logo。在本机 DSH 安装目录下找：\n1 2 3 4 5 6 npm root -g # 拼上相对路径： # \u0026lt;npm全局根\u0026gt;/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-web-frontend/dist/favicon.svg mkdir -p dsh-app \u0026amp;\u0026amp; cd dsh-app cp \u0026#34;$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-web-frontend/dist/favicon.svg\u0026#34; . Windows 上 npm root -g 输出形如 C:\\Users\\\u0026lt;你\u0026gt;\\AppData\\Roaming\\npm\\node_modules，同样拼上后面的相对路径。找不到的话用 find \u0026quot;$(npm root -g)/@deepseek-ai\u0026quot; -name favicon.svg 定位。\n2.2 图标生成脚本（双平台通用） 依赖：Python 3 + Pillow（pip install pillow）。保存为 make_icon.py：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;DSH Web app icon: official DeepSeek Harness whale (from favicon.svg) on a deep-blue gradient rounded square. 4x supersampled; self-checks at end.\u0026#34;\u0026#34;\u0026#34; from PIL import Image, ImageDraw, ImageFilter import os, re, subprocess, sys OUT = os.path.dirname(os.path.abspath(__file__)) S = 4096 # supersample canvas R = 1024 # final master def lerp(a, b, t): return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) # ---------- minimal SVG path parser (official favicon uses M/C only) ---------- def parse_svg_path(d): tokens = re.findall(r\u0026#39;([MmLlCcQqZzHhVvSsTtAa])|(-?\\d*\\.?\\d+(?:e-?\\d+)?)\u0026#39;, d) tokens = [t[0] or float(t[1]) for t in tokens] i = 0 def num(): nonlocal i v = float(tokens[i]); i += 1 return v def cubic(p0, p1, p2, p3, n=40): return [( (1-t)**3*p0[0]+3*(1-t)**2*t*p1[0]+3*(1-t)*t*t*p2[0]+t**3*p3[0], (1-t)**3*p0[1]+3*(1-t)**2*t*p1[1]+3*(1-t)*t*t*p2[1]+t**3*p3[1] ) for t in (k/n for k in range(1, n+1))] def quad(p0, p1, p2, n=30): return [( (1-t)**2*p0[0]+2*(1-t)*t*p1[0]+t*t*p2[0], (1-t)**2*p0[1]+2*(1-t)*t*p1[1]+t*t*p2[1] ) for t in (k/n for k in range(1, n+1))] subpaths, cur = [], [] pos = start = (0, 0); last_c = None; last_letter = \u0026#39;M\u0026#39; while i \u0026lt; len(tokens): if isinstance(tokens[i], str): c = tokens[i]; i += 1 if c in \u0026#39;Zz\u0026#39;: if cur and cur[-1] != start: cur.append(start) if cur: subpaths.append(cur) cur = []; pos = start; last_c = None continue else: c = last_letter # implicit repeat (M -\u0026gt; L) rel = c.islower(); C = c.upper() last_letter = \u0026#39;L\u0026#39; if C == \u0026#39;M\u0026#39; else C if C == \u0026#39;M\u0026#39;: x, y = num(), num() pos = (pos[0]+x, pos[1]+y) if rel else (x, y) if cur: subpaths.append(cur) cur = [pos]; start = pos; last_c = None elif C == \u0026#39;L\u0026#39;: x, y = num(), num() pos = (pos[0]+x, pos[1]+y) if rel else (x, y) cur.append(pos); last_c = None elif C == \u0026#39;H\u0026#39;: x = num(); pos = ((pos[0]+x) if rel else x, pos[1]); cur.append(pos); last_c = None elif C == \u0026#39;V\u0026#39;: y = num(); pos = (pos[0], (pos[1]+y) if rel else y); cur.append(pos); last_c = None elif C == \u0026#39;C\u0026#39;: x1, y1, x2, y2, x, y = num(), num(), num(), num(), num(), num() p1 = (pos[0]+x1, pos[1]+y1) if rel else (x1, y1) p2 = (pos[0]+x2, pos[1]+y2) if rel else (x2, y2) p3 = (pos[0]+x, pos[1]+y) if rel else (x, y) cur += cubic(pos, p1, p2, p3); pos = p3; last_c = p2 elif C == \u0026#39;S\u0026#39;: x2, y2, x, y = num(), num(), num(), num() p1 = (2*pos[0]-last_c[0], 2*pos[1]-last_c[1]) if last_c else pos p2 = (pos[0]+x2, pos[1]+y2) if rel else (x2, y2) p3 = (pos[0]+x, pos[1]+y) if rel else (x, y) cur += cubic(pos, p1, p2, p3); pos = p3; last_c = p2 elif C == \u0026#39;Q\u0026#39;: x1, y1, x, y = num(), num(), num(), num() p1 = (pos[0]+x1, pos[1]+y1) if rel else (x1, y1) p2 = (pos[0]+x, pos[1]+y) if rel else (x, y) cur += quad(pos, p1, p2); pos = p2; last_c = None else: raise ValueError(\u0026#34;unhandled command \u0026#34; + c) if cur: subpaths.append(cur) return subpaths def build_official_whale(canvas, span=0.62): \u0026#34;\u0026#34;\u0026#34;Render the official DSH whale (favicon.svg) white, centered, spanning `span` of canvas.\u0026#34;\u0026#34;\u0026#34; src = open(os.path.join(OUT, \u0026#34;favicon.svg\u0026#34;)).read() d = re.search(r\u0026#39;\\bd=\u0026#34;([^\u0026#34;]+)\u0026#34;\u0026#39;, src).group(1) subpaths = parse_svg_path(d) xs = [p[0] for sp in subpaths for p in sp] ys = [p[1] for sp in subpaths for p in sp] bw, bh = max(xs) - min(xs), max(ys) - min(ys) scale = canvas * span / max(bw, bh) ox = canvas / 2 - (min(xs) + max(xs)) / 2 * scale oy = canvas / 2 - (min(ys) + max(ys)) / 2 * scale - canvas * 0.015 mask = Image.new(\u0026#34;L\u0026#34;, (canvas, canvas), 0) dr = ImageDraw.Draw(mask) for sp in subpaths: dr.polygon([(p[0]*scale + ox, p[1]*scale + oy) for p in sp], fill=255) # white -\u0026gt; ice-blue vertical gradient inside the whale mask g1 = Image.new(\u0026#34;RGBA\u0026#34;, (1, canvas)) for y in range(canvas): g1.putpixel((0, y), lerp((255, 255, 255), (190, 224, 255), y / canvas) + (255,)) grad = g1.resize((canvas, canvas)) return Image.composite(grad, Image.new(\u0026#34;RGBA\u0026#34;, (canvas, canvas), (0, 0, 0, 0)), mask) def main(): # background: diagonal navy -\u0026gt; blue -\u0026gt; teal bg = Image.new(\u0026#34;RGBA\u0026#34;, (R, R)) px = bg.load() c1, c2, c3 = (7, 14, 40), (18, 56, 124), (9, 118, 168) for y in range(R): for x in range(R): t = (x + y) / (2 * R) c = lerp(c1, c2, t * 2) if t \u0026lt; 0.5 else lerp(c2, c3, (t - 0.5) * 2) px[x, y] = c + (255,) # rounded-rect mask mask = Image.new(\u0026#34;L\u0026#34;, (R, R), 0) ImageDraw.Draw(mask).rounded_rectangle([0, 0, R - 1, R - 1], radius=int(R * 0.2237), fill=255) bg.putalpha(mask) # whale: 4x supersample -\u0026gt; glow -\u0026gt; composite whale_small = build_official_whale(S).resize((R, R), Image.LANCZOS) glow = Image.new(\u0026#34;RGBA\u0026#34;, (S, S), (0, 0, 0, 0)) ImageDraw.Draw(glow).ellipse([S*0.15, S*0.20, S*0.85, S*0.85], fill=(70, 190, 255, 150)) glow = glow.filter(ImageFilter.GaussianBlur(S * 0.09)).resize((R, R), Image.LANCZOS) glow = Image.composite(glow, Image.new(\u0026#34;RGBA\u0026#34;, (R, R), (0, 0, 0, 0)), whale_small.split()[3]) bg = Image.alpha_composite(bg, glow) bg = Image.alpha_composite(bg, whale_small) bg.save(os.path.join(OUT, \u0026#34;icon_1024.png\u0026#34;)) # ---- Windows .ico ---- bg.save(os.path.join(OUT, \u0026#34;AppIcon.ico\u0026#34;), sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]) # ---- macOS .icns（仅 macOS 有 iconutil）---- if sys.platform == \u0026#34;darwin\u0026#34;: iconset = os.path.join(OUT, \u0026#34;AppIcon.iconset\u0026#34;) os.makedirs(iconset, exist_ok=True) for name, size in {\u0026#34;icon_16x16.png\u0026#34;: 16, \u0026#34;icon_16x16@2x.png\u0026#34;: 32, \u0026#34;icon_32x32.png\u0026#34;: 32, \u0026#34;icon_32x32@2x.png\u0026#34;: 64, \u0026#34;icon_128x128.png\u0026#34;: 128, \u0026#34;icon_128x128@2x.png\u0026#34;: 256, \u0026#34;icon_256x256.png\u0026#34;: 256, \u0026#34;icon_256x256@2x.png\u0026#34;: 512, \u0026#34;icon_512x512.png\u0026#34;: 512, \u0026#34;icon_512x512@2x.png\u0026#34;: 1024}.items(): bg.resize((size, size), Image.LANCZOS).save(os.path.join(iconset, name)) subprocess.run([\u0026#34;iconutil\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;icns\u0026#34;, iconset, \u0026#34;-o\u0026#34;, os.path.join(OUT, \u0026#34;AppIcon.icns\u0026#34;)], check=True) # self-check px = bg.load() bright = [(x, y) for y in range(R) for x in range(R) if px[x, y][3] \u0026gt; 200 and px[x, y][0] \u0026gt; 200 and px[x, y][1] \u0026gt; 200] assert len(bright) \u0026gt; 30000, f\u0026#34;whale too small: {len(bright)} bright pixels\u0026#34; xs = [p[0] for p in bright]; ys = [p[1] for p in bright] print(f\u0026#34;bright pixels: {len(bright)} whale bbox: x[{min(xs)}..{max(xs)}] y[{min(ys)}..{max(ys)}]\u0026#34;) print(\u0026#34;OK icon_1024.png + AppIcon.ico\u0026#34; + (\u0026#34; + AppIcon.icns\u0026#34; if sys.platform == \u0026#34;darwin\u0026#34; else \u0026#34;\u0026#34;)) if __name__ == \u0026#34;__main__\u0026#34;: main() 运行：\n1 python3 make_icon.py # Windows 用 python make_icon.py 产物：\n文件 用途 icon_1024.png 1024px 预览图 AppIcon.icns macOS 应用图标（仅 macOS 生成） AppIcon.ico Windows 快捷方式/程序图标（16~256 共 6 档尺寸） 脚本要点：内置了一个极简 SVG path 解析器（支持 M/L/C/S/Q/H/V/Z，官方 favicon 只用到了 M/C/Z），把鲸鱼路径 4 倍超采样渲染成白色渐变鲸鱼，再叠光晕合成到深蓝渐变圆角底板上；末尾自动做像素自检，鲸鱼没画出来会直接 assert 报错。脚本按平台自动决定生成 .icns 还是只出 .ico。\n三、macOS：编译原生 .app 3.1 依赖 1 2 xcode-select --install # 提供 swiftc / iconutil，装过可跳过 python3 -m pip install pillow 3.2 应用源码 main.swift 一个完整的原生应用，不到 200 行：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 import Cocoa import WebKit final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { private var window: NSWindow! private var webView: WKWebView! private var errorView: NSView? private var retryTimer: Timer? private let homeURL = URL(string: \u0026#34;http://127.0.0.1:3080\u0026#34;)! func applicationDidFinishLaunching(_ notification: Notification) { buildMenu() let rect = NSRect(x: 0, y: 0, width: 1280, height: 840) window = NSWindow(contentRect: rect, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.title = \u0026#34;DSH Web\u0026#34; window.minSize = NSSize(width: 800, height: 560) window.setFrameAutosaveName(\u0026#34;DSHWebWindow\u0026#34;) window.center() let config = WKWebViewConfiguration() webView = WKWebView(frame: rect, configuration: config) webView.navigationDelegate = self webView.allowsBackForwardNavigationGestures = true window.contentView = webView window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) loadHome() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -\u0026gt; Bool { true } // MARK: - Loading private func loadHome() { hideError() var req = URLRequest(url: homeURL) req.cachePolicy = .reloadIgnoringLocalCacheData req.timeoutInterval = 8 webView.load(req) } // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { stopAutoRetry() hideError() } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { showError() } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { showError() } // MARK: - Error screen (auto-retry while server is not up yet) private func showError() { if errorView == nil { buildErrorView() } errorView?.isHidden = false startAutoRetry() } private func hideError() { errorView?.isHidden = true } private func startAutoRetry() { guard retryTimer == nil else { return } retryTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { [weak self] _ in self?.loadHome() } } private func stopAutoRetry() { retryTimer?.invalidate() retryTimer = nil } private func buildErrorView() { guard let content = window.contentView else { return } let v = NSView(frame: content.bounds) v.autoresizingMask = [.width, .height] v.wantsLayer = true v.layer?.backgroundColor = NSColor(calibratedRed: 0.05, green: 0.08, blue: 0.17, alpha: 1).cgColor let title = NSTextField(labelWithString: \u0026#34;无法连接到 DSH Web\u0026#34;) title.font = .systemFont(ofSize: 22, weight: .semibold) title.textColor = .white let sub = NSTextField(labelWithString: \u0026#34;请先启动 dsh 服务（127.0.0.1:3080），正在自动重试，也可以点击下方按钮。\u0026#34;) sub.font = .systemFont(ofSize: 13) sub.textColor = NSColor(white: 0.72, alpha: 1) let btn = NSButton(title: \u0026#34;立即重试\u0026#34;, target: self, action: #selector(retryTapped)) btn.bezelStyle = .rounded btn.controlSize = .large btn.keyEquivalent = \u0026#34;\\r\u0026#34; let stack = NSStackView(views: [title, sub, btn]) stack.orientation = .vertical stack.alignment = .centerX stack.spacing = 14 stack.translatesAutoresizingMaskIntoConstraints = false v.addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: v.centerXAnchor), stack.centerYAnchor.constraint(equalTo: v.centerYAnchor), ]) content.addSubview(v, positioned: .above, relativeTo: webView) errorView = v } @objc private func retryTapped() { loadHome() } @objc private func reloadHome(_ sender: Any?) { loadHome() } @objc private func goBack(_ sender: Any?) { webView.goBack() } @objc private func goForward(_ sender: Any?) { webView.goForward() } // MARK: - Menus (Cmd+Q / Cmd+R / Cmd+W / copy-paste) private func buildMenu() { let mainMenu = NSMenu() let appItem = NSMenuItem() mainMenu.addItem(appItem) let appMenu = NSMenu() appMenu.addItem(withTitle: \u0026#34;关于 DSH Web\u0026#34;, action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: \u0026#34;\u0026#34;) appMenu.addItem(.separator()) appMenu.addItem(withTitle: \u0026#34;隐藏 DSH Web\u0026#34;, action: #selector(NSApplication.hide(_:)), keyEquivalent: \u0026#34;h\u0026#34;) appMenu.addItem(.separator()) appMenu.addItem(withTitle: \u0026#34;退出 DSH Web\u0026#34;, action: #selector(NSApplication.terminate(_:)), keyEquivalent: \u0026#34;q\u0026#34;) appItem.submenu = appMenu let navItem = NSMenuItem() mainMenu.addItem(navItem) let navMenu = NSMenu(title: \u0026#34;导航\u0026#34;) navMenu.addItem(withTitle: \u0026#34;重新加载\u0026#34;, action: #selector(reloadHome(_:)), keyEquivalent: \u0026#34;r\u0026#34;) navMenu.addItem(withTitle: \u0026#34;后退\u0026#34;, action: #selector(goBack(_:)), keyEquivalent: \u0026#34;[\u0026#34;) navMenu.addItem(withTitle: \u0026#34;前进\u0026#34;, action: #selector(goForward(_:)), keyEquivalent: \u0026#34;]\u0026#34;) navMenu.addItem(.separator()) navMenu.addItem(withTitle: \u0026#34;关闭窗口\u0026#34;, action: #selector(NSWindow.performClose(_:)), keyEquivalent: \u0026#34;w\u0026#34;) navItem.submenu = navMenu let editItem = NSMenuItem() mainMenu.addItem(editItem) let editMenu = NSMenu(title: \u0026#34;编辑\u0026#34;) editMenu.addItem(withTitle: \u0026#34;撤销\u0026#34;, action: Selector((\u0026#34;undo:\u0026#34;)), keyEquivalent: \u0026#34;z\u0026#34;) editMenu.addItem(withTitle: \u0026#34;重做\u0026#34;, action: Selector((\u0026#34;redo:\u0026#34;)), keyEquivalent: \u0026#34;Z\u0026#34;) editMenu.addItem(.separator()) editMenu.addItem(withTitle: \u0026#34;剪切\u0026#34;, action: #selector(NSText.cut(_:)), keyEquivalent: \u0026#34;x\u0026#34;) editMenu.addItem(withTitle: \u0026#34;拷贝\u0026#34;, action: #selector(NSText.copy(_:)), keyEquivalent: \u0026#34;c\u0026#34;) editMenu.addItem(withTitle: \u0026#34;粘贴\u0026#34;, action: #selector(NSText.paste(_:)), keyEquivalent: \u0026#34;v\u0026#34;) editMenu.addItem(withTitle: \u0026#34;全选\u0026#34;, action: #selector(NSText.selectAll(_:)), keyEquivalent: \u0026#34;a\u0026#34;) editItem.submenu = editMenu let winItem = NSMenuItem() mainMenu.addItem(winItem) let winMenu = NSMenu(title: \u0026#34;窗口\u0026#34;) winMenu.addItem(withTitle: \u0026#34;最小化\u0026#34;, action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: \u0026#34;m\u0026#34;) winMenu.addItem(withTitle: \u0026#34;缩放\u0026#34;, action: #selector(NSWindow.performZoom(_:)), keyEquivalent: \u0026#34;\u0026#34;) winItem.submenu = winMenu NSApp.mainMenu = mainMenu } } let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.regular) app.run() 功能：独立原生窗口（记住大小位置）、dsh 服务没起时显示提示页并每 4 秒自动重试（先开应用再启服务也行）、完整菜单快捷键（Cmd+R 重载、Cmd+[/] 前进后退、Cmd+W 关窗、Cmd+Q 退出、复制粘贴）。\n3.3 Info.plist 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;CFBundleName\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;DSH Web\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleDisplayName\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;DSH Web\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleIdentifier\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.deepseek.dsh-web\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleExecutable\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;DSHWeb\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundlePackageType\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;APPL\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleIconFile\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;AppIcon\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleIconName\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;AppIcon\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleShortVersionString\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;1.0\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;CFBundleVersion\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;1\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;LSMinimumSystemVersion\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;12.0\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;LSApplicationCategoryType\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;public.app-category.developer-tools\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;LSMultipleInstancesProhibited\u0026lt;/key\u0026gt; \u0026lt;true/\u0026gt; \u0026lt;key\u0026gt;NSHighResolutionCapable\u0026lt;/key\u0026gt; \u0026lt;true/\u0026gt; \u0026lt;key\u0026gt;NSAppTransportSecurity\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;NSAllowsLocalNetworking\u0026lt;/key\u0026gt; \u0026lt;true/\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; ⚠️ NSAllowsLocalNetworking 必须有，否则 ATS 可能拦截 http://127.0.0.1 的明文请求，窗口一直空白。\n3.4 一键构建 build.sh 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #!/bin/bash # Build \u0026#34;DSH Web.app\u0026#34;: native WKWebView wrapper for http://127.0.0.1:3080 set -euo pipefail cd \u0026#34;$(dirname \u0026#34;$0\u0026#34;)\u0026#34; APP=\u0026#34;DSH Web.app\u0026#34; echo \u0026#34;==\u0026gt; 生成图标\u0026#34; python3 make_icon.py echo \u0026#34;==\u0026gt; 编译 Swift\u0026#34; swiftc -O -o DSHWeb main.swift -framework Cocoa -framework WebKit echo \u0026#34;==\u0026gt; 组装 bundle\u0026#34; rm -rf \u0026#34;$APP\u0026#34; mkdir -p \u0026#34;$APP/Contents/MacOS\u0026#34; \u0026#34;$APP/Contents/Resources\u0026#34; cp DSHWeb \u0026#34;$APP/Contents/MacOS/DSHWeb\u0026#34; cp Info.plist \u0026#34;$APP/Contents/Info.plist\u0026#34; cp AppIcon.icns \u0026#34;$APP/Contents/Resources/AppIcon.icns\u0026#34; echo \u0026#34;==\u0026gt; ad-hoc 签名\u0026#34; codesign --force --deep -s - \u0026#34;$APP\u0026#34; echo \u0026#34;==\u0026gt; 完成: $(pwd)/$APP\u0026#34; 3.5 构建、安装、验证 1 2 3 4 5 6 7 8 9 10 chmod +x build.sh ./build.sh # 安装到用户应用目录（Launchpad / 聚焦搜索可见） cp -R \u0026#34;DSH Web.app\u0026#34; ~/Applications/ # 验证 curl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; http://127.0.0.1:3080/ # 期望 200 open ~/Applications/\u0026#34;DSH Web.app\u0026#34; pgrep -fl DSHWeb # 有进程即成功 四、Windows：两种方案 方案 A（推荐）：Edge/Chrome --app 快捷方式 零编译依赖，效果接近原生应用：独立窗口、无地址栏、独立任务栏图标。\n按第二节生成 AppIcon.ico（Windows 上直接 python make_icon.py）。 保存下面脚本为 make_shortcut.ps1，与 AppIcon.ico 放同一目录： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # make_shortcut.ps1 — 创建 DSH Web 桌面快捷方式（独立应用窗口） $edge86 = \u0026#34;C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe\u0026#34; $edge64 = \u0026#34;C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe\u0026#34; $chrome = \u0026#34;C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\u0026#34; $browser = if (Test-Path $edge86) { $edge86 } elseif (Test-Path $edge64) { $edge64 } elseif (Test-Path $chrome) { $chrome } else { throw \u0026#34;未找到 Edge 或 Chrome\u0026#34; } $ws = New-Object -ComObject WScript.Shell # 桌面快捷方式 $sc = $ws.CreateShortcut(\u0026#34;$env:USERPROFILE\\Desktop\\DSH Web.lnk\u0026#34;) $sc.TargetPath = $browser $sc.Arguments = \u0026#34;--app=http://127.0.0.1:3080\u0026#34; $sc.IconLocation = \u0026#34;$PSScriptRoot\\AppIcon.ico\u0026#34; $sc.WorkingDirectory = $PSScriptRoot $sc.Description = \u0026#34;DeepSeek Harness Web\u0026#34; $sc.Save() # 开始菜单（可选，便于搜索） $start = \u0026#34;$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\u0026#34; Copy-Item \u0026#34;$env:USERPROFILE\\Desktop\\DSH Web.lnk\u0026#34; \u0026#34;$start\\DSH Web.lnk\u0026#34; -Force Write-Host \u0026#34;OK: 已创建快捷方式（浏览器: $browser）\u0026#34; 运行（如遇执行策略限制）： 1 powershell -ExecutionPolicy Bypass -File make_shortcut.ps1 双击桌面的 DSH Web 即可，可右键快捷方式 →「固定到任务栏」。 方案 B（进阶）：WebView2 原生窗口 需要 .NET SDK（Win11 自带 WebView2 运行时）：\n1 2 3 4 dotnet new winforms -n DshWeb cd DshWeb dotnet add package Microsoft.Web.WebView2 copy ..\\AppIcon.ico . 把 Program.cs 改成：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 using Microsoft.Web.WebView2.WinForms; using System.Windows.Forms; var form = new Form { Text = \u0026#34;DSH Web\u0026#34;, Width = 1280, Height = 840, StartPosition = FormStartPosition.CenterScreen, Icon = new System.Drawing.Icon(\u0026#34;AppIcon.ico\u0026#34;) }; var web = new WebView2 { Dock = DockStyle.Fill }; form.Controls.Add(web); form.Shown += async (s, e) =\u0026gt; { await web.EnsureCoreWebView2Async(); web.CoreWebView2.Navigate(\u0026#34;http://127.0.0.1:3080\u0026#34;); }; Application.Run(form); 发布为单文件：\n1 2 dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true # 产物：bin\\Release\\net8.0-windows\\win-x64\\publish\\DshWeb.exe 五、验证清单 检查项 方法 期望 dsh 服务在线 访问 http://127.0.0.1:3080/ HTTP 200 图标正常 看 Dock / 桌面快捷方式 深蓝渐变底 + 白色鲸鱼 应用启动 双击图标 独立窗口打开 DSH Web 服务未启动 先开应用再启服务 提示页自动重试并成功加载 六、自定义 换端口/地址：macOS 改 main.swift 里的 homeURL；Windows 改快捷方式 --app= 参数，改完分别重跑 build.sh / make_shortcut.ps1 图标鲸鱼大小：make_icon.py 里 build_official_whale(S, span=0.62) 的 span（0~1） 背景配色：make_icon.py 里 c1, c2, c3 三个 RGB 窗口默认尺寸：main.swift 里 NSRect(... width: 1280, height: 840) 七、常见问题 macOS 提示\u0026quot;无法打开，因为无法验证开发者\u0026quot;：本机编译 + ad-hoc 签名一般不会有；如果是从别的机器拷贝过来的，先 xattr -dr com.apple.quarantine \u0026quot;DSH Web.app\u0026quot;。 窗口空白/打不开：确认 dsh web 已在运行且端口是 3080；macOS 版会自动重试，Windows 快捷方式需手动 Ctrl+R 刷新。 favicon.svg 找不到：DSH 版本目录结构可能有差异，用 find \u0026quot;$(npm root -g)/@deepseek-ai\u0026quot; -name favicon.svg 定位。 相比上一版 shell 脚本启动器，这版是真正的独立应用窗口，快捷键、菜单栏、Dock 图标一应俱全；图标也从手搓 SVG 渲染换成了直接复用官方鲸鱼——教训是：先翻翻项目自带的资源，比自己重画省事得多。\n","permalink":"https://idiotfan.wang/posts/dsh-web-cross-platform-desktop-app/","summary":"\u003cp\u003e之前\u003ca href=\"/posts/dsh-macos-launcher-app/\"\u003e写过一版 DSH 的 macOS 启动器\u003c/a\u003e：一个 shell 脚本包装成的 \u003ccode\u003e.app\u003c/code\u003e，双击后自动拉起 \u003ccode\u003edsh web\u003c/code\u003e 服务再开浏览器。能用，但打开的还是浏览器标签页，混在一堆网页里不够\u0026quot;应用\u0026quot;。\u003c/p\u003e\n\u003cp\u003e这次升级成\u003cstrong\u003e原生应用方案\u003c/strong\u003e，并且把整套方法整理成跨平台教程：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003emacOS\u003c/strong\u003e：Swift + WKWebView 编译成原生 \u003ccode\u003e.app\u003c/code\u003e，独立窗口、Dock 图标、断线自动重连\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eWindows\u003c/strong\u003e：Edge/Chrome \u003ccode\u003e--app\u003c/code\u003e 模式快捷方式（零依赖），或 WebView2 原生窗口（进阶）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e图标\u003c/strong\u003e：不再手绘，直接从 DSH 源码里提取\u003cstrong\u003e官方鲸鱼 SVG\u003c/strong\u003e 渲染，配深蓝渐变圆角背景\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e最终效果（图标）：\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"DSH Web 应用图标\" loading=\"lazy\" src=\"/images/dsh-web-app/icon.png\"\u003e\u003c/p\u003e\n\u003cp\u003e本文自包含：所有源码完整附上，照抄即可复现；也可以直接把本文链接发给任何 AI agent，让它照着做。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"一原理\"\u003e一、原理\u003c/h2\u003e\n\u003cp\u003eDSH 的 Web 界面跑在 \u003ccode\u003ehttp://127.0.0.1:3080\u003c/code\u003e。所谓\u0026quot;桌面应用\u0026quot;，本质就是\u003cstrong\u003e一个固定加载这个地址的独立窗口\u003c/strong\u003e：\u003c/p\u003e","title":"DSH Web 跨平台桌面启动器全教程：macOS 原生 .app + Windows 一键窗口"},{"content":"日常用 DeepSeek Harness（DSH）的 Web 界面和 AI 结对干活，每次都要先开终端敲 dsh web、再开浏览器输地址，步骤琐碎。于是让 AI 直接给我做了一个 macOS 应用：双击图标就打开 DSH 页面；如果 dsh web 没在跑，它会自动在后台把服务拉起来，就绪后再弹开浏览器。\n整个过程一次会话搞定，中间翻了两次挺有意思的车——一次在图标，一次在认证——记录如下。\n一、应用本体：手写 .app 结构 macOS 的应用本质上就是一个遵守约定目录结构的文件夹，~/Applications/DSH.app：\nDSH.app/ └── Contents/ ├── Info.plist # 应用元信息（指定可执行文件、图标、Bundle ID） ├── MacOS/ │ └── dsh-launcher # 启动脚本（chmod +x） └── Resources/ └── icon.icns # 图标 不需要 Xcode，不需要 Automator，三个文件就是全部。\n二、启动脚本：四个关键设计 核心逻辑：探测 3080 端口，通了就开网址；不通就拉起服务、等就绪、再开。但有四个容易踩的坑：\n1. GUI 应用的 PATH 是残的 从 Finder/Dock 启动的应用不会继承 shell 环境，PATH 只有 /usr/bin:/bin:...。我的 node 在 nvm 里（~/.nvm/versions/node/v24.15.0/bin），脚本里必须显式补 PATH，否则双击永远找不到 dsh 命令——终端里测试一切正常，双击就失败，非常经典的迷惑现场。\n2. 后台拉起用 nohup，加 --no-open --port 1 nohup \u0026#34;$DSH\u0026#34; web --no-open --port \u0026#34;$PORT\u0026#34; \u0026gt;\u0026gt;\u0026#34;$LOG\u0026#34; 2\u0026gt;\u0026amp;1 \u0026amp; nohup + 重定向让服务进程脱离启动器的生命周期，脚本退出后服务照常运行。dsh web 默认会自己弹浏览器，但就绪时序和要打开的 URL 都由启动器统一控制更可控（原因见第 3 点），所以加 --no-open 收归一处；--port 显式传，别让「检测的端口」和「启动的端口」各说各话——这个不一致我在测试时真踩了一次：环境变量改了检测端口，启动的实例却奔着默认端口去抢，EADDRINUSE 秒退。\n3. 认证机制：必须打开它「打印出来」的 URL（本篇最大翻车） 第一版上线后博主实测就翻车了：双击后页面报 \u0026ldquo;dsh web authentication required; reopen the URL printed by dsh web\u0026rdquo;。\n读源码才搞明白，dsh web 有一套 browser-trust 认证机制：\n每次 dsh web 启动会生成一个进程内一次性 launch token（randomBytes，只在内存里）； 它向 stdout 打印 dsh web: http://127.0.0.1:3080/?token=…，访问这个带 token 的 URL 会 303 重定向并种一个 30 天的签名 cookie（签名密钥持久化，跨重启有效），然后跳回干净的 /； 裸地址只有在浏览器已持有有效 cookie 时才能用。我 open 的是裸地址，而默认浏览器里恰恰没有有效 cookie → 401。 用 curl 三连验证了整条链路：\n1 2 3 裸地址 → 401 /?token=… → 303 + Set-Cookie 带 cookie 再访问 → 200 所以启动器的正确姿势是：等日志里出现那行 dsh web: \u0026lt;url\u0026gt;，打开它。这行还是比 nc 探端口更准的就绪信号——它打印出来就意味着 HTTP 服务已经可用。解析时有两个细节：\n只认「本次启动之后」打印的、且端口匹配的行。日志是追加式的，上一任实例留下的旧 token 对新进程无效，直接全量 tail -1 会在冷启动竞速里抢到过期 token； 区分「谁拉起的服务」：pidfile 里的 PID 必须和当前端口的监听者一致，才走「取日志 token」路径；如果是用户在终端手动启动的实例，从外面拿不到它的进程 token，只能开裸地址降级，并弹通知告知「若提示认证失败请重启服务」。 4. 超时兜底 实测冷启动约 1 秒就绪；90 秒超时后弹出系统对话框，可一键打开日志（~/Library/Logs/dsh-web.log）。启动期间用 osascript 发系统通知告知进度，体验比干等着好。\n三、图标翻车记 第一版：qlmanage 渲染 SVG，埋了两颗雷 DSH 的 Web 界面有个鲸鱼 favicon.svg，就地取材。macOS 没有自带 SVG 转 PNG 的正经工具，第一反应是用 qlmanage -t（Quick Look 缩略图）渲染。当时看着\u0026quot;出图了\u0026quot;就直接打包成 icns 用了，结果双击后 Dock 里还是默认图标。\n排查分两层：\n雷一：LaunchServices 图标缓存。 图标文件是在向系统注册（lsregister）之前就位的，系统一直用缓存里的默认图标。标准修法：\n1 2 3 touch ~/Applications/DSH.app # 更新时间戳，触发重读 lsregister -f ~/Applications/DSH.app # 强制重新注册 killall Dock \u0026amp;\u0026amp; killall Finder # 重启显示层 雷二（更隐蔽）：图标图稿本身就是坏的。 缓存问题解决后，我用一个客观方法验证图标内容——写个 Swift 脚本调 NSWorkspace.icon(forFile:) 把系统实际显示的图标导出成 PNG，再用 PIL 做像素统计。结果傻眼了：按颜色包围盒分析，蓝色背景只占了画面下半截（y=394~1022），上半截全被白色填充——qlmanage 对这个 SVG 的渲染完全是错的，只是 1024 缩略图肉眼没细看就信了。\n第二版：无头 Chrome + PIL 重造 qlmanage 靠不住，换真正的浏览器引擎。机器上正好有 Chrome：\n1 2 3 4 \u0026#34;Google Chrome\u0026#34; --headless --disable-gpu \\ --screenshot=render.png --window-size=1024,1024 \\ --default-background-color=00000000 \\ file:///tmp/render.html # HTML 里内联 SVG，CSS 拉满 1024×1024 顺手做了两个设计调整：\n鲸鱼缩放居中：favicon 的鲸鱼是满铺设计的，直接当 app 图标太挤。包一层 \u0026lt;g transform=\u0026quot;translate(10,10) scale(0.6)\u0026quot;\u0026gt; 缩到 60% 居中，留出蓝色呼吸边距。 圆角透明蒙版：Chrome 渲染的是方图，用 PIL 画一个 22.5% 半径的 rounded_rectangle 作为 alpha 通道，四角变透明，贴合 macOS 图标风格。最终：鲸鱼白色像素占 12.7%、蓝底 82.9%，比例舒服。 然后走标准流程：sips 缩出 16~1024 全套尺寸 → iconutil -c icns 打包 → 替换进 .app → touch + lsregister + killall 三连。\n验证方法论：对照组思维 最后怎么确认\u0026quot;系统里看到的图标真的对了\u0026quot;？光看自己的渲染不算数。用同一个 Swift 提取脚本，把 Safari 的图标也导出来做对照：\n指标 DSH Safari（对照） 不透明像素占比 60.7% 63.3% 内容包围盒 (69,78,955,963) (25,78,955,1001) 两者结构几乎一致——NSWorkspace 返回的图标自带系统级边距，我们的图标渲染特征和一个正经苹果应用完全对齐。中心取样是鲸鱼白、边缘取样是 DeepSeek 蓝、四角透明，收工。\n五、成果与备忘 一键启动：拖进 Dock 或 ⌘空格搜 \u0026ldquo;DSH\u0026rdquo;，点一下进页面；服务没在跑时自动拉起，全程约 1 秒。 日志：~/Library/Logs/dsh-web.log，PID 在 dsh-web.pid，想停服务 kill $(cat ...) 即可。 认证的边界：应用自己拉起的实例，token 就在日志里，随时能给出认证 URL；但终端里手动启动的实例，token 只在它的 stdout 上，外部拿不到——这种情况下双击应用走裸地址降级，浏览器有有效 cookie（30 天内登录过）就无事，没有就请重启服务。 一个已知维护点：脚本里写死了 nvm 的 node 版本路径（v24.15.0），以后 node 升级大版本要改 NODE_BIN 一行。这也算是 nvm 类版本管理器和\u0026quot;脱离 shell 运行的 GUI 应用\u0026quot;之间固有的摩擦了。 小结 这篇里真正值得带走的不是某个命令，而是三个调试直觉：\n\u0026ldquo;出图了\u0026rdquo;≠\u0026ldquo;图对了\u0026rdquo;。qlmanage 的缩略图管道对 SVG 的支持是有残缺的，肉眼扫一眼缩略图很难发现布局错位；用包围盒/像素统计做客观验证，一次就现形。 验证拿对照组。怀疑\u0026quot;系统显示的图标不对劲\u0026quot;时，把 Safari 这种原生应用用同一条管道过一遍——如果它也\u0026quot;不对劲\u0026quot;，那不对劲的就是你的测量管道，而不是被测对象。 报错文案里藏着答案。\u0026ldquo;reopen the URL printed by dsh web\u0026rdquo; 不是一句客套的报错，它就是解法本身：认证模型是\u0026quot;一次性 launch token 换 30 天签名 cookie\u0026quot;，而 token 只随进程打印在 stdout 上。第一版我把它当成了可有可无的启动横幅，直接被现实教育了——遇到带具体指令的报错，先照做，再深究。 本文由 Kimi K3（模型 ID：kimi-k3，月之暗面出品）在 DeepSeek Harness 会话中全程实操（应用创建、图标调试与重造、认证翻车修复与验证）并整理撰写，我负责提出需求、实测反馈、确认效果与审核。\n","permalink":"https://idiotfan.wang/posts/dsh-macos-launcher-app/","summary":"\u003cp\u003e日常用 \u003ca href=\"https://github.com/deepseek-ai/deepseek-harness\"\u003eDeepSeek Harness\u003c/a\u003e（DSH）的 Web 界面和 AI 结对干活，每次都要先开终端敲 \u003ccode\u003edsh web\u003c/code\u003e、再开浏览器输地址，步骤琐碎。于是让 AI 直接给我做了一个 \u003cstrong\u003emacOS 应用\u003c/strong\u003e：双击图标就打开 DSH 页面；如果 \u003ccode\u003edsh web\u003c/code\u003e 没在跑，它会自动在后台把服务拉起来，就绪后再弹开浏览器。\u003c/p\u003e\n\u003cp\u003e整个过程一次会话搞定，中间翻了两次挺有意思的车——一次在图标，一次在认证——记录如下。\u003c/p\u003e\n\u003ch2 id=\"一应用本体手写-app-结构\"\u003e一、应用本体：手写 .app 结构\u003c/h2\u003e\n\u003cp\u003emacOS 的应用本质上就是一个遵守约定目录结构的文件夹，\u003ccode\u003e~/Applications/DSH.app\u003c/code\u003e：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eDSH.app/\n└── Contents/\n    ├── Info.plist            # 应用元信息（指定可执行文件、图标、Bundle ID）\n    ├── MacOS/\n    │   └── dsh-launcher      # 启动脚本（chmod +x）\n    └── Resources/\n        └── icon.icns         # 图标\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e不需要 Xcode，不需要 Automator，三个文件就是全部。\u003c/p\u003e","title":"给 DSH 手搓一个 macOS 一键启动器：.app 结构、图标与认证的两次翻车记"},{"content":" ⚠️ 脱敏声明：本文是折腾过程的记录与复盘。所有服务器地址、域名、密码、密钥、token 一律以占位符代替，请勿在公开笔记里保存真实凭据——这是本文最重要的教训之一。\n为什么自建 商业机场的问题：节点质量不可控、流量共享、跑路风险。于是从 2026 年 6 月开始，我用三个多月时间搭了一套自己的网络：\n两个自建出口节点：一台美国 CN2 线路的 VPS（日常主力），一台日本大阪 IIJ 线路的 VPS（兜底） 一个自建订阅服务器：跑在美国 VPS 上，一个 URL 管所有客户端 客户端矩阵：家里主路由 + 多台店里旁路由（iStoreOS + OpenClash）、两台 Mac（Clash Verge Rev + TUN）、iPhone（Shadowrocket）、一台带电池和蜂窝网络的便携路由（出门即热点） 最终效果：五台路由器全部验证通过，故障切换自动兜底，换 VPS 的 IP 时所有客户端零改动。\n架构一句话 美国 VPS ──┐ ├── 订阅服务器(同一台美国VPS) ── 各客户端拉订阅 日本 VPS ──┘ 两个关键设计决定：\n节点寻址一律用域名 + DDNS，不写裸 IP。DuckDNS 免费，VPS 上 cron 每 5 分钟同步一次。后来美国 VPS 机房迁移、日本节点换了三台机器，客户端配置一行都没改过。 分组用 fallback（故障切换），美国优先、日本兜底。不用\u0026quot;自动选最快\u0026quot;——日本实测比美国快 3 倍，但我坚持美国优先，理由只有一个：出口 IP 要稳定不飘。登录态、风控都吃频繁换 IP 的亏。 折腾时间线 阶段一：单机起家（6 月上旬） 第一台美国 VPS 上用 3x-ui 管理 VLESS + Reality，监听 TCP 443；后来又装了 Hysteria2，监听 UDP 443。TCP 和 UDP 的 443 是两条独立通道，可以共存——这台机器从此一个端口都不浪费。\n很快吃到第一次教训：IP 被墙了。在商家面板里换机房迁移，数据无损、拿到新 IP——这时候才体会到\u0026quot;节点写域名不写 IP\u0026quot;有多值钱：改一条 DDNS 记录，全部设备几分钟内自动跟上。\n阶段二：QUIC 与 YouTube 的战争（6 月中旬） 症状很诡异：YouTube 网页视频能放但页面 UI 刷不出来；安卓/iOS 原生 app 直接连不上；手机单独挂代理又一切正常。修好安卓坏 iOS，按下葫芦浮起瓢。\n根因是 QUIC（UDP 443，HTTP/3 的底座）与节点传输方式的匹配问题：\n节点类型 QUIC 该怎么办 TCP 传输节点（Reality/Trojan 等） 必须屏蔽 QUIC（drop UDP 443）。QUIC 塞进 TCP 隧道 = UDP-over-TCP，又慢又不稳 UDP 原生节点（Hysteria2/TUIC） 必须放开 QUIC。这才是它的主场 \u0026ldquo;打地鼠现象\u0026quot;就是你在 TCP 节点上反复调 QUIC 开关——那条路上无解，根治只能换 UDP 原生节点。\n另一个结论：iOS 是老大难。透明代理下无论怎么调，iOS YouTube app 都时好时坏（它对静默 DROP 不回退）。正解简单粗暴：iOS 别走透明代理，直接 Shadowrocket 连 Hy2 节点。安卓的 Cronet 回退果断，反而没事。\n阶段三：DNS 污染，从 Passwall 迁到 OpenClash（6 月中旬） iStoreOS 升级 24.10 后 Passwall 连环报错，正好顺势搬家。压死骆驼的最后一根稻草其实是 DNS 污染：Passwall 默认的 chinadns-ng 明文 DNS 模式下，www.google.com 这类重污染域名直接被 GFW 投毒解析成假 IP，怎么调代理都没用。\nmihomo 系（OpenClash / Clash Verge）的 fake-ip 机制天生免疫：本地只发假 IP，真实解析发生在节点端。迁移后国内外分流正常，google 秒开。\n顺带踩坑：OpenClash 核心\u0026quot;一直在更新\u0026rdquo;，是核心自动更新在墙内反复下载失败，关掉 auto_update 即安。\n阶段四：日本节点的血泪史（7 月初） 想加个日本节点做兜底，结果一台机器贡献了三个经典案例：\nMTU 黑洞。某些\u0026quot;优化线路\u0026quot;本身是隧道，MTU 被压小，1500 字节的大包（恰好是 TLS 握手用的那种）在路上被静默丢弃、反复重传——表现为直连 TLS 握手要 9.6 秒。修复：把 VPS 网卡 MTU 压到 1280 并做成开机服务，握手立刻降到 0.28 秒。诊断口诀：\u0026ldquo;ping 得通但 TLS 握手巨慢 ≈ 先查 MTU\u0026rdquo;。 TCP 中间盒破坏 Reality。这条线路上有个 TCP 加速盒会重切分段，xray 日志狂刷 processed invalid connection——Reality 的字节级认证扛不住。同样的配置换个机房就好。而 shadow-tls v3 因为做的是真 TLS 握手，反而活下来了。 DDNS 抢域名战争（最阴的一个）。换机后服务\u0026quot;时好时坏\u0026quot;查了半天：旧 VPS 的 crontab 里还留着 DDNS 更新脚本，每 5 分钟把域名抢回去指向它自己！客户端一半时间连到旧机器（上面跑的还是旧协议），一半时间连新机器。教训刻进骨头里：复用 DDNS 域名换节点前，先 crontab -l | grep duck 清掉旧机器的定时任务。 8 月终于翻盘：换到一条干净线路的新机器，ping 41ms / 0% 丢包，Hysteria2 直接起飞，下载实测 140Mbps，比美国主力快 3 倍。MTU 都不用压——好线路和烂线路的差距就是这么赤裸裸。\n阶段五：自建订阅服务器（6–7 月） 不想手动给每台设备发配置，就在美国 VPS 上用 python3 起了个 HTTPS 小服务，按随机 token 路径提供两份订阅：Clash YAML（给 mihomo 全家）+ base64 分享链接（Shadowrocket 对 YAML 订阅支持差，必须单独喂）。\n两个值得记的坑：\n服务卡死 bug：最初把 TLS 握手直接包在 accept 主循环里同步做，任何一个境内客户端握手卡住就堵死所有人。改成每个连接在自己的线程里做握手 + 30 秒超时，世界清净了。 非标准端口会被掐：订阅端口不是 443，从家宽/店宽直连时 TCP 都建不起来。所以别指望各路由自动更新订阅，最可靠的方式是 scp 直接推配置文件再重启服务。根治方案（挪到标准 443）还在待办上。 彩蛋功能：给订阅响应注入 Subscription-Userinfo 头，Clash Verge 卡片上就有流量用量进度条，数据来自商家只读 API，缓存 5 分钟。\n阶段六：远程管理与自愈（7 月） 路由器散落在不同的内网，现场维护不现实。用 FRP 打通：一台境内云主机当中转，每台路由跑 frpc 反向连接，SSH 随时可达。铁律：境内实名中转机只做管理隧道，绝不跑代理流量——合规红线。\n再加一层保险：每台路由装 watchdog，每 5 分钟经节点探测 generate_204，连续两次不通就自动重启代理服务刷新 DNS 解析，15 分钟冷却防抖。VPS 再换 IP，路由器也能自己爬起来。\n沉淀下来的设计原则 一切寻址用域名，IP 只是临时状态 VPS 无状态化：一份重建脚本，新机器 root 下跑一遍 = 2 分钟还原全套服务（含证书续期、开机自启） 单一真源：订阅服务器是唯一配置源头，本地只留副本 fallback 保稳定，不为速度牺牲出口一致性 TCP 节点屏蔽 QUIC，UDP 节点放开 QUIC，先看协议再动手 换机先清旧机器的 cron（DDNS 血泪） 远程运维走独立通道，与业务流量物理隔离 安全清单（公开笔记前自查） 这次整理博客顺便做了遍审计：\n所有密码/密钥/token 是否已从文档移除？（包括\u0026quot;看起来无害\u0026quot;的面板地址） 曾在 AI 对话中出现过的凭据，当作已泄露处理，全部轮换 管理面板端口用防火墙 DROP 公网访问，仅留 SSH 隧道入口 SSH 改密钥登录、禁密码（待办清单上的常客，别学我拖了两个月） 家宽/店铺拓扑、内部网段不在公开内容里出现 后记 整个过程我是和 AI 结对完成的：让它读交接文档、远程上机器排查、把每次排障结论固化成可复制的 SOP。回头看，最值钱的不是哪条命令，而是\u0026quot;域名寻址、无状态重建、单一真源\u0026quot;这几个原则——它们让后面每一次故障都从\u0026quot;事故\u0026quot;降级成了\u0026quot;练习\u0026quot;。\n本文由 ox-alpha（模型 ID：openrouter/stealth/ox-alpha，undisclosed organization 出品）基于作者的项目交接文档协助整理撰写；文中所有敏感信息均已脱敏。\n","permalink":"https://idiotfan.wang/posts/self-built-vpn-journey/","summary":"\u003cblockquote\u003e\n\u003cp\u003e⚠️ \u003cstrong\u003e脱敏声明\u003c/strong\u003e：本文是折腾过程的记录与复盘。所有服务器地址、域名、密码、密钥、token 一律以占位符代替，请勿在公开笔记里保存真实凭据——这是本文最重要的教训之一。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"为什么自建\"\u003e为什么自建\u003c/h2\u003e\n\u003cp\u003e商业机场的问题：节点质量不可控、流量共享、跑路风险。于是从 2026 年 6 月开始，我用三个多月时间搭了一套自己的网络：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e两个自建出口节点\u003c/strong\u003e：一台美国 CN2 线路的 VPS（日常主力），一台日本大阪 IIJ 线路的 VPS（兜底）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e一个自建订阅服务器\u003c/strong\u003e：跑在美国 VPS 上，一个 URL 管所有客户端\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e客户端矩阵\u003c/strong\u003e：家里主路由 + 多台店里旁路由（iStoreOS + OpenClash）、两台 Mac（Clash Verge Rev + TUN）、iPhone（Shadowrocket）、一台带电池和蜂窝网络的便携路由（出门即热点）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e最终效果：五台路由器全部验证通过，故障切换自动兜底，换 VPS 的 IP 时\u003cstrong\u003e所有客户端零改动\u003c/strong\u003e。\u003c/p\u003e\n\u003ch2 id=\"架构一句话\"\u003e架构一句话\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e美国 VPS ──┐\n           ├── 订阅服务器(同一台美国VPS) ── 各客户端拉订阅\n日本 VPS ──┘\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e两个关键设计决定：\u003c/p\u003e","title":"自建 VPN 折腾记：从一台 VPS 到全屋全店的透明代理网络"},{"content":"在十几个人的小微精酿餐吧做用工合规，是一件极具挑战的事情。\n传统律所或大厂人事法务给出的方案，往往一上来就是一份主合同外加 10 个编号配套附件：\n配套 1：员工手册与规章制度 配套 2：岗位职责说明书 配套 3：绩效考核与试用期规定 配套 4：考勤与加班审批制度 配套 5：知识产权与账号归属条款 配套 6：专项保密协议 配套 7：非全日制（兼职）协议 配套 8：竞业限制协议 配套 9：专项培训服务期协议 配套 10：离职交接与保密交还确认书 现实情况是：前厅服务员、调酒师和厨师根本不会耐着性子签完这十几份密密麻麻的文件，店长也根本没有精力去逐一归档管理。过于繁重的法务形式，最后的结果必然是全员流于形式、甚至干脆不签，导致法律风险反而完全敞口。\n最近我们利用 Claude Fable 5 针对大梦的用工制度做了一次全面的合规梳理，核心目标是**「保底线、去冗余」，最终将整套体系精简为员工只需签 1~2 份文件的「最小必要合规包」**。\n这篇文章记录这次梳理的核心逻辑与避坑要点。\n一、 警惕「提示词污染」：死守真实的业务边界 在利用 AI 协助起草和审查法务合规文件时，我们踩过一个非常典型的坑：通用模板的提示词污染。\n在最初引入的一份标准用工合规指南中，某些通用示例提及了「私教课消」、「健身教练」等字眼。LLM 在随后的跨文件修订中，将这些术语自动扩散到了 8 份配套合同中，甚至把前厅主管的考核写成了「课消完成率」。\n核心经验：\n在让 AI 处理法律和人事文件前，必须在系统规则中建立铁一般的业务边界：本店属于精酿啤酒 + 餐饮 + 酒吧业态，岗位仅限侍酒师、调酒师、出品厨师、店长及前厅运营。 任何脱离真实业务场景的条款，不仅在仲裁时毫无说服力，还会让一线员工对制度产生荒谬感。 二、 实体餐饮的 4 大合规底线与设计策略 针对小微餐饮的高频劳动争议点，我们在合同中重点固化了 4 个合法合规的落地方案：\n1. 社保放弃与风险对冲 法律现实：根据最新劳动争议司法解释，员工自愿出具的「自愿放弃社保承诺书」在法律上均属无效，企业无法免除法定缴纳义务。 降损设计：对于因个人原因确实无法在本地参保的员工，废弃原先直接违法的放弃承诺书，改设《社保代偿补贴暨不当得利返还协议》： 将公司发放的社保补贴在工资条上独立单列，严禁与基本工资混同。 明确约定该款项专款专用于员工社保代偿；若日后发生补缴诉求，已领取的代偿款应作为不当得利予以返还或在补缴款中依法抵扣。 2. 工作时间与 9 小时排班合规 餐饮排班现状：餐饮晚班常见排班为 15:00~24:00，跨度共 9 小时。 工时口径明确：合同明确约定每日包含 1 小时脱离工作岗位的自由就餐与休息时间（不计入有效工时），实际日工时为标准 8 小时。 排班红线：若每周出勤满 6 天（48 小时），超出 40 小时部分必须依法支付加班费或安排等额调休；或者向人社部门正式申报「综合计算工时制」。 3. 非全日制用工（兼职）的合规红线 对于高峰期的兼职打酒师与周末钟点工，必须严格遵循非全日制法律特征：\n工时硬约束：同一用人单位每日不得超过 4 小时，每周累计不得超过 24 小时。 法定最低时薪：严格执行当地最新非全日制小时最低工资标准。 工伤先行投保：在兼职员工上岗第一天前，必须在人社系统完成单工伤险参保，杜绝工伤事故导致的赔偿风险。 随时解聘：双方均可随时通知终止用工，企业依法无需支付经济补偿金。 三、 重构落地：「最小必要合规包」 为了让合规能够 100% 执行到位，我们通过多智能体审议，将原本散落的 10 个附件进行了物理级合并：\n【历史冗余架构：10+ 份文件】 │ ┌───────────────┴───────────────┐ ▼ ▼ [保密协议] 并入 主合同 [考勤加班制度] 并入 员工手册 │ ▼ 【精简后：最小必要合规包】 ┌─────────────────────────────────────────────────────────────┐ │ 1. 《全日制劳动合同》（内嵌保密、知识产权与送达条款） │ │ 2. 《员工手册与规章制度》（内嵌严重违纪清单、考勤排班与公示） │ │ 3. 《非全日制用工协议》（针对小时工与兼职） │ │ 4. 《社保代偿补贴与返还确认书》（特定未参保人员专用） │ └─────────────────────────────────────────────────────────────┘ 实际签署流： 全职员工入职：只需签署 2 份文件（劳动合同 + 员工手册送达与签收表）。 兼职人员入职：只需签署 1 份文件（非全日制协议）。 特定情况：无法参保人员加签 1 份代偿确认书。 结语 在商业世界里，真正高水平的合规，不是把文件写得无限冗长、把责任推卸得干干净净，而是找到法律底线与商业执行力之间的最大公约数。\n把复杂的法务条款提炼为一线员工能看懂、店长能执行、仲裁能站得住脚的「最小必要包」，才是小微实体企业走向稳健经营最务实的第一步。\n🛠️ 项目环境与模型署名 法务条款审议与精简模型：Anthropic Claude Fable 5（桌面端默认模型） 审议工作流平台：Claude Desktop 桌面端（多智能体审议合并模式） 交付成果：大梦用工合规《最小必要-规避风险》4 份核心文件包 + 劳动合同修订前后对照表 ","permalink":"https://idiotfan.wang/posts/damon-labor-contract-compliance/","summary":"\u003cp\u003e在十几个人的小微精酿餐吧做用工合规，是一件极具挑战的事情。\u003c/p\u003e\n\u003cp\u003e传统律所或大厂人事法务给出的方案，往往一上来就是一份主合同外加 10 个编号配套附件：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e配套 1：员工手册与规章制度\u003c/li\u003e\n\u003cli\u003e配套 2：岗位职责说明书\u003c/li\u003e\n\u003cli\u003e配套 3：绩效考核与试用期规定\u003c/li\u003e\n\u003cli\u003e配套 4：考勤与加班审批制度\u003c/li\u003e\n\u003cli\u003e配套 5：知识产权与账号归属条款\u003c/li\u003e\n\u003cli\u003e配套 6：专项保密协议\u003c/li\u003e\n\u003cli\u003e配套 7：非全日制（兼职）协议\u003c/li\u003e\n\u003cli\u003e配套 8：竞业限制协议\u003c/li\u003e\n\u003cli\u003e配套 9：专项培训服务期协议\u003c/li\u003e\n\u003cli\u003e配套 10：离职交接与保密交还确认书\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e现实情况是：\u003cstrong\u003e前厅服务员、调酒师和厨师根本不会耐着性子签完这十几份密密麻麻的文件，店长也根本没有精力去逐一归档管理\u003c/strong\u003e。过于繁重的法务形式，最后的结果必然是全员流于形式、甚至干脆不签，导致法律风险反而完全敞口。\u003c/p\u003e\n\u003cp\u003e最近我们利用 \u003cstrong\u003eClaude Fable 5\u003c/strong\u003e 针对大梦的用工制度做了一次全面的合规梳理，核心目标是**「保底线、去冗余」\u003cstrong\u003e，最终将整套体系精简为员工只需签 1~2 份文件的\u003c/strong\u003e「最小必要合规包」**。\u003c/p\u003e\n\u003cp\u003e这篇文章记录这次梳理的核心逻辑与避坑要点。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"一-警惕提示词污染死守真实的业务边界\"\u003e一、 警惕「提示词污染」：死守真实的业务边界\u003c/h2\u003e\n\u003cp\u003e在利用 AI 协助起草和审查法务合规文件时，我们踩过一个非常典型的坑：\u003cstrong\u003e通用模板的提示词污染\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e在最初引入的一份标准用工合规指南中，某些通用示例提及了「私教课消」、「健身教练」等字眼。LLM 在随后的跨文件修订中，将这些术语自动扩散到了 8 份配套合同中，甚至把前厅主管的考核写成了「课消完成率」。\u003c/p\u003e","title":"小微餐饮用工合规改造：从 10 份冗余合同到「最小必要包」"},{"content":"在给可能实验室（大梦餐饮）制作午市特惠、台风天畅饮、球赛之夜等一系列商业促销海报时，我做了一个决定：不打开 Photoshop，全部用纯 HTML + CSS + Python 脚本来做。\n用 Web 技术做海报有极大的优势：布局可以用 Flexbox/Grid 极速调整、文案改动只需改一行字符串、多尺寸适配通过 CSS 变量瞬间完成，而且全套物料可以纳入 Git 版本管理。\n但在真正借助 Claude Fable 5 与 Grok Imagine 图像模型 将网页无损导出为 300DPI 商业级印刷海报的过程中，我们踩遍了前端渲染、字体子集化、图像抠图与色彩空间的几乎所有暗坑。\n这篇文章把这些工程细节与解决方案彻底拆解，给同样想用代码做设计的朋友一份避坑指南。\n一、 导出引擎的演进：从 html2canvas 到 Chrome Headless 网页在浏览器里看着很美，但要导出一张像素完美（Pixel Perfect）的 1600×2400 高清大图，首先要选对渲染引擎。\n1. html2canvas 的「翻车」表现 最初我们尝试了老牌的 html2canvas，结果导出的图片与原页面差异巨大：\nfilter: drop-shadow(...) 阴影几乎全丢。 径向渐变（Radial Gradient）在 Canvas 绘制时变成了生硬的同心圆硬聚光。 精细的金边暗纹和半透明毛玻璃背景变得惨白模糊。 2. html-to-image（SVG foreignObject 路线） 随后我们切换到了 html-to-image 库。它利用浏览器的 \u0026lt;foreignObject\u0026gt; 将 DOM 结构直接转化为 SVG，再绘制成 PNG，真正做到了「浏览器里看什么，导出来就是什么」。\n3. 最强终局方案：Chrome Headless 命令行直出 在批量生成物料时，最稳健的做法甚至不需要在页面里挂载任何导出 JS，直接用本地 Google Chrome 的无头模式截图：\n1 2 3 4 5 6 7 \u0026#34;/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\u0026#34; \\ --headless=new \\ --screenshot=\u0026#34;海报_1600x2400.png\u0026#34; \\ --window-size=800,1200 \\ --force-device-scale-factor=2 \\ --hide-scrollbars \\ \u0026#34;file://$PWD/午餐套餐海报.html\u0026#34; 通过 --force-device-scale-factor=2，浏览器会以 Retina 双倍像素密度渲染，零网络依赖，瞬间得到 1600×2400 的高保真图片。\n二、 字体子集化与「豆腐块□」幽灵 Bug 为了保证海报在任何离线环境下打开都拥有精美的衬线字体（如 Noto Serif SC 与 Cormorant Garamond），我们将字体通过 Google Fonts text= API 抓取字形子集，转为 Base64 内嵌进 HTML。\n但这里埋下了两个极其隐蔽的排版陷阱：\n1. 改动文案后的「豆腐块」危机 当我们将菜名从原先的通用名改为「泰式打抛猪肉饭」、「夏威夷菠萝牛肉饭」后，网页在浏览器里渲染正常（触发了本地系统字体兜底），但导出的 PNG 却在「泰、式、夏、菠」等新字上全部显示为方块豆腐□！\n根因：内嵌的 Base64 字体子集只包含老版文案的文字。 解法：编写自动化脚本，每次修改文案后，自动提取 HTML 中全部可见汉字、英文字母与标点符号，利用 fonttools 和 brotli 重新生成最小化的 woff2 子集并热替换到 CSS 中。\n2. macOS 宋体 900 缺失「¥」符号的渲染 Bug 在制作价格标签时，标题采用 Songti SC（宋体）搭配 font-weight: 900。我们发现页面上的价格符号 ¥ 莫名其妙地消失了，变成了一片空白。\n排查发现：macOS 自带的宋体在字重为 900（Heavy）时，其字符映射表中标准半角人民币符号 U+00A5 (¥) 存在字形空白缺失的缺陷。 解决方案：文案中强制使用全角人民币符号 U+FFE5 (￥)，或在 CSS 中设置字体回退链优先使用 PingFang SC 渲染金额符号。\n三、 OpenCV GrabCut 菜盘抠图与等面积对齐 在午市套餐海报中，需要展示三款主食（打抛饭、番茄肉酱饭、菠萝牛肉饭）的高清盘装实拍图。\n直接给实拍照片抠图并排版，会遇到一系列算法与视觉问题：\n[实拍带背景菜品] ──► [OpenCV GrabCut 保留盘沿] ──► [几何均值面积等比缩放] ──► [统一暖白平衡调色] ──► [内嵌 WebP] 1. 为什么 AI 抠图模型（rembg / BiRefNet）会失效？ rembg（基于 u2net/isnet）：会将浅色/米白色的陶瓷盘子误判为背景，抠完只剩下一堆悬空的肉末和米饭，完全失去了餐饮出品的质感。 BiRefNet：边缘识别精细，但在 CPU 上处理单张图耗时超过 12 分钟，无法进行快速迭代。 2. 解决方案：OpenCV GrabCut 智能保留整盘 我们改用 OpenCV 的 GrabCut 算法（矩形初始化，边框 Inset 3%）：\n对于盘子与阴影同色的疑难图片（如灰盘配灰色桌面），先对盘芯做腐蚀运算（Erosion），拟合出精确的椭圆轮廓（cv2.fitEllipse），再等比放大回盘沿，完美裁掉外部多余投影，完整保留陶瓷盘子的边缘光泽。 3. 三张主食图的「等面积对齐」 三张菜品如果简单地以正方形长轴缩放，扁平的椭圆盘子在视觉上会显得极其单薄瘦小。\n我们计算了三张盘子的几何均值面积（$\\text{Geomean} = \\sqrt{W \\times H}$），按等面积缩放后贴入统一的 860×647 画布底部居中。这样在 CSS 中只需固定 max-width: 206px，三款主食在视觉体量上就达到了完美的平衡。\n4. 颜色与方向标准化 白平衡校准：提取每张照片盘子的最高白点，通过色彩矩阵统一映射到暖白基准值 (236, 232, 223)，并统一增加 7% 对比度与自然饱和度。 PIL EXIF 方向坑：手机拍摄的原图常带有 Orientation: 6（顺时针 90° 旋转），PIL 的 Image.open 默认不会自动应用 EXIF 旋转矩阵。处理前必须显式调用 ImageOps.exif_transpose(im)，否则抠出来的菜品全部是倒挂的。 结语 从一行 HTML 标签，到一张可以直接送到印刷厂或挂在商场 LED 巨幕上的高清海报，中间横跨了排版引擎、字体编码、色彩校准与图像算法的诸多细节。\n代码赋予设计的，不仅是像素级别的精准控制力，更是一种可复现、可自动化、可规模化交付的工程确定性。\n🛠️ 项目环境与模型署名 主导架构与排版代码生成：Anthropic Claude Fable 5（桌面端默认模型） 商业海报主题背景生成：xAI Grok Imagine（grok.com/imagine 网页版 + grok-imagine-image-2.0 API） 客户端环境：Claude Desktop 桌面端 图像与排版工具链：OpenCV 4 (GrabCut, fitEllipse) + Python Pillow (ImageOps.exif_transpose) + Google Fonts API (fonttools, brotli) + html-to-image + Chrome Headless 截图管线 ","permalink":"https://idiotfan.wang/posts/maybelab-poster-web-export-pipeline/","summary":"\u003cp\u003e在给可能实验室（大梦餐饮）制作午市特惠、台风天畅饮、球赛之夜等一系列商业促销海报时，我做了一个决定：\u003cstrong\u003e不打开 Photoshop，全部用纯 HTML + CSS + Python 脚本来做\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e用 Web 技术做海报有极大的优势：布局可以用 Flexbox/Grid 极速调整、文案改动只需改一行字符串、多尺寸适配通过 CSS 变量瞬间完成，而且全套物料可以纳入 Git 版本管理。\u003c/p\u003e\n\u003cp\u003e但在真正借助 \u003cstrong\u003eClaude Fable 5\u003c/strong\u003e 与 \u003cstrong\u003eGrok Imagine 图像模型\u003c/strong\u003e 将网页无损导出为 300DPI 商业级印刷海报的过程中，我们踩遍了前端渲染、字体子集化、图像抠图与色彩空间的几乎所有暗坑。\u003c/p\u003e\n\u003cp\u003e这篇文章把这些工程细节与解决方案彻底拆解，给同样想用代码做设计的朋友一份避坑指南。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"一-导出引擎的演进从-html2canvas-到-chrome-headless\"\u003e一、 导出引擎的演进：从 html2canvas 到 Chrome Headless\u003c/h2\u003e\n\u003cp\u003e网页在浏览器里看着很美，但要导出一张像素完美（Pixel Perfect）的 1600×2400 高清大图，首先要选对渲染引擎。\u003c/p\u003e\n\u003ch3 id=\"1-html2canvas-的翻车表现\"\u003e1. html2canvas 的「翻车」表现\u003c/h3\u003e\n\u003cp\u003e最初我们尝试了老牌的 \u003ccode\u003ehtml2canvas\u003c/code\u003e，结果导出的图片与原页面差异巨大：\u003c/p\u003e","title":"用代码做商业餐饮海报：字体子集、GrabCut 抠图与无损导出避坑指南"},{"content":"实体餐饮经营最怕的是什么？固定成本在空转。\n在大梦滨江店的总账分析中，我们发现了一个极其明显的规律：周五六「周末夜」的日均营业额，接近周日至周四「工作日」的两倍（具体金额已脱敏）。\n然而，每月雷打不动的商场租金和晚班员工薪资，无论客人来不来都是按天硬性扣除的沉没成本。\n为了把周日至周四 17:00–20:00 的晚市流量做起来，我们策划了「工作日晚餐畅吃 · 微醺社交夜」活动。这篇文章将复盘整个项目的核心推演：如何用 Claude Fable 5 建立精确的边际成本模型重构四档定价、如何通过腾讯文档 API 实时协同落地、以及如何调用 xAI Grok Imagine 自动化生成从手机海报到 6912 像素户外 LED 巨屏的全套视觉物料。\n一、 算透边际成本：打破「全成本思维」的四档阶梯定价 很多餐饮营销方案往往死在粗暴的「成本估算」上。在最初收到的活动方案草案中，存在两个严重逻辑误区：\n盲目假定 50% 毛利率：草案认为精酿酒水均价 50 元，成本要占 25 元，于是设了一条「单客综合成本不可超过 45 元」的硬红线。 忽视了真实采购价：没有把后厨批发的真实原料价代入计算。 1. 真实原料成本反推 我们调取了总账审计审定的各部门原料率，并结合实际销售牌价做了加权核算（具体数值属经营机密，此处只讲结论）：\n门店精酿的真实加权均价，远高于草案拍脑袋假设的均价。 按原料率反推，精酿与经典鸡尾酒的真实边际成本只有草案估计的一半以下。 这意味着：正价好酒完全可以放进套餐和互动奖品池，完全无需使用廉价工业啤酒冲量。 2. 后厨真实采购单人均成本 结合后厨当月真实进货台账逐项代入核算（采购单价属供应链机密，此处不展开）：\n在 8~10 个 SKU、主食大锅化、荤菜限量补给的操作纪律下，单人畅吃的餐食边际成本可以被稳定锁定在一个可控区间——这正是低价引流档仍有毛利空间的底气。 3. 四档差异化定价体系 基于「增量收益」思维（房租和晚班人力已是沉没成本，每晚新增的活动增量支出只有小额奖品成本，多来两三位客人即可覆盖，之后每位都是净增量利润），我们重塑了四档阶梯价格（定价为对外公开物料口径；内部成本与毛利目标已脱敏）：\n档位 定价（元） 包含权益 定位策略 A 档 ¥59 单人畅吃（不含酒） 极低门槛引流下班干饭族 B 档 ¥79 单人畅吃 + 精酿 1 杯 全店主推爆款（微醺入门） C 档 ¥99 单人畅吃 + 精酿 2 杯 精酿爱好者性价比之选 D 档 ¥129 单人畅吃 + 精酿 1 杯 + 鸡尾酒 1 杯（微醺双饮） 高客单双饮组合，主推二人桌 考核口径也相应分档：低价档按「餐食综合成本红线」控制；两杯酒的高价档在数学上必然超线，改按每客绝对毛利考核。\n二、 腾讯文档 API 在线就地重构：13 章数字化落地 老板在审阅方案时提出了严格要求：「必须条理清晰、数字前置，并且不要反复新建文档破坏在线链接」。\n为此，我们通过脚本调用腾讯文档 OpenAPI 对在线策划文档进行了原地结构重组：\n1. 解决 Doc API 的硬约束 标题段落不可删：一级标题无法删除，只能通过 replace_text 原地替换标题文本。 逆序重构法：为了防止前段内容增删导致后段字符偏移漂移，重构脚本从第 13 章向第 1 章从后往前逐章执行替换。 章节重整：最终将文档梳理为《一页总览（毛利与回本测算前置）》、《四档套餐》、《成本红线》、《每晚时间表》、《复盘与续期》等 13 个标准化章节。 2. 搭建 19 字段每日复盘智能表 在腾讯文档 SmartSheet 中配置了每日复盘表，预填首期 4 周（周日至周四共 20 晚）的跟踪行：\n字段涵盖：当日四档售卖分布、总客流、增量新客数、复购券核销率、后厨备餐损耗率等。 三、 AI 视觉管线：从 Grok 生图到 6912 巨屏素材 一家实体餐吧的活动物料需要覆盖多种物理媒介。如果全部依靠平面设计师逐一制图，耗时至少数天。\n我们打通了一条以 AI 图像生成 + 代码排版 + Chrome Headless 矢量渲染 的自动化管线：\n1. 钻通 Grok CLI 凭据调用 xAI 图像 API 通过解析 ~/.grok/auth.json 中的 OIDC Access Token，编写了 grok_imagine.py 驱动：\n直连 api.x.ai/v1/images/generations，支持 aspect_ratio（2:3, 1:2, 2:1）与 2K 分辨率输出（1664×2496）。 实现 Refresh Token 自动轮换持久化，解决了 CLI 缺乏图像子命令但底层凭据通用的问题。 2. 商业餐饮视觉的 Prompt 调教经验 在生成餐饮底图时，我们总结了几条极其关键的实战经验：\n拒绝通用模版菜：Prompt 必须显式排除后厨不做的主题（如 no skewers, no kebabs, no tacos），加入真实的精酿酒头阵列与果盘组合。 去除暗黑遮罩：餐饮海报忌讳大面积黑灰色，应采用深琥珀暖棕（rgba(74,34,8) 系）搭配奶油白卡片（#FBF2DE），烘托温暖诱人的食欲感。 3. 多媒介物料全矩阵输出 ┌───► 手机海报 (1600×2400 PNG) ── 微信群发/朋友圈推广 │ [Grok 2K 底图] ────┼───► 门前水牌 (60×149cm PDF/PNG) ── 商场外街迎宾水牌 │ └───► 双向户外 LED 大屏 (6912×3456 PNG) ── 街角与下沉广场 手机海报（1600×2400）：双 Logo 36px 绝对等高对齐，纯黑阈值抠出微信收款码体，底端留白避开金属边框。 门前立地水牌（60×149cm）：根据商场标准制作 @page size 60cm 149cm 矢量 PDF，底部融入商场铺位与外街动线导视。 户外 LED 巨屏（6912×3456）：生成 2:1 超宽画幅底图（左侧 40% 留白，右侧 60% 出品），并自动生成正向与镜像双版本，完美适配连廊双向行人的视线流动。 结语 实体店的营销从来不是靠拍脑袋定个低价就能成功。\n从看清每一分原料成本的算账逻辑，到利用 AI 快速打通线上协作与全套线下视觉物料，技术在实体商业中的价值，正是帮助经营者在最短的时间内、用最低的试错成本，将一个商业想法转化为严密闭环的执行力。\n🛠️ 项目环境与模型署名 主导策划与文档重构：Anthropic Claude Fable 5（桌面端默认模型） 商业餐饮图像生成：xAI Grok Imagine（grok-imagine-image-2.0 / grok-imagine-image-quality，复用 grok CLI 的 OIDC 凭据直连 api.x.ai） 客户端与工作流支持：Claude Desktop 桌面端（定制 grok-imagine Skill + tencent-docs MCP） 协同与排版工具链：腾讯文档 在线Doc/SmartSheet API (mcporter) + Python 3 (grok_imagine.py) + Chrome Headless 矢量打印引擎 ","permalink":"https://idiotfan.wang/posts/damon-buffet-night-marketing-and-math/","summary":"\u003cp\u003e实体餐饮经营最怕的是什么？\u003cstrong\u003e固定成本在空转\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e在大梦滨江店的总账分析中，我们发现了一个极其明显的规律：\u003cstrong\u003e周五六「周末夜」的日均营业额，接近周日至周四「工作日」的两倍\u003c/strong\u003e（具体金额已脱敏）。\u003c/p\u003e\n\u003cp\u003e然而，每月雷打不动的商场租金和晚班员工薪资，无论客人来不来都是按天硬性扣除的沉没成本。\u003c/p\u003e\n\u003cp\u003e为了把周日至周四 17:00–20:00 的晚市流量做起来，我们策划了「\u003cstrong\u003e工作日晚餐畅吃 · 微醺社交夜\u003c/strong\u003e」活动。这篇文章将复盘整个项目的核心推演：\u003cstrong\u003e如何用 Claude Fable 5 建立精确的边际成本模型重构四档定价、如何通过腾讯文档 API 实时协同落地、以及如何调用 xAI Grok Imagine 自动化生成从手机海报到 6912 像素户外 LED 巨屏的全套视觉物料。\u003c/strong\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"一-算透边际成本打破全成本思维的四档阶梯定价\"\u003e一、 算透边际成本：打破「全成本思维」的四档阶梯定价\u003c/h2\u003e\n\u003cp\u003e很多餐饮营销方案往往死在粗暴的「成本估算」上。在最初收到的活动方案草案中，存在两个严重逻辑误区：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e盲目假定 50% 毛利率\u003c/strong\u003e：草案认为精酿酒水均价 50 元，成本要占 25 元，于是设了一条「单客综合成本不可超过 45 元」的硬红线。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e忽视了真实采购价\u003c/strong\u003e：没有把后厨批发的真实原料价代入计算。\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"1-真实原料成本反推\"\u003e1. 真实原料成本反推\u003c/h3\u003e\n\u003cp\u003e我们调取了总账审计审定的各部门原料率，并结合实际销售牌价做了加权核算（具体数值属经营机密，此处只讲结论）：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e门店精酿的真实加权均价，\u003cstrong\u003e远高于草案拍脑袋假设的均价\u003c/strong\u003e。\u003c/li\u003e\n\u003cli\u003e按原料率反推，\u003cstrong\u003e精酿与经典鸡尾酒的真实边际成本只有草案估计的一半以下\u003c/strong\u003e。\u003c/li\u003e\n\u003cli\u003e这意味着：正价好酒完全可以放进套餐和互动奖品池，完全无需使用廉价工业啤酒冲量。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"2-后厨真实采购单人均成本\"\u003e2. 后厨真实采购单人均成本\u003c/h3\u003e\n\u003cp\u003e结合后厨当月真实进货台账逐项代入核算（采购单价属供应链机密，此处不展开）：\u003c/p\u003e","title":"酒吧做工作日晚市畅吃：边际成本精算、AI 物料管线与腾讯文档在线协同"},{"content":"在中小餐饮连锁店，每个月最令人头疼的除了盘点，就是算工资。\n在大梦（可能实验室），两家门店涵盖了侍酒师、调酒师、咖啡师、主厨、出品厨师和兼任店长等多种角色。看似人不多，背后的算薪逻辑却极其复杂：\n考勤源散落各处：有人在群里发手写考勤表照片，有人交 .xlsx，店长交 .xls 评估表，西湖夜班员工在腾讯文档里打卡。 提成与多维营收挂钩：薪酬不仅看基本工资，还挂钩部门业绩（精酿/调酒/咖啡/厨房）、班次业绩（白班/中班/晚班），甚至需要按部门 × 班次矩阵进行交叉剥离。 新 SKU 归口漂移：每个月两家店都会上新酒水或新菜品，如果菜品库没有及时维护，POS 导出的几百条订单就会分错部门。 输出要求高：算完不仅要在腾讯文档 43 列大表里逐行填平并标注店色，还要给每位员工生成带有公章、大写金额、社保代扣明细的精美工资单。 这篇文章记录我如何通过一套组合拳（Claude Fable 5 驱动的 Python 管道 + 腾讯文档 API + 响应式 HTML 工资单 + Rails 8 HR 系统集成），将原本需要耗费一整天的结薪工作缩减为 10 分钟自动化流程。\n一、 考勤与数据清洗：手写照片与多格式兼容 结薪的第一步是收集出勤、法定假日、加班与请假数据。\n面对不同来源的考勤资料，我们建立了一条标准化的数据摄取流水线：\n手写纸质排班表：通过多模态 Vision 模型直接识别出勤天数与请假备注（休、年假、调休）。 员工个人提交的 xlsx/xls：使用 openpyxl 和 xlrd 脚本批量读取出勤字段。 腾讯文档夜班表：调用腾讯文档 API 自动拉取最新的夜班排班记录。 考勤规则的程序化 在计算出勤时，有几条必须严格执行的业务口径：\n法定节假日：按国家法定假日天数计算双倍津贴（基本工资 / 计薪基准 × 天数 × 2）。 加班是「存」还是「换钱」：员工备注若为「存」，则计入调休池，不折现发放；若为「换钱」，则按小时工资折算为加班费。 月计薪基准的口径区分：国家劳动法标准的月计薪天数是 21.75 天，而门店按「月休 4 天」的排班体系内部约定了自己的计薪基准。两者绝不可混用——这是薪酬计算里最容易犯的低级错误之一。 二、 破解营收交叉难题：伪菜品库与部门×班次矩阵 很多餐饮店算提成算不准，根源在于POS 订单数据无法自动归口到部门和班次。\n1. 「伪菜品库」反向生成法（100% 覆盖新 SKU） 早先我们维护了一份静态的「菜品库.xlsx」，但每月一到结薪，发现上月新上的十几款精酿或特调都在库外，脚本只能靠关键词模糊匹配，导致大量调酒被误归入精酿。\n为了彻底根治这个问题，我们从 6 月起设计了**「伪菜品库生成器」**（make_menu_lib.py）：\n直接读取美团/收钱吧 POS 导出的当月「菜品销售明细」（包含 POS 真实的「菜品大类」和「菜品小类」）。 脚本根据 POS 大类自动映射四部门，反向为两家门店各生成一份当月专用的全覆盖菜品库（数百个在售 SKU 全部自动归口）。 实测除扑克牌、雨伞等 2 笔杂物外，全部 SKU 100% 自动归口，彻底告别了人工维护。 2. 部门 × 班次交叉聚合算法 有了准确的菜品归属后，流水线运行 build_analysis.py 进行多维交叉切片：\n部门业绩：从全渠道订单中提取含团购套餐的实际到账金额。 班次业绩：按结账时间（17:00 前为白班，17:00 后为晚班，特定时段为中班）切分。 特殊规则硬编码：例如滨江后厨团队（主厨与出品厨师）只考核整体后厨部门业绩，班次业绩置 0；前厅运营无特定部门归属，部门业绩置 0。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # build_analysis 核心交叉切片逻辑示意 def compute_cross_matrix(orders_df, menu_lib): # 结合伪菜品库匹配部门 merged = orders_df.merge(menu_lib, on=\u0026#39;sku_id\u0026#39;, how=\u0026#39;left\u0026#39;) # 结合结账时间戳划分班次 merged[\u0026#39;shift\u0026#39;] = merged[\u0026#39;pay_time\u0026#39;].apply(classify_shift) # 交叉透视表：店 x 部门 x 班次 cross_pivot = merged.pivot_table( index=[\u0026#39;store\u0026#39;, \u0026#39;department\u0026#39;], columns=\u0026#39;shift\u0026#39;, values=\u0026#39;actual_amount\u0026#39;, aggfunc=\u0026#39;sum\u0026#39;, fill_value=0 ) return calibrate_totals(cross_pivot) 最终脚本自动输出包含 13 个分析页签的月度 Excel，并将部门起伏 MoM 根因下钻到具体 SKU（例如：精酿本月下滑主要由于哪几款酒头缺货）。\n三、 腾讯文档 V2 自动回写与店色渲染 大梦团队日常使用腾讯文档「员工档案 V2」作为薪酬管理中枢。整个表结构多达 43 列。\n算薪脚本在核算完成后，会调用腾讯文档开放接口完成自动化回填：\n行号探测与防偏移：自动读取表格末行，锁定当月全员的起始写入行（与上月行段空一行隔开），写完后立即回读校验。 写入动态计算字段：出勤天数、法假天数、部门业绩、班次业绩、基本工资、加班费、KPI 倍数、管理倍数、全勤行为规范奖、出品提成、社保个人代扣（在册员工按月代扣）、最终私账剩余应发。 自动化店色美化： 西湖店：全行设置浅绿底色（#FFE2EFDA） 滨江店：全行设置浅蓝底色（#FFDDEBF7） 四、 双轨工资单：从 HTML 模板到 Rails 8 系统集成 算完薪后，如何将工资条体面、私密、优雅地分发给每位员工？\n我们采取了「双轨制」交付方案：\n1. 独立 HTML/JS 工资单生成器 在本地提供一个自包含的单页应用：\n复古票据设计：两店分色边框、水印防伪印章、应发金额自动转换为中文大写（如「肆仟捌佰伍拾元整」）。 智能字段收起：没有提成的员工自动隐藏提成明细行；加班小时选择「存」的员工自动将加班费显示为 0 并附带文字备注。 一键导出 PNG：内置 html2canvas 引擎，右上角提供「EXPORT ALL」批量打包，单卡片下方支持单张保存。 2. 水龙头 HR 系统（Rails 8 + SQLite + Tailwind） 为了让店长和管理层具备更系统的历史查询与入职管理能力，我们将这套工资单生成逻辑无损移植进了内网 HR 系统 shuilongtou：\n无损组件复用：将 HTML 模板的 CSS 与 JS 渲染核心原样封装为 Rails View Component。 服务端数据注入：SalarySlipBuilder 服务从 SQLite 读取当月评定记录，构造成标准 JSON 挂载在前端 window.SALARY_DATA 上。 批量与单人导出：在后台 /salary-slips 页面，店长可以按月份一键预览全员并导出高清图片分发微信。 结语 在服务实体餐饮的过程中，我们往往容易走向两个极端：要么沉迷于引入庞大昂贵的全套 SaaS 软件，结果员工根本不会用；要么退回原始的手工复制粘贴，每个月重复踩坑。\n这套以 Python 脚本打通数据流、腾讯文档作为协作底座、轻量 HTML/Rails 提供交互展示的架构，恰好找到了那个平衡点：低成本、极度灵活，并且能在业务规则演进时随手调整。\n当技术真正落进日常的柴米油盐与账目明细里，解决真实世界的琐碎麻烦，那种踏实感才是最迷人的。\n🛠️ 项目环境与模型署名 主导逻辑与代码构建：Anthropic Claude Fable 5（桌面端默认模型） 多模态考勤识别：Claude Fable 5 视觉能力（手写纸质排班表与多源照片读取） 客户端与执行环境：Claude Desktop 桌面端（定制 dameng-salary Skill + tencent-docs MCP） 系统技术栈：Python 3 (pandas, openpyxl, xlrd) + 腾讯文档 OpenAPI + Rails 8 (shuilongtou 员工管理系统) + HTML/CSS 矢量打印模板 ","permalink":"https://idiotfan.wang/posts/damon-salary-automation-system/","summary":"\u003cp\u003e在中小餐饮连锁店，每个月最令人头疼的除了盘点，就是算工资。\u003c/p\u003e\n\u003cp\u003e在大梦（可能实验室），两家门店涵盖了侍酒师、调酒师、咖啡师、主厨、出品厨师和兼任店长等多种角色。看似人不多，背后的算薪逻辑却极其复杂：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e考勤源散落各处\u003c/strong\u003e：有人在群里发手写考勤表照片，有人交 \u003ccode\u003e.xlsx\u003c/code\u003e，店长交 \u003ccode\u003e.xls\u003c/code\u003e 评估表，西湖夜班员工在腾讯文档里打卡。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e提成与多维营收挂钩\u003c/strong\u003e：薪酬不仅看基本工资，还挂钩\u003cstrong\u003e部门业绩\u003c/strong\u003e（精酿/调酒/咖啡/厨房）、\u003cstrong\u003e班次业绩\u003c/strong\u003e（白班/中班/晚班），甚至需要按\u003cstrong\u003e部门 × 班次\u003c/strong\u003e矩阵进行交叉剥离。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e新 SKU 归口漂移\u003c/strong\u003e：每个月两家店都会上新酒水或新菜品，如果菜品库没有及时维护，POS 导出的几百条订单就会分错部门。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e输出要求高\u003c/strong\u003e：算完不仅要在腾讯文档 43 列大表里逐行填平并标注店色，还要给每位员工生成带有公章、大写金额、社保代扣明细的精美工资单。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这篇文章记录我如何通过一套组合拳（\u003cstrong\u003eClaude Fable 5\u003c/strong\u003e 驱动的 Python 管道 + 腾讯文档 API + 响应式 HTML 工资单 + Rails 8 HR 系统集成），将原本需要耗费一整天的结薪工作缩减为 10 分钟自动化流程。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"一-考勤与数据清洗手写照片与多格式兼容\"\u003e一、 考勤与数据清洗：手写照片与多格式兼容\u003c/h2\u003e\n\u003cp\u003e结薪的第一步是收集出勤、法定假日、加班与请假数据。\u003c/p\u003e\n\u003cp\u003e面对不同来源的考勤资料，我们建立了一条标准化的数据摄取流水线：\u003c/p\u003e","title":"给精酿连锁店做自动化薪酬引擎：从手写考勤、多维营收聚合到 HTML 工资单"},{"content":"开一家精酿美式餐吧到底要花多少钱？经营大半年后到底是赚是亏？钱都流向了哪里？\n这些问题听起来是基础会计常识，但当真正面对实体餐饮的一地鸡毛时\u0026ndash;一整本扫描版银行流水、微信支付宝混合刷卡、物业方系统各期抵扣、股东实物入股、负责人个人信用卡垫付\u0026ndash;账目会迅速变成一团乱麻。\n最近我用 Claude 桌面端（主力模型 Claude Fable 5），对大梦滨江店（DAMON BREWING / 可能实验室）从 2025 年建店到 2026 年 6 月全周期的账务做了一次彻底的总账梳理。最终输出了 12 页签的标准化 Excel 底账、一份 10 页的完整对账报告，以及一份面向全体股东的 3 页精简经营汇报。\n这篇文章记录这次梳理的核心方法论、踩坑教训，以及如何利用多 Agent 对抗式审计把 20 多个核心财务指标做到分文不差。\n⚠️ 脱敏声明：出于商业保密，本文不展示任何真实金额、比例与流水笔数，一律以定性描述代替，表格只保留指标间的推导结构。方法与流程是完整的，数字请自行代入自己的业务。\n一、 为什么账会算不清：实体餐饮的 5 大「账面陷阱」 在动手拉表格之前，必须先理清业务现实。实体店的流水如果直接生搬硬套做汇总，一定会得出荒谬的结论：\n混淆主体账户：滨江店以经营主账户为唯一现金账，但早期存在与西湖老店的店间结算和借调。如果直接抓商户名，西湖店的款项会混入。 第三方支付的「全量汇总陷阱」：微信和支付宝账单里包含了大量个人日常消费。如果全量汇总，两条渠道会各自凭空多出一大笔与门店无关的「假收入」！正确做法是只能通过付款通道子串匹配，精确提取实际扣自经营主账户的对手方明细。 团购的「双重计算」：美团到综团购的核销款，一方面已经反映在 POS 系统的「顾客实付」订单里，另一方面结算后又作为净款打入了银行卡。如果把团购单独列为一项收入加进来，营业额会被凭空重复计算一大块。 房东账单的「名目倒腾」：商业物业方在系统里将早期缴纳的意向金与首批款项打散冲抵到租赁保证金、物业保证金、能源保证金、首期租金、装修保证金与装修管理费中。按银行流水摘要搜「租金」只能看到零散转账，必须顺着物业系统的逐笔扣费凭证回溯。 信用卡垫付与公私倒挂：负责人个人信用卡替门店垫付了数万元房租与水电，但该卡同时存在个人日常消费，且经营主账户从未向该卡还款。如果把信用卡账单全量导入，会彻底污染负债端。 二、 6 条铁律：不可违背的底账原则 为了防止在多轮推演中产生数字漂移，我们在系统提示词与 Skill 中固化了 6 条铁律：\n1. 经营主账户 = 滨江店完整现金账，西湖店往来款项一律剔除。 2. 支付宝/微信流水只取实际扣自经营主账户的行还原对手方，绝不全量汇总。 3. 团购已包含在 POS 顾客实付与银行到账两端，不可二次相加。 4. 工资按「私账应发 / 剩余应发」实发口径计算（扣除社保代扣）。 5. 一切以银行流水为根本凭证；在线智能表格等零散记录仅做辅助参考。 6. 绝不编造数字；历史口径变更必须明确标注「已作废」并保留审计轨迹。 基于这 6 条铁律，我们对全期银行流水做了逐笔穿透，流入、流出与期末余额三方闭合。\n三、 定案核心指标：资金去向的推导结构 经过多源独立交叉复核，最终定案的核心指标全部闭合（元级精度对平）。金额一律脱敏，只保留指标间的推导关系：\n指标 推导关系（金额已脱敏） 开店总成本 建店硬支出（装修 + 设备 + 物料 + 首批进货 + 建店期工资等） + 三项保证金 资金总投入 股东实缴投资（现金 + 实物入股） + 负责人垫款（现金 + 信用卡代付，扣除已归还部分） 经营净亏损 经营主账户账面残差 + 账外个人卡代付的水电等费用 真实总亏损（押金若沉没） 经营净亏损 + 三项押金 营业额双口径 订单「顾客实付」毛额 vs 银行净到账，差额即支付渠道手续费（约 2%~3% 的行业常见水平） 物业方总付款 主账户支付 + 个人卡代付，与物业系统逐笔对清、无缺口 仓库附租 商场配套小仓库的押金与租金，逐笔对平 核心结论的商业叙事 这套账揭示了一个残酷的实体店常识：「股东凑的钱，几乎刚好只够把店建起来」。\n股东原始投资与建店硬支出几乎分毫不差地对齐\u0026ndash;店还没开业，账上的钱就已经见底。三项押金、首期租金和开业头几个月的营运周转缺口，全部依赖负责人个人后续垫款才把店开起来。这也是很多实体店「开业即负债」的真实写照。\n四、 多 Agent 对抗式审计：如何保证 0 幻觉？ 面对海量流水与多份 Excel，单个 LLM 上下文极其容易产生「看起来合理但实际加总不闭合」的幻觉。\n我们设计了一套两阶段多 Agent 对抗审计工作流：\n[原始数据源：银行流水PDF + 订单明细 + 合同 + 凭证] │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ 【Agent A: 报告审计】 【Agent B: Excel核算】 【Agent C: 汇报互验】 扫描未定义缩写/旧值 逐Sheet计算公式/闭合 SVG图表数据 vs MD文本 │ │ │ └───────────────┼───────────────┘ ▼ 【汇总 21 条差异清单】 │ ▼ 【Agent D: 独立对抗复核员】 （专门负责证伪、亲自算底账、不采信转述） │ ┌─────────┴─────────┐ ▼ ▼ 【16 条确认修复】 【5 条误报驳回】 第一阶段：多路并行独立审计\nAgent 1（文档数字闭合）：逐行扫描报告 markdown 中的所有加总等式，检测是否存在早期作废数字残留（如已被推翻的「押金缺口」假说或「押二付三」等错误口径）。 Agent 2（Excel 底账校验）：用 Python openpyxl 遍历 12 个 Sheet，提取每一个合计格的公式和实际值，验证横向与纵向交叉一致性。 Agent 3（跨文件一致性）：比对 HTML 汇报源码中的 JS 数据数组与 PDF、Word 文本是否绝对吻合。 Agent 4（完备性批评家）：站在挑剔股东的角度，寻找「别人一定会问但当前报告没解释」的逻辑跳跃。 第二阶段：对抗复核（Adversarial Verification） 对于第一阶段提出来的每一条「疑点」，派出一个不带上下文偏见的独立 Agent 专门去证伪。必须直接读取原始 PDF 流水计算，不采信前序 Agent 的自然语言转述。\n战果：21 条初审疑点中，确认修复了 16 处（如某月日均营业额的尾数校正、人力率四舍五入口径统一、员工报销按银行残差精确重算等），果断驳回了 5 处假阳性误报。 五、 Excel 与汇报文档的工程化输出 为了让专业财务人员和股东阅读时建立信任感，输出物的外观与结构必须具备专业水准。\n1. Excel 12 页签标准化版式系统 我们为 大梦滨江总账.xlsx 编写了统一的 openpyxl 格式化引擎：\n配色系统：标题通栏采用墨蓝（#16233B）、小计行浅冷灰（#EEF1F6）、关键亏损浅红（#FFFBEBE9 搭配红字 #B3261E）。 防 ### 溢出检查：自动计算带千分位和括号的负数字符串长度，若 len + 1 \u0026gt; col_width 则自动拓宽列宽，杜绝了 (1,234,567) 这类长数字变成 ### 的尴尬。 合并单元格换行：Excel 合并格默认会截断溢出文字，必须显式开启 wrap_text=True 并动态计算所需行高。 2. Chrome Headless 打造 3 页股东汇报 PDF 对外发放的汇报材料采用纯 HTML + 内联 SVG 图表编写，通过 Chrome Headless 直接渲染为矢量 PDF：\nP1 现状：总投入拆解（股东权益 + 负责人垫款），直面经营亏损现状。 P2 希望：亏损持续收窄（从开业期的高位收敛到近期的零头量级），并揭示周末夜（周五六）日均营业额约为工作日两倍的经营规律。 P3 抉择：给出两条清晰的出路建议——路径一（保店留念想，剥离后厨转为纯吧） vs 路径二（边做边转让，力保押金止损）。 结语 在 AI 辅助财务对账的实践中，「模型能算数」只是最表层的一步，「建立不容置疑的事实锚点与多重约束」才是灵魂。\n只有当每一笔银行流水都能在合同与凭证中找到呼应，每一个汇总等式都经过多 Agent 独立证伪，AI 输出的财务报告才能真正从「文字草稿」升级为「具备法律与商业决策效力的权威定案」。\n🛠️ 项目环境与模型署名 主导推理与审计模型：Anthropic Claude Fable 5（桌面端默认模型，1M 上下文） 客户端与工作流平台：Claude Desktop / Claude Code（Agent Workflow 并行子代理；期间子代理一度被重映射到火山方舟 ark-code-latest / kimi-k3，已修复） 外部重活分担：DeepSeek V4 Pro（火山 Coding Plan，经 opencode serve 委托执行） 核心数据工具链：Python 3 (openpyxl, pdfplumber, markdown) + Google Chrome Headless 底账与凭证归档：大梦滨江总账.xlsx (12 Sheets) + 总账梳理报告.pdf (10 Pages) + 大梦滨江店_经营汇报.pdf (3 Pages) ","permalink":"https://idiotfan.wang/posts/damon-binjiang-ledger-audit/","summary":"\u003cp\u003e开一家精酿美式餐吧到底要花多少钱？经营大半年后到底是赚是亏？钱都流向了哪里？\u003c/p\u003e\n\u003cp\u003e这些问题听起来是基础会计常识，但当真正面对实体餐饮的一地鸡毛时\u0026ndash;一整本扫描版银行流水、微信支付宝混合刷卡、物业方系统各期抵扣、股东实物入股、负责人个人信用卡垫付\u0026ndash;账目会迅速变成一团乱麻。\u003c/p\u003e\n\u003cp\u003e最近我用 \u003cstrong\u003eClaude 桌面端（主力模型 Claude Fable 5）\u003c/strong\u003e，对大梦滨江店（DAMON BREWING / 可能实验室）从 2025 年建店到 2026 年 6 月全周期的账务做了一次彻底的总账梳理。最终输出了 12 页签的标准化 Excel 底账、一份 10 页的完整对账报告，以及一份面向全体股东的 3 页精简经营汇报。\u003c/p\u003e\n\u003cp\u003e这篇文章记录这次梳理的核心方法论、踩坑教训，以及如何利用\u003cstrong\u003e多 Agent 对抗式审计\u003c/strong\u003e把 20 多个核心财务指标做到分文不差。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e⚠️ \u003cstrong\u003e脱敏声明\u003c/strong\u003e：出于商业保密，本文不展示任何真实金额、比例与流水笔数，一律以定性描述代替，表格只保留指标间的推导结构。方法与流程是完整的，数字请自行代入自己的业务。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"一-为什么账会算不清实体餐饮的-5-大账面陷阱\"\u003e一、 为什么账会算不清：实体餐饮的 5 大「账面陷阱」\u003c/h2\u003e\n\u003cp\u003e在动手拉表格之前，必须先理清业务现实。实体店的流水如果直接生搬硬套做汇总，一定会得出荒谬的结论：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e混淆主体账户\u003c/strong\u003e：滨江店以经营主账户为唯一现金账，但早期存在与西湖老店的店间结算和借调。如果直接抓商户名，西湖店的款项会混入。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第三方支付的「全量汇总陷阱」\u003c/strong\u003e：微信和支付宝账单里包含了大量个人日常消费。\u003cstrong\u003e如果全量汇总，两条渠道会各自凭空多出一大笔与门店无关的「假收入」\u003c/strong\u003e！正确做法是只能通过付款通道子串匹配，精确提取实际扣自经营主账户的对手方明细。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e团购的「双重计算」\u003c/strong\u003e：美团到综团购的核销款，一方面已经反映在 POS 系统的「顾客实付」订单里，另一方面结算后又作为净款打入了银行卡。如果把团购单独列为一项收入加进来，营业额会被凭空重复计算一大块。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e房东账单的「名目倒腾」\u003c/strong\u003e：商业物业方在系统里将早期缴纳的意向金与首批款项打散冲抵到租赁保证金、物业保证金、能源保证金、首期租金、装修保证金与装修管理费中。按银行流水摘要搜「租金」只能看到零散转账，必须顺着物业系统的逐笔扣费凭证回溯。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e信用卡垫付与公私倒挂\u003c/strong\u003e：负责人个人信用卡替门店垫付了数万元房租与水电，但该卡同时存在个人日常消费，且经营主账户从未向该卡还款。如果把信用卡账单全量导入，会彻底污染负债端。\u003c/li\u003e\n\u003c/ol\u003e\n\u003chr\u003e\n\u003ch2 id=\"二-6-条铁律不可违背的底账原则\"\u003e二、 6 条铁律：不可违背的底账原则\u003c/h2\u003e\n\u003cp\u003e为了防止在多轮推演中产生数字漂移，我们在系统提示词与 Skill 中固化了 6 条铁律：\u003c/p\u003e","title":"当一家精酿餐吧要对账：大梦滨江店总账梳理与多 Agent 审计实战"},{"content":"手上有两台红米 Note 13 5G（型号 2312DRAABC，内部代号 gold，天玑 6080，8+256，HyperOS / Android 15）。这 SoC 没有 Linux 主线内核支持，刷不了原生 Linux 发行版，但 8G 内存 + UFS 256G + 能一直插着电——拿来当 7×24 家庭服务器正好：音乐库离线听、远程可运维、双机互备。\n整个折腾历时一周，从解锁 bootloader 到音乐自动同步上线，中间还出了双机重启卡死的事故。这篇把过程和结果完整记一下。\n第一步：解锁 bootloader（最危险的一步） 小米官方解锁等待期长、限制多，走的是 Jz8Root 的 MTK RPMB 硬件解锁路线——通过 BROM 用 preloader 直接写 seccfg。但第一原则是先备份一切：动任何东西之前，先把所有 NV 分区（nvram / nvdata / nvcfg / persist / protect1 / protect2 / seccfg / lk / RPMB）用 mtkclient 完整导出来。\nnvram/nvdata 丢了就是丢 IMEI 和基带。每台 ~536MB 的备份是砖机或丢 IMEI 时的唯一保险，必须带走。\n进 BROM 有一套完整仪式，其中有一条铁律：必须用 USB 2.0 口——USB 3.0 口会静默损坏写入，没有任何报错：\n手机完全关机 同时按住音量+ 和音量− 插 USB 线，保持按住 15 秒以上，屏幕必须全黑 电脑端出现 0e8d:0003 电脑侧也有坑：要先停掉 ModemManager（它会抢占 MTK 串口）、卸载 option / usb_wwan 驱动。看到 mtkclient 不认的 0e8d:20ff，基本就是这两个没做干净。\nBROM 通了之后，流程其实不复杂：\nscan_lk.py # → COMPATIBLE_FULL，符合 Path A 硬件解锁条件 da rpmb e ... # 擦除 RPMB magic（这台定位在 sector 65504） da seccfg unlock # 写入解锁 两个细节：擦掉 RPMB magic 之后、seccfg unlock 之前绝不能碰电源键（preloader 会在任何启动时重写 magic）；解锁后首次开机必须擦掉 userdata/metadata，否则直接卡在 dm-verity 的\u0026quot;设备已损坏\u0026quot;界面。fastboot getvar unlocked 返回 yes，两台手机正式离开出厂状态。\n第二步：Root——这个机型没有 init_boot 这台机器的特殊之处：没有独立 init_boot 分区，ramdisk 放在 vendor_boot 分区里的 init_boot.cpio 中。所以 Magisk 标准的\u0026quot;修补 init_boot\u0026quot;流程走不通，得改刷 vendor_boot 补丁镜像，而且必须 Magisk v30.7 起步（PR #9370 才加入 vendor_boot 支持），旧版会刷入成功但 root 不生效。\n流程：\n1 2 3 4 # 1. dump 原厂 vendor_boot（root 前只能走 BROM，root 后直接 dd） # 2. push 到手机，Magisk app → 安装 → 选择并修补一个文件 # 3. 拉回刷入 fastboot flash vendor_boot_b magisk_patched-vendor_boot.img 还有两个坑：\n本机的提权入口是 /debug_ramdisk/magisk su，/sbin/su、/system/xbin/su 都不存在——别据此误判 root 失效 开机后第一次 su 请求可能被自动拒绝并记住，要到 Magisk 超级用户列表里手动把 shell 改成「允许」 第三步：裁 HyperOS 冗余（内存从 78MB 到 5G） 出厂状态 HyperOS 只给可用内存留了 78MB。裁剪之后：\n指标 裁剪前 裁剪后（重启稳定） Mem used 5366 MB 2456 MB Mem free 2212 MB 5122 MB Swap used 186 MB 0 MB 具体做了这些：\n禁用约 100 个系统包（全部 pm disable-user，可逆）：小爱/AI 全家桶（voiceassist、aiservice、aiasst…）、广告统计（systemAdSolution、analytics…）、应用商店/游戏中心/快应用、云同步/车联/投屏、支付钱包虚拟 SIM、各类测试工具和日志上报 卸载 21 个预装垃圾（pm uninstall -k --user 0）：淘宝、闲鱼、拼多多、美团、微博、抖音、快手、今日头条、番茄小说、腾讯视频……保留支付宝、高德、WPS、米家 hosts 广告拦截：Magisk hosts 模块（yhosts 列表 6428 条，含小米系广告域） 关掉负一屏和上滑资讯流（这俩会被系统偷偷重新启用，要重新禁） 踩的坑：\n第一轮误伤了天气和主题商店，广告 hosts 又误拦了天气域名。后来恢复 4 个包 + 给 hosts 模块加白名单 pm enable 状态落盘有延迟，启用后立刻重启会丢失，要等 20 秒再重启 漂移：重启 5 次后 HyperOS 把 24 个包重新启用了。给 trim-hyperos.sh 加了 verify 模式（只报告不改动）+ KEEP 例外表，以后每次重启后跑一次 verify 自查 第四步：服务器化 目标是 7×24 随时能连、能干活：\nDebian chroot：chroot-distro 模块装 Debian 12 bookworm arm64（rootfs 只有 74MB），里面跑 sshd:2222 + shellinabox（Web 终端）+ glances（监控面板） 保持唤醒：写 gold_server wakelock，防熄屏后 deep doze 挂起系统、掐网络 网络调优：TCP BBR + fq qdisc；CPU 最低频锁定（MTK power HAL 开机后会重置，脚本每 30 秒重写 5 分钟对抗）；GPU 从锁死的 dummy 调度换到 simple_ondemand WiFi 看门狗：每 60 秒 ping 网关，连续 5 次失败自动 svc wifi disable/enable 重连——前两天数据链路抽风了 3 次，全部被它自愈 80% 充电上限：ACC 模块的守护进程在这个机型上不工作（97% 还在充），自己写了脚本直接写 input_suspend：≥80% 停充、≤60% 恢复。长期插电当服务器，电池健康是刚需 双机重启卡死事故 第二天 13:45，两台手机重启后同时卡死：bootanimation 一直转、sys.boot_completed 为空、zygote 状态 restarting、crash buffer 里全是 zygote64 fork system_server 崩溃（/dev/binder 节点缺失）。magiskd 在跑，但 su 不可用。\n排查靠排除法：\n模块逐个全禁 → 能开机 Magisk 版本对比（30.7 / 30.6）→ 都卡 刷回原厂 vendor_boot → 连续 3 次重启正常（证明 ROM 本身稳定，问题在 Magisk 侧） 最终定位：chroot-sshd-autostart.sh 在启动早期把 Debian chroot 挂载进 /debug_ramdisk，与 Magisk preinit 机制冲突 → 第二次重启 preinit 挂载异常 → binder 节点缺失 → zygote 起不来 处置：设备 2 降级 Magisk 30.6 + 禁用 chroot 自启脚本，连续多次重启验证通过。\n这个事故还顺带挖出一个更隐蔽的地雷：Magisk 的 service.d 按可执行位运行脚本，不看扩展名。文档里写的\u0026quot;禁用 = 改名为 .bak\u0026ldquo;其实根本没禁——文件还是 -rwxr-xr-x，只是那台恰好 25 小时没重启没暴露。正确做法：文件移出目录、去掉执行位。\n远程运维：FRP 双隧道 手机在 CGNAT 后面（4G 公网 IP 一天换 4 次），外网直连无解。方案：手机主动出站连 VPS 上的 frps（就是「我的自建服务清单」里那台），每台开两条隧道：\n设备 SSH adb（保底通道） 设备 1 :6012 :6014 设备 2 :6008 :6009 SSH 仅密钥认证，全世界任何地方一条 ssh gold2 进手机 adb 隧道是保底通道：即使 Termux 整个被 MIUI 干死，root 层的 adb 依然可进，能修一切。在此之前 Termux 一挂手机就够不着了，只能人肉去插 USB 一个有意思的细节：VPS 的 frps 是 0.51.3（ini 配置），手机 frpc 是 0.71.0（toml 配置），跨 20 个小版本握手正常。但别为此升级 VPS 的 frps——上面挂着 8 条生产隧道（几家店的 SSH、旁路由、远程桌面），升级风险远大于收益。\n音乐自动同步链路 手机的主力用途是音乐：/sdcard/Music/音乐U盘 里 682 首 / 6.2GB，Musicolet 离线播。但我想\u0026quot;歌单加歌 → 自动到手机，随时离线听\u0026rdquo;，于是搭了这条链路：\n网易云歌单 │ systemd timer 每 30 分钟 ▼ VPS（sync.py：按 song_id 比对线上歌单 ↔ 本地，只下新增） │ HTTPS 静态分发 + NATS 通知 ▼ 手机（gold-sync.py --daemon，root 层） └ 落盘后触发 MediaStore 重扫 → Musicolet 立即可见 几个设计点：\nNATS 只报信，文件走 HTTP 拉：NATS 默认 max_payload 1MB，一首歌 8~12MB；VPS 才 1.6G 内存，开 JetStream 不划算。manifest.json 是唯一真相来源，漏收消息不影响最终一致性（还有 30 分钟轮询兜底） 序号对齐重命名（最值的一段逻辑）：文件名是 NNN. 歌名 - 歌手.mp3，序号 = 歌单位置。我习惯把新歌加在第一首，加一首 → 后面所有歌序号 +1 → 全部文件名改变。不对齐的话，下载器会重下整个 156 首的歌单（~1.4GB）；对齐后是 155 个重命名 + 只下 1 首 冷启动匹配：手机里已有 676 首、VPS 全新下载序号不同，首次同步要用\u0026quot;去掉 NNN. 前缀的文件名\u0026quot;把本地文件认领回 song_id。且一个本地文件只能被认领一次——歌单里有同名同歌手的重复曲目，认领两次就是对同一文件重命名两次直接崩（实际踩过，99 个文件卡在临时名，写了还原逻辑） 最隐蔽的坑：\nMIUI 会切断非前台应用的网络：Termux 里 curl 全返回 000，连直连 IP 都超时；同一时刻 root 和其他应用全部正常。DNS、WiFi、防火墙全排查了一遍才定位。解法是 termux-wake-lock，开机脚本第一行必须是它 目录名带半角冒号，Android FUSE 拒绝一切写入：歌单目录 Bar 20:30～ 里那个 : 是 FAT 非法字符，Termux 写不进去，连 root 都写不进去（root 继承了 app 的挂载命名空间，su -mm 也无效）。本地目录改成全角冒号 Bar 20：30～，视觉几乎无差别 Termux:Boot 靠不住：MIUI 会把 Termux 打成 stopped=true，Android 不向 stopped 应用投递 BOOT_COMPLETED，Termux:Boot 开机永远不触发。最终把所有守护进程迁到 Magisk service.d（root 层）——开机必跑、永远有网、不受任何应用管理影响 实测端到端：22:07 歌单加歌 → 22:17 VPS 下好 → 22:18 手机下好，全程 11 分钟。\n最终结果 设备 1 设备 2 ROM OS3.0.9.0 / _b 槽 OS3.0.10.0 / _a 槽 Root Magisk 30.7 Magisk 30.6（30.7 在 3.0.10 上卡开机，刻意降级） 可用内存 ~5.1GB ~5.1GB 曲库 700 首 / 6.2GB 700 首 / 6.2GB 远程 SSH + adb 双隧道（公网） SSH + adb 双隧道（公网） 限充 80% 停充 80% 停充 SIM 无 联通 5G SA（数据关闭） 几个数字：\n裁剪后内存可用 78MB → 5.1GB 音乐自动同步端到端 11 分钟 5G 实测（设备 2）：下行 2433Mbps，偏低（联通 5G 正常 100300Mbps，套餐限速或信号，待查） root 层看门狗统一巡检 sshd / frpc / 同步守护 / 电池限充，每 60 秒，谁挂了谁拉起 另外写了一个 health-check.sh 例行体检脚本，一条命令出完整报告：身份、服务进程数、网络验证状态、全局代理、电池限充、内存、曲库与 MediaStore 对账、修剪漂移。其中\u0026quot;全局代理\u0026quot;一项是后来加的教训：设备 1 残留过一条 http_proxy 127.0.0.1:8080，所有应用断网但 root 完全正常，WiFi 设置界面还显示\u0026quot;已连接\u0026quot;，UI 上根本查不到——这种不对称是排查时最大的误导。\n最近还给 Termux 装了 musicfox（网易云音乐终端版），配了 Termux:Widget 桌面快捷方式，不用开 app 直接敲命令听歌。后续打算在它们身上试 NAS（OTG 外接存储）和下载节点。\n几点收获 先备份一切。那两份 ~536MB 的 NV 分区备份是这个项目的保命资产——没有它们，解锁那一步根本不敢做。 root 层比应用层可靠。所有\u0026quot;开机必须跑\u0026quot;的守护进程放应用层（Termux:Boot）全被 MIUI 干掉，迁到 Magisk service.d 之后零失效。 \u0026ldquo;锁文件 + PID\u0026rdquo; 的单实例写法是重灾区。两个看门狗脚本都因为 PID 复用死锁静默停摆过一天以上（进程死了，PID 被别的进程回收占用，存活检查永远通过）。锁检查必须配合 /proc/\u0026lt;pid\u0026gt;/cmdline 核对身份。 不要在 adb/ssh 里堆嵌套引号。排查系统状态一律写成脚本文件推上去执行——su -c '...$(cat ...)...' 这种嵌套我把 wakelock 丢失、曲库清零误判了四次，实际都好好的。 自建的乐趣在这：每个零件都看得见摸得着，坏了修得快，修完还懂了。 本文由 Qwen3.8-27B（OpenRouter）根据两台设备的接手包文档与会话记录整理撰写，我负责审核与修改。\n","permalink":"https://idiotfan.wang/posts/redmi-note-13-server/","summary":"\u003cp\u003e手上有两台红米 Note 13 5G（型号 2312DRAABC，内部代号 \u003ccode\u003egold\u003c/code\u003e，天玑 6080，8+256，HyperOS / Android 15）。这 SoC 没有 Linux 主线内核支持，刷不了原生 Linux 发行版，但 8G 内存 + UFS 256G + 能一直插着电——拿来当 \u003cstrong\u003e7×24 家庭服务器\u003c/strong\u003e正好：音乐库离线听、远程可运维、双机互备。\u003c/p\u003e\n\u003cp\u003e整个折腾历时一周，从解锁 bootloader 到音乐自动同步上线，中间还出了双机重启卡死的事故。这篇把过程和结果完整记一下。\u003c/p\u003e\n\u003ch2 id=\"第一步解锁-bootloader最危险的一步\"\u003e第一步：解锁 bootloader（最危险的一步）\u003c/h2\u003e\n\u003cp\u003e小米官方解锁等待期长、限制多，走的是 \u003cstrong\u003eJz8Root 的 MTK RPMB 硬件解锁\u003c/strong\u003e路线——通过 BROM 用 preloader 直接写 seccfg。但第一原则是\u003cstrong\u003e先备份一切\u003c/strong\u003e：动任何东西之前，先把所有 NV 分区（nvram / nvdata / nvcfg / persist / protect1 / protect2 / seccfg / lk / RPMB）用 mtkclient 完整导出来。\u003c/p\u003e","title":"我是怎么把两台红米 Note 13 5G 变成 7×24 家庭服务器的"},{"content":"《飘渺之旅》算得上修真小说的开山之作，我一直挺喜欢。最近突发奇想：能不能让 AI 先把整本小说读完，再用 Three.js 把书里的世界观宇宙\u0026quot;架构\u0026quot;出来——不是画几张概念图，而是做成一个能在浏览器里拖拽、缩放、点击的 3D 宇宙。\n于是有了这个项目：一个零依赖、纯静态、完全离线的单页应用，双击 index.html 就能打开。\n第一步：先让 AI 把小说读完 原始文本是 5.8MB 的 txt，46,806 行，GB18030 编码，而且尾部拖了两段垃圾——一段第 14 集的残章，加一段第 15~24 集的逐字重复拷贝（约 12,000 行）。\n第一件事就是把语料洗干净：\n删掉全部垃圾，46,806 行 → 34,311 行，精确统计出 28 集 × 292 章 编码从 GB18030 转成 UTF-8，macOS 直接能打开 修了第 2 集标题里的 ?? 乱码（\u0026ldquo;星星宫??寒冰原\u0026rdquo; → \u0026ldquo;星星宫·寒冰原\u0026rdquo;） 修了 2 处 GB18030 私有区字符（\u0026ldquo;水流突然化作…\u0026quot;、\u0026ldquo;突然出现一个巨大的黑洞\u0026rdquo;） 干净的语料是一切的基础。后面 AI 写的所有世界观数据，都要能回到这份原文里查到出处。\n第二步：逐条考据世界观 这一步是最好玩的。书里那些设定散落在 292 章里，我让 Codex 全文检索核对，不许自由发挥：\n五层主界域：凡人界 → 修真界 → 仙界 → 原界 → 神界（书中正式叫法其实是\u0026quot;天神界\u0026rdquo;） 平行界与神秘地带：灵鬼界、黑魔界、佛宗，以及鑫波角、幻星神阵 修真界七颗星球：封缘星、天庭星、潜杰星、霖明星、坦邦星、幻树星、紫魂星 修炼体系：修真十一境（加散仙旁支）→ 仙界七级 → 修神九重天（九重 × 三境 = 27 境） 主角线：李强从第 1 集\u0026quot;误入天庭\u0026quot;一路到第 28 集\u0026quot;神罚之眼\u0026quot; 顺便考据出几个有意思的冷知识：神界在书里叫天神界，全书没有\u0026quot;大罗金仙、仙帝、神域\u0026quot;这些说法；傅山用紫炎心给李强筑基；赤明魔尊是李强师弟，两人同拜青帝；原界大约只有幻星神阵的十分之一；神之战魂来自紫魂星。\n第三步：一个单文件的 3D 宇宙 最终交付物非常朴素：一个 index.html（946 行，HTML + CSS + 内联 JS）+ vendor/ 里两个本地文件（three.min.js r128 + OrbitControls.js）。没有构建步骤、没有 npm、没有 CDN，file:// 直接双击打开，断网也能玩。这是故意的——越简单的交付越长寿。\n页面有三种视图模式：\n宇宙总览：五层界域竖直堆叠，加上三个平行界、两处神秘地带、七颗星球，以及连接凡人界与修真界的\u0026quot;逆行通道\u0026quot; 修炼阶梯：修真十一境 → 仙界七级 → 修神九重天，27 个境界排成一条螺旋阶梯，像在爬一座无形的塔 主角旅程：李强 28 集剧情节点的紫色螺旋线，从误入天庭一路到神罚之眼 交互上就是常规三板斧：OrbitControls 拖动旋转、滚轮缩放、raycast 点击节点弹出详情面板。细节上花心思的是标签系统：所有标签都是 DOM 元素，每帧把 3D 坐标投影到屏幕坐标，还要做防重叠排版（固定组先放，可移动组最多重试 10 次下移）和 SVG 引导线，保证缩放之后不会糊成一团。另有侧栏 chip 跳转、自动旋转开关、920px / 640px 两档响应式断点。\n验证：headless 跑一遍才算数 写完不是直接交付，而是用无头 Chrome + SwiftShader 软件 WebGL 实测了一轮：\n0 个 JS 错误 三种模式切换正常 点击\u0026quot;元婴\u0026quot;标签 → 面板正确显示元婴期；点击\u0026quot;第 28 集\u0026quot; → 显示\u0026quot;神罚之眼\u0026quot; 侧栏 chip 跳转、自动旋转、700px 移动端视口全部正常 软件渲染下 13~18 FPS，真实 GPU 上应该满帧——这不是性能问题。\n几点收获 动手前先定\u0026quot;事实底线\u0026quot;。中途有一次 review 误报章数应该是 296，其实是把垃圾残章的 4 个标题数进去了。定死\u0026quot;原文是唯一考据源、292 章是清洗后的精确数\u0026quot;，AI 才不会在细节上自由发挥。 无构建交付很舒服。一个 HTML 文件加两个本地库，没有依赖链可以坏，拷到任何电脑都能开。 vendor 文件要成对升级。three.js r128 和它的 OrbitControls 是配对关系，只换一半必然出问题；换版本必须全量回归三个视图。 清洗语料是最值的苦力活。5.8MB 的脏文本 → 34,311 行干净的 UTF-8，决定了后面所有 AI 工作的质量上限。 目前这个 3D 宇宙已经挂到博客上了，在线可以直接玩：飘渺之旅 · 世界观宇宙。整个项目以 Hugo 静态文件的形式原样托管，没有加任何依赖和构建步骤，双击本地 index.html 和在线打开是同一个版本。\n这次自娱自乐的 AI 折腾算是闭环了——从读小说、考据、建模到上线，全程只花了一个周末的碎片时间。\n","permalink":"https://idiotfan.wang/posts/use-codex-to-build-piaomiao-3d-universe/","summary":"\u003cp\u003e《飘渺之旅》算得上修真小说的开山之作，我一直挺喜欢。最近突发奇想：能不能让 AI 先把整本小说读完，再用 Three.js 把书里的世界观宇宙\u0026quot;架构\u0026quot;出来——不是画几张概念图，而是做成一个能在浏览器里拖拽、缩放、点击的 3D 宇宙。\u003c/p\u003e\n\u003cp\u003e于是有了这个项目：一个\u003cstrong\u003e零依赖、纯静态、完全离线\u003c/strong\u003e的单页应用，双击 \u003ccode\u003eindex.html\u003c/code\u003e 就能打开。\u003c/p\u003e\n\u003ch2 id=\"第一步先让-ai-把小说读完\"\u003e第一步：先让 AI 把小说读完\u003c/h2\u003e\n\u003cp\u003e原始文本是 5.8MB 的 txt，46,806 行，GB18030 编码，而且尾部拖了两段垃圾——一段第 14 集的残章，加一段第 15~24 集的逐字重复拷贝（约 12,000 行）。\u003c/p\u003e\n\u003cp\u003e第一件事就是把语料洗干净：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e删掉全部垃圾，46,806 行 → \u003cstrong\u003e34,311 行\u003c/strong\u003e，精确统计出 \u003cstrong\u003e28 集 × 292 章\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e编码从 GB18030 转成 UTF-8，macOS 直接能打开\u003c/li\u003e\n\u003cli\u003e修了第 2 集标题里的 \u003ccode\u003e??\u003c/code\u003e 乱码（\u0026ldquo;星星宫??寒冰原\u0026rdquo; → \u0026ldquo;星星宫·寒冰原\u0026rdquo;）\u003c/li\u003e\n\u003cli\u003e修了 2 处 GB18030 私有区字符（\u0026ldquo;水流突然化作…\u0026quot;、\u0026ldquo;突然出现一个巨大的黑洞\u0026rdquo;）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e干净的语料是一切的基础。后面 AI 写的所有世界观数据，都要能回到这份原文里查到出处。\u003c/p\u003e","title":"用 Codex 把《飘渺之旅》的世界观装进浏览器"},{"content":"skills.idiotfan.wang 是我给 Claude Code / Codex CLI / DSH 准备的技能包分发站，目前挂着 21 个 skill。这篇文章记一下它是怎么搭的——不复杂，但有几个值得说的小细节。\n为什么会有这个站 我有一堆 skill，散落在 Gitea 的私有仓库里：大梦工资计算、腾讯文档、微信读书、GitNexus、各种图像/音乐生成…… 目录结构是标准的 Anthropic skill 格式——每个技能一个目录，里面一个 SKILL.md（front matter 里写 name / description / version），大一点的再带 scripts/、references/。\n仓库自己用没问题，但有几个痛点：\n换台机器就要重新 clone 私有仓库，还要配认证，麻烦 想给朋友用，总不能把 Gitea 账号密码给他 技能更新了，别人拿到的还是旧版 所以我要的其实很简单：一个看得见、能下载、能校验的静态站——首页列出所有技能，点一下就能下载打包好的 tar.gz，附 sha256 校验和。\n站点长什么样 纯手写静态页，零框架，就一个 index.html 内嵌 CSS + 一小段原生 JS：\nGitHub 暗色风格，卡片网格展示技能（emoji 图标、名称、版本徽标、两行描述、体积、适用目标） 顶部搜索框 + 分类筛选：全部 / 3D / 办公 / 图像 / 大梦 / 媒体 / 开发 / 系统 / 网络 统计条：21 个技能 · 384 KB 打包体积 · 更新于 xx UTC 数据全部由一个 Python 脚本生成，没有任何运行时后端。\n同步机制：cron 轮询，不用 webhook 一开始我也考虑过 Gitea Actions 或者 webhook，但最后选了最简单可靠的方式——VPS 上挂 cron：\n*/15 * * * * /opt/skill-server/rebuild.sh \u0026gt;\u0026gt; /var/log/skill-rebuild.log 2\u0026gt;\u0026amp;1 rebuild.sh 干两件事：拉代码、跑构建：\n1 2 3 4 5 6 7 #!/bin/bash # 从 Gitea 拉取最新 skills 源码 → 重新打包 → 更新静态站 set -e TOKEN=$(cat /root/.gitea-skill-token) cd /opt/skill-server/repo git pull -q \u0026#34;https://idiotfan:$TOKEN@git.idiotfan.wang/idiotfan/skills.git\u0026#34; main 2\u0026gt;/dev/null || true python3 /opt/skill-server/build.py 几个设计点：\nToken 存在 /root/.gitea-skill-token 文件里，脚本里不写死密码，换 token 只改一个文件 git pull 失败也继续跑（|| true），旧版本照常发布，不会把站搞挂 15 分钟一次的轮询对个人站完全够用，还省掉了 webhook 的运维负担 build.py：一站式的构建脚本 构建脚本在 /opt/skill-server/build.py（12KB，无第三方依赖，只用标准库）。它依次做四件事：\n1. 解析 SKILL.md front matter 每个技能目录读 SKILL.md 的头部 YAML，提取 name / version / description / whenToUse：\n1 2 3 4 5 6 7 8 9 10 11 12 def read_frontmatter(skill_dir): name = os.path.basename(skill_dir) version, description, when = \u0026#34;1.0.0\u0026#34;, \u0026#34;\u0026#34;, None md = os.path.join(skill_dir, \u0026#34;SKILL.md\u0026#34;) if os.path.exists(md): with open(md, encoding=\u0026#34;utf-8\u0026#34;, errors=\u0026#34;replace\u0026#34;) as f: head = f.read(4096) if head.startswith(\u0026#34;---\u0026#34;): fm = head.split(\u0026#34;---\u0026#34;, 2)[1] for line in fm.splitlines(): m = re.match(r\u0026#34;^(\\w[\\w-]*):\\s*(.*)$\u0026#34;, line) ... 版本号直接从仓库里的 SKILL.md 来，所以升级技能 = 改 front matter 里的 version 再 push，站上自动出现新版本。\n2. 打包 + 同步 每个技能目录打成 {name}-{version}.tar.gz，丢到 /var/www/skills/bundles/ 原始目录整个复制到 /var/www/skills/skills/{name}/，nginx 开 autoindex，目录列表直接当详情页用，连详情页都不用写 3. 生成 index.json 每个技能记录 name / version / description / whenToUse / url / sha256 / size / targets，其中 sha256 是打包文件的校验和——下载后可以核对完整性，这是\u0026quot;一键安装\u0026quot;的信任基础。\n4. 生成 index.html Python 里直接拼 HTML 字符串（内嵌 CSS），加上 emoji 图标映射表和分类映射表：\n1 2 ICONS = {\u0026#34;dameng-salary\u0026#34;: \u0026#34;💰\u0026#34;, \u0026#34;tencent-docs\u0026#34;: \u0026#34;📝\u0026#34;, ...} CATEGORY = {\u0026#34;dameng-salary\u0026#34;: \u0026#34;大梦\u0026#34;, \u0026#34;tencent-docs\u0026#34;: \u0026#34;办公\u0026#34;, ...} 标题里的\u0026quot;更新于\u0026quot;时间戳用 datetime.now(timezone.utc) 生成，所以你能在站上看到 UTC 时间。\n容易被忽略的安全细节 技能包里有不少敏感内容：API key、cookies、访问凭证。发布前做了两道保险：\n1 2 3 4 5 EXCLUDE_FILES = {\u0026#34;key.txt\u0026#34;, \u0026#34;cookies.txt\u0026#34;, \u0026#34;.DS_Store\u0026#34;} # 直接不发布 REDACT = [ (r\u0026#39;DEFAULT_KEY = \u0026#34;[0-9a-f]{16,}\\.[A-Za-z0-9]{10,}\u0026#34;\u0026#39;, \u0026#39;DEFAULT_KEY = \u0026#34;\u0026#34; # 服务器版不含内置 key，请设置环境变量 GLM_API_KEY\u0026#39;), ] EXCLUDE_FILES：打包和同步时直接跳过敏感文件 REDACT：对 .py / .sh / .js / .md 做正则脱敏，把硬编码的密钥替换成空串和提示 提醒：代码里的密钥永远是隐患。脱敏是兜底，最好还是在源仓库里就别提交密钥。\n访问控制：nginx basic auth 站点整套用 nginx 服务，外面套了一层 Basic Auth：\n1 2 3 4 5 6 server { server_name skills.idiotfan.wang; auth_basic \u0026#34;idiotfan skills (需认证)\u0026#34;; auth_basic_user_file /etc/nginx/.htpasswd-skills; ... } 访问时浏览器会弹认证框，realm 就是\u0026quot;idiotfan skills (需认证)\u0026quot;。自己用或者给朋友发个账号都行，不用把 Gitea 权限开出去。\n顺带一提：博客也是同一个套路 这套\u0026quot;cron 轮询 + git pull + 构建脚本\u0026quot;的思路我博客也在用：\n*/5 * * * * /opt/blog-server/build.sh \u0026gt;\u0026gt; /var/log/blog-build.log 2\u0026gt;\u0026amp;1 1 2 3 4 5 6 7 #!/bin/bash # 从 Gitea 拉取博客源码 → Hugo 构建 → 发布到 /var/www/blog set -e TOKEN=$(cat /root/.gitea-blog-token) cd /opt/blog-server/site git pull -q \u0026#34;https://idiotfan:$TOKEN@git.idiotfan.wang/idiotfan/blog.git\u0026#34; main 2\u0026gt;/dev/null || true hugo --minify --destination /var/www/blog 所以博客\u0026quot;写完 push、几分钟内自动上线\u0026quot;就是这么来的——没有 webhook、没有 Actions runner，就一条 cron。对个人自建站来说，越简单的机制越不容易坏。\n小结 整个站就三个组成部分：\n组件 作用 Gitea 私有仓库 技能源码的唯一事实来源 /opt/skill-server/（cron + 脚本） 拉取、脱敏、打包、生成页面 nginx + /var/www/skills 静态服务 + 认证 + autoindex 下一步可能给站上加\u0026quot;更新日志\u0026quot;或者按安装目标（claude/codex/dsh）过滤，不过现在的版本已经够用了。自建服务的乐趣大概就在这：每个零件都看得见摸得着，坏了也修得快。\n","permalink":"https://idiotfan.wang/posts/my-skills-sync-site/","summary":"\u003cp\u003e\u003ca href=\"https://skills.idiotfan.wang\"\u003eskills.idiotfan.wang\u003c/a\u003e 是我给 Claude Code / Codex CLI / DSH 准备的技能包分发站，目前挂着 21 个 skill。这篇文章记一下它是怎么搭的——不复杂，但有几个值得说的小细节。\u003c/p\u003e\n\u003ch2 id=\"为什么会有这个站\"\u003e为什么会有这个站\u003c/h2\u003e\n\u003cp\u003e我有一堆 skill，散落在 Gitea 的私有仓库里：大梦工资计算、腾讯文档、微信读书、GitNexus、各种图像/音乐生成…… 目录结构是标准的 Anthropic skill 格式——每个技能一个目录，里面一个 \u003ccode\u003eSKILL.md\u003c/code\u003e（front matter 里写 \u003ccode\u003ename\u003c/code\u003e / \u003ccode\u003edescription\u003c/code\u003e / \u003ccode\u003eversion\u003c/code\u003e），大一点的再带 \u003ccode\u003escripts/\u003c/code\u003e、\u003ccode\u003ereferences/\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003e仓库自己用没问题，但有几个痛点：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e换台机器就要重新 clone 私有仓库\u003c/strong\u003e，还要配认证，麻烦\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e想给朋友用\u003c/strong\u003e，总不能把 Gitea 账号密码给他\u003c/li\u003e\n\u003cli\u003e技能更新了，别人拿到的还是旧版\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e所以我要的其实很简单：一个\u003cstrong\u003e看得见、能下载、能校验\u003c/strong\u003e的静态站——首页列出所有技能，点一下就能下载打包好的 tar.gz，附 sha256 校验和。\u003c/p\u003e","title":"我是怎么搭自己的 Skills 同步站的"},{"content":"这台 1.6G 内存的阿里云 VPS 上跑着我的全套自建服务：\n服务 地址 说明 博客（本站） idiotfan.wang Hugo + PaperMod 静态站 Skills 仓库 skills.idiotfan.wang 我的 21 个技能包分发站 Gitea git.idiotfan.wang 自托管 git（博客 + skills 源码） 服务门户 hello.idiotfan.wang 所有服务一处直达 可能实验室 HR maybelab.idiotfan.wang 水龙头系统 博客的发布方式：本地 Markdown → git push → VPS 自动构建，5 分钟内上线。\n","permalink":"https://idiotfan.wang/posts/my-selfhosted-services/","summary":"\u003cp\u003e这台 1.6G 内存的阿里云 VPS 上跑着我的全套自建服务：\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003e服务\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e地址\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003e说明\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003e博客（本站）\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ca href=\"https://idiotfan.wang\"\u003eidiotfan.wang\u003c/a\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eHugo + PaperMod 静态站\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eSkills 仓库\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ca href=\"https://skills.idiotfan.wang\"\u003eskills.idiotfan.wang\u003c/a\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e我的 21 个技能包分发站\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003eGitea\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ca href=\"https://git.idiotfan.wang\"\u003egit.idiotfan.wang\u003c/a\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e自托管 git（博客 + skills 源码）\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003e服务门户\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ca href=\"https://hello.idiotfan.wang\"\u003ehello.idiotfan.wang\u003c/a\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e所有服务一处直达\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003e可能实验室 HR\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ca href=\"https://maybelab.idiotfan.wang\"\u003emaybelab.idiotfan.wang\u003c/a\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e水龙头系统\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e博客的发布方式：本地 Markdown → \u003ccode\u003egit push\u003c/code\u003e → VPS 自动构建，5 分钟内上线。\u003c/p\u003e","title":"我的自建服务清单"},{"content":"欢迎来到 idiotfan 的个人博客。\n这里会记录：\n技术折腾笔记（服务器、网络、自建服务） 生活随笔 遇到的有趣问题与解法 博客本身是用 Hugo + PaperMod 构建的静态站，源码托管在自建的 Gitea 上：写完文章 git push，VPS 上的构建任务几分钟内自动发布。\n本站也提供 RSS 订阅，欢迎关注。\n","permalink":"https://idiotfan.wang/posts/hello/","summary":"\u003cp\u003e欢迎来到 idiotfan 的个人博客。\u003c/p\u003e\n\u003cp\u003e这里会记录：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e技术折腾笔记（服务器、网络、自建服务）\u003c/li\u003e\n\u003cli\u003e生活随笔\u003c/li\u003e\n\u003cli\u003e遇到的有趣问题与解法\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e博客本身是用 \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e + \u003ca href=\"https://github.com/adityatelange/hugo-PaperMod\"\u003ePaperMod\u003c/a\u003e 构建的静态站，源码托管在自建的 \u003ca href=\"https://git.idiotfan.wang\"\u003eGitea\u003c/a\u003e 上：写完文章 \u003ccode\u003egit push\u003c/code\u003e，VPS 上的构建任务几分钟内自动发布。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e本站也提供 \u003ca href=\"/index.xml\"\u003eRSS 订阅\u003c/a\u003e，欢迎关注。\u003c/p\u003e\n\u003c/blockquote\u003e","title":"你好，世界"}]