Files
claw/docs/collect_lineloss_troubleshooting_guide.md
木炎 c60cd308ca feat: service console auto-connect, settings panel, and batch of enhancements
- Auto-connect WebSocket on page load in service console
- Settings modal for editing sgclaw_config.json (API key, base URL, model, skills dir, etc.)
- UpdateConfig/ConfigUpdated protocol messages for remote config save
- save_to_path() for SgClawSettings serialization
- ConfigUpdated handler in sg_claw_client binary
- Protocol serialization tests for new message types
- HTML test assertions for auto-connect and settings UI
- Additional pending changes: deterministic submit, org units, lineloss xlsx export, browser script tool, and docs

🤖 Generated with [Qoder][https://qoder.com]
2026-04-14 14:32:46 +08:00

423 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# collect_lineloss.js 从生成到可用的完整排查记录
本文档记录了 `tq-lineloss-report` skill 脚本从初始生成到最终可用的全部排查过程,包括遇到的每个错误、根因分析和修复方法。可作为后续类似 skill 开发的排查模板。
---
## 背景
### 架构概览
```
用户输入 "兰州公司 月累计 2026-03。。。"
sgClaw Rust 进程
├── 解析指令 → DeterministicExecutionPlan
├── 读取 collect_lineloss.js 脚本
├── 包装为 IIFE(function(){ const args = {...}; <脚本内容> })()
├── 调用 sgBrowserExcuteJsCodeByDomain(domain, wrappedJs)
│ 注入到浏览器中匹配 domain 的页面执行
├── 等待回调:脚本通过 callBackJsToCpp 返回 JSON 结果
├── 解析 artifact JSON → 提取 status/rows/reasons
└── 生成 XLSXRust 侧)→ 返回 outcome
```
### 关键差异:原始场景 vs Skill 模式
| 对比项 | 原始场景 (index.html) | Skill 模式 |
|--------|----------------------|------------|
| 脚本注入方式 | `sgBrowserExcuteJsCode(exactURL, js)` — 精确 URL | `sgBrowserExcuteJsCodeByDomain(domain, js)` — 仅域名匹配 |
| 执行页面 | 业务子页面 `/tqLinelossStatis/tqQualifyRateMonitor` | 可能命中父框架页 `/gsllys` |
| `window.mac` | 有Vue 实例,`mounted()``window.mac = this` | 无(没有 Vue 实例) |
| 导出 Excel | JS 调 `localhost:13313`(本地场景页可访问) | JS 无法调 `localhost:13313`CORS 阻断) |
| 结果回传 | Rust 只需要 `.then()` 回调结果 | 同左,但脚本是 async 函数需 `.then()` 处理 |
---
## 排查时间线
### 第 1 阶段:基础管道问题
#### 问题 1: `missing_expected_domain`
**现象**: `status=blocked reasons=missing_expected_domain`
**根因**: Rust 侧 `deterministic_submit.rs` 构造 args 时没有传 `expected_domain` 字段。`derive_expected_domain()``page_url` 提取 host 时只取了域名不含端口,但传入 args 时 key 不匹配。
**修复**: 确保 `deterministic_submit_args()` 正确插入 `expected_domain` 到 args Map。
**涉及文件**: `src/compat/deterministic_submit.rs`
**是否需要重新编译**: 是
---
#### 问题 2: `target_url` 缺少端口号
**现象**: 脚本注入失败或注入到错误页面。
**根因**: `target_url` 被设为 `http://20.76.57.61`(无端口),但实际业务页面在 `http://20.76.57.61:18080/gsllys/...``sgBrowserExcuteJsCodeByDomain` 需要能匹配到正确的标签页。
**修复**: 在 `deterministic_submit.rs` 中设置完整 `target_url`
```rust
const LINELLOSS_TARGET_URL: &str = "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor";
```
**涉及文件**: `src/compat/deterministic_submit.rs`
**是否需要重新编译**: 是
---
#### 问题 3: 脚本返回 `{}` 空对象
**现象**: Rust 侧收到的 artifact 是 `{}`,无任何数据。
**根因**: `collect_lineloss.js` 的入口 `buildBrowserEntrypointResult()``async` 函数,返回 Promise。Rust 侧 `build_eval_js` 包装器原来直接调用 `_s(v)` 发送结果,但 `v` 是一个 Promise 对象JSON.stringify 后变成 `{}`
**修复**: 在 `build_eval_js``callback_backend.rs`)中增加 Promise 检测:
```rust
// 旧代码
"_s(v);"
// 新代码
"if(v&&typeof v.then==='function'){v.then(_s).catch(function(){});}else{_s(v);}"
```
如果返回值是 thenablePromise等它 resolve 后再发送回调。
**涉及文件**: `src/browser/callback_backend.rs``build_eval_js` 函数
**是否需要重新编译**: 是
**教训**: 所有 browser_script skill 如果入口函数是 async返回 Promise都需要这个 `.then()` 处理。这是管道层的通用修复。
---
### 第 2 阶段:页面上下文问题
#### 问题 4: `page_context_unavailable` (mac_missing)
**现象**:
```
tq-lineloss-report 国网兰州供电公司 2026-03 status=blocked rows=0 reasons=page_context_unavailable
```
**排查过程**:
1.`validatePageContext` 中添加诊断信息:
```javascript
// 临时诊断代码
const diag = 'href=' + href + '|host=' + host + '|port=' + port + '|title=' + title + '|mac=' + hasMac;
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
```
2. 页面返回的诊断结果:
```
href=http://20.76.57.61:18080/gsllys
host=20.76.57.61
port=18080
title=台区线损大数据分析模块
mac=false
```
**根因**: `sgBrowserExcuteJsCodeByDomain("20.76.57.61")` 匹配到了父框架页 `/gsllys`,而不是业务子页面。`window.mac` 是业务子页面的 Vue 实例,在 `mounted()` 中通过 `window.mac = this` 设置,父框架页没有这个实例。
**关键认知**: 在 Skill 模式下没有 Vue 实例,`window.mac` 检查在架构上就不适用。脚本通过 AJAX 发绝对 URL 请求,不依赖页面本地状态。
**修复**: 删除 `globalThis.mac` 检查,只保留 host 匹配:
```javascript
// 修复前
validatePageContext(args) {
// ... 含 mac 检查 + 诊断代码
if (!hasMac) {
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
}
}
// 修复后
validatePageContext(args) {
const host = normalizeText(globalThis.location?.hostname);
const expected = normalizeText(args.expected_domain);
if (!host) {
return { ok: false, reason: 'page_context_unavailable' };
}
if (host !== expected) {
return { ok: false, reason: 'page_context_mismatch' };
}
return { ok: true };
},
```
**涉及文件**: `collect_lineloss.js``validatePageContext` 函数
**是否需要重新编译**: 否JS 文件运行时读取)
**排查技巧**: 在 reasons 中拼接诊断信息href/host/port/title/mac不需要 F12 console直接通过 Rust 侧的 summary 输出就能看到。
---
### 第 3 阶段API 请求问题
#### 问题 5: `api_query_failed` — 返回 HTML 而非 JSON
**现象**:
```
status=error rows=0 reasons=api_query_failed:month_api_failed: SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
```
**根因**: 后端服务检测到请求缺少 `X-Requested-With: XMLHttpRequest` 头,认为这不是 AJAX 请求,返回了 HTML 登录页面。jQuery 的 `$.ajax` 不会自动添加这个头。
**修复**: 在 `queryMonthData``queryWeekData``$.ajax` 调用中添加请求头:
```javascript
$.ajax({
url,
type: 'POST',
dataType: 'json',
crossDomain: true,
headers: { 'X-Requested-With': 'XMLHttpRequest' }, // <-- 新增
data: request,
contentType: 'application/x-www-form-urlencoded;charset=UTF-8',
success: resolve,
error: (xhr, _status, err) => reject(new Error(
`month_api_failed(${xhr.status}): ${String(err)}|body=${String(xhr.responseText || '').substring(0, 200)}`
))
});
```
**涉及文件**: `collect_lineloss.js``queryMonthData``queryWeekData`
**是否需要重新编译**: 否
**排查技巧**: 在 error handler 中拼接 `xhr.responseText` 的前 200 字符到 reasons 中。如果看到 `<!DOCTYPE` 开头,说明后端返回了 HTML 而非 JSON。
**通用规则**: 内网 Java 后端通常依赖 `X-Requested-With: XMLHttpRequest` 来区分页面请求和 AJAX 请求。所有对内网 API 的 `$.ajax` 调用都应加上此头。
---
### 第 4 阶段:数据规范化问题
#### 问题 6: `row_normalization_failed` — 列名不匹配
**现象**:
```
status=error rows=0 reasons=row_normalization_failed:rawRows=12|keys=YGDL,ORG_NO,YXSL,TG_NUM...
```
**根因**: 初始生成的 `MONTH_COLUMN_DEFS` 使用了猜测的列名:
```javascript
// 错误的列名
['LINE_LOSS_RATE', '线损完成率(%)'],
['PPQ', '累计供电量'],
['UPQ', '累计售电量'],
```
而 API 实际返回的列名是(参考原始场景 `index.html` 中的 `cols2`
```javascript
// 正确的列名
['ORG_NAME', '供电单位'],
['YGDL', '累计供电量'],
['YYDL', '累计售电量'],
['YXSL', '线损完成率(%)'],
['RAT_SCOPE', '线损率累计目标值'],
['BLANK3', '目标完成率'],
['BLANK2', '排行']
```
**修复**: 按原始场景 `index.html``cols2` 的定义修正 `MONTH_COLUMN_DEFS`
**排查技巧**: 在 `reasons` 中拼接 `rawRows.length``Object.keys(rawRows[0]).join(',')` 可以直接看到 API 返回了哪些字段。
**通用规则**: 生成 skill 脚本时,列定义必须从原始场景代码中精确复制,不能靠猜测。找 `cols1`/`cols2` 或表格渲染相关代码。
---
#### 问题 7: `row_normalization_failed` — 数值类型不兼容
**现象**: 列名修正后仍报 `row_normalization_failed:rawRows=12`12 行全部被过滤。
**根因**: `pickFirstNonEmpty()` 函数只识别字符串类型:
```javascript
function pickFirstNonEmpty(...values) {
for (const value of values) {
if (isNonEmptyString(value)) { // isNonEmptyString: typeof value === 'string'
return value.trim();
}
}
return ''; // API 返回数字 12345.67typeof === 'number',被当作空值
}
```
API 返回的字段值是数字(如 `YGDL: 12345.67`),不是字符串。`pickFirstNonEmpty` 对数字返回 `''`,导致所有行的所有字段都为空,全部被过滤。
**修复**: `normalizeMonthRow` 不使用 `pickFirstNonEmpty`,改为直接处理任意类型值:
```javascript
// 修复前
function normalizeMonthRow(rawRow) {
const row = {};
for (const key of MONTH_COLUMNS) {
row[key] = pickFirstNonEmpty(rawRow?.[key]); // 数字类型 → ''
}
return MONTH_COLUMNS.every((key) => row[key] !== '') ? row : null;
}
// 修复后
function normalizeMonthRow(rawRow) {
const row = {};
for (const key of MONTH_COLUMNS) {
const v = rawRow?.[key];
row[key] = (v === null || v === undefined || v === '') ? '' : String(v).trim();
}
return MONTH_COLUMNS.every((key) => row[key] !== '') ? row : null;
}
```
**涉及文件**: `collect_lineloss.js``normalizeMonthRow`
**是否需要重新编译**: 否
**通用规则**: 内网 API 返回的 JSON 中数值字段通常是 `number` 类型而非字符串。行规范化函数必须用 `String(v)` 进行类型转换,不能依赖 `typeof === 'string'` 判断。
---
### 第 5 阶段:导出问题(架构级)
#### 问题 8: 导出永久挂起
**现象**:
```
tq-lineloss-report 国网兰州供电公司 2026-03 status=pl rows=12
```
数据采集成功12 行),但之后永远没有返回,脚本卡死在导出步骤。
**排查过程**:
1. `exportWorkbook` 调用 `fetch('http://localhost:13313/...')` — CORS 阻断
2. 改用 `$.ajax({ crossDomain: true })` — 同样阻断
3. 确认这是浏览器安全模型限制,不是配置问题
**根因**: 脚本运行在远程页面 `http://20.76.57.61:18080` 上,浏览器禁止从远程页面向 `localhost:13313` 发起请求(同源策略 + Mixed Content`crossDomain: true` 只是告诉 jQuery 用跨域模式,并不能绕过浏览器安全策略。
原始场景的解决方式:有一个本地场景页面(`localhost` 上的 `index.html`)充当代理,先在远程页面采集数据,再通过 `postMessage` 或回调传回本地页面,由本地页面调用 `localhost:13313`
Skill 模式没有本地场景页面,因此这种代理机制不存在。
**解决方案**: 将导出逻辑从浏览器 JS 移到 Rust 侧(方案 A2: Rust 本地生成 XLSX
**最终架构**:
```
JS (浏览器): 采集数据 → 返回 artifact { rows, column_defs, status }
Rust (本地): 解析 artifact → 提取 rows + column_defs → 生成 XLSX 文件
```
**具体修改**:
1. **JS 侧**: 删除 `exportWorkbook()``writeReportLog()``postJson()``buildExportPayload()` 等导出相关代码。artifact 中添加 `column_defs` 字段export 状态设为 `deferred_to_rust`
2. **Rust 侧**: 新增 `lineloss_xlsx_export.rs`,用 `zip` crate + OpenXML XML 生成 XLSX。在 `deterministic_submit.rs` 中,收到 artifact 后调用 XLSX 生成。
**涉及文件**:
- `collect_lineloss.js` — 删除导出代码,添加 `column_defs`
- `src/compat/lineloss_xlsx_export.rs` — 新增
- `src/compat/deterministic_submit.rs` — 新增导出集成
- `src/compat/mod.rs` — 注册新模块
**是否需要重新编译**: 是
**通用规则**: 任何从远程页面调用 `localhost` 的操作在 Skill 模式下都不可行。导出/写日志等需要访问本地服务的功能必须放到 Rust 侧实现。
---
## 排查方法论总结
### 1. 诊断信息注入模式
脚本运行在浏览器中,无法看 F12 console。唯一的信息通道是 artifact JSON 的 `reasons` 字段。
```javascript
// 在 catch 块中注入详细错误
reasons: ['api_query_failed:' + String(error?.message || error || 'unknown')]
// 在规范化失败时注入原始数据摘要
reasons: ['row_normalization_failed:rawRows=' + rawRows.length + '|keys=' + Object.keys(rawRows[0]).join(',')]
// 在页面上下文检查中注入环境信息
reason: 'page_context_unavailable:mac_missing|href=' + href + '|host=' + host + '|port=' + port
```
Rust 侧的 summary 输出会包含这些 reasons直接在日志中可见。
### 2. 逐层排查顺序
```
Layer 1: 管道层Rust
├── args 是否正确传入?(expected_domain, target_url, org_code 等)
├── 脚本文件是否正确读取?
├── async 返回值是否被正确处理?(.then() 模式)
└── 回调是否成功返回?
Layer 2: 页面上下文JS
├── 脚本注入到了哪个页面?(href, title)
├── 页面是否有需要的全局变量?(window.mac 等)
└── domain 匹配是否正确?
Layer 3: API 请求JS
├── 请求头是否完整?(X-Requested-With)
├── 返回格式是否正确?(JSON vs HTML)
└── 返回状态码?
Layer 4: 数据处理JS
├── API 返回的字段名是否匹配列定义?
├── 字段值类型是否兼容?(number vs string)
└── 规范化后是否有有效行?
Layer 5: 导出(架构)
├── 是否涉及跨域请求?
├── localhost 是否可达?
└── 是否需要 Rust 侧处理?
```
### 3. 修改后验证检查清单
- [ ] JS 文件语法检查:`node -e "require('./collect_lineloss.js')"`
- [ ] 如果改了 Rust 代码:`cargo build` 编译通过
- [ ] `cargo test` 全部通过(排除已知的 pre-existing failures
- [ ] 替换 JS 文件到部署目录
- [ ] 如果改了 Rust重新部署编译后的 sgclaw 二进制
---
## 最终文件清单
### JS 文件: `collect_lineloss.js`
**位置**: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
**功能**: 纯数据采集。注入到浏览器,查询线损平台 API返回结构化 artifact。
**不做的事**: 不调 localhost:13313不导出 Excel不写 report log。
### Rust 文件: 修改清单
| 文件 | 修改内容 | 修改类型 |
|------|---------|---------|
| `src/browser/callback_backend.rs` | `build_eval_js` 增加 `.then()` 处理 async 返回值 | 管道层通用修复 |
| `src/compat/deterministic_submit.rs` | 完整 `target_url`; 解析 artifact 后调 XLSX 导出 | 业务集成 |
| `src/compat/lineloss_xlsx_export.rs` | XLSX 生成zip + OpenXML | 新增 |
| `src/compat/mod.rs` | 注册 `lineloss_xlsx_export` 模块 | 新增 |
---
## 快速复用模板
新建类似 skill 时,直接检查以下要点:
1. **`build_eval_js` 是否支持 async**:入口函数如果是 `async`,确认 `callback_backend.rs` 中有 `.then()` 处理。
2. **`validatePageContext` 不检查页面局部状态**:只检查 host不检查 `window.mac``window.app` 等场景页专属变量。
3. **API 请求必须带 `X-Requested-With: XMLHttpRequest`**:内网 Java 后端的标配。
4. **列定义从原始场景代码精确复制**:找 `cols1`/`cols2` 或表格 `columns` 配置。
5. **`normalizeRow``String(v)` 而非 `pickFirstNonEmpty`**API 返回数字不是字符串。
6. **导出不走浏览器,走 Rust 侧**JS 返回 rows + column_defsRust 生成 XLSX。