给静态主页加一张 Wordle 成绩卡:从手动导入到自动同步
本文由 DeepSeek-V4.1-Flash 生成
一张“看一眼”的卡片
我每天都会玩 Wordle。成绩本身不复杂:第几期、几次猜中、六行颜色方块。既然个人主页已经有一个每天都会打开的时钟页,把成绩放到旁边,应该是一件很自然的事。
但主页是纯静态的:没有数据库,没有常驻后端,也不打算为了一个小卡片引入一套服务。于是问题变成了——Wordle 的成绩在纽约时报的页面里,怎么让它跑到我的首页上?
这篇文章记录最终方案,以及中途放弃的做法。出于安全考虑,文中省略了实际域名、服务器路径、认证信息和具体的 Nginx 配置,示例只保留结构与思路。
先排除两条路
最直接的想法是爬 Wordle 页面,或者找它的成绩接口。两条都不太合适:
- 页面 DOM 和 class 名属于实现细节,改版就失效;
- 没有稳定的公开成绩 API,跨域也拿不到;
- 为了一个卡片去模拟登录、逆向接口,维护成本远高于收益。
真正稳定的“接口”其实是用户每天都会点的那个 Share 按钮。它把一段固定格式的文本放进剪贴板:
1 | Wordle 1,958 3/6 |
这段文本本来就是给用户看的,格式也足够稳定。与其猜页面结构,不如在复制发生的那一刻把文本接住。
先用手动版验证数据
第一版没有直接上自动同步,而是先做了手动粘贴:P2 放一张卡片,粘贴分享文本,解析后存进 localStorage,再提供“导出 JSON”方便把数据提交到仓库。
它验证了两件事:数据格式够不够用,以及卡片在桌面和手机上怎么排。但用起来还是麻烦——每天都要复制、打开首页、粘贴;而且 localStorage 只存在当前浏览器里,换设备就看不到。于是第二阶段把写入搬到了服务端,首页只保留只读展示。
整体链路
最终的结构可以拆成两半:写和读。
1 | Wordle 页面 |
前端不再提供“导入成绩”按钮,也不需要用户手动粘贴。访客看到的只是一张只读卡片;写入只发生在安装了油猴脚本的浏览器里。
数据格式:只存颜色,不存答案
成绩 JSON 的结构大致如下:
1 | { |
几个约定:
grid只保存correct/present/absent三种状态,不保存猜测的单词,更不保存答案;score是 1–6 的数字;X/6存为null,表示没猜中;hardMode记录分享文本末尾是否带*;- 同期数重复写入时,后一次覆盖前一次,避免历史里出现两条 1958。
这个数据量很小,一期一条,全年也就三百多条。用一个 JSON 文件足够,不需要数据库。
油猴脚本:在 Share 的瞬间接住文本
脚本挂在 Wordle 页面,@run-at document-start,在页面自己的脚本之前执行。核心是替换 navigator.clipboard.writeText:
1 | const original = navigator.clipboard.writeText; |
几个实现上的细节:
- 先调用原始方法,保证 Wordle 自己的“已复制”提示和剪贴板行为不受影响;
- 复制成功或失败都触发同步。复制失败不应该让成绩也丢掉;
- 对同一段文本做短时间去重,避免用户连点两次 Share 产生两次写入;
- 用
GM_xmlhttpRequest发请求,绕开普通网页的跨域限制; - 密码只存在油猴自己的存储里,第一次分享时提示输入,仓库和前端代码中都没有它。
菜单里另外留了“重设密码”“重新同步上次文本”“清除密码”三个入口。同步结果用页面右下角的小提示条展示,不打断 Wordle 本身。
服务端:把读和写分开
VPS 上仍然是 Nginx,没有引入新的应用进程。关键是两个入口分离:
- 公开读入口:返回 JSON,允许跨域,禁止缓存;
- 受保护写入口:只允许
PUT,需要 Basic Auth,限制请求体大小。
写入口启用 Nginx 的 WebDAV 模块,把 PUT 落到一个固定的 JSON 文件。数据文件不放进 Git 工作区,避免部署时和仓库内容互相覆盖。
这样做的好处是权限面很清楚:
- 任何访客只能读,不能写;
- 写入口即使被扫描到,没有凭据也只能拿到 401;
- 前端代码、仓库、静态资源里都没有凭据;
- 数据是普通 JSON,出问题时可以直接查看和备份。
有一个需要明确的边界:这是单人使用的场景。脚本先 GET、合并、再 PUT,没有做多客户端并发控制。如果以后要多设备同时写,应该加版本号、ETag 或服务端锁,而不是假设“最后写入者赢”永远没问题。
前端:只读、校验、算连续天数
首页侧的逻辑反而更简单:
- 从配置里读取公开 JSON 地址;
- 每次请求带时间戳并设置
cache: "no-store",避免 CDN 或浏览器缓存旧成绩; - 用
normalizeResult校验每一条数据:期数、日期、分数、grid的行列数和颜色值; - 用
sanitizeResults按期数去重、排序; - 渲染最新一条,并从最新期数往前推算连续天数;
- 页面可见时每 5 分钟刷新一次,重新回到标签页时也会检查。
连续天数的规则很简单:从最新一期开始,期数连续且 score !== null 就加一天;遇到 X 或缺失的期数就中断。这样“昨天没玩”或“昨天没猜中”都会正确地归零,而不是把历史最长纪录误当成当前连续。
渲染时全部使用 textContent 和 DOM API,不拼接 innerHTML。颜色方块用 CSS Grid 排布,同时给容器设置动态的 aria-label,例如“第 1958 期,3/6,第一行……”。这样即使看不到颜色,也能知道每行的状态。
空状态、加载状态和请求失败状态也都单独处理:
- 加载中:“正在加载成绩…”
- 没有记录:“还没有同步成绩。”
- 请求失败:“成绩加载失败,请稍后刷新。”
失败时保留已有内容,不会因为一次网络抖动把卡片清空。
踩过的坑
1. 全局 margin: 0 会把 <dialog> 顶到左上角。
早期手动导入版用 <dialog> 做弹窗,浏览器默认的 margin: auto 被全局 reset 覆盖,弹窗直接贴在视口左上角。后来给弹窗单独补了 margin: auto 才恢复居中。这个坑很典型:reset 写得越彻底,越要记得给需要浏览器默认布局的元素补回来。
2. 移动端不是“把桌面缩小”。
在 390px 和 320px 宽度下分别检查后,卡片的标题、分数和方块网格都需要重新排布。最终在窄屏下缩小方块、压缩留白,但保留 5 列结构,避免最后一行被挤成 4 个。
3. 浏览器截图工具会记住上一次的视口。
用 CDP 做自动化截图时,会话关闭后视口会回到默认的 500×320。第一次看到“弹窗没居中”的截图,其实是视口变了,不是 CSS 失效。后来固定在同一会话里先设置设备尺寸,再截图并读取 getBoundingClientRect(),才排除掉这个假问题。
4. 缓存和时间戳要一起用。
只加 Cache-Control: no-store 还不够,前端请求也要带时间戳;反过来,只带时间戳而服务端允许强缓存,某些代理仍可能返回旧内容。两边都做,调试时最省心。
安全边界
这个项目没有“用户系统”,所以安全目标不是防止恶意用户登录,而是:
- 不把写权限交给浏览器前端;
- 不把密码写进仓库、静态资源或文章;
- 不让公开读接口暴露任何隐私;
- 只收集展示成绩所必需的颜色数据。
Basic Auth 的密码只存在油猴存储中。需要说明的是,油猴存储不是密码保险箱,它只是避免密码进入网页代码和版本库;如果本机已经有恶意扩展或恶意软件,本地存储同样可能被读取。对个人小项目来说,这个威胁模型是够用的,但不应把它宣传成“绝对安全”。
另外,公开读接口和写接口虽然指向同一个文件,但 Nginx 层把方法限制得很死:读接口只读,写接口只接受 PUT。这样即使前端代码被篡改,也无法通过公开页面直接改写成绩。
验证方式
实现完成后做了几层检查:
- 用 Node 直接测解析器:正常分享文本、
X/6、hard mode、少一行、多一列、行数与分数不匹配; - 用 Node VM 模拟
GM_xmlhttpRequest,确认 GET、合并、PUT、Authorization 头和同期数覆盖都正确; - 用无头浏览器在 1280×900、390×844、320×568 三个尺寸下检查布局;
- 检查明暗主题、空状态、加载状态,以及主动阻断数据请求时的错误状态;
- 部署后再次打开线上页面,确认资源版本、公开 JSON 和空状态都正常。
测试数据走通后会清空线上 JSON,避免把样例成绩当成真实记录。真正的内容等下一次在 Wordle 点 Share 时自然写入。
最后
这个功能没有引入框架,也没有把静态站改造成动态应用。它只是把一条链路拆清楚:
- Wordle 负责产生成绩;
- 油猴脚本负责在正确的时机接住它;
- VPS 负责提供一个受控的写入口和一个公开的读出口;
- 首页只负责展示。
对个人项目来说,这种“小后端 + 静态前端”的组合很划算:平时没有常驻进程,出问题时每一段都能单独检查,数据也始终是可直接阅读的 JSON。
最重要的经验可能是:找一个稳定的集成点,比写一个聪明的爬虫更值。 剪贴板里的那段文本,看起来只是给用户分享用的,但它恰好是整个流程里最稳定的契约。


