msh-exam-analysis — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited msh-exam-analysis (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
两个独立功能,根据用户请求自动判断用哪个:
| 用户说 | 走哪个 |
|---|---|
"错题分析" / "模考" / 粘贴 homework/student/<id> URL | → 模考错题分析 |
"听写" / "单词" / "list" / "单词测试" / homework-submission-items?homework_id=<id> | → 单词测试自动化 |
针对 mingshihuichuguo.com/homework/student/<id> 的模考作业,按学生 本人手写习惯输出两份错题分析 txt 到桌面:
阅读.txt听力.txt只写阅读和听力。写作 / 口语不在范围内(用户不要求)。
https://mingshihuichuguo.com/homework/student/<id> 网址注意:粘贴错题内容(不含 URL)不触发,必须给完整 URL。
~/.msh-skill-state.json,下次不重复问eval 一次拿整段 innerText,不逐题 snapshot目标:10 分钟内完成(含浏览器重启等待 ≤ 8s)。
怎么知道刚才走错路了:
playwright-cli --s=default click → 走错路,回去重抓playwright-cli --s=default snapshot → 立刻停,回去换 evalplaywright-cli tab-select → 删掉,换 eval 找链接下面 4 段 eval 代码覆盖 90% 工作流。先复制,再按需改。
playwright-cli --s=default eval "() => {
const a = document.querySelector('a[href*=\"/homework/student/\"]');
if (a) { a.click(); return 'clicked: ' + a.href; }
return 'not found, current: ' + location.href;
}"playwright-cli --s=default eval "() => {
const b = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '阅读M1');
if (b) { b.click(); return 'clicked 阅读M1'; }
return 'not found';
}"把 '阅读M1' 换 '阅读M2' / '听力M1' / '听力M2' / '答题解析' 都行。
playwright-cli --s=default eval "() => {
const items = Array.from(document.querySelectorAll('div'))
.filter(d => /^\(\d+\)\s*[✓✗]$/.test(d.textContent.trim().replace(/\s+/g, '')))
.filter(d => !d.querySelector('div'));
const seen = new Set();
return items.map(d => {
const m = d.textContent.match(/\((\d+)\)\s*([✓✗])/);
return m ? {n: +m[1], wrong: m[2] === '✗'} : null;
}).filter(Boolean).filter(i => seen.has(i.n) ? false : (seen.add(i.n), true));
}"返回 [{n:1, wrong:true}, ...]。这次 eval 不点任何东西,纯读。
playwright-cli --s=default eval "(wrongNums) => new Promise(async resolve => {
const results = {};
const allItems = Array.from(document.querySelectorAll('div'))
.filter(d => /^\(\d+\)\s*[✓✗]$/.test(d.textContent.trim().replace(/\s+/g, '')))
.filter(d => !d.querySelector('div'));
for (const n of wrongNums) {
const item = allItems.find(d => d.textContent.includes('(' + n + ')'));
if (!item) { results[n] = 'NOT FOUND'; continue; }
item.click();
await new Promise(r => setTimeout(r, 400));
const detail = document.body.innerText;
const start = detail.search(/(题目前引导|题干|得分)/);
let end = detail.length;
const nextMatch = detail.slice(start + 50).match(/\n\(\d+\)\s*[✓✗]/);
if (nextMatch) end = start + 50 + nextMatch.index;
results[n] = detail.slice(start, end);
}
resolve(results);
})" '[1,8,17]'调用方式:'<上面整段代码>' '[1,8,17]' —— 外层引号包代码,第二个参数是 JSON 数组字符串。
playwright-cli --s=default eval "(wrongNums) => new Promise(async resolve => {
const tr = {};
const allItems = Array.from(document.querySelectorAll('div'))
.filter(d => /^\(\d+\)\s*[✓✗]$/.test(d.textContent.trim().replace(/\s+/g, '')))
.filter(d => !d.querySelector('div'));
for (const n of wrongNums) {
const item = allItems.find(d => d.textContent.includes('(' + n + ')'));
item.click();
await new Promise(r => setTimeout(r, 400));
const btn = Array.from(document.querySelectorAll('button, div, span'))
.find(e => e.textContent.trim() === '原文' && e.children.length === 0);
if (btn) {
btn.scrollIntoView({block: 'center'});
btn.click();
await new Promise(r => setTimeout(r, 300));
const pop = Array.from(document.querySelectorAll('.el-popover'))
.find(p => getComputedStyle(p).display !== 'none' && p.offsetHeight > 0);
tr[n] = pop ? pop.innerText : '(no popover)';
} else {
tr[n] = '(no 原文 btn)';
}
}
resolve(tr);
})" '[5,14,26,27]'T2 切 section → T3 拿错题列表 → T4 拿右栏 innerText → T5 拿 transcript(仅听力)4 个 section 一共 4 × (1 T2 + 1 T3 + 1 T4 + 0/1 T5) = 12-16 次 eval,0 次 click,0 次 snapshot。
从用户消息里抽 homework/student/(\d+),抽不到就自然语言追问一次:
"请粘贴完整的 mingshihuichuguo.com 作业链接,格式:https://mingshihuichuguo.com/homework/student/<数字>"合法:https / http 都行,纯数字 id。追问第二次还错就退出。
~/.msh-skill-state.json文件不存在就跳过这一步。文件存在则读 {browser, lastRestartAck}:
OS:uname -s(失败 fallback node -e "console.log(process.platform)")。
浏览器探测按 OS 走(只跑第一个找到的两个就停):
ls Edge/Chrome 常见安装路径 ls "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" 2>/dev/null
ls "C:/Program Files/Microsoft/Edge/Application/msedge.exe" 2>/dev/null
ls "C:/Program Files/Google/Chrome/Application/chrome.exe" 2>/dev/nullls /Applications/Microsoft Edge.app /Applications/Google Chrome.appwhich msedge microsoft-edge google-chrome google-chrome-stable判定:
npx playwright-cli --version
ls "$USERPROFILE/.playwright/cli.config.json" 2>/dev/null
curl -s --max-time 2 http://127.0.0.1:9222/json/versionnpm install -g @playwright/cli(只这一次)references/数据获取.md 重启浏览器跑完跑一次:
node -e "require('fs').writeFileSync(require('os').homedir()+'/.msh-skill-state.json', JSON.stringify({browser:'msedge',lastRestartAck:new Date().toISOString()},null,2))"替换 msedge 为实际浏览器名。
只在第一次(缓存里没 lastRestartAck)问一次:
"要附加到 Edge 抓数据,需要带 --remote-debugging-port=9222 重启 Edge。会杀掉所有 Edge 进程(未保存 tab/表单会丢),重启后会恢复 tab 和登录态。"
确认后跑一次;之后同会话内不重复问。下次有缓存直接跳过。
参考 references/数据获取.md 的"快进抓取"小节:
输出位置(用户桌面):
~/Desktop/阅读.txt
~/Desktop/听力.txt只分析错的题。对的题不写,不总结"对了几道/错了哪几道"。
告诉用户两个文件路径(仅此一句,别复述做了什么)。
写报告前先 bash 数一次这次跑的 playwright-cli 命令分布:
# bash 历史可能没用,改用:scan 之前所有调用的命令记录
# 简化:自己数一下
echo "playwright-cli click count: <自己数>"
echo "playwright-cli snapshot count: <自己数>"
echo "playwright-cli tab-select count: <自己数>"过线标准:
playwright-cli --s=default click 调用次数 ≤ 4(最多 4 个 section 各 1 次 T2 切 section)playwright-cli --s=default snapshot = 0playwright-cli --s=default tab-select = 0任意一项超线 = 回去重抓。问自己:
写完报告再 grep 一次桌面文件长度(wc -l 两份),过短说明数据没抓全。
完整对照见 references/style-guide.md。最关键的几条:
/。阅读.txt,别带"第五次模考-真题16-"这种长前缀)。不要:完整答案列表、抄题目、原文整段贴、生词三栏字典。
references/style-guide.md — 风格正反例references/数据获取.md — 拿 URL → 抓数据的流程(含快进抓取代码段)references/模板对照.md — 同一道题,手写版 vs AI 版的对比手写错题分析/ — 5 份原始手写样本自动完成 mingshihuichuguo.com/homework-submission-items?homework_id=<id> 的单词"混合测试"(听音译中 + 看词译中)。
homework-submission-items?homework_id=<id> 链接# Edge 需带调试端口启动
powershell -Command "Stop-Process -Name msedge -Force; Start-Process 'msedge' -ArgumentList '--remote-debugging-port=9222'"
# 连接
playwright-cli attach --cdp=http://localhost:9222npx skills add 只装 SKILL.md 进 skill 目录,不会装 `run_v5.js`。第一次跑前要落地脚本:
$HOME/.agents/skills/msh-exam-analysis/run_v5.js 是否存在## run_v5 内嵌版本 章节$HOME/.agents/skills/msh-exam-analysis/run_v5.js--filename=$HOME/.agents/skills/msh-exam-analysis/run_v5.js 绝对路径,不再依赖 cwd如果改了 SKILL.md 末尾的内嵌版本,重新 Write 一遍覆盖文件即可(保留 source-of-truth 在项目根 ~/Desktop/coding/msh-skill/run_v5.js ↔ SKILL.md 末尾内嵌区块,保持两份同步)。
playwright-cli goto "https://mingshihuichuguo.com/homework-submission-items?homework_id=<ID>" && playwright-cli run-code --filename="$HOME/.agents/skills/msh-exam-analysis/run_v5.js"&& 串行:goto 阻塞等页面 ready 后才进 run-code,两条命令之间的间隙被压成 0。
✅ 一发 bash 后脚本开始跑,30 秒-3 分钟出 OK: N/40。
❌ 不准在 goto 和 run-code 之间发任何额外命令(eval / console / snapshot 都是浪费 round trip)。脚本会自己处理首次加载,等 waitForTimeout(1200) × 第一题足够。
playwright-cli --s=default eval "async () => {
const btn = Array.from(document.querySelectorAll('button'))
.find(b => b.textContent.trim() === '提交');
if (!btn) return 'NO SUBMIT BTN; body=' + document.body.innerText.split('\\n').slice(0,15).join(' | ');
btn.click();
await new Promise(r => setTimeout(r, 1500));
return document.body.innerText.includes('测试完成') ? 'submitted OK' : 'clicked, awaiting';
}"跑完这发 eval 直接汇报用户,不要再 round trip 确认。
run_v5.js 返回 OK: N/40 那一刻起,agent 行为严格限定:
console.log 不会冒到 playwright 的 console 流里,这条命令稳 0 命中每个 list 重复步骤 2-3。不同 list 的 homework_id 从 homework 列表页获取。
1. page.evaluate 注入 window.Audio 构造器 hook → 抓音频题的英文单词
2. DOM 遍历 textarea 前 siblings → 抓文本题的英文单词
3. page.context().request 调有道词典 API → 获取中文翻译
4. page.getByRole('textbox').fill(翻译) → 填入答案
5. page.getByRole('button', {name: /下一题|下一步/}).click() → 下一题querySelector('input') 永远 null,必须 querySelector('textarea, input[type="text"]')page.evaluate 在当前页注入 hook,不 reloadpage.getByRole('textbox').fill()page.locator('button').filter({has: page.locator('img')}).first() 不稳定,可改用 page.$$('button') + btn.$('svg') 或 btn.$('img') 找播放按钮npx skills add 不装 JS 文件,agent 按"### 1.5 脚本准备"说明的步骤把这段代码 Write 到 $HOME/.agents/skills/msh-exam-analysis/run_v5.js。改脚本:项目根 run_v5.js 和这里两端保持同步——代码块边界用 ``js 起始 + `` 结束,agent 提取时按这两个标记剥壳。
// V5: No reload - inject Audio hook via evaluate, use getByRole('textbox').fill()
async (page) => {
const ctx = page.context();
async function tr(word) {
try {
const u = 'https://dict.youdao.com/jsonapi?q=' + encodeURIComponent(word) + '&le=eng&dicts=%7B%22count%22%3A99%2C%22dicts%22%3A%5B%5B%22ec%22%5D%5D%7D';
const r = await ctx.request.get(u);
const d = await r.json();
const raw = d?.ec?.word?.[0]?.trs?.[0]?.tr?.[0]?.l?.i?.[0] || '';
return raw.replace(/^[a-z]+\.\s*/i, '').split(';')[0].split(',')[0].trim();
} catch(e) { return null; }
}
// Inject Audio hook WITHOUT reload - works for current page session
await page.evaluate(() => {
const OrigAudio = window.Audio;
window.Audio = function(src) {
window.__word = (src || '').match(/audio=([^&]+)/)?.[1] || null;
return new OrigAudio(src);
};
});
let ok = 0;
for (let i = 0; i < 40; i++) {
await page.waitForTimeout(1200);
let word = await page.evaluate(() => {
const w = window.__word;
window.__word = null;
if (w) return w;
const inp = document.querySelector('textarea, input[type="text"]');
let el = inp;
while (el && el !== document.body) {
const children = el.parentElement ? Array.from(el.parentElement.children) : [];
for (const child of children) {
const t = (child.textContent || '').trim();
if (/^[a-zA-Z][a-zA-Z-]*[a-zA-Z]$/.test(t)) return t;
}
el = el.parentElement;
}
return null;
});
if (!word) {
try {
await page.locator('button').filter({ has: page.locator('img') }).first().click({ timeout: 3000 });
await page.waitForTimeout(3000);
word = await page.evaluate(() => {
const w = window.__word;
window.__word = null;
return w;
});
if (!word) {
word = await page.evaluate(() => {
const inp = document.querySelector('textarea, input[type="text"]');
let el = inp;
while (el && el !== document.body) {
const children = el.parentElement ? Array.from(el.parentElement.children) : [];
for (const child of children) {
const t = (child.textContent || '').trim();
if (/^[a-zA-Z][a-zA-Z-]*[a-zA-Z]$/.test(t)) return t;
}
el = el.parentElement;
}
return null;
});
}
} catch(e) {}
}
console.log('Q' + (i+1) + ': ' + (word || '??'));
if (word) {
const translation = await tr(word);
if (translation) {
await page.getByRole('textbox').fill(translation);
ok++;
console.log(' -> ' + translation);
}
}
try {
await page.getByRole('button', { name: /下一题|下一步/ }).click({ timeout: 2000 });
} catch(e) {}
await page.waitForTimeout(700);
}
return 'OK: ' + ok + '/40';
}~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.