第4节批改作业时用了
angular.element().scope()注入,但只讲了流程没讲原理。这一节深入 Angular 1.x 的数据绑定机制,解决自动化中最头疼的问题:DOM 操作无效,必须走 scope。
为什么直接操作 DOM 没用
普通页面,你 document.querySelector('input').value = '张三' 就完事了。但 Angular 1.x 页面不行:
lua
你的操作: input.value = "张三"
↓
DOM 确实变了: input 显示 "张三"
↓
但 Angular 的 scope 里还是旧值
↓
用户点"提交"时,Angular 读 scope → 提交旧值
↓
你的修改被丢弃
Angular 1.x 的双向绑定是 scope → DOM 方向,不是 DOM → scope。你改了 DOM,Angular 不知道。
解决方案:三条路
| 方案 | 做法 | 适用场景 |
|---|---|---|
| scope 注入 | angular.element(el).scope() 直接改 scope |
Angular 1.x 页面(本文重点) |
| 事件触发 | 改完 DOM 后 dispatchEvent(new Event('input')) |
React / Vue 等现代框架 |
| Playwright 原生 | page.fill() / page.click() |
普通页面、非框架组件 |
OUCHN 平台是 Angular 1.x,所以全文围绕 scope 注入展开。
Scope 注入通用模式
基本三步
javascript
// 1. 找到带 ng-controller 的元素
var el = document.querySelector("[ng-controller='CourseListController']");
// 2. 取出 scope 对象
var scope = angular.element(el).scope();
// 3. 修改数据 + 触发更新
scope.conditions.role = ['assistant_instructor'];
scope.$apply(); // 关键:通知 Angular "数据变了,重新渲染"
为什么需要 $apply()
Angular 的脏检查机制:只在"Angular 知道的事件"(ng-click、 http回调等)触发时自动检查。你从外部改scope,Angular不知道,必须手动调'apply()`。
javascript
// ❌ 不调 $apply(),Angular 不知道数据变了
scope.conditions.keyword = '数学';
// 页面不会更新,后续操作还是旧数据
// ✅ 调了 $apply(),Angular 重新渲染
scope.conditions.keyword = '数学';
scope.$apply();
// 页面立即更新
踩坑:
$apply()在 diggest 循环进行中调用会报错。如果不确定,用$applyAsync()替代,它会在下一个 tick 执行。
在 Playwright 中执行
python
result = await page.evaluate("""
async () => {
var el = document.querySelector(
"[ng-controller='CourseListController']"
);
var scope = angular.element(el).scope();
scope.conditions.role = ['assistant_instructor'];
scope.search(); // 触发后端筛选请求
scope.$apply();
// 等待 Angular 处理完
await new Promise(r => setTimeout(r, 2000));
// 读取筛选后的课程列表
return JSON.stringify(scope.courses || []);
}
""")
courses = json.loads(result)
Select2 / MultiSelect 组件处理
这是第4节"坑1"的完整版。OUCHN 课程列表的角色选择器是 jQuery UI MultiSelect,直接设 DOM value 无效。
错误尝试
python
# ❌ Playwright 原生 select_option 无效
await page.select_option(".course-role-select", "assistant_instructor")
# MultiSelect 不是原生 <select>,这个方法不生效
# ❌ 直接改 DOM value 也无效
await page.evaluate("""
document.querySelector('.course-role').value = 'assistant_instructor';
""")
# DOM 变了但 Angular scope 没变
正确做法:走 scope
python
result = await page.evaluate("""
async () => {
// 1. 找到课程列表控制器
var el = document.querySelector(
'[ng-controller="CourseListController"]'
);
var scope = angular.element(el).scope();
// 2. 设角色条件(注意是数组,MultiSelect 支持多选)
scope.conditions.role = ['assistant_instructor'];
// 3. 触发搜索(后端会根据条件筛选)
scope.search();
// 4. 通知 Angular 更新视图
scope.$apply();
// 5. 等待后端返回
await new Promise(r => setTimeout(r, 3000));
// 6. 读取结果
return JSON.stringify({
total: scope.courses.length,
sample: scope.courses.slice(0, 3).map(c => c.name)
});
}
""")
data = json.loads(result)
print(f"筛选后课程数: {data['total']}")
效果对比
| 方式 | 课程数 | 原因 |
|---|---|---|
| 不筛选(默认全部角色) | 83 | 包含学生、教师、管理员的所有课程 |
| DOM value 设置 | 83 | Angular 没收到,条件没变 |
| scope 注入 + search() | 20 | 只剩辅导老师负责的课程 |
从 83 门到 20 门,后续每门课的处理时间 × 4 倍速度提升。
setViewingData 与异步弹窗
第4节批改作业的核心操作:调用 scope.setViewingData(idx) 触发评分弹窗。
为什么不能直接点按钮
评分弹窗不是页面上的固定元素,而是 Angular 动态生成的。它需要 setViewingData() 先设置当前查看的学生数据,Angular 才会渲染弹窗。
javascript
async (params) => {
// 1. 取学生列表 scope
var psc = document.querySelector(
"[ng-controller='PagedSubmissionListController']"
);
var s = angular.element(psc).scope();
// 2. setViewingData 是异步的!必须 await
await s.setViewingData(params.idx);
// 3. 弹窗可能默认 display:none,暴力显示
var modal = document.getElementById('give-score');
modal.style.display = 'block';
modal.style.visibility = 'visible';
modal.style.opacity = '1';
// 4. 等弹窗内 Angular 控制器初始化
await new Promise(r => setTimeout(r, 1500));
// 5. 取评分 scope,填入分数和评语
var g = angular.element(
document.querySelector("[ng-controller='GiveScoreController']")
).scope();
g.dataBindTo.score = params.score;
g.dataBindTo.reviewer_comment = params.comment;
g.$apply();
// 6. 点击保存
var btns = document.querySelectorAll('#give-score button');
for (var i = 0; i < btns.length; i++) {
if (btns[i].textContent.indexOf('保存') >= 0) {
btns[i].click();
break;
}
}
// 7. 等待保存完成,隐藏弹窗
await new Promise(r => setTimeout(r, 3000));
modal.style.display = 'none';
return 'ok';
}
关键细节
| 步骤 | 为什么这么做 |
|---|---|
await s.setViewingData() |
它是异步的,不 await 会等弹窗渲染完 |
强制 display:block |
某些情况下 Angular 设了 display:none |
| 等 1500ms | GiveScoreController 需要时间初始化 |
g.$apply() |
改了 score 和 comment,必须通知 Angular |
| 模糊匹配"保存" | 按钮文本可能是"保存""提交""确认" |
表单提交与按钮匹配
问题:按钮文本不固定
python
# ❌ 硬编码文本,容易失败
await page.click('button:has-text("保存")')
# 如果按钮文本是"提交评分"或"确认",就点不到
# ✅ 模糊匹配多个关键词
await page.evaluate("""
() => {
var btns = document.querySelectorAll('button');
var keywords = ['保存', '提交', '确认', '确定'];
for (var btn of btns) {
var text = btn.textContent.trim();
for (var kw of keywords) {
if (text.indexOf(kw) >= 0 && btn.offsetParent !== null) {
btn.click();
return text;
}
}
}
return null;
}
""")
只点可见按钮
javascript
// 有时页面上有多个匹配按钮(比如弹窗和背景页各一个)
// 只点可见的那个
var btns = document.querySelectorAll('button');
for (var btn of btns) {
if (btn.offsetParent === null) continue; // 不可见,跳过
if (btn.textContent.indexOf('保存') >= 0) {
btn.click();
break;
}
}
offsetParent === null是检测元素是否可见的可靠方法。display:none、visibility:hidden、opacity:0的元素 offsetParent 都是 null(opacity:0 除外,需单独检测)。
分页数据提取
Angular 分页通常不会改 URL,而是通过 scope 变量控制。直接操作 scope 翻页:
python
all_items = []
page_num = 1
while True:
result = await page.evaluate("""
async (pageNum) => {
var scope = angular.element(
document.querySelector('[ng-controller]')
).scope();
// 切换到指定页
scope.currentPage = pageNum;
scope.$apply();
// 等待数据加载
await new Promise(r => setTimeout(r, 2000));
var items = scope.list || [];
var totalPages = scope.totalPages || 1;
return JSON.stringify({
items: items,
totalPages: totalPages,
currentPage: scope.currentPage
});
}
""", page_num)
data = json.loads(result)
all_items.extend(data['items'])
if page_num >= data['totalPages']:
break
page_num += 1
print(f"共提取 {len(all_items)} 条数据,{page_num} 页")
五个真实踩坑
坑 1 --- scope 是 undefined
现象 :angular.element(el).scope() 返回 undefined。
原因 :元素没有 ng-controller 属性,或者 Angular 还没初始化。
修复:等待 Angular 加载完成再取 scope:
python
# 等待 Angular 初始化
await page.wait_for_function(
"() => window.angular && angular.element(document).scope()",
timeout=30000
)
# OUCHN 的 Angular 初始化特别慢,需要 10-15 秒
await asyncio.sleep(10)
坑 2 --- $apply() 报错 "digest already in progress"
现象 :调用 $apply() 抛异常。
原因 :Angular 正在进行脏检查循环,不能嵌套 $apply()。
修复 :用 $applyAsync() 或 setTimeout 延迟执行:
javascript
// 安全写法
if (scope.$$phase) {
// 正在 digest 中,用 setTimeout 延迟
setTimeout(function() {
scope.conditions.role = ['assistant_instructor'];
scope.$apply();
}, 0);
} else {
scope.conditions.role = ['assistant_instructor'];
scope.$apply();
}
坑 3 --- setViewingData 不返回 Promise
现象 :await s.setViewingData(idx) 报错 "not a function" 或 "cannot await"。
原因 :不同版本的 Angular 控制器,setViewingData 可能不是异步的。
修复:兼容写法:
javascript
try {
await s.setViewingData(params.idx);
} catch(e) {
s.setViewingData(params.idx); // 同步调用
}
await new Promise(r => setTimeout(r, 2000)); // 手动等待
坑 4 --- 弹窗里的 scope 取不到
现象 :GiveScoreController 的 scope 取出来是 null。
原因:弹窗还没渲染完就取 scope。
修复:等弹窗内 Angular 控制器初始化:
javascript
// 等 modal 出现
await new Promise(r => setTimeout(r, 1500));
// 循环等待 scope 可用
for (var i = 0; i < 10; i++) {
var g = angular.element(
document.querySelector("[ng-controller='GiveScoreController']")
).scope();
if (g && g.dataBindTo) break;
await new Promise(r => setTimeout(r, 500));
}
坑 5 --- 保存按钮点了但没提交
现象:按钮 click 了,但分数没保存成功。
原因 :$apply() 没调或调得太晚,Angular 还没把新数据同步到 DOM。
修复 :确保 $apply() 在 click 之前执行,且留足时间:
javascript
g.dataBindTo.score = params.score;
g.dataBindTo.reviewer_comment = params.comment;
g.$apply();
await new Promise(r => setTimeout(r, 500)); // 等 Angular 渲染
// 然后才点保存
btn.click();
小结
| 学会的 | 要点 |
|---|---|
| Angular 1.x 绑定 | scope → DOM 单向,改 DOM 不影响 scope |
| scope 注入三步 | angular.element(el).scope() → 改数据 → $apply() |
| Select2 处理 | 走 scope 设 conditions + 调 search() + $apply() |
| 异步弹窗 | setViewingData() 是异步的,需 await + 等渲染 |
| 按钮匹配 | 模糊匹配关键词 + 只点可见按钮 |
| 分页提取 | 操作 scope.currentPage + $apply() + 等数据加载 |
| 踩坑预防 | 等 Angular 初始化、$apply 嵌套检测、弹窗 scope 轮询 |
下一篇预告
第 7 节:定时任务与自动化调度------RRULE 调度规则、recurring/once 两种模式、有效期控制、多任务编排。让脚本每天自己跑起来。
📺 系列文章:
- 第1节:WorkBuddy Skill 入门
- 第2节:CDP 浏览器操控原理
- 第3节:云端共享与多终端部署
- 第4节:WorkBuddy 自动化实战
- 第5节:登录态持久化
- 第6节:Angular 页面自动化进阶(本文)
- 第7节:定时任务与自动化调度(待发布)
本文首发于稀土掘金。欢迎关注后续连载。