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

16 KiB
Raw Blame History

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:13313CORS 阻断)
结果回传 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

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_jscallback_backend.rs)中增加 Promise 检测:

// 旧代码
"_s(v);"

// 新代码
"if(v&&typeof v.then==='function'){v.then(_s).catch(function(){});}else{_s(v);}"

如果返回值是 thenablePromise等它 resolve 后再发送回调。

涉及文件: src/browser/callback_backend.rsbuild_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 中添加诊断信息:
// 临时诊断代码
const diag = 'href=' + href + '|host=' + host + '|port=' + port + '|title=' + title + '|mac=' + hasMac;
return { ok: false, reason: 'page_context_unavailable:mac_missing|' + diag };
  1. 页面返回的诊断结果:
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 匹配:

// 修复前
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.jsvalidatePageContext 函数

是否需要重新编译: 否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 不会自动添加这个头。

修复: 在 queryMonthDataqueryWeekData$.ajax 调用中添加请求头:

$.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.jsqueryMonthDataqueryWeekData

是否需要重新编译: 否

排查技巧: 在 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 使用了猜测的列名:

// 错误的列名
['LINE_LOSS_RATE', '线损完成率(%)'],
['PPQ', '累计供电量'],
['UPQ', '累计售电量'],

而 API 实际返回的列名是(参考原始场景 index.html 中的 cols2

// 正确的列名
['ORG_NAME', '供电单位'],
['YGDL', '累计供电量'],
['YYDL', '累计售电量'],
['YXSL', '线损完成率(%)'],
['RAT_SCOPE', '线损率累计目标值'],
['BLANK3', '目标完成率'],
['BLANK2', '排行']

修复: 按原始场景 index.htmlcols2 的定义修正 MONTH_COLUMN_DEFS

排查技巧: 在 reasons 中拼接 rawRows.lengthObject.keys(rawRows[0]).join(',') 可以直接看到 API 返回了哪些字段。

通用规则: 生成 skill 脚本时,列定义必须从原始场景代码中精确复制,不能靠猜测。找 cols1/cols2 或表格渲染相关代码。


问题 7: row_normalization_failed — 数值类型不兼容

现象: 列名修正后仍报 row_normalization_failed:rawRows=1212 行全部被过滤。

根因: pickFirstNonEmpty() 函数只识别字符串类型:

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,改为直接处理任意类型值:

// 修复前
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.jsnormalizeMonthRow

是否需要重新编译: 否

通用规则: 内网 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 ContentcrossDomain: 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 字段。

// 在 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.macwindow.app 等场景页专属变量。
  3. API 请求必须带 X-Requested-With: XMLHttpRequest:内网 Java 后端的标配。
  4. 列定义从原始场景代码精确复制:找 cols1/cols2 或表格 columns 配置。
  5. normalizeRowString(v) 而非 pickFirstNonEmptyAPI 返回数字不是字符串。
  6. 导出不走浏览器,走 Rust 侧JS 返回 rows + column_defsRust 生成 XLSX。