- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验 - SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示 - 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签 - 补充历史导入相关脚本和工具文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
427 lines
15 KiB
Python
427 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp", ".gif"}
|
|
NON_DIRECTORY_EXTENSIONS = IMAGE_EXTENSIONS | {
|
|
".xlsx", ".xls", ".psd", ".psb", ".zip", ".rar", ".7z", ".pdf", ".txt", ".doc", ".docx"
|
|
}
|
|
SKU_DIR_PATTERN = re.compile(r"sku", re.IGNORECASE)
|
|
SPU_PATTERN = re.compile(r"^[A-Za-z]{1,5}\d{3,5}$")
|
|
SPU_TOKEN_PATTERN = re.compile(r"([A-Za-z]{1,5}\d{3,5})")
|
|
BRANDED_SPU_TOKEN_PATTERN = re.compile(r"([A-Za-z]{2,5}-[A-Za-z]{1,5}\d{3,5})")
|
|
BRANDED_SKU_FILE_PATTERN = re.compile(r"([A-Za-z]{2,5})-([A-Za-z]{1,5})(\d{4,5})(\d{2})(?:\D|$)")
|
|
KNOWN_SHOP_PREFIXES = {"GBPM", "GBSFY", "GBGJ", "GBJP", "XYL"}
|
|
IGNORED_SPU_PREFIXES = {"TASK", "SKU", "IMG"}
|
|
KNOWN_CATEGORY_CODES = {
|
|
"SN", "DG", "XG", "BG", "SG", "CBG", "CZ", "SZ", "DNZ", "SJZ", "ZDZ",
|
|
"CBZ", "CY", "AD", "SZD", "HXD", "SJ", "ZSJ", "ZWJ", "CFZW", "CFZWJ",
|
|
"YMJ", "BJ", "CJ", "DSG", "CTG", "CYJ", "SZT", "XJ", "LRSF", "SMC",
|
|
}
|
|
IGNORED_PARTS = {"@eadir", "dingtalkappdata", ".recycle", "$nsec.recycle.bin"}
|
|
DETAIL_DIR_KEYWORDS = ("images", "详情页", "详情")
|
|
MAIN_DIR_KEYWORD = "主图"
|
|
MAIN_DIMENSION_DIR_NAMES = {"750", "800"}
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
|
|
def normalize_spu(value: str) -> str:
|
|
return value.strip().upper()
|
|
|
|
|
|
def looks_like_spu_token(value: str) -> bool:
|
|
normalized = normalize_spu(value)
|
|
if "-" in normalized:
|
|
prefix, _, sku_part = normalized.partition("-")
|
|
return prefix in KNOWN_SHOP_PREFIXES and bool(SPU_PATTERN.match(sku_part))
|
|
|
|
return bool(SPU_PATTERN.match(normalized)) and not any(
|
|
normalized.startswith(prefix) for prefix in IGNORED_SPU_PREFIXES
|
|
)
|
|
|
|
|
|
def is_standalone_spu_match(text: str, start: int, end: int) -> bool:
|
|
prev_char = text[start - 1] if start > 0 else ""
|
|
next_char = text[end] if end < len(text) else ""
|
|
if re.match(r"[A-Za-z0-9]", prev_char or ""):
|
|
return False
|
|
if not re.match(r"[A-Za-z0-9]", next_char or ""):
|
|
return True
|
|
return bool(re.match(r"K\d{1,2}(?![A-Za-z0-9])", text[end:], re.IGNORECASE))
|
|
|
|
|
|
def get_sku_part(spu: str) -> str:
|
|
normalized = normalize_spu(spu)
|
|
return normalized.partition("-")[2] if "-" in normalized else normalized
|
|
|
|
|
|
def has_known_category_code(spu: str) -> bool:
|
|
sku_part = get_sku_part(spu)
|
|
for code in sorted(KNOWN_CATEGORY_CODES, key=len, reverse=True):
|
|
if sku_part.startswith(code) and len(sku_part) > len(code) and sku_part[len(code)].isdigit():
|
|
return True
|
|
return False
|
|
|
|
|
|
def collect_spu_tokens(path_obj: Path):
|
|
tokens = []
|
|
seen = set()
|
|
for part in reversed(path_obj.parts):
|
|
matches = list(BRANDED_SPU_TOKEN_PATTERN.finditer(part))
|
|
for match in reversed(matches):
|
|
token = normalize_spu(match.group(1))
|
|
if (
|
|
token not in seen
|
|
and looks_like_spu_token(token)
|
|
and is_standalone_spu_match(part, match.start(), match.end())
|
|
):
|
|
tokens.append(token)
|
|
seen.add(token)
|
|
|
|
for part in reversed(path_obj.parts):
|
|
normalized = normalize_spu(part)
|
|
if normalized not in seen and looks_like_spu_token(normalized):
|
|
tokens.append(normalized)
|
|
seen.add(normalized)
|
|
|
|
matches = list(SPU_TOKEN_PATTERN.finditer(part))
|
|
for match in reversed(matches):
|
|
token = normalize_spu(match.group(1))
|
|
if (
|
|
token not in seen
|
|
and looks_like_spu_token(token)
|
|
and is_standalone_spu_match(part, match.start(), match.end())
|
|
):
|
|
tokens.append(token)
|
|
seen.add(token)
|
|
|
|
return tokens
|
|
|
|
|
|
def guess_spu(path_obj: Path) -> str:
|
|
tokens = collect_spu_tokens(path_obj)
|
|
for token in tokens:
|
|
if has_known_category_code(token):
|
|
return token
|
|
if tokens:
|
|
return tokens[0]
|
|
return ""
|
|
|
|
|
|
def guess_spu_from_file_names(file_names) -> str:
|
|
for file_name in file_names:
|
|
match = BRANDED_SKU_FILE_PATTERN.search(file_name)
|
|
if not match:
|
|
continue
|
|
token = normalize_spu(f"{match.group(1)}-{match.group(2)}{match.group(3)}")
|
|
if looks_like_spu_token(token):
|
|
return token
|
|
return ""
|
|
|
|
|
|
def matches_sku_dir(name: str) -> bool:
|
|
return bool(SKU_DIR_PATTERN.search(name or ""))
|
|
|
|
|
|
def should_ignore_path(path_obj: Path) -> bool:
|
|
for part in path_obj.parts:
|
|
if part.strip().lower() in IGNORED_PARTS:
|
|
return True
|
|
return False
|
|
|
|
|
|
def looks_like_file_name(name: str) -> bool:
|
|
return Path(name).suffix.lower() in NON_DIRECTORY_EXTENSIONS
|
|
|
|
|
|
def normalize_dir_name(name: str) -> str:
|
|
return re.sub(r"[\s_\-]+", "", (name or "")).strip().lower()
|
|
|
|
|
|
def is_detail_dir_name(name: str) -> bool:
|
|
normalized = normalize_dir_name(name)
|
|
return any(keyword in normalized for keyword in DETAIL_DIR_KEYWORDS)
|
|
|
|
|
|
def is_main_dir_name(name: str) -> bool:
|
|
normalized = normalize_dir_name(name)
|
|
return MAIN_DIR_KEYWORD in normalized or normalized in MAIN_DIMENSION_DIR_NAMES
|
|
|
|
|
|
def list_sibling_dirs(parent_path: Path, current_dir_name: str):
|
|
try:
|
|
entries = list(parent_path.iterdir())
|
|
except OSError:
|
|
return [], [], []
|
|
|
|
sibling_names = []
|
|
detail_names = []
|
|
main_names = []
|
|
|
|
for entry in entries:
|
|
if not entry.is_dir():
|
|
continue
|
|
if entry.name == current_dir_name:
|
|
continue
|
|
if should_ignore_path(entry) or looks_like_file_name(entry.name):
|
|
continue
|
|
|
|
sibling_names.append(entry.name)
|
|
if is_detail_dir_name(entry.name):
|
|
detail_names.append(entry.name)
|
|
if is_main_dir_name(entry.name):
|
|
main_names.append(entry.name)
|
|
|
|
return sibling_names, detail_names, main_names
|
|
|
|
|
|
def ensure_schema(conn: sqlite3.Connection) -> None:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sku_folder_scans (
|
|
scan_id TEXT PRIMARY KEY,
|
|
root_path TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
total_rows INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sku_folder_scan_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
scan_id TEXT NOT NULL,
|
|
root_path TEXT NOT NULL,
|
|
sku_dir_path TEXT NOT NULL,
|
|
relative_path TEXT NOT NULL,
|
|
directory_name TEXT NOT NULL,
|
|
parent_path TEXT,
|
|
grandparent_path TEXT,
|
|
spu TEXT,
|
|
depth INTEGER NOT NULL DEFAULT 0,
|
|
file_count INTEGER NOT NULL DEFAULT 0,
|
|
image_count INTEGER NOT NULL DEFAULT 0,
|
|
child_dir_count INTEGER NOT NULL DEFAULT 0,
|
|
sibling_dir_names_json TEXT NOT NULL DEFAULT '[]',
|
|
sibling_detail_dir_names_json TEXT NOT NULL DEFAULT '[]',
|
|
sibling_main_dir_names_json TEXT NOT NULL DEFAULT '[]',
|
|
has_detail_dir_sibling INTEGER NOT NULL DEFAULT 0,
|
|
has_main_dir_sibling INTEGER NOT NULL DEFAULT 0,
|
|
has_detail_and_main_siblings INTEGER NOT NULL DEFAULT 0,
|
|
sample_files_json TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (scan_id) REFERENCES sku_folder_scans(scan_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sku_folder_scan_items_scan_id
|
|
ON sku_folder_scan_items(scan_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sku_folder_scan_items_spu
|
|
ON sku_folder_scan_items(spu);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sku_folder_scan_items_relative_path
|
|
ON sku_folder_scan_items(relative_path);
|
|
"""
|
|
)
|
|
|
|
expected_columns = {
|
|
"sibling_dir_names_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"sibling_detail_dir_names_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"sibling_main_dir_names_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"has_detail_dir_sibling": "INTEGER NOT NULL DEFAULT 0",
|
|
"has_main_dir_sibling": "INTEGER NOT NULL DEFAULT 0",
|
|
"has_detail_and_main_siblings": "INTEGER NOT NULL DEFAULT 0",
|
|
}
|
|
existing_columns = {
|
|
row[1]
|
|
for row in conn.execute("PRAGMA table_info(sku_folder_scan_items)").fetchall()
|
|
}
|
|
for column_name, column_definition in expected_columns.items():
|
|
if column_name in existing_columns:
|
|
continue
|
|
conn.execute(
|
|
f"ALTER TABLE sku_folder_scan_items ADD COLUMN {column_name} {column_definition}"
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def scan_sku_dirs(root_path: Path, skip_repeated_root_name: bool = False):
|
|
for current_root, dir_names, file_names in os.walk(root_path):
|
|
current_path = Path(current_root)
|
|
if should_ignore_path(current_path):
|
|
dir_names[:] = []
|
|
continue
|
|
if skip_repeated_root_name and current_path == root_path:
|
|
root_name = root_path.name.casefold()
|
|
dir_names[:] = [name for name in dir_names if name.casefold() != root_name]
|
|
|
|
for dir_name in list(dir_names):
|
|
if not matches_sku_dir(dir_name):
|
|
continue
|
|
|
|
sku_dir_path = current_path / dir_name
|
|
if should_ignore_path(sku_dir_path) or looks_like_file_name(dir_name) or not sku_dir_path.is_dir():
|
|
continue
|
|
try:
|
|
child_entries = list(sku_dir_path.iterdir())
|
|
except OSError:
|
|
child_entries = []
|
|
|
|
files = [entry for entry in child_entries if entry.is_file()]
|
|
child_dirs = [entry for entry in child_entries if entry.is_dir()]
|
|
image_files = [entry.name for entry in files if entry.suffix.lower() in IMAGE_EXTENSIONS]
|
|
sibling_names, detail_sibling_names, main_sibling_names = list_sibling_dirs(
|
|
current_path, sku_dir_path.name
|
|
)
|
|
|
|
relative_path = sku_dir_path.relative_to(root_path).as_posix()
|
|
yield {
|
|
"root_path": str(root_path),
|
|
"sku_dir_path": str(sku_dir_path),
|
|
"relative_path": relative_path,
|
|
"directory_name": sku_dir_path.name,
|
|
"parent_path": str(sku_dir_path.parent),
|
|
"grandparent_path": str(sku_dir_path.parent.parent) if sku_dir_path.parent != root_path else "",
|
|
"spu": guess_spu(sku_dir_path) or guess_spu_from_file_names(image_files),
|
|
"depth": len(sku_dir_path.relative_to(root_path).parts),
|
|
"file_count": len(files),
|
|
"image_count": len(image_files),
|
|
"child_dir_count": len(child_dirs),
|
|
"sibling_dir_names_json": json.dumps(sibling_names, ensure_ascii=False),
|
|
"sibling_detail_dir_names_json": json.dumps(detail_sibling_names, ensure_ascii=False),
|
|
"sibling_main_dir_names_json": json.dumps(main_sibling_names, ensure_ascii=False),
|
|
"has_detail_dir_sibling": 1 if detail_sibling_names else 0,
|
|
"has_main_dir_sibling": 1 if main_sibling_names else 0,
|
|
"has_detail_and_main_siblings": 1 if detail_sibling_names and main_sibling_names else 0,
|
|
"sample_files_json": json.dumps(image_files[:12], ensure_ascii=False),
|
|
}
|
|
|
|
|
|
def insert_rows(conn: sqlite3.Connection, scan_id: str, rows) -> int:
|
|
created_at = utc_now()
|
|
total = 0
|
|
for row in rows:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO sku_folder_scan_items (
|
|
scan_id, root_path, sku_dir_path, relative_path, directory_name,
|
|
parent_path, grandparent_path, spu, depth, file_count, image_count,
|
|
child_dir_count, sibling_dir_names_json, sibling_detail_dir_names_json,
|
|
sibling_main_dir_names_json, has_detail_dir_sibling, has_main_dir_sibling,
|
|
has_detail_and_main_siblings, sample_files_json, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
scan_id,
|
|
row["root_path"],
|
|
row["sku_dir_path"],
|
|
row["relative_path"],
|
|
row["directory_name"],
|
|
row["parent_path"],
|
|
row["grandparent_path"],
|
|
row["spu"],
|
|
row["depth"],
|
|
row["file_count"],
|
|
row["image_count"],
|
|
row["child_dir_count"],
|
|
row["sibling_dir_names_json"],
|
|
row["sibling_detail_dir_names_json"],
|
|
row["sibling_main_dir_names_json"],
|
|
row["has_detail_dir_sibling"],
|
|
row["has_main_dir_sibling"],
|
|
row["has_detail_and_main_siblings"],
|
|
row["sample_files_json"],
|
|
created_at,
|
|
),
|
|
)
|
|
total += 1
|
|
conn.commit()
|
|
return total
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description="Scan all sku folders and save results to sqlite.")
|
|
parser.add_argument(
|
|
"--root",
|
|
default="/mnt/history-sc/大货营销策划图片",
|
|
help="Root directory to scan.",
|
|
)
|
|
parser.add_argument(
|
|
"--roots",
|
|
default="",
|
|
help="Multiple root directories separated by |. When provided, --root is ignored.",
|
|
)
|
|
parser.add_argument(
|
|
"--db-path",
|
|
default="/root/gongxiangp2/output/history-sku-folders.db",
|
|
help="SQLite database file path.",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-repeated-root-name",
|
|
action="store_true",
|
|
help="Skip an immediate child directory whose name equals the scan root name.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
root_paths = [Path(item.strip()) for item in args.roots.split("|") if item.strip()] if args.roots else [Path(args.root)]
|
|
db_path = Path(args.db_path)
|
|
|
|
missing_roots = [str(root_path) for root_path in root_paths if not root_path.exists()]
|
|
if missing_roots:
|
|
print(f"Root path not found: {missing_roots[0]}", file=sys.stderr)
|
|
return 1
|
|
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
ensure_schema(conn)
|
|
all_previews = []
|
|
grand_total = 0
|
|
scan_ids = []
|
|
for root_path in root_paths:
|
|
scan_id = str(uuid.uuid4())
|
|
scan_ids.append(scan_id)
|
|
started_at = utc_now()
|
|
conn.execute(
|
|
"INSERT INTO sku_folder_scans (scan_id, root_path, started_at) VALUES (?, ?, ?)",
|
|
(scan_id, str(root_path), started_at),
|
|
)
|
|
conn.commit()
|
|
|
|
rows = list(scan_sku_dirs(root_path, args.skip_repeated_root_name))
|
|
total_rows = insert_rows(conn, scan_id, rows)
|
|
grand_total += total_rows
|
|
finished_at = utc_now()
|
|
conn.execute(
|
|
"UPDATE sku_folder_scans SET finished_at = ?, total_rows = ? WHERE scan_id = ?",
|
|
(finished_at, total_rows, scan_id),
|
|
)
|
|
conn.commit()
|
|
all_previews.extend(rows[:3])
|
|
|
|
print("SKU folder scan completed")
|
|
print(f"scan_ids={','.join(scan_ids)}")
|
|
print(f"db_path={db_path}")
|
|
print(f"total_rows={grand_total}")
|
|
print(json.dumps(all_previews[:12], ensure_ascii=False, indent=2))
|
|
return 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|