gongxiangp2/scripts/scan_sku_folders_to_sqlite.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

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())