Files
arcrun-collector/convert_table.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

322 lines
9.9 KiB
Go
Raw Permalink 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.
// convert_table.go — 表格類抽取(.csv / .xlsx
//
// leo 2026-07-27 定調:
//
// 「企業的 excel 通常不會是 1 萬行,人工做不出這麼多,**但你可以轉結構資料丟進去**。」
// 「要思考 Excel 和 csv 的問題,因為**企業用很多**。」
//
// 這修正了先前「CSV 會變成數字牆、先不做」的判斷——那是拿「機器產生的百萬列資料」
// 當前提,但**人工維護的企業表格**(維修紀錄、報價單、料號表、值班表)
// 通常幾十到幾百列。實測:60 列 × 6 欄 ≈ 2,200 token,對 LLM 完全不是問題。
//
// **為什麼轉成 Markdown 表格**LLM 對 Markdown 表格的理解遠優於 CSV 原文
//(欄位對齊、標題行明確);且與現有 .md 卡片格式同語言,萃出來的卡自然帶得走表格。
//
// 防呆:超大表格(機器產生的那種)仍會截斷並明說截斷了——不能讓一個檔案吃掉
// 整個 context,也不能靜默丟資料(靜默是本專案一再犯的病)。
package collector
import (
"archive/zip"
"bytes"
"encoding/csv"
"encoding/xml"
"fmt"
"io"
"strconv"
"strings"
)
// maxTableRows:超過就截斷。
// 依據=人工維護的表格幾乎不會超過這個量(leo 判斷);超過多半是機器匯出的原始資料,
// 那種東西整份塞進知識庫本來就沒意義。截斷會在輸出尾端明講。
const maxTableRows = 500
// maxCellRunes:單格過長(有人把整篇文章塞進一格)截斷,避免一格撐爆整張表。
const maxCellRunes = 500
func extractCSV(data []byte) (string, error) {
// 去 UTF-8 BOM——Excel 另存 CSV 幾乎一定帶 BOM,不去掉第一個欄名會多出看不見的字元,
// 導致標題對不上(實務上很常見的坑)。
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
r := csv.NewReader(bytes.NewReader(data))
r.FieldsPerRecord = -1 // 允許每列欄數不同(手工表格常見尾列缺欄)
r.LazyQuotes = true // 容忍不規範的引號,別為了格式潔癖整份讀不到
var rows [][]string
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
// 讀到一半壞掉:保留已讀到的部分而不是整份放棄——
// 有內容總比沒有好,但要在輸出裡說明。
if len(rows) == 0 {
return "", fmt.Errorf("CSV 解析失敗:%w", err)
}
rows = append(rows, []string{fmt.Sprintf("(後續內容解析失敗:%v", err)})
break
}
rows = append(rows, rec)
if len(rows) > maxTableRows {
break
}
}
return rowsToMarkdown(rows, len(rows) > maxTableRows), nil
}
// ── .xlsx ────────────────────────────────────────────────────────────────
//
// xlsx 與 docx 同為 Office Open XMLZIP+XML),但多一層轉折:
// 字串不直接寫在儲存格裡,而是集中放在 sharedStrings.xml,儲存格用索引指過去
//t="s" 代表這格的值是 sharedStrings 的索引)。所以要先讀字串表再讀工作表。
type xlsxSST struct {
Items []struct {
// <si> 底下可能是單一 <t>,也可能被拆成多個 <r><t>(同格內有不同格式時)
T string `xml:"t"`
R []string `xml:"r>t"`
} `xml:"si"`
}
// 分頁名住在 xl/workbook.xml,靠 r:id 對應到 xl/worksheets/sheetN.xml
//(對應關係在 xl/_rels/workbook.xml.rels)。
//
// **為什麼非做這個對應不可**:企業表格的分頁名本身就是語意——「維修紀錄」「報價單」
// 「2026 Q3 預算」。直接用檔名會輸出 `## sheet1`,LLM 就少掉了「這張表是什麼」的關鍵脈絡。
type xlsxWorkbook struct {
Sheets []struct {
Name string `xml:"name,attr"`
// r:id 帶 namespace。Go 的 encoding/xml 用「namespace URI 空格 屬性名」比對;
// 寫成 `xml:"id,attr"` 在有 namespace 前綴時抓不到(實測踩過)。
ID string `xml:"http://schemas.openxmlformats.org/officeDocument/2006/relationships id,attr"`
} `xml:"sheets>sheet"`
}
type xlsxRels struct {
Rels []struct {
ID string `xml:"Id,attr"`
Target string `xml:"Target,attr"`
} `xml:"Relationship"`
}
type xlsxSheet struct {
Rows []struct {
Cells []struct {
Ref string `xml:"r,attr"`
Type string `xml:"t,attr"`
Value string `xml:"v"`
// inlineStr 形態(有些產生器不用 sharedStrings
IS struct {
T string `xml:"t"`
R []string `xml:"r>t"`
} `xml:"is"`
} `xml:"c"`
} `xml:"sheetData>row"`
}
func extractXLSX(data []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", fmt.Errorf("xlsx 解壓失敗(可能是舊版 .xls 或檔案損壞):%w", err)
}
var sst xlsxSST
var wb xlsxWorkbook
var rels xlsxRels
sheets := map[string][]byte{}
for _, f := range zr.File {
switch {
case f.Name == "xl/sharedStrings.xml":
b, err := readZipFile(f)
if err != nil {
return "", err
}
if err := xml.Unmarshal(b, &sst); err != nil {
return "", fmt.Errorf("讀 sharedStrings 失敗:%w", err)
}
case f.Name == "xl/workbook.xml":
if b, err := readZipFile(f); err == nil {
xml.Unmarshal(b, &wb) // 失敗只是拿不到分頁名,退回檔名,不該讓整份失敗
}
case f.Name == "xl/_rels/workbook.xml.rels":
if b, err := readZipFile(f); err == nil {
xml.Unmarshal(b, &rels)
}
case strings.HasPrefix(f.Name, "xl/worksheets/sheet") && strings.HasSuffix(f.Name, ".xml"):
b, err := readZipFile(f)
if err != nil {
return "", err
}
sheets[f.Name] = b
}
}
// r:id → 檔案路徑 → 使用者取的分頁名。
//
// ⚠️ Target 有兩種寫法,兩種都要吃(openpyxl 用前者、Excel 常用後者,實測踩過):
// 絕對:/xl/worksheets/sheet1.xml → 去掉開頭的 / 就是 zip 內路徑
// 相對:worksheets/sheet1.xml → 相對於 xl/,要補前綴
relTarget := map[string]string{}
for _, r := range rels.Rels {
t := r.Target
if strings.HasPrefix(t, "/") {
t = strings.TrimPrefix(t, "/")
} else if !strings.HasPrefix(t, "xl/") {
t = "xl/" + t
}
relTarget[r.ID] = t
}
sheetNames := map[string]string{}
for _, s := range wb.Sheets {
if p, ok := relTarget[s.ID]; ok && s.Name != "" {
sheetNames[p] = s.Name
}
}
if len(sheets) == 0 {
return "", fmt.Errorf("xlsx 找不到任何工作表")
}
// 字串表攤平成 []string 供索引
strs := make([]string, 0, len(sst.Items))
for _, si := range sst.Items {
if si.T != "" {
strs = append(strs, si.T)
} else {
strs = append(strs, strings.Join(si.R, ""))
}
}
// 多工作表都輸出,各自標名稱——企業表格常一個檔多個分頁,只取第一頁會漏資料。
names := sortedKeys(sheets)
var out strings.Builder
for _, name := range names {
var sh xlsxSheet
if err := xml.Unmarshal(sheets[name], &sh); err != nil {
continue // 單一工作表壞掉不該讓整份失敗
}
var rows [][]string
truncated := false
for _, r := range sh.Rows {
if len(rows) >= maxTableRows {
truncated = true
break
}
var row []string
for _, c := range r.Cells {
row = append(row, xlsxCellText(c.Type, c.Value, strings.Join(append([]string{c.IS.T}, c.IS.R...), ""), strs))
}
rows = append(rows, row)
}
if len(rows) == 0 {
continue
}
// 分頁名優先用使用者取的(「維修紀錄」),拿不到才退回檔名(sheet1)。
// 單一工作表也標名稱——那個名字常常就是這張表的主題,對萃卡很有用。
title := sheetNames[name]
if title == "" {
title = sheetTitle(name)
}
out.WriteString("\n## " + title + "\n\n")
out.WriteString(rowsToMarkdown(rows, truncated))
out.WriteString("\n")
}
return out.String(), nil
}
func xlsxCellText(typ, val, inline string, strs []string) string {
if inline != "" {
return inline
}
if typ == "s" { // sharedStrings 索引
if i, err := strconv.Atoi(val); err == nil && i >= 0 && i < len(strs) {
return strs[i]
}
return ""
}
return val
}
func readZipFile(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("讀 %s 失敗:%w", f.Name, err)
}
defer rc.Close()
return io.ReadAll(rc)
}
func sortedKeys(m map[string][]byte) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
// sheet1, sheet2… 字串排序即可(sheet10 會排在 sheet2 前,可接受)
for i := 0; i < len(ks); i++ {
for j := i + 1; j < len(ks); j++ {
if ks[j] < ks[i] {
ks[i], ks[j] = ks[j], ks[i]
}
}
}
return ks
}
func sheetTitle(path string) string {
n := strings.TrimSuffix(strings.TrimPrefix(path, "xl/worksheets/"), ".xml")
return n
}
// rowsToMarkdown 把二維字串轉成 Markdown 表格。
// 第一列當標題(企業表格慣例);只有一列時就不畫分隔線。
func rowsToMarkdown(rows [][]string, truncated bool) string {
if len(rows) == 0 {
return ""
}
width := 0
for _, r := range rows {
if len(r) > width {
width = len(r)
}
}
var b strings.Builder
for i, r := range rows {
b.WriteString("|")
for c := 0; c < width; c++ {
cell := ""
if c < len(r) {
cell = clampCell(r[c])
}
b.WriteString(" " + cell + " |")
}
b.WriteString("\n")
if i == 0 && len(rows) > 1 {
b.WriteString("|")
for c := 0; c < width; c++ {
b.WriteString(" --- |")
}
b.WriteString("\n")
}
}
if truncated {
// 明說截斷——靜默丟資料是本專案一再犯的病
b.WriteString(fmt.Sprintf("\n(表格過大,只取前 %d 列)\n", maxTableRows))
}
return b.String()
}
// clampCell 清掉會破壞 Markdown 表格的字元,並限制單格長度。
func clampCell(s string) string {
s = strings.ReplaceAll(s, "|", "\\|") // 跳脫,否則欄位會錯位
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
s = strings.TrimSpace(s)
rs := []rune(s)
if len(rs) > maxCellRunes {
return string(rs[:maxCellRunes]) + "…(截斷)"
}
return s
}