acmer-coach 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +258 -0
- package/assets/extension/luogu/README.md +104 -0
- package/assets/extension/luogu/content.js +137 -0
- package/assets/extension/luogu/manifest.json +22 -0
- package/assets/knowledge/DEPENDS.yaml +391 -0
- package/assets/knowledge/LICENSE +41 -0
- package/assets/knowledge/MAP.yaml +2540 -0
- package/assets/knowledge/NODE_ENTRY.yaml +465 -0
- package/assets/knowledge/NODE_META.yaml +457 -0
- package/assets/knowledge/skilltree.html +1702 -0
- package/assets/oiwiki/LICENSE +49 -0
- package/assets/oiwiki/chunks.jsonl +2177 -0
- package/assets/oiwiki/node-pages.json +1208 -0
- package/assets/pool/LICENSE +83 -0
- package/assets/pool/problems.jsonl.gz +0 -0
- package/assets/rules/coach-rules.md +425 -0
- package/build-node-meta.mjs +98 -0
- package/coach/analyze_map.py +138 -0
- package/coach/build_map.py +230 -0
- package/coach/build_node_pages.py +105 -0
- package/coach/check_page_syntax.mjs +48 -0
- package/coach/coach-metrics.py +313 -0
- package/coach/export_oiwiki_chunks.py +100 -0
- package/coach/export_problem_pool.py +64 -0
- package/coach/extract_oiwiki.py +110 -0
- package/coach/oiwiki_tree.json +2987 -0
- package/coach/render_map.py +226 -0
- package/coach/render_skilltree.py +197 -0
- package/coach/serve_map.py +181 -0
- package/coach/skilltree_template.html +1702 -0
- package/coach/validate_map.py +184 -0
- package/coach/verify_drag.mjs +51 -0
- package/coach/verify_interact.mjs +82 -0
- package/coach/verify_serve.py +138 -0
- package/cordis.patch.yml +10 -0
- package/dev/parity-check.mjs +169 -0
- package/dev/run-all.sh +70 -0
- package/dev/setup-check.mjs +191 -0
- package/dev/w1-check.mjs +170 -0
- package/dev/wiki-check.mjs +100 -0
- package/dev.sh +142 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +325 -0
- package/index.js +5820 -0
- package/lib/client.js +209 -0
- package/lib/setup.js +455 -0
- package/package.json +33 -0
- package/progress-cli.mjs +67 -0
- package/verify-shape.mjs +315 -0
- package/verify.mjs +3535 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miantiao1231
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
⚠️ 本许可只覆盖**代码**。
|
|
26
|
+
|
|
27
|
+
`assets/knowledge/` 下的知识资产衍生自 OI Wiki,适用另一份许可 ——
|
|
28
|
+
详见 `assets/knowledge/LICENSE`。
|
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# acmer-coach · 小鲸教练
|
|
2
|
+
|
|
3
|
+
一个给 ACMer 用的算法训练教练插件,跑在 [dsh](https://github.com/deepseek-ai/deepseek-harness) 上。
|
|
4
|
+
|
|
5
|
+
**它解决的问题**:不是「不知道学什么」——那是搜一下就有答案的。
|
|
6
|
+
是「**不知道现在该学哪一个、该投多少精力**」。
|
|
7
|
+
|
|
8
|
+
这两件事的差别值得说清楚:
|
|
9
|
+
|
|
10
|
+
- 网站给的是**池子**(可以无限,你自己挑)
|
|
11
|
+
- 教练给的是**位置**(必须唯一,有人替你担责)
|
|
12
|
+
|
|
13
|
+
所以这个插件不给你候选列表。你问「今天干什么」,答案**是一个动作**,
|
|
14
|
+
带时间盒和交付物。
|
|
15
|
+
|
|
16
|
+
## 它到底做了什么
|
|
17
|
+
|
|
18
|
+
20 个工具,两条轴:
|
|
19
|
+
|
|
20
|
+
| 轴 | 含义 |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `tier` | 这个知识点在赛场上重不重要 |
|
|
23
|
+
| `entry` | 什么水平**学得起**(题池 p25 ≈ 入门段位) |
|
|
24
|
+
|
|
25
|
+
核心闭环:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
coach_setup 首次:体检 → 建目录建库 → 灌题池 → 从 CF 同步数据
|
|
29
|
+
coach_import 洛谷/牛客的记录从这里进(CF 不用,有公开接口)
|
|
30
|
+
↓
|
|
31
|
+
coach_next 看游标能开哪些节点(前置满足的)
|
|
32
|
+
↓
|
|
33
|
+
coach_assign 布置**一个**动作:做什么 + 时间盒 + 交付物
|
|
34
|
+
↓
|
|
35
|
+
coach_diagnose 没过 → 读代码判因(不会 / 不熟 / 不认真),每条要引你代码原文
|
|
36
|
+
coach_log 过了 → 记一条,然后夸
|
|
37
|
+
↓
|
|
38
|
+
coach_pool → coach_test → coach_grade 章节检测,过了才标「已验证」
|
|
39
|
+
↓
|
|
40
|
+
coach_schedule → coach_plan 排课
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`coach_wiki` 是知识来源:讲任何知识点之前先查本地 OI Wiki,**别凭记忆讲**。
|
|
44
|
+
|
|
45
|
+
**「学过」和「已验证」是两级,绝不合并。** 自评 ≠ 掌握 ——
|
|
46
|
+
合成一个「会/不会」就等于退回「从数据推断掌握度」,而数据能说「花了很久」,
|
|
47
|
+
说不出「卡在哪」。所以 `verified` 手标不了,它只能由检测产生。
|
|
48
|
+
|
|
49
|
+
## 装
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 1. 装 dsh(需要 Node ≥ 24)
|
|
53
|
+
npm i -g @deepseek-ai/dsh
|
|
54
|
+
|
|
55
|
+
# 2. 装插件
|
|
56
|
+
dsh plugin --profile web add acmer-coach
|
|
57
|
+
|
|
58
|
+
# 3. 重启 dsh,然后跟小鲸说「初始化」
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**初始化流程**(说话就行,不用敲命令):
|
|
62
|
+
|
|
63
|
+
1. `coach_setup { action: "status" }` —— 体检,看缺什么
|
|
64
|
+
2. `coach_setup { action: "init" }` —— 建数据目录、铺地图、建训练库
|
|
65
|
+
3. 它会问你 CF handle,然后 `coach_setup { action: "sync", handle: "..." }`
|
|
66
|
+
|
|
67
|
+
**同步不需要 CF 登录、不需要 API key** —— 用的都是 CF 的公开接口
|
|
68
|
+
(`user.info` / `user.status` / `problemset.problems`)。
|
|
69
|
+
同步只搬运**事实**:你过了哪些题、什么难度。**它不推断你会什么。**
|
|
70
|
+
|
|
71
|
+
> ⚠️ **改完代码必须重启**:cordis 的 HMR 只重载配置,不重新 import 模块本体。
|
|
72
|
+
> ESM 模块在进程里只求值一次,之后永远命中缓存 —— 旧代码继续跑,
|
|
73
|
+
> **不报错、不报警**。唯一例外是只改浏览器端(`lib/client.js`),刷新页面即可。
|
|
74
|
+
|
|
75
|
+
### 首次使用:前两周它就是在瞎猜
|
|
76
|
+
|
|
77
|
+
**说清楚这一点**:装完它对你一无所知。没有检测记录、没有判因台账,
|
|
78
|
+
它给的判断只能靠猜。这不是 bug,是数据不够。
|
|
79
|
+
|
|
80
|
+
大概做几次检测之后才开始准。别在前两周就下结论。
|
|
81
|
+
|
|
82
|
+
**定位不是"推断",是二分搜索。** 349 个节点,第一次怎么知道你在哪:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
拉 CF 的 AC 记录 + 标签
|
|
86
|
+
→ 映射到地图节点,算「接触证据」(相关题过了几道、什么难度)
|
|
87
|
+
→ 挑「有证据、且前置也都有证据」的**最深**节点当起点候选
|
|
88
|
+
→ 出卷考一次确认
|
|
89
|
+
过了 → 往前走
|
|
90
|
+
不过 → 往回退一层
|
|
91
|
+
→ 几次收敛
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
为什么不做成「拉数据自动算出你会什么」:**试过,证伪了。**
|
|
95
|
+
一个人某题提交 11 次,数据能说"花了很久",说不出"缺哪个机制"。
|
|
96
|
+
"他碰过"和"他会"是两件事,前者能从数据看出来,后者只能靠检测。
|
|
97
|
+
|
|
98
|
+
## 三个平台怎么接
|
|
99
|
+
|
|
100
|
+
**题池是随包发的**(41,518 道,三个平台),`coach_setup init` 时灌进本地库。
|
|
101
|
+
没有它 `coach_pool` 挑不出「他没做过」的题 —— 那个条件永远满足不了。
|
|
102
|
+
|
|
103
|
+
**提交记录**是另一回事,三个平台难度差很多:
|
|
104
|
+
|
|
105
|
+
| 平台 | 题池 | 提交记录 | 怎么拿 |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| **Codeforces** | ✓ | ✓ | **官方公开接口**,`coach_setup { action: "sync", handle: "..." }` 一条命令,不需要登录 |
|
|
108
|
+
| **Nowcoder** | ✓ | — | 没有公开的记录接口。走 `coach_import`(自己导出 / 手敲) |
|
|
109
|
+
| **Luogu** | ✓ | — | 同上。仓库里带了个**可选**的浏览器扩展,见下 |
|
|
110
|
+
|
|
111
|
+
### ⚠️ 洛谷:先说风险
|
|
112
|
+
|
|
113
|
+
洛谷的服务条款不欢迎抓取,而且**这个项目的原版有账号因爬取被封的真实先例**。
|
|
114
|
+
题池里那 16,483 道洛谷题是爬来的(见 `assets/pool/LICENSE`)。
|
|
115
|
+
|
|
116
|
+
`assets/extension/luogu/` 里带了一个扩展,它**不发任何网络请求** ——
|
|
117
|
+
只读你屏幕上已经显示的内容,然后让你下载一个文件。比爬虫轻得多,**但不等于零**。
|
|
118
|
+
|
|
119
|
+
**用不用你自己判断。** 不想担风险的话:只关注 CF(零风险),
|
|
120
|
+
或者手动记几道题。详见 `assets/extension/luogu/README.md`。
|
|
121
|
+
|
|
122
|
+
## 文件
|
|
123
|
+
|
|
124
|
+
| 文件 | 作用 |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `index.js` | 插件本体(host 端,20 个工具 + 规则注入) |
|
|
127
|
+
| `lib/setup.js` | 环境搭建 + CF 公开数据同步 |
|
|
128
|
+
| `lib/client.js` | 浏览器端:会话头部「技能树」按钮 + 弹层 |
|
|
129
|
+
| `package.json` | 声明 `dsh.bundle.patch` + `dsh.client` |
|
|
130
|
+
| `cordis.patch.yml` | bundle 层的 insert 行 |
|
|
131
|
+
| `progress-cli.mjs` | 进度 CLI。**它不自己实现规则**,只翻译 argv |
|
|
132
|
+
| `verify.mjs` | 验收(几百条机器检查)。跑的是**已安装副本**,不是源码 |
|
|
133
|
+
| `verify-shape.mjs` | 形状扫描:把每个工具的返回值喂回它自己的 `output.schema`,抓「返回里有、schema 里没有」这类只在运行时才炸的错 |
|
|
134
|
+
| `dev.sh` | 开发回路:同步 + 验收,一条命令 |
|
|
135
|
+
| `build-node-meta.mjs` | 算每个节点的 `entry`(跑题池,离线) |
|
|
136
|
+
| `assets/rules/` | **教练规则** —— 由插件注入 system prompt |
|
|
137
|
+
| `assets/knowledge/` | 地图与元数据 |
|
|
138
|
+
| `assets/oiwiki/` | OI Wiki 本地检索库 |
|
|
139
|
+
| `assets/pool/` | 随包发的题池(41,518 道,三平台)—— 见该目录下的 LICENSE(有风险说明) |
|
|
140
|
+
| `assets/extension/` | 可选的浏览器扩展(洛谷记录导出) |
|
|
141
|
+
| `coach/` | 地图工具链(Python)+ 指标脚本 |
|
|
142
|
+
|
|
143
|
+
### 三层
|
|
144
|
+
|
|
145
|
+
| 层 | 内容 | 怎么生效 |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| **规则** | `assets/rules/coach-rules.md` | 插件用 `systemPrompt.section()` 注进 system prompt(和人设同一条路)。**改它要重启 dsh** |
|
|
148
|
+
| **知识** | 地图 4 份 + OI Wiki 检索库 | `coach_setup init` 铺到数据目录;OI Wiki 直接从包里读 |
|
|
149
|
+
| **工具** | 19 个 | 装完就在 |
|
|
150
|
+
|
|
151
|
+
规则进的是 **system prompt,不是消息流**:行为约束每轮都得在场,
|
|
152
|
+
而消息流会被长对话把尾巴挤掉。文本是**静态**的(只在加载时读一次)——
|
|
153
|
+
每轮现算会打穿 prompt cache。
|
|
154
|
+
|
|
155
|
+
### assets/knowledge/
|
|
156
|
+
|
|
157
|
+
| 文件 | 内容 |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `MAP.yaml` | 349 个知识点 / 401 条前置边。**由 `coach/build_map.py` 生成,别手改** |
|
|
160
|
+
| `DEPENDS.yaml` | 前置关系的**唯一真相源**(人维护),改它再重跑 `build_map.py` |
|
|
161
|
+
| `NODE_ENTRY.yaml` | 每个节点的入门段位(题池 p25),机器算 |
|
|
162
|
+
| `NODE_META.yaml` | `tier` 标注(core / normal / rare / skip),人维护 |
|
|
163
|
+
|
|
164
|
+
`PROGRESS.yaml` 和 `SCHEDULE.yaml` **不随包发** —— 它们是你的进度和日程,
|
|
165
|
+
首次写入时由插件自己生成(带正确的文件头)。发模板等于给文件头造第二份真相。
|
|
166
|
+
|
|
167
|
+
## 开发回路
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
bash dev.sh # 同步副本 + 验收 + 形状扫描 + 比账本指纹
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
单独跑:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
node verify.mjs && node verify-shape.mjs # 主验收
|
|
177
|
+
node dev/w1-check.mjs # 解绑 + 闸门 + 规则注入
|
|
178
|
+
node dev/wiki-check.mjs # OI Wiki 检索
|
|
179
|
+
node dev/setup-check.mjs # 冷启动(离线)
|
|
180
|
+
COACH_TEST_NET=1 node dev/setup-check.mjs <handle> # 冷启动(真打 CF)
|
|
181
|
+
bash dev/trace-scan.sh # 开源前反证:没留私人痕迹
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
同步用 `cp`,**不走 `dsh plugin remove/add`** —— 那条路实测不可靠
|
|
185
|
+
(pnpm 可能只复制 store 里的缓存版本,于是 add 打印成功、副本却仍是旧的)。
|
|
186
|
+
|
|
187
|
+
`verify.mjs` 第 0 关是第二道保险:源码和副本不一致就 `exit 1`,不许往下测。
|
|
188
|
+
|
|
189
|
+
## 设计上的几个坑(都踩过,写下来)
|
|
190
|
+
|
|
191
|
+
**1. `file:` 依赖装出来的是硬链接,不是拷贝。**
|
|
192
|
+
原地写两边同变,但**改名式写入**(Edit 工具、VS Code 原子保存、
|
|
193
|
+
`vim backupcopy=no`)会断开链接 —— 断开后两边分家,而 `dsh plugin add`
|
|
194
|
+
认不出分家、**不重链也不报错**。于是「改完 → 重装 → 测试绿」能一路绿到底,
|
|
195
|
+
验的却是老代码。这是静默的,所以 `dev.sh` 逐个文件比 inode,
|
|
196
|
+
`verify.mjs` 第 0 关再比一次内容。
|
|
197
|
+
|
|
198
|
+
**2. 同一件事别写两遍。**
|
|
199
|
+
夹具写接口的闸门,判据和 `DATA_DIR` **必须同源、同一时刻捕获**。
|
|
200
|
+
早先判据是"调用时读环境变量"、而 `DATA_DIR` 是"加载时定"——
|
|
201
|
+
两个时刻的事实可以不一样,于是出现「数据目录是默认的、闸门却放行」。
|
|
202
|
+
闸门自己长出了它要防的那种 bug。
|
|
203
|
+
|
|
204
|
+
**3. 查不到的时候,不许硬凑。**
|
|
205
|
+
`coach_wiki` 的检索把查询词分两档:整串/词(**必须命中**)和中文二元组
|
|
206
|
+
(**只参与打分**)。二元组太松 ——「不存在的知识点」能切出「知识」「存在」,
|
|
207
|
+
满库都是,于是随便输点什么都能返回 5 条**看着像结果**的东西。
|
|
208
|
+
那比返回空更坏:空结果会让人换个词,假结果会让人直接开讲。
|
|
209
|
+
|
|
210
|
+
> 闸门只能拦住**机械可判**的那一半。剩下的(该不该复习、这块讲没讲过、
|
|
211
|
+
> 这个节点是不是他其实早不想学了)只能靠判断,写不进代码。
|
|
212
|
+
> 别指望工具把教练的活全干了。
|
|
213
|
+
|
|
214
|
+
## 对外暴露的 HTTP 路由
|
|
215
|
+
|
|
216
|
+
`/coach` 一棵子树(`ctx.webServer.register`,prefix 一条,内部再分发):
|
|
217
|
+
|
|
218
|
+
| 路径 | 干什么 |
|
|
219
|
+
|---|---|
|
|
220
|
+
| `/coach` | 302 → `/coach/skilltree.html`(不给目录列表) |
|
|
221
|
+
| `/coach/skilltree.html` | 吐技能树页面 |
|
|
222
|
+
| `/coach/api/progress` | GET 读进度 / POST 写(`mark` / `cursor`) |
|
|
223
|
+
| `/coach/api/schedule` | GET 读日程 / POST 写。日程视图读写 `SCHEDULE.yaml` 的唯一通道 |
|
|
224
|
+
|
|
225
|
+
**鉴权交给 dsh 自己**:主闸是 `connection.requestRejection(req)` ——
|
|
226
|
+
dsh 的签名 cookie + Host fence。**不自己抄一套 loopback 检查**:
|
|
227
|
+
抄的话 dsh 改鉴权规则这边就落后了。
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## 致谢与许可
|
|
232
|
+
|
|
233
|
+
### 知识点地图来自 OI Wiki
|
|
234
|
+
|
|
235
|
+
`MAP.yaml` 的知识点与分类取自 [**OI Wiki**](https://github.com/OI-wiki/OI-wiki)
|
|
236
|
+
的导航结构(`mkdocs.yml`,351 条),前置关系由人工维护的 `DEPENDS.yaml` 补充。
|
|
237
|
+
`assets/oiwiki/` 直接是 OI Wiki 正文的本地检索副本。
|
|
238
|
+
|
|
239
|
+
OI Wiki 是个了不起的社区项目 —— 如果你觉得这里的地图有用,
|
|
240
|
+
**请去给上游点个 star**:<https://github.com/OI-wiki/OI-wiki>
|
|
241
|
+
|
|
242
|
+
> ⚠️ **前置边是人工整理的,首轮未经全面校对。** 出现「为什么 X 依赖 Y」
|
|
243
|
+
> 这种疑问是正常的,欢迎提 issue 修正。地图说「**允许**学什么」,
|
|
244
|
+
> 不说「**合适**学什么」—— 后者是教练的判断,不是地图的。
|
|
245
|
+
|
|
246
|
+
### 许可分层
|
|
247
|
+
|
|
248
|
+
| 部分 | 许可 |
|
|
249
|
+
|---|---|
|
|
250
|
+
| 代码(`index.js` / `lib/` / `*.mjs` / `coach/*.py` 等) | MIT(见根目录 `LICENSE`) |
|
|
251
|
+
| `assets/knowledge/` | **CC BY-SA 4.0** —— 衍生自 OI Wiki |
|
|
252
|
+
| `assets/oiwiki/` | **CC BY-SA 4.0** —— OI Wiki 正文 |
|
|
253
|
+
| `assets/pool/` | **来源与风险见该目录下的 `LICENSE`** —— CF 部分是官方公开接口(无风险),洛谷部分是爬取的(有风险),仓库不对它主张权利 |
|
|
254
|
+
|
|
255
|
+
OI Wiki 的内容部分采用 **CC BY-SA 4.0**(署名 + 相同方式共享)及附加的
|
|
256
|
+
[The Star And Thank Author License](https://github.com/zTrix/sata-license)。
|
|
257
|
+
本仓库对衍生部分沿用同一许可,署名 **OI Wiki Team**。
|
|
258
|
+
分层声明见各目录下的 `LICENSE`。
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# 洛谷记录导出(可选)
|
|
2
|
+
|
|
3
|
+
一个极简浏览器扩展:**把当前打开的洛谷评测记录页导出成 JSON 文件**,
|
|
4
|
+
再交给教练导入。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## ⚠️ 先说风险,再说怎么用
|
|
9
|
+
|
|
10
|
+
**这一条请看完再决定装不装。**
|
|
11
|
+
|
|
12
|
+
| 风险 | 说明 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| **洛谷的 ToS 不欢迎抓取** | 洛谷的服务条款对自动化抓取是禁止的。本扩展不抓取,但也属于"工具化地取数据",**存在解释空间** |
|
|
15
|
+
| **有封号先例,而且是真实的** | 这个项目的最初版本用服务器代理 + 浏览器直连抓记录,**账号被封过**。这不是理论风险 |
|
|
16
|
+
| **本扩展做得更轻,但不等于零** | 它只读**你屏幕上已经显示的内容**,不发任何额外请求(详见下)。风险显著低于爬虫,**但不为零** |
|
|
17
|
+
| **出问题只能自己担** | 本扩展和 acmer-coach 都不提供任何担保 |
|
|
18
|
+
|
|
19
|
+
**要不要用,你自己判断。** 如果你不想承担这个风险,
|
|
20
|
+
也有别的路:手动记几道题、或者只关注 CF(CF 有官方公开接口,一点风险没有)。
|
|
21
|
+
|
|
22
|
+
### 为什么说它比爬虫轻得多
|
|
23
|
+
|
|
24
|
+
| 做法 | 发请求? | 洛谷看到的 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 爬虫 / 脚本 | ✅ 大量 | 一个不正常的客户端在高速拉数据 |
|
|
27
|
+
| 早期版本(已废弃) | ✅ 有 | 服务器机房 IP 在拉数据 |
|
|
28
|
+
| **本扩展** | ❌ **一个都不发** | **和"用户自己在看页面"完全一样** |
|
|
29
|
+
|
|
30
|
+
具体地说:扩展**没有 `host_permissions`,没有任何 fetch/XHR**。
|
|
31
|
+
它做的事只有两件 —— 读当前页面 DOM 里已经存在的那个 `<script>`,
|
|
32
|
+
然后让你下载一个文件。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 装
|
|
37
|
+
|
|
38
|
+
1. 浏览器开 `edge://extensions`(Edge)或 `chrome://extensions`(Chrome)
|
|
39
|
+
2. 打开右上角「开发人员模式」
|
|
40
|
+
3. 点「加载解压缩的扩展」,选**本文件夹**
|
|
41
|
+
4. 打开任意洛谷页面 → 右下角出现「📥 导出洛谷记录」
|
|
42
|
+
|
|
43
|
+
## 用
|
|
44
|
+
|
|
45
|
+
1. 打开**你自己的**评测记录页:`www.luogu.com.cn/record/list?user=你的uid`
|
|
46
|
+
2. 点右下角那个按钮
|
|
47
|
+
3. 浏览器下载一个 `luogu-<uid>-<日期>.json`
|
|
48
|
+
4. 把文件交给教练,说:
|
|
49
|
+
|
|
50
|
+
> 导入这个文件:`C:\Users\你\Downloads\luogu-1855438-2026-09-29.json`
|
|
51
|
+
|
|
52
|
+
或者直接让教练调 `coach_import`,参数 `path` 填那个路径。
|
|
53
|
+
|
|
54
|
+
## 已知限制
|
|
55
|
+
|
|
56
|
+
**只导出当前这一页。** 洛谷的记录页是分页的,按钮导出的是**你打开的那一页**
|
|
57
|
+
(通常是最新的 20 条左右)。要更多记录,得逐页打开再各点一次。
|
|
58
|
+
|
|
59
|
+
为什么不自动翻页:那就要发请求了 —— 而「发请求」正是上面表里风险最大的那一档。
|
|
60
|
+
**宁可麻烦一点。**
|
|
61
|
+
|
|
62
|
+
## 导出格式
|
|
63
|
+
|
|
64
|
+
一个 JSON 数组,直接就是 `coach_import` 认的格式:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
[
|
|
68
|
+
{"platform":"luogu","problem_id":"P1001","verdict":"12",
|
|
69
|
+
"submitted_at":"2026-09-01T10:00:00.000Z","time_ms":0,"language":"lang28"}
|
|
70
|
+
]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`verdict` 保留的是**洛谷的原始数字码**(`12` = AC)——
|
|
74
|
+
扩展不做解释,映射成 AC / 不 AC 是插件那边的事(见 `lib/setup.js` 的 `normalizeRecord`)。
|
|
75
|
+
这么分是因为:**判断逻辑只能有一份**,放在扩展里就是第二份。
|
|
76
|
+
|
|
77
|
+
## 排障
|
|
78
|
+
|
|
79
|
+
| 现象 | 原因 |
|
|
80
|
+
|---|---|
|
|
81
|
+
| 「这个页面上没有记录数据」 | 你不在记录页上。URL 里要有 `/record/list` |
|
|
82
|
+
| 右下角没有按钮 | 扩展没启用,或者页面不是 luogu.com.cn,或者没刷新 |
|
|
83
|
+
| 改了代码没生效 | 扩展页点 ↻ 重新加载,然后刷新洛谷页面 |
|
|
84
|
+
| 导出的条数比预期少 | 见上面「已知限制」—— 只导当前页 |
|
|
85
|
+
|
|
86
|
+
## 文件
|
|
87
|
+
|
|
88
|
+
| 文件 | 职责 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `manifest.json` | MV3。**没有任何 host_permissions** —— 它连不出去 |
|
|
91
|
+
| `content.js` | 读当前页内嵌数据 + 下载按钮 |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 和本项目其它部分的关系
|
|
96
|
+
|
|
97
|
+
**这个扩展是可选的。** 教练本身不需要它 ——
|
|
98
|
+
CF 用户完全用不上(`coach_setup sync` 直接拉官方公开接口)。
|
|
99
|
+
|
|
100
|
+
它的存在只是因为:**洛谷和牛客没有公开的提交记录接口**,
|
|
101
|
+
而"读过哪些题"这条数据对教练有用(`coach_pool` 靠它排除你做过的题)。
|
|
102
|
+
|
|
103
|
+
如果你有别的办法拿到这份数据,只要能凑成上面那个 JSON 格式,
|
|
104
|
+
就完全不需要这个扩展。
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 洛谷记录导出 —— 给 acmer-coach 用的一个按钮。
|
|
3
|
+
*
|
|
4
|
+
* 它做且只做一件事:**把当前这一页里已经存在的记录数据抠出来,存成 JSON 文件。**
|
|
5
|
+
*
|
|
6
|
+
* ── 设计上的三个「不做」,每一个都是被逼的 ──
|
|
7
|
+
*
|
|
8
|
+
* 1. **不发任何网络请求。** 包括不发给你自己的 coach 插件。
|
|
9
|
+
* 早期版本是 POST 给一台服务器落库,但那样要处理鉴权、端口发现、
|
|
10
|
+
* 以及"扩展能往本机哪个端口发"这一堆事;而且你没法确认它到底发了什么。
|
|
11
|
+
* 现在它只读当前页面 + 存文件 —— **对洛谷来说,这跟"用户自己在看页面"没有区别**。
|
|
12
|
+
*
|
|
13
|
+
* 2. **不自己 fetch 数据接口。** 洛谷对 `_contentOnly=1` 那种 XHR 请求会返回
|
|
14
|
+
* 首页壳(HTTP 200 + text/html),换账号、换登录态都一样。
|
|
15
|
+
* 但你自己打开记录页时,服务端直出的数据是**完整嵌在页面里**的
|
|
16
|
+
* (某个 <script> 里躺着 {"instance":"main","template":"record.list",…,"data":{…}}),
|
|
17
|
+
* 字段和接口返回值一模一样。所以读"已经在你眼前的那份"就够了。
|
|
18
|
+
*
|
|
19
|
+
* 3. **不解析、不改写。** 导出的是原始记录(`status` 还是洛谷的数字码),
|
|
20
|
+
* 映射成 AC / 不 AC 是插件那边的事 —— 扩展不该持有第二份判断逻辑。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ 风险:洛谷的服务条款不欢迎抓取,且**已有账号因此被封的先例**。
|
|
23
|
+
* 本扩展做的是"读你自己屏幕上已经显示的内容",风险显著低于爬虫,
|
|
24
|
+
* 但不等于零。用不用你自己判断 —— 见同目录 README。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
(() => {
|
|
28
|
+
if (window.__acmerCoachExportLoaded) return;
|
|
29
|
+
window.__acmerCoachExportLoaded = true;
|
|
30
|
+
|
|
31
|
+
const UI_ID = 'acmer-coach-export';
|
|
32
|
+
|
|
33
|
+
/** 从当前页面的 <script> 里抠出记录数据。找不到返回 null。 */
|
|
34
|
+
function readPageRecords() {
|
|
35
|
+
for (const s of document.querySelectorAll('script')) {
|
|
36
|
+
const t = (s.textContent || '').trim();
|
|
37
|
+
// 只可能是内嵌的 JSON 对象;日志/统计脚本一堆,先按形状筛
|
|
38
|
+
if (t.length < 200 || t[0] !== '{') continue;
|
|
39
|
+
let j = null;
|
|
40
|
+
try { j = JSON.parse(t); } catch { continue; }
|
|
41
|
+
const recs = j?.data?.records;
|
|
42
|
+
if (recs && Array.isArray(recs.result)) {
|
|
43
|
+
return { records: recs.result, count: typeof recs.count === 'number' ? recs.count : null };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 这一页是谁的?优先看 URL 的 ?user=,没有就从记录自带的 user.uid 认。 */
|
|
50
|
+
function pageUid(page) {
|
|
51
|
+
const u = new URLSearchParams(location.search).get('user');
|
|
52
|
+
if (u && /^\d+$/.test(u)) return Number(u);
|
|
53
|
+
const first = page?.records?.[0];
|
|
54
|
+
return first?.user?.uid ? Number(first.user.uid) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 原始记录 → 导入格式。
|
|
59
|
+
* **只搬运,不解释** —— verdict 保留洛谷的原始数字码,插件那边认 `12` 是 AC。
|
|
60
|
+
*/
|
|
61
|
+
function toImportFormat(records) {
|
|
62
|
+
return records.map((r) => ({
|
|
63
|
+
platform: 'luogu',
|
|
64
|
+
problem_id: r?.problem?.pid || r?.pid || '',
|
|
65
|
+
verdict: String(r?.status ?? ''),
|
|
66
|
+
submitted_at: r?.submitTime ? new Date(r.submitTime * 1000).toISOString() : '',
|
|
67
|
+
time_ms: Number(r?.time ?? 0) || 0,
|
|
68
|
+
language: String(r?.language ? `lang${r.language}` : ''),
|
|
69
|
+
})).filter((r) => r.problem_id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function download(name, text) {
|
|
73
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
|
74
|
+
const a = document.createElement('a');
|
|
75
|
+
a.href = url;
|
|
76
|
+
a.download = name;
|
|
77
|
+
document.body.appendChild(a);
|
|
78
|
+
a.click();
|
|
79
|
+
a.remove();
|
|
80
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── UI:右下角一个小圆钮 ────────────────────────────────────────
|
|
84
|
+
function build() {
|
|
85
|
+
if (document.getElementById(UI_ID)) return;
|
|
86
|
+
const box = document.createElement('div');
|
|
87
|
+
box.id = UI_ID;
|
|
88
|
+
box.style.cssText = [
|
|
89
|
+
'position:fixed', 'right:18px', 'bottom:18px', 'z-index:2147483647',
|
|
90
|
+
'font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif',
|
|
91
|
+
].join(';');
|
|
92
|
+
box.innerHTML = `
|
|
93
|
+
<button id="${UI_ID}-btn" style="all:unset;cursor:pointer;background:#1a1a1a;color:#fff;
|
|
94
|
+
padding:9px 14px;border-radius:20px;box-shadow:0 2px 10px rgba(0,0,0,.25);user-select:none">
|
|
95
|
+
📥 导出洛谷记录
|
|
96
|
+
</button>
|
|
97
|
+
<div id="${UI_ID}-msg" style="margin-top:6px;max-width:260px;padding:6px 10px;border-radius:8px;
|
|
98
|
+
background:#fff;color:#333;box-shadow:0 2px 10px rgba(0,0,0,.15);display:none;white-space:pre-wrap"></div>
|
|
99
|
+
`;
|
|
100
|
+
document.body.appendChild(box);
|
|
101
|
+
|
|
102
|
+
const msg = box.querySelector(`#${UI_ID}-msg`);
|
|
103
|
+
const say = (t, ms = 6000) => {
|
|
104
|
+
msg.textContent = t;
|
|
105
|
+
msg.style.display = 'block';
|
|
106
|
+
clearTimeout(say._t);
|
|
107
|
+
if (ms) say._t = setTimeout(() => { msg.style.display = 'none'; }, ms);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
box.querySelector(`#${UI_ID}-btn`).addEventListener('click', () => {
|
|
111
|
+
const page = readPageRecords();
|
|
112
|
+
if (!page) {
|
|
113
|
+
say('这个页面上没有记录数据。\n请打开「评测记录」页再点 —— \nwww.luogu.com.cn/record/list?user=你的uid');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const rows = toImportFormat(page.records);
|
|
117
|
+
if (!rows.length) { say('这一页里没解析出记录。'); return; }
|
|
118
|
+
const uid = pageUid(page) ?? 'unknown';
|
|
119
|
+
const fname = `luogu-${uid}-${new Date().toISOString().slice(0, 10)}.json`;
|
|
120
|
+
download(fname, JSON.stringify(rows));
|
|
121
|
+
|
|
122
|
+
const total = page.count ?? '?';
|
|
123
|
+
say(`已导出 ${rows.length} 条 → ${fname}\n` +
|
|
124
|
+
(typeof page.count === 'number' && page.count > page.records.length
|
|
125
|
+
? `⚠️ 这个号总共有 ${total} 条记录,**这一页只是第一页**。\n` +
|
|
126
|
+
`要全部的话得逐页打开再各点一次(翻页会带上 page= 参数)。`
|
|
127
|
+
: `总共 ${total} 条,都在这一页里了。`) +
|
|
128
|
+
`\n\n把这个文件交给教练:说一句「导入这个文件」+ 路径。`);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (document.readyState === 'loading') {
|
|
133
|
+
document.addEventListener('DOMContentLoaded', build);
|
|
134
|
+
} else {
|
|
135
|
+
build();
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "洛谷记录导出(给 acmer-coach 用)",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "读你当前打开的洛谷评测记录页,导出成 JSON 文件。零网络请求、零定时、只在你点击时跑。",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"downloads"
|
|
8
|
+
],
|
|
9
|
+
"content_scripts": [
|
|
10
|
+
{
|
|
11
|
+
"matches": [
|
|
12
|
+
"https://www.luogu.com.cn/*",
|
|
13
|
+
"https://luogu.com.cn/*"
|
|
14
|
+
],
|
|
15
|
+
"js": [
|
|
16
|
+
"content.js"
|
|
17
|
+
],
|
|
18
|
+
"run_at": "document_idle",
|
|
19
|
+
"all_frames": false
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|