feat(t73): Excel/CSV 抽取器——企業用很多(leo)

leo 兩句定調:
①「要思考 Excel 和 csv 的問題,因為企業用很多」
②「企業的 excel 通常不會是 1 萬行,人工做不出這麼多,但你可以轉結構資料丟進去」
→ 修正我先前「CSV 會變數字牆、先不加」的判斷——那是拿機器產生的百萬列當前提。
   實測 60列x6欄 ≈ 2,200 token,對 LLM 完全不是問題。

統一中間格式=Markdown(leo 提 n8n 對照後定調):
n8n 一切轉 JSON 是對的,因為它下游是程式(.欄位 取值);
我們下游是 LLM ⇒ Markdown 表格更好。實測同一份表格
JSON 6,872 字元 vs Markdown 3,341=JSON 多花 2.1 倍 token(每列重複寫欄位名)。

測試 8/8,用 openpyxl 產的真 Excel 檔(非自捏 XML):
   測試抓到兩個真 bug(讀原始碼看不出來):
     ① rels 的 Target 是絕對路徑 /xl/... 我卻又補 xl/ 前綴 → 變 xl/xl/...
     ② r:id 帶 namespace,寫 xml:"id,attr" 抓不到,要用完整 namespace URI
     修好後分頁名從 ## sheet1 變成 ## 維修紀錄(企業分頁名本身就是語意)
  CSV:去 BOM(Excel 另存必帶)/引號內逗號不拆欄/| 跳脫/欄數不齊不整份失敗
  XLSX:多工作表全讀(只取第一頁會漏)/sharedStrings 索引/壞檔報錯

防呆:超過 500 列截斷並明說截斷了(不靜默丟資料);單格超 500 字截斷。
This commit is contained in:
2026-07-27 20:50:45 +08:00
parent 09bf454402
commit 12c8098b5f
4 changed files with 467 additions and 2 deletions
+321
View File
@@ -0,0 +1,321 @@
// 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 main
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
}