本文由 DeepSeek-V4.1-Flash 生成


一张“看一眼”的卡片

我每天都会玩 Wordle。成绩本身不复杂:第几期、几次猜中、六行颜色方块。既然个人主页已经有一个每天都会打开的时钟页,把成绩放到旁边,应该是一件很自然的事。

但主页是纯静态的:没有数据库,没有常驻后端,也不打算为了一个小卡片引入一套服务。于是问题变成了——Wordle 的成绩在纽约时报的页面里,怎么让它跑到我的首页上?

这篇文章记录最终方案,以及中途放弃的做法。出于安全考虑,文中省略了实际域名、服务器路径、认证信息和具体的 Nginx 配置,示例只保留结构与思路。

先排除两条路

最直接的想法是爬 Wordle 页面,或者找它的成绩接口。两条都不太合适:

  • 页面 DOM 和 class 名属于实现细节,改版就失效;
  • 没有稳定的公开成绩 API,跨域也拿不到;
  • 为了一个卡片去模拟登录、逆向接口,维护成本远高于收益。

真正稳定的“接口”其实是用户每天都会点的那个 Share 按钮。它把一段固定格式的文本放进剪贴板:

1
2
3
4
5
Wordle 1,958 3/6

⬛⬛🟨⬛🟨
🟨⬛🟨⬛⬛
🟩🟩🟩🟩🟩

这段文本本来就是给用户看的,格式也足够稳定。与其猜页面结构,不如在复制发生的那一刻把文本接住。

先用手动版验证数据

第一版没有直接上自动同步,而是先做了手动粘贴:P2 放一张卡片,粘贴分享文本,解析后存进 localStorage,再提供“导出 JSON”方便把数据提交到仓库。

它验证了两件事:数据格式够不够用,以及卡片在桌面和手机上怎么排。但用起来还是麻烦——每天都要复制、打开首页、粘贴;而且 localStorage 只存在当前浏览器里,换设备就看不到。于是第二阶段把写入搬到了服务端,首页只保留只读展示。

整体链路

最终的结构可以拆成两半:写和读。

1
2
3
4
5
6
7
8
9
10
11
12
13
Wordle 页面
└─ 点击 Share
└─ 油猴脚本拦截剪贴板
├─ 解析期数 / 分数 / 颜色
├─ 读取现有 JSON
├─ 按期数 upsert
└─ 带认证 PUT 到 VPS
│
▼
公开只读 JSON
│
▼
个人主页 P2 的 Wordle 卡片

前端不再提供“导入成绩”按钮,也不需要用户手动粘贴。访客看到的只是一张只读卡片;写入只发生在安装了油猴脚本的浏览器里。

数据格式:只存颜色,不存答案

成绩 JSON 的结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"version": 1,
"updatedAt": "2026-10-29T12:00:00.000Z",
"results": [
{
"puzzle": 1958,
"date": "2026-10-29",
"score": 3,
"hardMode": false,
"grid": [
["absent", "absent", "present", "absent", "present"],
["present", "absent", "present", "absent", "absent"],
["correct", "correct", "correct", "correct", "correct"]
]
}
]
}

几个约定:

  • grid 只保存 correct / present / absent 三种状态,不保存猜测的单词,更不保存答案;
  • score 是 1–6 的数字;X/6 存为 null,表示没猜中;
  • hardMode 记录分享文本末尾是否带 *;
  • 同期数重复写入时,后一次覆盖前一次,避免历史里出现两条 1958。

这个数据量很小,一期一条,全年也就三百多条。用一个 JSON 文件足够,不需要数据库。

油猴脚本:在 Share 的瞬间接住文本

脚本挂在 Wordle 页面,@run-at document-start,在页面自己的脚本之前执行。核心是替换 navigator.clipboard.writeText:

1
2
3
4
5
6
7
8
9
10
11
12
const original = navigator.clipboard.writeText;

navigator.clipboard.writeText = function (text) {
const result = original.apply(this, arguments);

if (isWordleShareText(text)) {
// 不阻塞页面原本的复制行为
Promise.resolve(result).finally(() => handleShareText(text));
}

return result;
};

几个实现上的细节:

  1. 先调用原始方法,保证 Wordle 自己的“已复制”提示和剪贴板行为不受影响;
  2. 复制成功或失败都触发同步。复制失败不应该让成绩也丢掉;
  3. 对同一段文本做短时间去重,避免用户连点两次 Share 产生两次写入;
  4. 用 GM_xmlhttpRequest 发请求,绕开普通网页的跨域限制;
  5. 密码只存在油猴自己的存储里,第一次分享时提示输入,仓库和前端代码中都没有它。

菜单里另外留了“重设密码”“重新同步上次文本”“清除密码”三个入口。同步结果用页面右下角的小提示条展示,不打断 Wordle 本身。

服务端:把读和写分开

VPS 上仍然是 Nginx,没有引入新的应用进程。关键是两个入口分离:

  • 公开读入口:返回 JSON,允许跨域,禁止缓存;
  • 受保护写入口:只允许 PUT,需要 Basic Auth,限制请求体大小。

写入口启用 Nginx 的 WebDAV 模块,把 PUT 落到一个固定的 JSON 文件。数据文件不放进 Git 工作区,避免部署时和仓库内容互相覆盖。

这样做的好处是权限面很清楚:

  • 任何访客只能读,不能写;
  • 写入口即使被扫描到,没有凭据也只能拿到 401;
  • 前端代码、仓库、静态资源里都没有凭据;
  • 数据是普通 JSON,出问题时可以直接查看和备份。

有一个需要明确的边界:这是单人使用的场景。脚本先 GET、合并、再 PUT,没有做多客户端并发控制。如果以后要多设备同时写,应该加版本号、ETag 或服务端锁,而不是假设“最后写入者赢”永远没问题。

前端:只读、校验、算连续天数

首页侧的逻辑反而更简单:

  1. 从配置里读取公开 JSON 地址;
  2. 每次请求带时间戳并设置 cache: "no-store",避免 CDN 或浏览器缓存旧成绩;
  3. 用 normalizeResult 校验每一条数据:期数、日期、分数、grid 的行列数和颜色值;
  4. 用 sanitizeResults 按期数去重、排序;
  5. 渲染最新一条,并从最新期数往前推算连续天数;
  6. 页面可见时每 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。

最重要的经验可能是:找一个稳定的集成点,比写一个聪明的爬虫更值。 剪贴板里的那段文本,看起来只是给用户分享用的,但它恰好是整个流程里最稳定的契约。