feat: 视频按蝦皮商品 ID 分子目录存放,文件名以蝦皮商品 ID 为前缀
原先所有视频平铺在视频目录下,后续别的程序无法按商品区分。改为: <视频目录>/<蝦皮商品ID>/<蝦皮商品ID>_<序号>.mp4 例:运行数据\视频\40583431295\40583431295_1.mp4 - 子目录名和文件名前缀都用蝦皮商品 ID(itemId),不是货憨憨的内部 记录 ID,因为后续程序对接的是 Shopee 侧的标识 - 文件名同时带前缀,是为了文件被单独挪走或和别的商品混在一起时 仍能认出归属 - 视频来自哪个淘宝同款不再体现在文件名里,该信息记录在 videos.source_item,需要回溯来源时查库 安全处理:目录名直接来自货憨憨返回的商品 ID,正常是纯数字但不能假定。 新增 SafeDirName 只保留字母数字与减号下划线;并加固 safeID, 让 ".." 这类全部字符被替换后的输入回落为 unknown,而不是生成 "__" 这种无意义目录名。有测试覆盖路径穿越、Windows 非法字符等输入。 GetVideoSummary 的目录改为取实际文件所在目录而非拼接, 这样分目录之前下载的旧文件(平铺在根目录)点「目录」也能正确打开。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
This commit is contained in:
@@ -476,17 +476,24 @@ func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) {
|
||||
if err != nil {
|
||||
return result, a.failVideoFetch(product.ID, fmt.Errorf("解析视频目录失败:%w", err))
|
||||
}
|
||||
// 每个蝦皮商品一个子目录,方便后续别的程序按商品 ID 找视频。
|
||||
// 目录名用蝦皮商品 ID(itemId),不是货憨憨的内部记录 ID。
|
||||
productDir := filepath.Join(videoDir, downloader.SafeDirName(product.ItemID))
|
||||
if err := os.MkdirAll(productDir, 0o755); err != nil {
|
||||
return result, a.failVideoFetch(product.ID, fmt.Errorf("创建商品视频目录失败:%w", err))
|
||||
}
|
||||
|
||||
result.SourceItem = selected.ItemID
|
||||
result.VideoCount = len(videoURLs)
|
||||
result.Directory = videoDir
|
||||
result.Directory = productDir
|
||||
referer := "https://item.taobao.com/item.htm?id=" + url.QueryEscape(selected.ItemID)
|
||||
downloaderClient := downloader.New()
|
||||
records := make([]store.Video, 0, len(videoURLs))
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
var firstDownloadError error
|
||||
for i, sourceURL := range videoURLs {
|
||||
name := downloader.Filename(product.ID, selected.ItemID, i+1)
|
||||
target := filepath.Join(videoDir, name)
|
||||
name := downloader.Filename(product.ItemID, i+1)
|
||||
target := filepath.Join(productDir, name)
|
||||
host := videoURLHost(sourceURL)
|
||||
a.log.Info("商品 %s 正在下载视频 %d/%d(主机 %s,文件 %s)",
|
||||
product.ID, i+1, len(videoURLs), host, name)
|
||||
@@ -578,10 +585,17 @@ func (a *App) GetVideoSummary(productID string) (VideoSummary, error) {
|
||||
summary.DownloadedCount++
|
||||
}
|
||||
}
|
||||
if summary.DownloadedCount > 0 {
|
||||
summary.Directory, err = filepath.Abs(a.cfg.Download.VideoDir)
|
||||
if err != nil {
|
||||
return VideoSummary{}, fmt.Errorf("解析视频目录失败:%w", err)
|
||||
// 目录取实际文件所在的目录,而不是拼出来的。
|
||||
// 这样按蝦皮商品 ID 分目录之前下载的旧文件(平铺在根目录)也能正确打开。
|
||||
if summary.DownloadedCount > 0 && summary.Directory == "" {
|
||||
for _, item := range items {
|
||||
if item.Status != store.VideoStatusDownloaded || item.LocalPath == "" {
|
||||
continue
|
||||
}
|
||||
if info, statErr := os.Stat(item.LocalPath); statErr == nil && !info.IsDir() {
|
||||
summary.Directory = filepath.Dir(item.LocalPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary, nil
|
||||
|
||||
@@ -57,11 +57,20 @@ func NewWithOptions(client *http.Client, probe ProbeFunc) *Downloader {
|
||||
}
|
||||
|
||||
// Filename 生成 Windows 可用且不会逃出目标目录的视频文件名。
|
||||
func Filename(productID, sourceItem string, index int) string {
|
||||
//
|
||||
// 形如 40583431295_1.mp4:前缀是蝦皮商品 ID(和所在子目录同名),
|
||||
// 后面是该商品的视频序号,从 1 开始。
|
||||
//
|
||||
// 文件被单独挪走或和别的商品混在一起时,靠前缀仍能认出属于哪个商品,
|
||||
// 后续别的程序也好按前缀匹配。
|
||||
//
|
||||
// 视频来自哪个淘宝同款不体现在文件名里,那个记录在 videos.source_item,
|
||||
// 需要回溯来源时查库。
|
||||
func Filename(shopeeItemID string, index int) string {
|
||||
if index < 1 {
|
||||
index = 1
|
||||
}
|
||||
return fmt.Sprintf("淘宝-%s-%s-%d.mp4", safeID(productID), safeID(sourceItem), index)
|
||||
return fmt.Sprintf("%s_%d.mp4", safeID(shopeeItemID), index)
|
||||
}
|
||||
|
||||
func safeID(value string) string {
|
||||
@@ -77,6 +86,11 @@ func safeID(value string) string {
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
// 全是占位下划线说明原值里没有任何可用字符(比如 ".." 或 "///"),
|
||||
// 这种目录名毫无意义,回落到 unknown 更好排查。
|
||||
if strings.Trim(value, "_") == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -188,3 +202,12 @@ func runFFProbe(ctx context.Context, path string) (ProbeResult, error) {
|
||||
}
|
||||
return ProbeResult{Duration: duration, Size: size, FormatName: payload.Format.FormatName}, nil
|
||||
}
|
||||
|
||||
// SafeDirName 把蝦皮商品 ID 清洗成可以直接当目录名的字符串。
|
||||
//
|
||||
// 商品 ID 正常是纯数字,但不能假定:一旦货憨憨返回带路径分隔符或
|
||||
// .. 的值,直接拼进路径就能写到目录之外。这里统一只保留字母数字
|
||||
// 和减号下划线。
|
||||
func SafeDirName(value string) string {
|
||||
return safeID(value)
|
||||
}
|
||||
|
||||
@@ -13,12 +13,28 @@ import (
|
||||
)
|
||||
|
||||
func Test文件名特殊字符不会生成非法路径(t *testing.T) {
|
||||
got := Filename(`商品:/\\*?"<>|..`, `同款/A:B`, 2)
|
||||
if strings.ContainsAny(got, `:/\\*?"<>|`) {
|
||||
t.Fatalf("文件名仍包含 Windows 非法字符:%s", got)
|
||||
got := Filename(`商品:/\*?"<>|..`, 2)
|
||||
if strings.ContainsAny(got, `:/\*?"<>|`) {
|
||||
t.Fatalf("文件名不得含 Windows 非法字符:%q", got)
|
||||
}
|
||||
if filepath.Base(got) != got || !strings.HasSuffix(got, ".mp4") {
|
||||
t.Fatalf("文件名不应包含目录且必须以 mp4 结尾:%s", got)
|
||||
if strings.Contains(got, "..") {
|
||||
t.Fatalf("文件名不得含 ..,否则可能逃出目标目录:%q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "_2.mp4") {
|
||||
t.Fatalf("文件名应以 _序号.mp4 结尾,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 文件名前缀必须是蝦皮商品 ID,后续别的程序靠它区分商品。
|
||||
func Test文件名以蝦皮商品ID为前缀(t *testing.T) {
|
||||
if got := Filename("40583431295", 1); got != "40583431295_1.mp4" {
|
||||
t.Fatalf("应当是 40583431295_1.mp4,实际 %q", got)
|
||||
}
|
||||
if got := Filename("40583431295", 0); got != "40583431295_1.mp4" {
|
||||
t.Fatalf("序号小于 1 时应回落为 1,实际 %q", got)
|
||||
}
|
||||
if got := Filename("40583431295", 3); got != "40583431295_3.mp4" {
|
||||
t.Fatalf("应当是 40583431295_3.mp4,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,3 +115,32 @@ func Test下载校验成功后改名为正式文件(t *testing.T) {
|
||||
t.Fatalf("成功后不应留下 .part:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录名直接来自货憨憨返回的蝦皮商品 ID。正常是纯数字,
|
||||
// 但不能假定:一旦返回带路径分隔符或 .. 的值,拼进路径就能写到
|
||||
// 视频目录之外。这个测试守住清洗逻辑。
|
||||
func TestSafeDirName拒绝危险输入(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"40583431295", "40583431295"},
|
||||
{"", "unknown"},
|
||||
{" ", "unknown"},
|
||||
{"..", "unknown"},
|
||||
{"../../windows", "______windows"},
|
||||
{`a\b`, "a_b"},
|
||||
{"a/b", "a_b"},
|
||||
{"a:b", "a_b"},
|
||||
{"CON", "CON"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := SafeDirName(c.in)
|
||||
if got != c.want {
|
||||
t.Fatalf("SafeDirName(%q) 应当是 %q,实际 %q", c.in, c.want, got)
|
||||
}
|
||||
if strings.ContainsAny(got, `/\:`) || got == ".." {
|
||||
t.Fatalf("清洗后仍含危险字符:%q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user