← back to Fentucci Naturals
scripts/retown-collisions.py
170 lines
#!/usr/bin/env python3
"""Re-town pattern-name collisions against the LIVE Fentucci family.
The original town assignment (normalize.js) picked Italian coastal towns without
checking the 748 LIVE/DRAFT products already under vendor ILIKE '%fentucci%' on
the DW store — 27 of the 64 assigned towns already appear as pattern names in
live titles (e.g. "Portofino Ostrica | Fentucci Naturals",
"Salerno Marmo | Fentucci Naturals", "Gaeta Grasscloth Wallcovering | Fentucci").
This script is DETERMINISTIC and IDEMPOTENT:
- collision = assigned town word-boundary-matches any live/draft Fentucci title
- colliding rename-map keys are processed in sorted order
- replacements come from the fixed CANDIDATES list below, in order, taking the
first town that (a) does not word-boundary-match ANY live Fentucci title and
(b) is not used by any other assignment (existing or newly chosen)
- non-colliding assignments are NEVER touched (map stability rule)
Applies the rename to:
- data/rename-map.json (town value)
- data/products.json (pattern_name per row)
- data/pattern-copy.json (re-key town -> town, word-boundary swap in copy)
- dw_unified.tokiwa_catalog (pattern_name, description regex swap, tags array)
data/shopify-drafts.jsonl is NOT touched here — regenerate it afterwards with
scripts/build-drafts.py (it rebuilds titles/handles/copy from tokiwa_catalog;
DW SKUs are unaffected).
Usage: python3 scripts/retown-collisions.py [--dry-run]
"""
import json, os, re, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CONN = "host=/tmp dbname=dw_unified"
# Fixed, ordered replacement pool — Italian coastal / riviera comuni not used by
# the original 64 assignments. Order matters for determinism; never reshuffle,
# only append.
CANDIDATES = [
"Tellaro", "Bonassola", "Framura", "Moneglia", "Varazze", "Spotorno",
"Bergeggi", "Laigueglia", "Andora", "Taggia", "Cervia", "Chioggia",
"Fano", "Termoli", "Vasto", "Ortona", "Giulianova", "Pineto",
"Peschici", "Ostuni", "Fasano", "Maratea", "Sapri", "Palinuro",
"Agropoli", "Formia", "Anzio", "Nettuno", "Sabaudia", "Talamone",
"Orbetello", "Follonica", "Piombino", "Capoliveri", "Stintino",
"Alghero", "Bosa", "Carloforte", "Villasimius", "Milazzo", "Sciacca",
"Trapani", "Marsala", "Mondello", "Lipari", "Salina", "Favignana",
"Pantelleria",
]
def psql(sql, args=None):
cmd = ["psql", CONN, "-Atc", sql]
return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
def word_hit(town, titles):
pat = re.compile(r"\b" + re.escape(town) + r"\b", re.I)
return [t for t in titles if pat.search(t)]
def main():
dry = "--dry-run" in sys.argv
live_titles = [
l.strip() for l in psql(
"SELECT title FROM shopify_products "
"WHERE vendor ILIKE '%fentucci%' AND status IN ('ACTIVE','DRAFT')"
).splitlines() if l.strip()
]
print(f"live/draft Fentucci titles: {len(live_titles)}")
rm_path = os.path.join(ROOT, "data", "rename-map.json")
rename_map = json.load(open(rm_path))
assigned = {k: v["town"] for k, v in rename_map.items()}
if len(set(assigned.values())) != len(assigned):
sys.exit("FATAL: intra-line duplicate towns already present in rename-map")
colliding = {k: t for k, t in sorted(assigned.items()) if word_hit(t, live_titles)}
print(f"colliding assignments: {len(colliding)}")
if not colliding:
print("nothing to do — map is clean vs live titles")
return
used = set(assigned.values())
pool = iter(CANDIDATES)
mapping = {} # key -> (old_town, new_town)
for key, old in colliding.items():
while True:
try:
cand = next(pool)
except StopIteration:
sys.exit("FATAL: candidate pool exhausted — append more comuni")
if cand in used:
continue
if word_hit(cand, live_titles):
print(f" pool skip {cand}: collides with live title")
continue
break
mapping[key] = (old, cand)
used.add(cand)
used.discard(old)
print("\nOLD TOWN -> NEW TOWN")
for key, (old, new) in mapping.items():
print(f" {key:28s} {old:12s} -> {new}")
if dry:
print("\n--dry-run: no files/DB touched")
return
old_to_new = {old: new for old, new in mapping.values()}
# 1. rename-map.json
for key, (old, new) in mapping.items():
rename_map[key]["town"] = new
json.dump(rename_map, open(rm_path, "w"), indent=2, sort_keys=True, ensure_ascii=False)
# 2. products.json
pj_path = os.path.join(ROOT, "data", "products.json")
products = json.load(open(pj_path))
n = 0
for p in products:
if p["pattern_key"] in mapping:
p["pattern_name"] = mapping[p["pattern_key"]][1]
n += 1
json.dump(products, open(pj_path, "w"), indent=2, ensure_ascii=False)
print(f"products.json: {n} rows re-towned")
# 3. pattern-copy.json (keyed by town; copy text mentions the town)
pc_path = os.path.join(ROOT, "data", "pattern-copy.json")
pc = json.load(open(pc_path))
for old, new in old_to_new.items():
if old in pc:
pc[new] = re.sub(r"\b" + re.escape(old) + r"\b", new, pc.pop(old))
json.dump(pc, open(pc_path, "w"), indent=2, sort_keys=True, ensure_ascii=False)
# 4. tokiwa_catalog (PostgreSQL BEFORE Shopify — Shopify untouched here)
for old, new in old_to_new.items():
sql = (
"UPDATE tokiwa_catalog SET "
f"pattern_name = '{new}', "
f"description = regexp_replace(description, '\\m{old}\\M', '{new}', 'g'), "
f"tags = array_replace(tags, '{old}', '{new}') "
f"WHERE pattern_name = '{old}'"
)
out = psql(sql)
print(f"tokiwa_catalog: {old} -> {new}: {out.strip()}")
# 4b. specs jsonb carries its own pattern copy — sync it (2026-07-22 gap:
# the first run left 235 rows with the OLD town in specs->>'pattern')
out = psql(
"UPDATE tokiwa_catalog SET specs = jsonb_set(specs, '{pattern}', to_jsonb(pattern_name)) "
"WHERE specs->>'pattern' IS DISTINCT FROM pattern_name"
)
print(f"tokiwa_catalog specs.pattern sync: {out.strip()}")
# ---- GATE: re-check ----
assigned2 = {k: v["town"] for k, v in json.load(open(rm_path)).items()}
dupes = len(assigned2) - len(set(assigned2.values()))
post = {k: t for k, t in assigned2.items() if word_hit(t, live_titles)}
print(f"\nGATE: post-fix collisions vs live titles = {len(post)}; intra-line duplicates = {dupes}")
if post or dupes:
sys.exit("GATE FAILED")
print("GATE PASSED — now regenerate drafts: python3 scripts/build-drafts.py")
if __name__ == "__main__":
main()