洛谷团队管理的同学,多少都经历过这种场景:比赛前一天要把报名链接、比赛规则、注意事项整理成公告发到团队帖子里,发布后还得在帖子里逐一把成员 @ 一遍,提醒大家及时查看。团队小一点还好,五十人上百人的时候,光复制粘贴用户名就能耗掉十几分钟,中途手抖漏掉一两个人也没法立刻发现。我在管理自己的刷题团队时被这件事折磨了挺久,最后干脆写了一个脚本,在洛谷团队帖子的编辑页里加一个按钮,点一下就把全体成员按可识别的格式 @ 出来。这篇文章就把这个"一键 @ 所有人"小工具从需求到落地的完整过程写清楚,包括洛谷团队页面背后的数据交互逻辑、成员列表的抓取方式、@ 消息的构造技巧,以及实测中踩过的权限和频率限制的坑。
1. 痛点从哪来:洛谷团队管理的通知困境
先说清楚我为什么会动这个心思。洛谷的团队功能其实是很多刷题组织、校内 ACM 集训队、算法爱好者群组沉淀内容和组织比赛的重要载体。团队管理员通常要做的事情不仅仅是发公告,还包括:每周固定出题、组织队内模拟赛、分享题解和学习路线、在比赛前提醒成员按时参赛。这些场景几乎都离不开"通知到每一个人"。
洛谷的帖子机制里,@ 功能是存在的,但它的设计是针对"单个成员"的,并没有提供"一键 @ 全部成员"的原生入口。我在团队帖子里发公告时,最原始的流程是这样的:
- 打开团队成员列表页,一页一页翻,把每个成员的用户名复制下来。
- 回到公告编辑框,在对应位置粘贴用户名,前面补上 @ 符号。
- 反复检查有没有漏掉人,尤其是那些刚加入、名字排在后面几页的成员。
这个过程有非常明显的问题。第一是极耗时间,五十个成员就要复制粘贴五十次,一百个成员就是一百次,每次还容易在编辑器里点错位置。第二是容易遗漏,只要翻页时漏看一个用户,对方就收不到通知,回头还要单独补发,很尴尬。第三是公告一旦需要修订重发,整个 @ 名单又得重新来一遍,没有任何复用性。
我当时的第一反应是:洛谷的团队页面一定会在前端向服务器请求成员列表,只要拿到那个请求的地址和参数,脚本里直接调用就能获取所有成员。有了成员数据,剩下的就是把名字拼成 @ 文本塞进编辑器。整个思路听起来不复杂,但真正动手之后才发现,有几个环节比预想的要麻烦得多。
首先是洛谷前端对用户信息的渲染方式。直接抓页面 HTML 里的用户名是可行的,但如果页面只渲染了当前可见的一页成员,那就必须触发翻页或者直接去找更底层的 JSON 接口。其次是编辑器的内容格式。洛谷的帖子编辑器不是简单的<textarea>,它是一套富文本编辑器,直接往里面写纯文本,保存后未必能正确解析 @ 关系。这两个问题不解决,脚本就只能在表面做做样子。
所以我的思路很明确:先通过浏览器开发者工具观察团队页面加载时发出的网络请求,找到返回全量成员数据的那个接口;再研究肉眼可见的 @ 行为背后到底向服务端提交了什么格式的数据。只有这两步走通,才能实现真正能用的"一键 @ 所有人"。
2. 先搞清楚平台底层的机制
2.1 洛谷团队页面的数据来源
洛谷的网页端是一个典型的 SPA 应用,页面内容是 JavaScript 动态渲染的,直接看 HTML 源代码看不到多少有价值的东西。我在 Chrome 里按 F12 打开开发者工具,切到 Network 面板,然后刷新团队成员页面,很快就能看到页面在加载过程中发起的各种请求。
这里面最让我关注的是一类返回 JSON 数据的接口。洛谷的大部分异步接口都是POST请求,路径类似于/api/team/xxx/members这种风格,请求体里带上团队 ID 和分页参数。响应数据的结构通常是这样的:
{ "code": 0, "data": { "members": [ { "uid": 12345, "username": "example_user", "name": "昵称", "role": "admin" } ], "pagination": { "currentPage": 1, "totalPages": 5, "totalCount": 83 } } }注意这里的code字段,洛谷的接口通常约定code为 0 表示成功,非 0 就是失败。data.pagination.totalPages告诉我们成员检索分了多少页,脚本里要拿到全部成员,就必须按页码把所有页的数据都请求一遍,不能只取第一页。
这一步是整个工具的基础。成员数据里每个对象都有uid和username,后者是 @ 消息里真正会展示给成员看的标识。不同团队的成员可能还有role字段,比如admin、user之类,这在后面判断"哪些人能触发 @ 所有人"时也用得到。
2.2 帖子编辑器里的 @ 到底是什么
解决了"从哪里拿成员列表"的问题,下一个问题是"@ 消息在编辑器里到底是什么形式"。我一开始以为,只要在内容里拼上@用户名就能达到效果,结果发现洛谷的编辑器对这种纯文本格式并不买单,发出去之后可能只是普通文本,并不会触发真正的通知逻辑。
为了搞清楚编辑器到底怎么保存 @ 信息,我做了个实验:在一个测试团队帖子里手动 @ 一个成员,然后用开发者工具观察提交帖子时发出的请求体。洛谷的帖子提交接口同样是一个 POST 请求,请求体里有一个字段保存着帖子的"内容",但这个内容并不是编辑器里看到的那个纯文本,而是一段带有特殊标记的结构化文本。
对洛谷来说,帖子内容在内部使用一种私有标记语言。当你在编辑器里 @ 了某个用户时,这段内容里会插入类似这样的标记片段:
@{username:uid}也就是说,@ 一个用户并不仅仅是文本上的"@名字",它需要把用户的唯一标识uid一起带上,这样服务端才能准确解析出被通知的对象。如果只写@username而不带 uid,就算显示出来了,追问通知链路的时候也很容易出问题。
这里有一个很关键的经验:在看不清平台存储格式的时候,最好的办法不是推测,而是自己手动操作一次,再把发出的请求体拿出来逐字段拆解。浏览器开发者工具里的网络请求面板就是最好的逆向参考。
2.3 手动操作后,请求体的拆解
我的测试流程是这样的:
- 在团队帖子里新建一个回复,正文随意写一句"测试 @ 通知"。
- 点击编辑器工具栏的 @ 按钮,从弹出的成员列表里选择一个成员。
- 点击发布,同时注意在 Network 面板里找到发布帖子的请求。
- 查看请求的 Payload,把里面的 content 字段复制出来分析。
结果发现,当我只 @ 一个成员的时候,content 里对应的片段是@{test_user:10086}这种格式。从这里就明白了:只要把团队所有成员的username和uid拼成连续的多段@{username:uid}文本,粘贴进编辑器再发布,服务端就会把这些成员全部拉入通知范围。
拿到这个结论后,剩下的工程问题就简单了:成员的uid和username在第一步的成员列表接口里都有,直接把它们映射成@{username:uid}格式并拼接起来就行。拼接顺序甚至可以按成员加入团队的顺序来排,不重要的,通知逻辑只看有没有这个标记。
3. 开发前的准备工作:选型与调试环境
3.1 为什么不写独立程序,而选用户脚本
确定了技术方向之后,我面临一个选型问题:用什么形式来做这个工具?我当时考虑了三条路:
| 方案 | 优势 | 劣势 |
|---|---|---|
| 浏览器插件(扩展) | 功能完整、可以做得非常复杂 | 需要维护 manifest 版本、打包、在扩展商店上架,开发成本高 |
| 独立 Python 脚本 | 可以直接调用登录接口、脱离浏览器 | 需要处理登录凭证,容易被风控拦截,使用门槛高 |
| 用户脚本(Tampermonkey) | 开发简单、直接跑在浏览器里、复用已登录的会话 | 依赖油猴扩展,普通用户需要额外安装 |
最后我选择了油猴用户脚本。理由很实际:我自己的需求就是"在编辑团队帖子时,点一下按钮把成员列表变成 @ 消息",这本质上是一个页面前端增强功能,用户脚本正好长在这个场景里。油猴脚本不需要打包、不需要上架,写好 JS 代码就能直接跑,而且它天然继承了浏览器里已有的登录态,访问网页的接口不需要额外处理 Cookie 或 Token,省掉了最麻烦的登录认证环节。
另外,油猴脚本的安装也对团队成员友好。别的小白成员如果想用,只需要装一个 Tampermonkey 扩展,再把脚本地址导入进去就行了,不需要理解任何代码。
3.2 搭建本地调试环境
开发用户脚本的调试环境并不复杂,但我还是建议按照下面这套标准流程来,能省掉很多反复折腾的时间:
- 浏览器:Chrome 或 Edge 都行,我用的是 Chrome。
- 油猴扩展:Tampermonkey,去官方应用商店装最新版。
- F12 开发者工具:主要用 Network 面板观察请求,用 Console 面板调试 JS。
- 本地服务或直接编辑:油猴脚本的编辑界面可以直接写代码,我习惯先在编辑器里写完再粘过去。
写油猴脚本时,最值得注意的就是脚本头部的那段==UserScript==元信息。它决定了脚本在哪些页面上生效、需要申请哪些权限。我的头部配置是这样的:
// ==UserScript== // @name 洛谷团队一键@所有人 // @namespace com.example.luogu.atall // @version 1.0.0 // @description 在洛谷团队帖子编辑页一键插入@全体成员 // @match https://www.luogu.com.cn/team/* // @match https://www.luogu.com.cn/team/* // @grant GM_addStyle // @run-at document-idle // ==/UserScript==注意两个@match规则。洛谷的团队页面有两种形态,一种是团队主页https://www.luogu.com.cn/team/503890,另一种是团队内的帖子列表https://www.luogu.com.cn/team/503890#discuss之类。为了保险起见,我直接把整个team路径下的页面都纳入了匹配范围,脚本运行后先判断页面上有没有编辑器,有才渲染按钮。
@run-at document-idle也很重要,它让脚本在页面 DOM 基本加载完成后才执行,避免我挂载按钮时还没找到目标容器。
3.3 观察请求,确定接口细节
在写具体代码之前,我在 Network 面板里把团队成员列表接口的参数整理成了表格:
| 参数 | 类型 | 说明 |
|---|---|---|
| teamId | number | 团队 ID,页面 URL 上就能看到 |
| page | number | 当前页码,从 1 开始 |
| size | number | 每页条数,我测试时用 100 |
响应里的members数组是我最关心的字段。它的每个元素至少包含uid、username、name三个字段。我在脚本里只需要uid和username,前者是我拼 @ 标记时必须的,后者是为了让输出更易读。这样请求参数就固定下来了,脚本可以直接站在这个接口之上。
4. 核心实现:从成员列表到 @ 消息的转化
4.1 获取全量成员列表的分页逻辑
写代码之前先想清楚一个边界情况:如果一个团队有 200 个人,接口每页最多返回 100 个,那么脚本就得循环请求两次。如果团队人少,比如只有 20 个,那么请求第一页就能拿完。我在脚本里定义了一个核心函数来做这件事:
async function fetchAllMembers(teamId) { const allMembers = []; let page = 1; let totalPages = 1; while (page <= totalPages) { const params = new URLSearchParams({ teamId: teamId, page: page, size: 100, }); const resp = await fetch("/api/team/members", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), }); const json = await resp.json(); if (json.code !== 0) { throw new Error(`成员接口返回异常: ${json.code}`); } const data = json.data; allMembers.push(...data.members); // 注意:总页数来自第一次响应的 pagination 字段 if (page === 1 && data.pagination && data.pagination.totalPages) { totalPages = data.pagination.totalPages; } page += 1; } return allMembers; }这段代码有两个细节值得说。第一是fetch的默认行为,洛谷的接口在用户脚本里直接调用时,浏览器会自动带上当前站点下的Cookie,所以只要用户已经登录了洛谷,这个请求就能正常通过身份校验。第二是分页循环的正确边界,totalPages是从第一次响应的pagination里取出来的,不能每次都重新赋值,否则如果接口返回的总页数有抖动,循环可能会提前退出。
我在实际测试中发现,单次请求 100 条数据是最稳妥的,既不会因为单页数据太大导致响应缓慢,也不会因为频繁翻页触发风控阈值。一个一百人的团队,两次请求就能拉完,耗时基本在一秒以内。
4.2 拼接 @ 消息的两种策略
拿到成员列表后,怎么拼 @ 消息就有讲究了。如果团队有五十个人,直接拼一个超长的文本扔进编辑器,虽然可用,但后面编辑起来非常痛苦。我在实际使用中总结了两种策略,分别应对不同发布场景。
策略一:纯列表拼接。适合发那种"全体成员都要看到"的正式公告。把所有成员按@{username:uid}格式连成一行,前面加一句引导语。它的优点是一次性把所有人圈进来,绝不会漏;缺点是太长了,消息正文里全是 at 标记,观感一般。
function buildAtAllText(members) { return members.map((m) => `@{${m.username}:${m.uid}}`).join(" "); }策略二:分批分段拼接。适合在回复帖子里简单提醒一下"各位队员请注意"的场景。我会把成员分成几组,每组之间用换行隔开,消息中间穿插一句正文。这样做的好处是阅读体验好很多,也便于在后续版本里给成员按角色分组(比如先 @ 管理员,再 @ 普通成员)。
function buildGroupedAtText(members) { const admins = members.filter((m) => m.role === "admin"); const normal = members.filter((m) => m.role !== "admin"); const group = (list) => list.map((m) => `@{${m.username}:${m.uid}}`).join(" "); return `管理员:${group(admins)}\n成员:${group(normal)}`; }我个人的习惯是,大型比赛公告用策略一,日常训练提醒用策略二。脚本默认提供策略一的拼接方式,如果后续有需要,可以再加一个分组模式的下拉菜单。
4.3 往编辑器里注入 @ 文本
这是整个实现里最容易翻车的一步。洛谷的帖子编辑器不是原始的<textarea>,直接找到textarea并修改value并不能让编辑器内部状态同步。我踩了这个坑之后,重新查看编辑器 DOM 结构,发现它的内容区实际上是一个contenteditable元素,存储内容的容器带有类似.ProseMirror或.editor-content的类名。
既然是contenteditable元素,严格的做法是使用document.execCommand('insertText', false, text)来把文字插入到光标位置。这个方法虽然被标记为废弃,但在实际浏览器处理富文本编辑器的场景里依然非常稳定,而且能触发编辑器自己的输入事件。
function insertTextIntoEditor(text) { const editor = document.querySelector(".editor-content"); if (!editor) return; editor.focus(); // 把光标移到编辑器末尾,避免覆盖已有内容 const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand("insertText", false, text); }这里有一个经验分界线:插入@{username:uid}这种标记文本,绝对不能靠直接改innerHTML,否则编辑器内部的状态树不同步,保存时内容会丢失一部分。execCommand('insertText')的好处在于它走的是浏览器原生编辑路径,能被富文本编辑器正确捕获,插入之后的内容在用户视角里也完全可以再编辑。
4.4 在页面合适的位置放置触发按钮
脚本完整流程的最后一步,是把按钮挂到页面上。洛谷团队帖子编辑页的顶部一般有一个操作栏,我通过查找 DOM 中经典的按钮容器,把按钮插入到了一个相对稳定的位置。代码大概是这样的:
function mountButton(teamId) { const btn = document.createElement("button"); btn.textContent = "@ 所有人"; btn.style.marginLeft = "12px"; btn.style.padding = "6px 16px"; btn.style.borderRadius = "6px"; btn.style.border = "1px solid #3498db"; btn.style.backgroundColor = "#3498db"; btn.style.color = "#fff"; btn.style.cursor = "pointer"; btn.addEventListener("click", async () => { try { const members = await fetchAllMembers(teamId); const text = buildAtAllText(members); insertTextIntoEditor(text); } catch (err) { alert("拉取成员列表失败:" + err.message); } }); // 找到工具栏容器 const toolbar = document.querySelector(".team-post-toolbar, .edit-actions"); if (toolbar) { toolbar.appendChild(btn); } else { // 找不到就挂在页面左上角,也能用 document.body.appendChild(btn); } }按钮的样式我特意做得比较大,尽量显眼,毕竟这是团队管理员自己用的工具,按钮越明显越好。实际用的时候,点一下按钮,等一两秒,编辑器末尾就会自动多出整段 @ 标记,检查无误后直接点发布即可。
5. 实测效果与踩坑记录
5.1 真实团队中的实测数据
我在自己管理的一个八十多人的洛谷团队里做了测试。第一次完整测试时,从点击按钮到编辑器里出现全部成员 @ 标记,耗时大约 1.2 秒,其中大部分时间消耗在两次分页请求上。发布之后,登录小号查看通知,能正常收到 @ 提醒,确认整个链路是通的。
这个工具上线以后,我发比赛公告的效率提升非常明显。以前发一个带 @ 全员的公告,从打开成员列表到逐个人复制粘贴,至少要五六分钟;现在点一下按钮,再补一句正文,十秒内就能完成。它彻底解决了"发公告必须蹲在电脑前折腾半天"的问题。
5.2 踩坑一:权限与角色边界
测试的时候我还特意试了非管理员账号,结果发现普通成员虽然没有官方的"@ 所有人"权限限制,但洛谷在接口层面会有数据可见性的差异。非管理员调用团队成员列表接口时,返回的members数组里可能只有部分成员,甚至只有自己和管理员。这意味着这个工具在设计上更适合管理员使用,普通成员即使装了脚本,也拉不全整个团队成员列表,最终拼出来的 @ 名单会缺人。
这不是脚本 bug,而是平台的数据可见性规则。我后来在设计上加了身份检测:脚本运行后先去拿当前登录用户的团队角色,如果不是admin,就弹一个提示框告诉用户"管理员权限下才能拉取全量成员",而不是闷着头继续执行。
5.3 踩坑二:接口频率限制
分页获取成员时,如果团队人数上千,脚本会在短时间内发出很多次请求。我在另一个大型团队(团队人数四百多)里测试时,连续翻页请求到第六页左右,接口返回了一次code: 403的异常响应。这说明洛谷对接口请求的频率是有隐式限制的。
解决办法有两个方向。一个是降低请求频率,在每次请求之间加一个 200 到 500 毫秒的延迟,虽然总耗时变长了,但能稳定拿完全部数据:
if (page > 1) { await new Promise((resolve) => setTimeout(resolve, 400)); }另一个是利用缓存。团队成员的名单在短时间内其实很少变动,我可以把第一次拉取的结果存在localStorage里,设置有效期,比如 12 小时内不再重复请求接口。这样日常用完一次之后,再点按钮就直接从缓存读出数据,连网络请求都不需要发起。
我实测后把两个方案结合了:首次拉取全量数据时加延迟,拉完写缓存;后续 12 小时内命中缓存就直接用,既不触发频率限制,也极大提升了响应速度。
5.4 踩坑三:编辑器失效与局部刷新
还有一个隐藏问题。洛谷的团队页面是单页应用,如果你在团队成员列表和帖子编辑页之间切换,页面并不会整页刷新,而是局部 DOM 更新。这会导致我第一次挂载的按钮所在的容器被替换掉了,按钮也就随之消失了。
解决办法是在脚本里加一个MutationObserver,监听页面的 DOM 变化,一旦发现编辑器的容器重新出现且按钮不在里面,就重新挂载按钮。这样就算用户来回切换页面,按钮也能自动恢复。
const observer = new MutationObserver(() => { if (!document.querySelector(".at-all-btn")) { mountButton(teamId); } }); observer.observe(document.body, { childList: true, subtree: true });5.5 性能优化一览
前前后后做完这些调整,我整理了一个简单的优化对比表:
| 项目 | 优化前 | 优化后 |
|---|---|---|
| 首次拉取 400 人成员列表 | 大概率触发频率限制 | 分页间加 400ms 延迟,稳定成功 |
| 重复使用 @ 功能 | 每次都要重新请求接口 | 12 小时 localStorage 缓存,秒出结果 |
| 页面切换后按钮丢失 | 必须刷新页面 | MutationObserver 自动重挂 |
| 非管理员误用 | 拉取到不完整列表 | 弹出角色提示,阻止操作 |
优化之后的脚本在实际使用中已经非常稳定,我连续用了两个月没有出过任何问题。
6. 使用注意事项和后续能扩展的方向
工具在内部团队用着很顺手,但我还是想提醒一句:这个脚本的适用场景是"管理自家团队的日常工作",不要拿它来刷屏打扰别人。团队成员数量多的时候,@ 全体成员会推送给每一个人,所以只在确实需要全员知晓的时候才用,比如比赛即将开始、重大规则变更、紧急通知。平时发个普通题解分享,完全没必要 @ 全员。
另外,脚本的核心逻辑依赖洛谷的接口和页面结构,如果洛谷改版了接口路径或者编辑器实现,脚本可能需要同步更新。作为用户脚本,它的维护成本很低,改几行请求路径就行,不用重新打包发布。
目前这个脚本只能处理"在现有帖子编辑框里插入 @ 成员列表"这一件事。如果后面有精力,我打算再加两个功能:一个是定时提醒,到点自动在团队帖子里发布一条带全员的提醒;另一个是团队成员分组管理,按照角色或者自定义分组来定向 @,比如只 @ 管理员或者只 @ 参赛组。这两个功能做起来其实也不难,核心还是复用现有的成员列表接口和 @ 文本构造逻辑。
最后,从我个人的实际使用感受来说,这个工具最大的价值并不是省了那几分钟,而是它把"通知全体成员"从一件让人下意识想拖延的麻烦事,变成了一件随手就能完成的小事。公告发得及时了,团队成员对比赛和训练安排的响应速度明显提升,团队整体的活跃度也好了很多。如果你也在管理洛谷团队,正被"手动 @ 全员"折磨着,完全可以照着这篇文章的思路自己实现一个。不用纠结代码细节,先打开开发者工具看看团队页面到底请求了什么接口,迈出第一步,后面的路就顺了。