Files

500 lines
18 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.
// Package config 负责读写和校验程序的参数设置。
//
// 配置文件是程序目录下的 config.yaml,用 YAML 而不是 JSON,
// 是因为 YAML 能写注释——很多坑(比如密码必须加引号)只有写在
// 字段旁边,同事才不会踩。
//
// 设计约定(改代码前请先读):
//
// - config.yaml 含密码,已在 .gitignore 里,绝不能提交进 Git。
// 进 Git 的是 config.example.yaml,那份不含真实凭据。
//
// - 淘宝没有账号密码字段,将来也不要加。淘宝登录必须由使用者
// 在专属 Chrome 里手动完成,程序只保存浏览器路径,不碰凭据。
// 这是 AGENTS.md 里的红线。
//
// - 保存配置时用 Render() 按固定模板重新渲染,不要用 yaml.Marshal,
// 否则注释会被全部冲掉。
//
// - 新增一个字段要改四处:结构体、Default()、Validate()、
// Render() 里的模板。漏掉任何一处,单元测试会失败。
package config
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Config 是程序的全部参数设置,对应界面上的「参数设置」页。
//
// yaml tag 就是配置文件里的键名。改名会导致老配置读不出来,
// 所以除非有充分理由,不要改已有字段的 tag。
type Config struct {
ERPGo ERPGoConfig `yaml:"erpgo" json:"erpgo"`
Huohanhan HuohanhanConfig `yaml:"huohanhan" json:"huohanhan"`
Taobao TaobaoConfig `yaml:"taobao" json:"taobao"`
Download DownloadConfig `yaml:"download" json:"download"`
}
// ERPGoConfig 用于查询店铺和商品;APIKey 只保存在本机,禁止进入日志。
type ERPGoConfig struct {
BaseURL string `yaml:"base_url" json:"baseUrl"`
APIKey string `yaml:"api_key" json:"apiKey"`
}
// Validate 允许未配置的新旧安装继续使用本地缓存及原上传功能。
func (c ERPGoConfig) Validate() error {
if strings.TrimSpace(c.BaseURL) != "" {
u, err := url.Parse(strings.TrimSpace(c.BaseURL))
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
return fmt.Errorf("erpgo 地址须为 http:// 或 https:// 服务地址,不能包含账号、查询参数或片段")
}
}
if strings.ContainsAny(c.APIKey, "\r\n") {
return fmt.Errorf("erpgo API Key 不能包含换行")
}
return nil
}
// HuohanhanConfig 仅保留旧 config.yaml 的字段兼容,运行时不再使用。
type HuohanhanConfig struct {
// BaseURL 是货憨憨网站地址,不带结尾的斜杠。
BaseURL string `yaml:"base_url" json:"baseUrl"`
// Account 是登录账号。
Account string `yaml:"account" json:"account"`
// Password 是登录密码。属于凭据,禁止写入日志、工单和 Wiki。
Password string `yaml:"password" json:"password"`
// OCRURL 是识别登录验证码的外部服务地址。
OCRURL string `yaml:"ocr_url" json:"ocrUrl"`
}
// TaobaoConfig 是淘宝专属浏览器的设置。
//
// 这里故意没有账号和密码字段:淘宝登录由使用者手动完成,
// 登录态保存在 Chrome 用户数据目录里,程序不读取也不保存。
type TaobaoConfig struct {
// ChromePath 是 chrome.exe 的完整路径,注意是文件不是目录。
ChromePath string `yaml:"chrome_path" json:"chromePath"`
// UserDataDir 是专属 Chrome 的用户数据目录,淘宝登录态存在这里。
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
// DebugPortStart / DebugPortEnd 是分配调试端口的范围。
DebugPortStart int `yaml:"debug_port_start" json:"debugPortStart"`
DebugPortEnd int `yaml:"debug_port_end" json:"debugPortEnd"`
}
// DownloadConfig 是下载与任务参数。
type DownloadConfig struct {
// VideoDir 是下载的视频保存目录。
VideoDir string `yaml:"video_dir" json:"videoDir"`
// MaxVideosPerProduct 是每个商品最多下载几个视频。
MaxVideosPerProduct int `yaml:"max_videos_per_product" json:"maxVideosPerProduct"`
// SearchTopN 是图搜结果里取前几个同款去找视频。
SearchTopN int `yaml:"search_top_n" json:"searchTopN"`
// Concurrency 是同时下载几个视频。
// 仅控制 CDN 文件下载,不并行访问淘宝页面。
Concurrency int `yaml:"concurrency" json:"concurrency"`
// WaitSecondsMin / WaitSecondsMax 是商品及候选页面之间随机等待的秒数区间。
WaitSecondsMin float64 `yaml:"wait_seconds_min" json:"waitSecondsMin"`
WaitSecondsMax float64 `yaml:"wait_seconds_max" json:"waitSecondsMax"`
DetailWaitSeconds float64 `yaml:"detail_wait_seconds" json:"detailWaitSeconds"`
GuardWaitSeconds float64 `yaml:"guard_wait_seconds" json:"guardWaitSeconds"`
RiskEmptyThreshold int `yaml:"risk_empty_threshold" json:"riskEmptyThreshold"`
DownloadRetries int `yaml:"download_retries" json:"downloadRetries"`
}
// Default 返回一份可以直接使用的默认配置。
//
// 账号和密码故意留空:程序不内置任何凭据,必须由使用者自己填。
func Default() Config {
return Config{
ERPGo: ERPGoConfig{},
Huohanhan: HuohanhanConfig{
BaseURL: "https://www.huohanhan.com",
Account: "",
Password: "",
OCRURL: "https://ocr.ilapage.cn/ocr",
},
Taobao: TaobaoConfig{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
UserDataDir: DefaultChromeUserDataDir(),
DebugPortStart: 19666,
DebugPortEnd: 19765,
},
Download: DownloadConfig{
VideoDir: DefaultVideoDir(),
MaxVideosPerProduct: 3,
SearchTopN: 5,
Concurrency: 2,
WaitSecondsMin: 10,
WaitSecondsMax: 20,
DetailWaitSeconds: 8,
GuardWaitSeconds: 3,
RiskEmptyThreshold: 8,
DownloadRetries: 3,
},
}
}
// DefaultChromeUserDataDir 返回专属 Chrome 用户数据目录的默认位置。
//
// 这个路径沿用迁移前 Python 版本的目录,改动它会导致同事需要重新
// 扫码登录淘宝,所以不要随手改。
func DefaultChromeUserDataDir() string {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", "淘宝浏览器", "默认账号")
}
base = home
}
return filepath.Join(base, "电商视频自动下载工具", "淘宝浏览器", "默认账号")
}
// DataRoot 返回程序存放数据的根目录。
//
// 规则很简单:**配置文件在哪,数据就在哪**。
//
// 这样两种用法都对:
// - 打包后双击 exe:config.yaml 在 exe 旁边,数据也在 exe 旁边
// - 开发模式:config.yaml 在项目根目录(wails dev 的工作目录),
// 数据也落在项目根目录
//
// 不能直接用 exe 所在目录:wails dev 跑的是 buildin\cmsp-dev.exe,
// 数据会落进 buildin,而 `wails build -clean` 会清空那个目录,
// 已经下载好的视频会被一起删掉。
func DataRoot() string {
return filepath.Dir(DefaultPath())
}
// DefaultVideoDir 返回视频默认保存目录:数据根目录下的「运行数据/视频」。
func DefaultVideoDir() string {
return filepath.Join(DataRoot(), "运行数据", "视频")
}
// Validate 检查配置是否可用。返回的错误信息会直接显示给使用者,
// 所以要写成一句能看懂的中文,并说明允许范围。
//
// 这里不校验账号密码对不对,那要等真正登录时才知道。
// 这里只保证「格式上能用」。
func (c Config) Validate() error {
if err := c.ERPGo.Validate(); err != nil {
return err
}
// 旧 huohanhan 配置只为兼容现有 config.yaml 保留,不再用于请求。
t := c.Taobao
if strings.TrimSpace(t.ChromePath) == "" {
return fmt.Errorf("Chrome 可执行文件路径不能为空")
}
if strings.TrimSpace(t.UserDataDir) == "" {
return fmt.Errorf("Chrome 用户数据目录不能为空")
}
if err := checkPort(t.DebugPortStart, "调试端口起始"); err != nil {
return err
}
if err := checkPort(t.DebugPortEnd, "调试端口结束"); err != nil {
return err
}
if t.DebugPortEnd < t.DebugPortStart {
return fmt.Errorf("调试端口结束不能小于起始端口")
}
d := c.Download
if strings.TrimSpace(d.VideoDir) == "" {
return fmt.Errorf("视频保存目录不能为空")
}
if err := checkIntRange(d.MaxVideosPerProduct, 1, 10, "每个商品最多下载视频数"); err != nil {
return err
}
if err := checkIntRange(d.SearchTopN, 1, 60, "图搜取前 N 个同款"); err != nil {
return err
}
if err := checkIntRange(d.Concurrency, 1, 8, "下载并发数"); err != nil {
return err
}
if d.WaitSecondsMin < 0 || d.WaitSecondsMin > 60 {
return fmt.Errorf("商品间最短等待秒数不合法,允许范围 0—60")
}
if d.WaitSecondsMax < 0 || d.WaitSecondsMax > 120 {
return fmt.Errorf("商品间最长等待秒数不合法,允许范围 0—120")
}
if d.WaitSecondsMax < d.WaitSecondsMin {
return fmt.Errorf("商品间最长等待秒数不能小于最短等待秒数")
}
if d.DetailWaitSeconds < 3 || d.DetailWaitSeconds > 30 {
return fmt.Errorf("详情页加载等待秒数不合法,允许范围 3—30")
}
if d.GuardWaitSeconds < 1 || d.GuardWaitSeconds > 15 {
return fmt.Errorf("登录守卫等待秒数不合法,允许范围 1—15")
}
if err := checkIntRange(d.RiskEmptyThreshold, 3, 50, "疑似风控连续空结果阈值"); err != nil {
return err
}
if err := checkIntRange(d.DownloadRetries, 0, 5, "下载重试次数"); err != nil {
return err
}
return nil
}
func checkPort(port int, name string) error {
if port < 1024 || port > 65535 {
return fmt.Errorf("%s不合法,允许范围 1024—65535", name)
}
return nil
}
func checkIntRange(value, min, max int, name string) error {
if value < min || value > max {
return fmt.Errorf("%s不合法,允许范围 %d—%d", name, min, max)
}
return nil
}
// Desensitized 返回一份把密码换成固定占位符的副本。
//
// 任何要写日志、写工单,或者传给不需要密码的地方的场景,都用这个方法,
// 不要直接传 Config。
func (c Config) Desensitized() Config {
copied := c
if copied.ERPGo.APIKey != "" {
copied.ERPGo.APIKey = "******"
}
if copied.Huohanhan.Password != "" {
copied.Huohanhan.Password = "******"
}
return copied
}
// Load 从 path 读取配置。
//
// 文件不存在时返回默认配置而不是错误——第一次启动本来就没有配置文件,
// 这时应该让程序正常打开、显示默认值,由使用者去填。
func Load(path string) (Config, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return Default(), nil
}
if err != nil {
return Config{}, fmt.Errorf("读取配置文件失败:%w", err)
}
// 先铺上默认值再解析,这样老配置文件缺少新字段时,
// 新字段会保留默认值而不是变成零值。
cfg := Default()
if err := yaml.Unmarshal(raw, &cfg); err != nil {
// YAML 类型错误可能包含原始字段值,配置含凭据,不能透传到日志。
return Config{}, fmt.Errorf("配置文件不是有效的 YAML,请检查格式及字段类型")
}
return cfg, nil
}
// Save 校验并写入配置。
//
// 先校验再写,避免把一份用不了的配置存进去。
// 写入的是 Render() 渲染的带注释版本,不是 yaml.Marshal 的裸数据。
func Save(path string, cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("创建配置目录失败:%w", err)
}
// 权限 0600:只有当前用户能读。文件里有密码。
if err := os.WriteFile(path, []byte(cfg.Render()), 0o600); err != nil {
return fmt.Errorf("写入配置文件失败:%w", err)
}
return nil
}
// Render 按固定模板把配置渲染成带注释的 YAML。
//
// 为什么不用 yaml.Marshal:那样会把注释全部丢掉,同事下次打开
// config.yaml 就只剩一堆键值对,不知道每项什么意思、有什么坑。
//
// 新增字段时记得在这里的模板中也加上,并补一句说明。
func (c Config) Render() string {
return fmt.Sprintf(`# cmsp 本机配置
#
# 本文件由「参数设置」页保存时自动重写,注释会保留。
# 也可以直接用记事本改,改完重启程序生效。
#
# [必须] 本文件含密码,已在 .gitignore 里,绝不能提交进 Git,
# 也不要打包发给别人、截图或粘贴到工单和日志里。
# [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成
# 整数,读取时直接报错,前导 0 也会丢。
erpgo:
# 店铺刷新、商品同步和指定商品视频上传使用的服务根地址。
base_url: %s
# API Key 只保存在本机,不得提交、分享或写入日志。
api_key: %s
huohanhan:
# 历史配置,保留旧值兼容;程序不再直接请求货憨憨。
base_url: %s
# 历史登录账号,不再用于请求。
account: %s
# 历史登录密码,继续保存在本机以便兼容旧文件。
password: %s
# 历史 OCR 服务地址,不再使用。
ocr_url: %s
taobao:
# [必须] 这里没有淘宝账号和密码,将来也不要加。
# 淘宝登录必须由你在程序打开的专属 Chrome 里手动扫码完成,
# 登录态保存在下面的 user_data_dir 里,程序不读取也不保存。
# 程序不会代填密码,也不会绕过验证码或滑块。
# chrome.exe 的完整路径。注意是文件不是目录。
chrome_path: %s
# 专属 Chrome 的用户数据目录,淘宝登录态就存在这里。
# [注意] 改了这个路径就要重新扫码登录一次,不要随手改。
# 这个目录不要提交、打包或共享。
user_data_dir: %s
# 给专属 Chrome 分配调试端口的范围。端口被占用时会往后找。
debug_port_start: %d
debug_port_end: %d
download:
# 下载的视频保存到哪个目录。
video_dir: %s
# 每个商品最多下载几个视频,允许 1—10。
max_videos_per_product: %d
# 图搜结果里取前几个同款去找视频,允许 1—60。取太多会明显变慢。
search_top_n: %d
# 同时下载几个视频,允许 1—8。
# 仅控制视频 CDN 文件下载;淘宝页面访问保持串行。
concurrency: %d
# 商品和候选详情访问间的等待区间;降低请求量,不保证解除风控。
# 默认 10—20 秒是降低负载的起点,不是平台公布的安全阈值。
wait_seconds_min: %s
wait_seconds_max: %s
# 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。
detail_wait_seconds: %s
# 每批首次访问「我的淘宝」的深度登录守卫等待秒数。
guard_wait_seconds: %s
# 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。
risk_empty_threshold: %d
# 网络层下载失败后的重试次数;HTTP 4xx 和 ffprobe 校验失败不会重试。
download_retries: %d
`,
quoted(c.ERPGo.BaseURL),
quoted(c.ERPGo.APIKey),
yamlString(c.Huohanhan.BaseURL),
yamlString(c.Huohanhan.Account),
quoted(c.Huohanhan.Password),
yamlString(c.Huohanhan.OCRURL),
quoted(c.Taobao.ChromePath),
quoted(c.Taobao.UserDataDir),
c.Taobao.DebugPortStart,
c.Taobao.DebugPortEnd,
quoted(c.Download.VideoDir),
c.Download.MaxVideosPerProduct,
c.Download.SearchTopN,
c.Download.Concurrency,
trimFloat(c.Download.WaitSecondsMin),
trimFloat(c.Download.WaitSecondsMax),
trimFloat(c.Download.DetailWaitSeconds),
trimFloat(c.Download.GuardWaitSeconds),
c.Download.RiskEmptyThreshold,
c.Download.DownloadRetries,
)
}
// quoted 把值渲染成带双引号的 YAML 字符串。
//
// Windows 路径里有反斜杠和空格,密码可能是纯数字或含特殊字符,
// 这些都必须加引号,否则 YAML 解析会出错或类型不对。
func quoted(v string) string {
// YAML 双引号字符串里,反斜杠和双引号要转义。
escaped := strings.ReplaceAll(v, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
// yamlString 渲染普通字符串。空值写成一对空引号,避免出现裸的冒号后什么都没有。
func yamlString(v string) string {
if strings.TrimSpace(v) == "" {
return `""`
}
// 含特殊字符时一律加引号,省得判断哪些安全。
if strings.ContainsAny(v, `:#{}[],&*?|<>=!%@\"' `) {
return quoted(v)
}
return v
}
// trimFloat 把 2.0 渲染成 2,把 2.5 保留成 2.5,让配置文件更好看。
func trimFloat(v float64) string {
s := fmt.Sprintf("%.2f", v)
s = strings.TrimRight(s, "0")
return strings.TrimSuffix(s, ".")
}
// DefaultPath 返回配置文件的默认位置。
//
// 放在程序目录而不是系统目录,是为了让同事能直接看到和备份它。
//
// 查找顺序(这个顺序是为了同时照顾两种用法,改之前先读完):
//
// 1. exe 旁边已有 config.yaml → 用它。这是同事双击 exe 的正常情况。
// 2. 当前工作目录已有 config.yaml → 用它。这是开发模式的情况:
// `wails dev` 跑的是 build\bin\cmsp-dev.exe,按 exe 目录算会把配置
// 写到 build\bin\ 里,开发的人在项目根目录怎么找都找不到,
// 还以为保存没生效。
// 3. 两个都没有 → 新建在 exe 旁边。
//
// 换句话说:已经存在的配置优先,谁都不存在时才按 exe 目录建。
func DefaultPath() string {
return resolvePath("config.yaml")
}
// resolvePath 按「exe 目录 → 工作目录 → exe 目录(兜底)」找一个文件。
// 配置文件和数据库都用这套规则,行为保持一致。
func resolvePath(name string) string {
exeDir := ""
if exe, err := os.Executable(); err == nil {
exeDir = filepath.Dir(exe)
if candidate := filepath.Join(exeDir, name); fileExists(candidate) {
return candidate
}
}
if wd, err := os.Getwd(); err == nil {
if candidate := filepath.Join(wd, name); fileExists(candidate) {
return candidate
}
}
if exeDir != "" {
return filepath.Join(exeDir, name)
}
return name
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// ResolveDataPath 供其它包复用同一套查找规则,例如数据库文件。
func ResolveDataPath(name string) string {
return resolvePath(name)
}