2026-06-14 15:24:02 +08:00
|
|
|
package excelparse
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/xuri/excelize/v2"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var parsers []FormatParser
|
|
|
|
|
|
|
|
|
|
func init() {
|
2026-07-05 08:06:28 +09:00
|
|
|
parsers = append(parsers, &NativeProductParser{})
|
2026-06-14 15:24:02 +08:00
|
|
|
parsers = append(parsers, &MiaozhangParser{})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RegisterParser adds a format parser at runtime (e.g. for plugins).
|
|
|
|
|
func RegisterParser(p FormatParser) {
|
|
|
|
|
parsers = append(parsers, p)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// xlsToolPath returns the path to the xls2json.js script.
|
|
|
|
|
// Checks XLS_TOOL_PATH env var first, then falls back to a path
|
|
|
|
|
// relative to the binary's directory so it works both locally and in Docker.
|
|
|
|
|
func xlsToolPath() (string, error) {
|
|
|
|
|
if p := os.Getenv("XLS_TOOL_PATH"); p != "" {
|
|
|
|
|
return p, nil
|
|
|
|
|
}
|
|
|
|
|
// Docker layout: /app/tools/xls2json.js
|
|
|
|
|
candidates := []string{
|
|
|
|
|
"/app/tools/xls2json.js",
|
|
|
|
|
}
|
|
|
|
|
// Local dev: look relative to the process working directory
|
|
|
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
|
|
|
candidates = append(candidates,
|
|
|
|
|
filepath.Join(cwd, "deploy/tools/xls2json.js"),
|
|
|
|
|
filepath.Join(cwd, "../deploy/tools/xls2json.js"),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
for _, p := range candidates {
|
|
|
|
|
if _, err := os.Stat(p); err == nil {
|
|
|
|
|
return p, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("xls2json.js not found; set XLS_TOOL_PATH env var or deploy to /app/tools/")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ParseFile reads an Excel file, auto-detects the competitor format, and returns parsed products.
|
|
|
|
|
func ParseFile(filePath string) (*ParseResult, error) {
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
|
|
|
|
|
|
var headers []string
|
|
|
|
|
var rows [][]string
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
switch ext {
|
|
|
|
|
case ".xlsx":
|
|
|
|
|
headers, rows, err = readXLSX(filePath)
|
|
|
|
|
case ".xls":
|
|
|
|
|
headers, rows, err = readXLS(filePath)
|
|
|
|
|
default:
|
|
|
|
|
return nil, ErrUnsupportedExt
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("read excel: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(rows) == 0 {
|
|
|
|
|
return nil, ErrEmptyFile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, p := range parsers {
|
|
|
|
|
if p.Match(headers) {
|
|
|
|
|
result, err := p.ParseRows(headers, rows)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse %s: %w", p.Name(), err)
|
|
|
|
|
}
|
|
|
|
|
result.ParserName = p.Name()
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil, ErrUnknownFormat
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func readXLSX(filePath string) ([]string, [][]string, error) {
|
|
|
|
|
f, err := excelize.OpenFile(filePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
sheets := f.GetSheetList()
|
|
|
|
|
if len(sheets) == 0 {
|
|
|
|
|
return nil, nil, ErrEmptyFile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
allRows, err := f.GetRows(sheets[0])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
if len(allRows) < 2 {
|
|
|
|
|
return nil, nil, ErrEmptyFile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return allRows[0], allRows[1:], nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readXLS delegates to the xls2json.js Node.js script via subprocess.
|
|
|
|
|
// This handles legacy .xls (BIFF8) files including those with embedded images,
|
|
|
|
|
// which pure-Go libraries cannot reliably process.
|
|
|
|
|
func readXLS(filePath string) ([]string, [][]string, error) {
|
|
|
|
|
toolPath, err := xlsToolPath()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
|
cmd := exec.CommandContext(ctx, "node", toolPath, filePath)
|
|
|
|
|
cmd.Stdout = &stdout
|
|
|
|
|
cmd.Stderr = &stderr
|
|
|
|
|
|
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
|
|
|
msg := strings.TrimSpace(stderr.String())
|
|
|
|
|
if msg == "" {
|
|
|
|
|
msg = err.Error()
|
|
|
|
|
}
|
|
|
|
|
return nil, nil, fmt.Errorf("xls2json: %s", msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// JSON format: [][]interface{} (first row = headers)
|
|
|
|
|
var raw [][]interface{}
|
|
|
|
|
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("xls2json output parse: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(raw) < 2 {
|
|
|
|
|
return nil, nil, ErrEmptyFile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toStrings := func(row []interface{}) []string {
|
|
|
|
|
out := make([]string, len(row))
|
|
|
|
|
for i, v := range row {
|
|
|
|
|
if v == nil {
|
|
|
|
|
out[i] = ""
|
|
|
|
|
} else {
|
|
|
|
|
out[i] = strings.TrimSpace(fmt.Sprintf("%v", v))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
headers := toStrings(raw[0])
|
|
|
|
|
rows := make([][]string, 0, len(raw)-1)
|
|
|
|
|
for _, r := range raw[1:] {
|
|
|
|
|
rows = append(rows, toStrings(r))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return headers, rows, nil
|
|
|
|
|
}
|