# 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
    └── 生成 XLSX（Rust 侧）→ 返回 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);}"
```

如果返回值是 thenable（Promise），等它 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.67，typeof === '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_defs，Rust 生成 XLSX。
