- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验 - SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示 - 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签 - 补充历史导入相关脚本和工具文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1283 lines
42 KiB
Python
1283 lines
42 KiB
Python
#!/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())
|