gongxiangp2/scripts/scan_spu_ledger.py
chenpei 9cd3c0f231 feat: 任务详情无条码开关 + 上传面板信息密度优化 + 历史导入/脚本补全
- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验
- SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示
- 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签
- 补充历史导入相关脚本和工具文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:01:08 +08:00

1283 lines
42 KiB
Python
Raw 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.

#!/usr/bin/env python3
"""
Scan real material folders into an SPU-centered SQLite ledger.
This scanner intentionally does not read the older SKU-anchor CSV/SQLite outputs.
Those files can be used later as evidence for old-batch review, but the ledger
itself is built from filesystem facts under the provided roots.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sqlite3
import sys
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
OTHER_IMAGE_EXTENSIONS = {".tif", ".tiff", ".bmp", ".gif"}
PS_SOURCE_EXTENSIONS = {".psd", ".psb"}
SOURCE_ARCHIVE_EXTENSIONS = {".zip", ".rar", ".7z"}
RELEVANT_EXTENSIONS = IMAGE_EXTENSIONS | OTHER_IMAGE_EXTENSIONS | PS_SOURCE_EXTENSIONS | SOURCE_ARCHIVE_EXTENSIONS
BRANDED_SPU_BODY = r"[A-Z]{2,5}-[A-Z]{1,6}\d{3,5}(?:K\d{1,3})?"
UNBRANDED_SPU_BODY = r"[A-Z]{1,6}\d{3,5}(?:K\d{1,3})?"
BRANDED_SPU_RE = re.compile(rf"(?<![A-Z0-9]){BRANDED_SPU_BODY}(?![A-Z0-9])", re.IGNORECASE)
UNBRANDED_SPU_RE = re.compile(rf"(?<![A-Z0-9]){UNBRANDED_SPU_BODY}(?![A-Z0-9])", re.IGNORECASE)
COMBO_SPU_RE = re.compile(
rf"(?<![A-Z0-9]){BRANDED_SPU_BODY}(?:\s*[++]\s*(?:{BRANDED_SPU_BODY}|{UNBRANDED_SPU_BODY}))+(?![A-Z0-9])",
re.IGNORECASE,
)
SKU_FILE_SPU_RE = re.compile(
r"(?<![A-Z0-9])([A-Z]{2,5})-([A-Z]{1,6})(\d{4,5})(\d{2,3})(?!\d)",
re.IGNORECASE,
)
SKU_FILE_CODE_RE = re.compile(
r"(?<![A-Z0-9])([A-Z]{2,5}-[A-Z]{1,6}\d{4,5}\d{2,3})(?!\d)",
re.IGNORECASE,
)
KNOWN_SHOP_PREFIXES = {
"GBPM": "朴美",
"GBSFY": "索菲亚",
"GBGJ": "顾家",
"GBJP": "顾家",
"XYL": "橡野里",
}
CATEGORY_CODE_MAP = {
"CFZWJ": "置物架",
"CFZW": "置物架",
"LRSF": "沙发",
"HXD": "换鞋凳",
"SJZ": "电脑桌",
"DNZ": "电脑桌",
"ZDZ": "电脑桌",
"CBZ": "电脑桌",
"CYJ": "穿衣镜",
"SZT": "化妆台",
"SZD": "椅子",
"YMJ": "衣帽架",
"ZSJ": "花架书架",
"CBG": "餐边柜",
"CTG": "床头柜",
"SMC": "床",
"SN": "收纳柜斗柜角柜飘窗",
"DG": "收纳柜斗柜角柜飘窗",
"XG": "鞋柜",
"BG": "边柜",
"SG": "书柜",
"CZ": "餐桌",
"SZ": "书桌",
"CY": "椅子",
"AD": "椅子",
"SJ": "花架书架",
"ZWJ": "置物架",
"BJ": "边几",
"CJ": "电视柜茶几",
"DSG": "电视柜茶几",
"XJ": "鞋架",
}
ROOT_STORE_HINTS = {
"顾家": "顾家",
"金牌": "顾家",
"朴美": "朴美",
"索菲亚": "索菲亚",
}
TRANSFER_HINTS = {
"转索菲亚": "索菲亚",
"转顾家": "顾家",
"转金牌": "顾家",
"转朴美": "朴美",
}
EXCLUDED_DIR_NAMES = {
"GB主图模版",
"顾家品牌背书",
"顾家首页通版",
"所有主图视频(运营提供)",
"安装视频汇总",
"朴美 logo活动模板",
"朴美产品链接布局表",
}
HARD_SKIP_PATTERNS = [
re.compile(pattern, re.IGNORECASE)
for pattern in (
r"错误勿用",
r"图片有误",
r"勿用",
r"废稿",
r"作废",
r"3D建模",
r"不要",
r"弃用",
r"@eadir",
r"dingtalkappdata",
r"\$nsec\.recycle",
r"\.recycle",
)
]
VERSION_TOKEN_RE = re.compile(r"(?:^|[-_\s((])(V\d+|新品|优化|3D|AI|实拍|建模|原图|源文件|PSD|PSB)", re.IGNORECASE)
VERSION_HINT_RE = re.compile(
r"(?:^|[-_\s((])(\d{12,}|V\d+|实拍|AI|3D|平面|全套|视觉|原图|建模|美工|详情全套|平面内容|新品|优化)",
re.IGNORECASE,
)
MAIN_HINT_RE = re.compile(r"(主图|新主图|750主图|800主图)", re.IGNORECASE)
SKU_HINT_RE = re.compile(r"sku|SKU|Sku|白底|场景|带信息|带尺寸", re.IGNORECASE)
DETAIL_HINT_RE = re.compile(r"详情|详情页|详情图|详情切片|微详情", re.IGNORECASE)
SOURCE_HINT_RE = re.compile(r"源文件|psd|psb|PS源|源文件包", re.IGNORECASE)
EXCLUDE_PREVIEW_RE = re.compile(r"通道|遮罩|mask|alpha|材质|测试|过程|未修", re.IGNORECASE)
AUTHOR_MARK_RE = re.compile(r"(?:美工|平面|建模|摄影|设计)[-_ ]+([\u4e00-\u9fffA-Za-z0-9]{2,8})")
AI_SOURCE_RE = re.compile(r"(^|[\\/\\s_-])AI(?=$|[\\/\\s_-]|[\u4e00-\u9fff])|AI合成|AIGC", re.IGNORECASE)
REAL_PHOTO_SOURCE_RE = re.compile(r"实拍|拍摄|摄影")
RENDER_SOURCE_RE = re.compile(r"渲染|3D|建模", re.IGNORECASE)
MODELING_SOURCE_SEGMENT_RE = re.compile(
r"(^|[\\/])(3D|3D建模|3D原图|建模图|建模原图|渲染原图|原图)($|[\\/])",
re.IGNORECASE,
)
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def normalize_token_text(value: str) -> str:
return (
value.replace("—", "-")
.replace("–", "-")
.replace("-", "-")
.replace("+", "+")
.upper()
.strip()
)
def normalize_spu(value: str) -> str:
normalized = normalize_token_text(value)
normalized = re.sub(r"\s*\+\s*", "+", normalized)
return normalized
def split_combo(spu: str) -> list[str]:
normalized = normalize_spu(spu)
return [part for part in normalized.split("+") if part]
def expand_combo_spu_token(token: str) -> str:
parts = split_combo(token)
if len(parts) < 2:
return normalize_spu(token)
prefix = spu_prefix(parts[0])
expanded = [parts[0]]
for part in parts[1:]:
expanded.append(part if "-" in part or not prefix else f"{prefix}-{part}")
return "+".join(expanded)
def spu_prefix(spu: str) -> str:
first_component = split_combo(spu)[0] if spu else ""
return first_component.split("-", 1)[0] if "-" in first_component else ""
def spu_body(spu: str) -> str:
normalized = normalize_spu(spu)
return normalized.split("-", 1)[1] if "-" in normalized else normalized
def has_unknown_branded_prefix(spu: str) -> bool:
prefix = spu_prefix(spu)
return bool(prefix and prefix not in KNOWN_SHOP_PREFIXES)
def looks_like_unbranded_spu(token: str) -> bool:
normalized = normalize_spu(token)
if any(normalized.startswith(prefix) for prefix in ("IMG", "SKU", "TASK", "TALOG")):
return False
return any(re.match(rf"^{code}\d", normalized) for code in CATEGORY_CODE_MAP)
def extract_spu_candidates(text: str, allow_unbranded: bool = True) -> list[str]:
normalized = normalize_token_text(text)
candidates: list[str] = []
seen = set()
for match in COMBO_SPU_RE.finditer(normalized):
token = expand_combo_spu_token(match.group(0))
if token not in seen:
candidates.append(token)
seen.add(token)
for match in BRANDED_SPU_RE.finditer(normalized):
token = normalize_spu(match.group(0))
if token not in seen:
candidates.append(token)
seen.add(token)
if allow_unbranded:
for match in UNBRANDED_SPU_RE.finditer(normalized):
token = normalize_spu(match.group(0))
if token in seen or "-" in token or not looks_like_unbranded_spu(token):
continue
candidates.append(token)
seen.add(token)
return candidates
def extract_file_spu_candidates(file_name: str) -> list[str]:
normalized = normalize_token_text(file_name)
candidates: list[str] = []
seen = set()
for match in SKU_FILE_SPU_RE.finditer(normalized):
token = normalize_spu(f"{match.group(1)}-{match.group(2)}{match.group(3)}")
if token not in seen:
candidates.append(token)
seen.add(token)
for token in extract_spu_candidates(normalized, allow_unbranded=False):
if token not in seen:
candidates.append(token)
seen.add(token)
return candidates
def has_hard_skip(path_text: str) -> bool:
return any(pattern.search(path_text) for pattern in HARD_SKIP_PATTERNS)
def has_modeling_source_segment(path_text: str) -> bool:
normalized = path_text.replace("\\", "/")
return bool(MODELING_SOURCE_SEGMENT_RE.search(normalized))
def safe_relative(path_obj: Path, root: Path) -> str:
try:
return str(path_obj.relative_to(root))
except ValueError:
return str(path_obj)
def fingerprint_for(*parts: str) -> str:
payload = "|".join(parts)
return hashlib.sha1(payload.encode("utf-8", errors="ignore")).hexdigest()
def json_dumps(value) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def load_visual_author_names(path_text: str) -> list[str]:
if not path_text:
return []
path_obj = Path(path_text)
if not path_obj.exists():
raise SystemExit(f"视觉人员名单不存在: {path_text}")
names: list[str] = []
seen = set()
def add_name(value: str) -> None:
name = value.strip()
if not name or name in seen:
return
if not re.search(r"[\u4e00-\u9fff]", name):
return
if len(name) > 8:
return
seen.add(name)
names.append(name)
if path_obj.suffix.lower() == ".json":
payload = json.loads(path_obj.read_text(encoding="utf-8-sig"))
if isinstance(payload, list):
for item in payload:
if isinstance(item, str):
add_name(item)
elif isinstance(item, dict):
add_name(str(item.get("username") or ""))
add_name(str(item.get("realName") or ""))
else:
for line in path_obj.read_text(encoding="utf-8-sig").splitlines():
for cell in re.split(r"[\t,,]", line):
add_name(cell)
return sorted(names, key=lambda item: (-len(item), item))
@dataclass
class FileEntry:
full_path: str
relative_path: str
file_name: str
file_size: int
extension: str
group_hint: str
sku_code: str = ""
@dataclass
class DirInfo:
full_path: str
name: str
parent_path: str
depth: int
root_path: str
relative_path: str
children: list[str] = field(default_factory=list)
files: list[FileEntry] = field(default_factory=list)
subtree_files: int = 0
subtree_groups: dict[str, int] = field(default_factory=dict)
name_spus: list[str] = field(default_factory=list)
@dataclass
class Candidate:
full_path: str
root_path: str
relative_path: str
spu: str
spu_source: str
spu_evidence: list[str]
def classify_file(relative_path: str, file_name: str, extension: str) -> str:
normalized = relative_path.replace("\\", "/")
lower_ext = extension.lower()
if lower_ext in PS_SOURCE_EXTENSIONS:
return "source"
if lower_ext in SOURCE_ARCHIVE_EXTENSIONS:
return "source" if SOURCE_HINT_RE.search(normalized) else "other_source_archive"
if lower_ext in OTHER_IMAGE_EXTENSIONS:
return "other_image"
if lower_ext not in IMAGE_EXTENSIONS:
return "other"
if EXCLUDE_PREVIEW_RE.search(normalized):
return "other_image"
if AI_SOURCE_RE.search(normalized):
return "other_image"
if SKU_HINT_RE.search(normalized):
return "sku"
if DETAIL_HINT_RE.search(normalized):
return "detail"
if re.search(r"(^|/)(790)(/|$)", normalized):
return "detail"
if MAIN_HINT_RE.search(normalized):
return "main"
if re.search(r"(^|/)(750|800|1440|1920|1440-1440|1440-1920|750-1000|800-800)(/|$)", normalized):
return "main"
return "other_image"
def walk_tree(root: Path, max_depth: int) -> dict[str, DirInfo]:
root = root.absolute()
dirs: dict[str, DirInfo] = {}
nested_duplicate_root_name = root.name.lower()
def visit(current: Path, parent: Path | None, depth: int) -> None:
if depth > max_depth:
return
current_str = str(current)
parent_str = str(parent) if parent else ""
relative = safe_relative(current, root)
dir_info = DirInfo(
full_path=current_str,
name=current.name,
parent_path=parent_str,
depth=depth,
root_path=str(root),
relative_path="" if relative == "." else relative,
name_spus=extract_spu_candidates(current.name, allow_unbranded=True),
)
dirs[current_str] = dir_info
if parent_str and parent_str in dirs:
dirs[parent_str].children.append(current_str)
try:
entries = sorted(os.scandir(current), key=lambda item: item.name.lower())
except OSError:
return
for entry in entries:
full_path = Path(entry.path)
if entry.is_dir(follow_symlinks=False):
if entry.name in EXCLUDED_DIR_NAMES:
print(f"[skip] excluded dir: {entry.path}")
continue
if depth == 0 and entry.name.lower() == nested_duplicate_root_name:
print(f"[skip] nested duplicate root: {entry.path}")
continue
visit(full_path, current, depth + 1)
continue
if not entry.is_file(follow_symlinks=False):
continue
extension = full_path.suffix.lower()
if extension not in RELEVANT_EXTENSIONS:
continue
try:
stat = entry.stat(follow_symlinks=False)
except OSError:
continue
relative_file = safe_relative(full_path, root)
group = classify_file(relative_file, entry.name, extension)
dir_info.files.append(
FileEntry(
full_path=str(full_path),
relative_path=relative_file,
file_name=entry.name,
file_size=int(stat.st_size),
extension=extension,
group_hint=group,
)
)
visit(root, None, 0)
for dir_info in sorted(dirs.values(), key=lambda item: item.depth, reverse=True):
group_counts: dict[str, int] = {}
file_count = 0
for file in dir_info.files:
file_count += 1
group_counts[file.group_hint] = group_counts.get(file.group_hint, 0) + 1
for child_path in dir_info.children:
child = dirs[child_path]
file_count += child.subtree_files
for group, count in child.subtree_groups.items():
group_counts[group] = group_counts.get(group, 0) + count
dir_info.subtree_files = file_count
dir_info.subtree_groups = group_counts
return dirs
def find_nearest_spu_ancestor(dir_info: DirInfo, dirs: dict[str, DirInfo]) -> tuple[str, str] | tuple[str, str]:
current_parent = dir_info.parent_path
while current_parent:
parent = dirs.get(current_parent)
if not parent:
break
if parent.name_spus:
return parent.name_spus[0], parent.relative_path
current_parent = parent.parent_path
return "", ""
def infer_spu_from_files(dir_info: DirInfo, dirs: dict[str, DirInfo]) -> tuple[str, list[str]]:
token_counts: dict[str, int] = {}
samples: dict[str, list[str]] = {}
for file in collect_files_under(dir_info.full_path, dirs, set()):
tokens = extract_file_spu_candidates(file.file_name)
for token in tokens:
if any(token in component or component in token for component in split_combo(token)):
pass
token_counts[token] = token_counts.get(token, 0) + 1
samples.setdefault(token, []).append(file.relative_path)
if not token_counts:
return "", []
ranked = sorted(token_counts.items(), key=lambda item: (-item[1], item[0]))
best = ranked[0][0]
evidence = samples.get(best, [])[:6]
return best, evidence
def has_asset_signal(dir_info: DirInfo) -> bool:
return any(dir_info.subtree_groups.get(group, 0) > 0 for group in ("main", "sku", "detail", "source"))
def discover_candidates(dirs: dict[str, DirInfo], limit: int | None = None) -> list[Candidate]:
candidates: dict[str, Candidate] = {}
for dir_info in sorted(dirs.values(), key=lambda item: (item.root_path, item.relative_path)):
if not has_asset_signal(dir_info):
continue
if dir_info.name_spus:
spu = dir_info.name_spus[0]
candidates[dir_info.full_path] = Candidate(
full_path=dir_info.full_path,
root_path=dir_info.root_path,
relative_path=dir_info.relative_path,
spu=spu,
spu_source="directory_combo" if "+" in spu else "directory",
spu_evidence=[dir_info.name],
)
continue
ancestor_spu, ancestor_relative = find_nearest_spu_ancestor(dir_info, dirs)
if ancestor_spu and VERSION_HINT_RE.search(dir_info.name):
candidates[dir_info.full_path] = Candidate(
full_path=dir_info.full_path,
root_path=dir_info.root_path,
relative_path=dir_info.relative_path,
spu=ancestor_spu,
spu_source="ancestor",
spu_evidence=[ancestor_relative, dir_info.name],
)
continue
if not ancestor_spu and VERSION_HINT_RE.search(dir_info.name):
inferred_spu, evidence = infer_spu_from_files(dir_info, dirs)
if inferred_spu:
candidates[dir_info.full_path] = Candidate(
full_path=dir_info.full_path,
root_path=dir_info.root_path,
relative_path=dir_info.relative_path,
spu=inferred_spu,
spu_source="file_inferred",
spu_evidence=evidence,
)
ordered = list(candidates.values())
ordered.sort(key=lambda item: (item.root_path, item.relative_path))
if limit:
return ordered[:limit]
return ordered
def collect_files_under(candidate_path: str, dirs: dict[str, DirInfo], boundary_paths: set[str]) -> list[FileEntry]:
result: list[FileEntry] = []
candidate_norm = os.path.normcase(os.path.normpath(candidate_path))
candidate_has_hard_skip = has_hard_skip(candidate_path)
boundary_norms = {
os.path.normcase(os.path.normpath(boundary))
for boundary in boundary_paths
if os.path.normcase(os.path.normpath(boundary)) != candidate_norm
}
stack = [candidate_path]
while stack:
current_path = stack.pop()
current_norm = os.path.normcase(os.path.normpath(current_path))
if current_norm != candidate_norm and current_norm in boundary_norms:
continue
if not candidate_has_hard_skip and current_norm != candidate_norm and has_hard_skip(current_path):
continue
dir_info = dirs.get(current_path)
if not dir_info:
continue
result.extend(dir_info.files)
stack.extend(reversed(dir_info.children))
return result
def choose_store(root_name: str, full_path: str, spu: str) -> tuple[str, str]:
normalized_path = full_path.replace("\\", "/")
for marker, store in TRANSFER_HINTS.items():
if marker in normalized_path:
return store, marker
for marker, store in ROOT_STORE_HINTS.items():
if marker in root_name:
return store, f"root:{root_name}"
prefix = spu_prefix(spu)
if prefix in KNOWN_SHOP_PREFIXES:
return KNOWN_SHOP_PREFIXES[prefix], f"spu_prefix:{prefix}"
return "其他", "fallback"
def infer_product_category(spu: str, relative_path: str) -> tuple[str, str]:
category_names = set(CATEGORY_CODE_MAP.values())
for part in re.split(r"[\\/]", relative_path):
cleaned = part.strip()
if cleaned in category_names:
return cleaned, f"path:{cleaned}"
primary = split_combo(spu)[0] if spu else ""
sku_part = primary.split("-", 1)[1] if "-" in primary else primary
for code in sorted(CATEGORY_CODE_MAP, key=len, reverse=True):
if re.match(rf"^{code}\d", sku_part, re.IGNORECASE):
return CATEGORY_CODE_MAP[code], f"spu_code:{code}"
return "", "unknown"
def match_visual_author(relative_path: str, visual_author_names: list[str]) -> tuple[str, str]:
if not visual_author_names:
return "", ""
parts = [part for part in re.split(r"[\\/]", relative_path) if part]
for part in reversed(parts):
matches: list[tuple[int, int, str]] = []
for name in visual_author_names:
index = part.rfind(name)
if index >= 0:
matches.append((index, len(name), name))
if matches:
matches.sort(key=lambda item: (item[0], item[1]), reverse=True)
return matches[0][2], f"visual_user:{part}"
return "", ""
def infer_author(relative_path: str, visual_author_names: list[str] | None = None) -> tuple[str, str]:
visual_author, visual_evidence = match_visual_author(relative_path, visual_author_names or [])
if visual_author:
return visual_author, visual_evidence
parts = [part for part in re.split(r"[\\/]", relative_path) if part]
for part in reversed(parts):
match = AUTHOR_MARK_RE.search(part)
if match:
return match.group(1), f"marker:{part}"
stop_words = {
"正版",
"盗版",
"正版原创",
"全套图",
"全套",
"视觉原图",
"全套视觉图",
"全套图(视觉原图)",
"主图",
"主图优化",
"功能图",
"详情",
"详情动态",
"详情静态",
"SKU",
"sku",
"美工",
"平面",
"平面全套",
"平面内容",
"3D",
"3D渲染原图",
"D渲染原图",
"渲染原图",
"AI",
"AI合成图",
"原图",
"源文件",
"psd源文件",
"PSD源文件",
"PS源文件",
"新品",
"优化",
"建模图",
"3D建模",
"3D原图",
"坐具类",
"书架",
"桌类",
"椅类",
"凳类",
"矮凳",
"架类",
"柜类",
"几类",
"床类",
"沙发类",
"厨房置物架",
"桌面收纳",
"升降桌",
"懒人沙发",
"客厅",
"卧室",
"书房",
"厨房",
"餐厅",
"方向",
"卧室方向",
"书房方向",
"客厅方向",
"图片有误",
"出图",
"精修图片",
"电竞",
"收纳",
"斗柜",
"餐椅",
"梳妆台",
"梳妆凳",
"实木床",
"折叠桌",
"茶室",
"视觉",
"内容",
"单双电机",
"大图",
"白底",
"白底图",
"转索菲亚",
"转顾家",
"转金牌",
"转朴美",
*CATEGORY_CODE_MAP.values(),
*ROOT_STORE_HINTS.values(),
}
descriptor_fragments = (
"全套图",
"视觉原图",
"全套视觉",
"详情",
"主图",
"SKU",
"sku",
"类",
"源文件",
"建模原图",
"建模",
"渲染",
"原图",
"出图",
"方向",
"图片有误",
"实拍",
"拍摄",
"摄影",
"优化",
"新品",
"找不到人",
"精修图片",
"电竞",
"收纳",
"斗柜",
"餐椅",
"梳妆台",
"梳妆凳",
"实木床",
"折叠桌",
"茶室",
"视觉",
"内容",
"单双电机",
"电机",
"谁出的图",
"上传的人",
"设计师",
"改款",
"正确",
"最新",
"客厅",
"卧室",
"书房",
"厨房",
"餐厅",
"平面内容",
"白底图",
"书架",
"桌类",
"椅类",
"凳类",
"矮凳",
"架类",
"柜类",
"几类",
"床类",
"厨房置物架",
"桌面收纳",
)
def clean_author_tokens(part: str) -> list[str]:
tail = part
for spu in extract_spu_candidates(part):
tail = re.sub(re.escape(spu), "", tail, flags=re.IGNORECASE)
tail = re.sub(COMBO_SPU_RE, "", tail)
tail = re.sub(BRANDED_SPU_RE, "", tail)
tokens = [
token.strip(" -_+()()0123456789.")
for token in re.split(r"[-_\s+]+", tail)
if token.strip(" -_+()()0123456789.")
]
names: list[str] = []
descriptor_prefixes = ("平面", "美工", "建模", "设计", "摄影", "实拍")
for token in tokens:
cleaned = token
for prefix in descriptor_prefixes:
if cleaned.startswith(prefix) and len(cleaned) > len(prefix) + 1:
cleaned = cleaned[len(prefix) :]
cleaned = cleaned.strip(" -_+()()0123456789.")
break
for suffix in descriptor_prefixes:
if cleaned.endswith(suffix) and len(cleaned) > len(suffix) + 1:
cleaned = cleaned[: -len(suffix)]
cleaned = cleaned.strip(" -_+()()0123456789.")
break
if (
cleaned in stop_words
or cleaned.startswith("转")
or any(fragment in cleaned for fragment in descriptor_fragments)
or VERSION_TOKEN_RE.search(cleaned)
or not re.search(r"[\u4e00-\u9fff]", cleaned)
or len(cleaned) > 8
):
continue
names.append(cleaned)
return names
for part in reversed(parts):
names = clean_author_tokens(part)
if names:
return names[-1], f"path:{part}"
return "历史导入", "fallback"
def infer_task_detail_source_type(path_text: str) -> tuple[str, str]:
if AI_SOURCE_RE.search(path_text):
return "AI", "path:AI"
if REAL_PHOTO_SOURCE_RE.search(path_text):
return "实拍", "path:实拍"
if RENDER_SOURCE_RE.search(path_text):
return "渲染", "path:渲染/3D/建模"
return "渲染", "default"
def is_empty_container_item(item: dict) -> bool:
if item["status"] != "skipped":
return False
if item["path_flags_json"] and json.loads(item["path_flags_json"]).get("has_hard_skip"):
return False
count_keys = (
"main_count",
"sku_count",
"detail_count",
"source_count",
"other_image_count",
"other_source_archive_count",
)
return all(int(item.get(key) or 0) == 0 for key in count_keys)
def build_sku_code(spu: str, file_name: str, index: int) -> str:
normalized_name = normalize_token_text(file_name)
file_code_match = SKU_FILE_CODE_RE.search(normalized_name)
if file_code_match:
return normalize_spu(file_code_match.group(1))
escaped_spu = re.escape(normalize_spu(spu))
match = re.search(rf"({escaped_spu}\d{{2,3}})", normalized_name, re.IGNORECASE)
if match:
return normalize_spu(match.group(1))
return f"{normalize_spu(spu)}{str(index + 1).zfill(2)}"
def is_compatible_spu_token(candidate_spu: str, token: str) -> bool:
candidate_components = split_combo(candidate_spu)
token_normalized = normalize_spu(token)
token_prefix = spu_prefix(token_normalized)
token_body = spu_body(token_normalized)
for component in candidate_components:
component_normalized = normalize_spu(component)
if token_normalized == component_normalized:
return True
component_prefix = spu_prefix(component_normalized)
component_body = spu_body(component_normalized)
if token_body != component_body:
continue
if not token_prefix or not component_prefix:
return True
if KNOWN_SHOP_PREFIXES.get(token_prefix) == KNOWN_SHOP_PREFIXES.get(component_prefix):
return True
return False
def detect_asset_spu_conflicts(spu: str, grouped: dict[str, list[FileEntry]]) -> list[dict[str, str]]:
conflicts: list[dict[str, str]] = []
for group in ("main", "sku", "detail"):
for file in grouped.get(group, []):
tokens = extract_file_spu_candidates(file.file_name)
if not tokens:
continue
if any(is_compatible_spu_token(spu, token) for token in tokens):
continue
conflicts.append(
{
"group": group,
"file": file.relative_path,
"tokens": ",".join(tokens),
}
)
if len(conflicts) >= 12:
return conflicts
return conflicts
def blocking_asset_spu_conflicts(conflicts: list[dict[str, str]]) -> list[dict[str, str]]:
# 业务上 SKU 可能天然混入其它 SPU 的组合/配套 SKU;只记录证据,不阻断 ready。
return [item for item in conflicts if item.get("group") in {"main", "detail"}]
def build_item(
candidate: Candidate,
dirs: dict[str, DirInfo],
boundary_paths: set[str],
visual_author_names: list[str],
) -> tuple[dict, dict[str, list[FileEntry]]]:
dir_info = dirs[candidate.full_path]
files = collect_files_under(candidate.full_path, dirs, boundary_paths)
grouped: dict[str, list[FileEntry]] = {
"main": [],
"sku": [],
"detail": [],
"source": [],
"other_image": [],
"other_source_archive": [],
}
for file in files:
grouped.setdefault(file.group_hint, []).append(file)
for group in grouped:
grouped[group].sort(key=lambda item: item.relative_path.lower())
for index, file in enumerate(grouped.get("sku", [])):
file.sku_code = build_sku_code(candidate.spu, file.file_name, index)
root_name = Path(candidate.root_path).name
store, store_evidence = choose_store(root_name, candidate.full_path, candidate.spu)
product_category, category_evidence = infer_product_category(candidate.spu, candidate.relative_path)
author, author_evidence = infer_author(candidate.relative_path, visual_author_names)
task_detail_source_type, task_detail_source_type_evidence = infer_task_detail_source_type(candidate.relative_path)
path_text = candidate.full_path
has_skip = has_hard_skip(path_text)
has_modeling_source = has_modeling_source_segment(path_text)
main_count = len(grouped.get("main", []))
sku_count = len(grouped.get("sku", []))
detail_count = len(grouped.get("detail", []))
source_count = len(grouped.get("source", []))
asset_count = main_count + sku_count + detail_count + source_count
asset_spu_conflicts = detect_asset_spu_conflicts(candidate.spu, grouped)
blocking_spu_conflicts = blocking_asset_spu_conflicts(asset_spu_conflicts)
if has_skip:
status = "skipped"
status_reason = "路径包含错误勿用/废稿/勿用等跳过标识"
elif has_unknown_branded_prefix(candidate.spu):
status = "needs_review"
status_reason = f"SPU 前缀 {spu_prefix(candidate.spu)} 不在已知店铺前缀表,需人工确认"
elif candidate.spu_source == "file_inferred":
status = "needs_review"
status_reason = "目录名不是标准 SPU,仅能从文件名反推,需人工确认"
elif asset_count == 0:
status = "skipped"
status_reason = "未发现可导入素材"
elif has_modeling_source:
status = "needs_review"
status_reason = "路径属于 3D建模/建模图/原图,按 3Dmax 渲染原图处理,不自动作为任务-详情导入"
elif blocking_spu_conflicts:
status = "needs_review"
conflict_tokens = sorted({item["tokens"] for item in blocking_spu_conflicts})[:4]
status_reason = f"主图/详情文件名出现与工作区 SPU 不一致的型号:{';'.join(conflict_tokens)},需人工确认"
elif main_count > 0 and sku_count > 0 and detail_count > 0:
status = "ready"
status_reason = "主图、SKU 图、详情图均存在"
elif asset_count > 0:
status = "needs_review"
missing = [
label
for label, count in (("主图", main_count), ("SKU图", sku_count), ("详情图", detail_count))
if count == 0
]
status_reason = f"缺少{','.join(missing)},不能自动导入"
components = split_combo(candidate.spu)
fingerprint = fingerprint_for(candidate.root_path, candidate.full_path, candidate.spu)
flags = {
"has正版": "正版" in path_text,
"has盗版": "盗版" in path_text,
"has_transfer": any(marker in path_text for marker in TRANSFER_HINTS),
"has_hard_skip": has_skip,
"has_modeling_source_segment": has_modeling_source,
"asset_spu_conflicts": asset_spu_conflicts,
"is_combo_spu": "+" in candidate.spu,
}
item = {
"fingerprint": fingerprint,
"source_root": candidate.root_path,
"source_root_name": root_name,
"source_batch": "",
"workspace_path": candidate.full_path,
"relative_workspace_path": candidate.relative_path,
"spu": candidate.spu,
"spu_components_json": json_dumps(components),
"spu_source": candidate.spu_source,
"spu_evidence_json": json_dumps(candidate.spu_evidence),
"store": store,
"store_evidence": store_evidence,
"product_category": product_category,
"product_category_evidence": category_evidence,
"author": author,
"author_evidence": author_evidence,
"task_detail_source_type": task_detail_source_type,
"task_detail_source_type_evidence": task_detail_source_type_evidence,
"version_label": dir_info.name,
"path_flags_json": json_dumps(flags),
"status": status,
"status_reason": status_reason,
"main_count": main_count,
"sku_count": sku_count,
"detail_count": detail_count,
"source_count": source_count,
"other_image_count": len(grouped.get("other_image", [])),
"other_source_archive_count": len(grouped.get("other_source_archive", [])),
}
return item, grouped
def ensure_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS spu_ledger_runs (
run_id TEXT PRIMARY KEY,
source_batch TEXT NOT NULL,
roots_json TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
total_items INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS spu_ledger_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
source_batch TEXT NOT NULL,
source_root TEXT NOT NULL,
source_root_name TEXT NOT NULL,
workspace_path TEXT NOT NULL,
relative_workspace_path TEXT NOT NULL,
spu TEXT NOT NULL,
spu_components_json TEXT NOT NULL,
spu_source TEXT NOT NULL,
spu_evidence_json TEXT NOT NULL,
store TEXT NOT NULL,
store_evidence TEXT NOT NULL,
product_category TEXT NOT NULL,
product_category_evidence TEXT NOT NULL,
author TEXT NOT NULL,
author_evidence TEXT NOT NULL,
task_detail_source_type TEXT NOT NULL DEFAULT '渲染',
task_detail_source_type_evidence TEXT NOT NULL DEFAULT 'default',
version_label TEXT NOT NULL,
path_flags_json TEXT NOT NULL,
status TEXT NOT NULL,
status_reason TEXT NOT NULL,
main_count INTEGER NOT NULL DEFAULT 0,
sku_count INTEGER NOT NULL DEFAULT 0,
detail_count INTEGER NOT NULL DEFAULT 0,
source_count INTEGER NOT NULL DEFAULT 0,
other_image_count INTEGER NOT NULL DEFAULT 0,
other_source_archive_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (run_id) REFERENCES spu_ledger_runs(run_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_spu_ledger_items_run_fingerprint
ON spu_ledger_items(run_id, fingerprint);
CREATE INDEX IF NOT EXISTS idx_spu_ledger_items_status
ON spu_ledger_items(status);
CREATE INDEX IF NOT EXISTS idx_spu_ledger_items_spu
ON spu_ledger_items(spu);
CREATE TABLE IF NOT EXISTS spu_ledger_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
asset_group TEXT NOT NULL,
file_path TEXT NOT NULL,
relative_file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
extension TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
sku_code TEXT,
FOREIGN KEY (item_id) REFERENCES spu_ledger_items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_spu_ledger_files_item_group
ON spu_ledger_files(item_id, asset_group);
"""
)
def reset_database(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
DROP TABLE IF EXISTS spu_ledger_files;
DROP TABLE IF EXISTS spu_ledger_items;
DROP TABLE IF EXISTS spu_ledger_runs;
"""
)
ensure_schema(conn)
def insert_item(conn: sqlite3.Connection, run_id: str, source_batch: str, item: dict, grouped: dict[str, list[FileEntry]]) -> None:
now = utc_now()
columns = [
"run_id",
"fingerprint",
"source_batch",
"source_root",
"source_root_name",
"workspace_path",
"relative_workspace_path",
"spu",
"spu_components_json",
"spu_source",
"spu_evidence_json",
"store",
"store_evidence",
"product_category",
"product_category_evidence",
"author",
"author_evidence",
"task_detail_source_type",
"task_detail_source_type_evidence",
"version_label",
"path_flags_json",
"status",
"status_reason",
"main_count",
"sku_count",
"detail_count",
"source_count",
"other_image_count",
"other_source_archive_count",
"created_at",
"updated_at",
]
payload = {
**item,
"run_id": run_id,
"source_batch": source_batch,
"created_at": now,
"updated_at": now,
}
placeholders = ",".join("?" for _ in columns)
conn.execute(
f"INSERT INTO spu_ledger_items ({','.join(columns)}) VALUES ({placeholders})",
[payload[column] for column in columns],
)
item_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
file_rows = []
for group in ("main", "sku", "detail", "source", "other_image", "other_source_archive"):
for index, file in enumerate(grouped.get(group, [])):
file_rows.append(
(
item_id,
group,
file.full_path,
file.relative_path,
file.file_name,
file.file_size,
file.extension,
index,
file.sku_code or None,
)
)
if file_rows:
conn.executemany(
"""
INSERT INTO spu_ledger_files (
item_id, asset_group, file_path, relative_file_path, file_name,
file_size, extension, sort_order, sku_code
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
file_rows,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Scan SPU-centered material ledger.")
parser.add_argument("--roots", required=True, help="Root paths separated by |")
parser.add_argument("--db-path", default=str(Path.cwd() / "output" / "spu-ledger.db"))
parser.add_argument("--source-batch", default="tianlan-z")
parser.add_argument("--max-depth", type=int, default=14)
parser.add_argument("--limit", type=int, default=0, help="Limit candidate items after discovery")
parser.add_argument("--visual-authors-file", default="", help="Optional TSV/CSV/JSON names from 视觉中心 users")
parser.add_argument("--reset", action="store_true", help="Drop and recreate ledger tables before scanning")
return parser.parse_args()
def main() -> int:
args = parse_args()
roots = [Path(item.strip()) for item in args.roots.split("|") if item.strip()]
if not roots:
raise SystemExit("缺少有效 --roots")
db_path = Path(args.db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys = ON")
if args.reset:
reset_database(conn)
else:
ensure_schema(conn)
run_id = str(uuid.uuid4())
started_at = utc_now()
conn.execute(
"INSERT INTO spu_ledger_runs (run_id, source_batch, roots_json, started_at) VALUES (?, ?, ?, ?)",
(run_id, args.source_batch, json_dumps([str(root) for root in roots]), started_at),
)
total_items = 0
visual_author_names = load_visual_author_names(args.visual_authors_file) if args.visual_authors_file else []
if visual_author_names:
print(f"[authors] visual_author_names={len(visual_author_names)}")
try:
for root in roots:
if not root.exists():
print(f"[WARN] root missing: {root}", file=sys.stderr)
continue
print(f"[scan] {root}")
dirs = walk_tree(root, args.max_depth)
candidates = discover_candidates(dirs, args.limit if args.limit > 0 else None)
boundary_paths = {candidate.full_path for candidate in candidates}
print(f"[scan] candidates={len(candidates)}")
for candidate in candidates:
item, grouped = build_item(candidate, dirs, boundary_paths, visual_author_names)
if is_empty_container_item(item):
continue
insert_item(conn, run_id, args.source_batch, item, grouped)
total_items += 1
conn.execute(
"UPDATE spu_ledger_runs SET finished_at = ?, total_items = ? WHERE run_id = ?",
(utc_now(), total_items, run_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
print("SPU 台账扫描完成")
print(f"- run_id: {run_id}")
print(f"- total_items: {total_items}")
print(f"- db_path: {db_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())