4d3a6a09a6
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(不再夾帶第二份完整程式)
322 lines
9.9 KiB
Go
322 lines
9.9 KiB
Go
// 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 XML(ZIP+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
|
||
}
|