import argparse import json import os import re import sys from datetime import datetime from urllib.parse import quote_plus, urlparse, urlunparse from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError REGISTRY_FILE = "model_registry.json" URL_LOG_FILE = "model_url_scan.log" USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ) CATEGORY_SPECS = [ "checkpoints", "loras", "controlnets", "vae", "text_encoders", "diffusion_models", "clip", "embeddings", ] def load_registry(path): if not os.path.exists(path): return None with open(path, "r", encoding="utf-8") as f: return json.load(f) def save_registry(path, data): with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) def normalize_key(value): return re.sub(r"[^a-z0-9]+", "", (value or "").lower()) def filename_stem(filename): return os.path.splitext(filename or "")[0] def is_huggingface_url(url): try: host = urlparse(url).netloc.lower() except Exception: return False return host.endswith("huggingface.co") def is_civitai_url(url): try: host = urlparse(url).netloc.lower() except Exception: return False return host.endswith("civitai.com") def normalize_huggingface_url(url, filename): parsed = urlparse(url.strip()) scheme = parsed.scheme or "https" host = parsed.netloc path = parsed.path or "" path = path.replace("/blob/", "/resolve/") path = path.replace("/tree/", "/resolve/") clean_parts = [p for p in path.split("/") if p] if "resolve" in clean_parts: idx = clean_parts.index("resolve") repo_prefix = clean_parts[:idx] tail = clean_parts[idx + 1 :] branch = tail[0] if len(tail) >= 1 else "main" resolved_parts = repo_prefix + ["resolve", branch] if len(tail) >= 2: # keep any subpath, replace final filename with registry filename sub_parts = tail[1:-1] if len(tail) > 2 else [] resolved_parts.extend(sub_parts) if filename: resolved_parts.append(filename) elif len(tail) >= 2: resolved_parts.append(tail[-1]) else: resolved_parts.append("model.safetensors") else: if filename: resolved_parts = clean_parts + ["resolve", "main", filename] else: resolved_parts = clean_parts + ["resolve", "main"] new_path = "/" + "/".join(resolved_parts) return urlunparse((scheme, host, new_path, "", parsed.query, "")) def extract_civitai_model_id(url): path = urlparse(url).path patterns = [ r"/models/(\d+)", r"/api/v1/models/(\d+)", ] for pattern in patterns: m = re.search(pattern, path, flags=re.IGNORECASE) if m: return m.group(1) return None def canonical_civitai_model_url(model_id): return f"https://civitai.com/models/{model_id}" def fetch_json(url, timeout): req = Request( url, headers={ "User-Agent": USER_AGENT, "Accept": "application/json", }, ) with urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", errors="ignore") return json.loads(raw) def extract_slug_from_url(url): parts = [p for p in urlparse(url).path.split("/") if p] if len(parts) >= 2 and parts[0].lower() == "models": token = parts[1] if token.isdigit(): return "" return token return "" def score_civitai_candidate(candidate, filename, stem_key, slug_key): score = 0 c_name = str(candidate.get("name", "")) c_key = normalize_key(c_name) if c_key and (c_key in stem_key or stem_key in c_key): score += 20 if slug_key and c_key and (slug_key in c_key or c_key in slug_key): score += 30 versions = candidate.get("modelVersions") or [] for version in versions: for file_info in (version.get("files") or []): f_name = str(file_info.get("name", "")) f_key = normalize_key(f_name) if not f_key: continue if f_name.lower() == filename.lower(): score += 100 elif stem_key and (stem_key in f_key or f_key in stem_key): score += 25 return score def resolve_civitai_model_url(current_url, filename, timeout, cache): existing_id = extract_civitai_model_id(current_url) if existing_id: return canonical_civitai_model_url(existing_id), "existing_id" stem = filename_stem(filename) stem_key = normalize_key(stem) slug = extract_slug_from_url(current_url) slug_key = normalize_key(slug) queries = [] if slug: queries.append(slug) if stem: queries.append(stem) # unique while preserving order dedup = [] seen = set() for q in queries: qn = q.strip() if not qn: continue low = qn.lower() if low in seen: continue seen.add(low) dedup.append(qn) best_url = None best_score = -1 best_reason = "" for query in dedup: cache_key = query.lower() if cache_key in cache: data = cache[cache_key] else: api_url = f"https://civitai.com/api/v1/models?query={quote_plus(query)}&limit=30" try: data = fetch_json(api_url, timeout=timeout) except Exception: data = None cache[cache_key] = data if not isinstance(data, dict): continue for candidate in (data.get("items") or []): model_id = candidate.get("id") if not model_id: continue score = score_civitai_candidate(candidate, filename, stem_key, slug_key) if score > best_score: best_score = score best_url = canonical_civitai_model_url(model_id) best_reason = f"search:{query}" if best_url and best_score >= 30: return best_url, best_reason return current_url, "unresolved_slug" def check_url_status(url, timeout): # HEAD first, then lightweight GET fallback if HEAD is blocked. headers = {"User-Agent": USER_AGENT} try: req = Request(url, headers=headers, method="HEAD") with urlopen(req, timeout=timeout) as resp: return int(resp.getcode() or 0), "HEAD" except HTTPError as err: return int(err.code), "HEAD" except Exception: pass try: headers["Range"] = "bytes=0-0" req = Request(url, headers=headers, method="GET") with urlopen(req, timeout=timeout) as resp: return int(resp.getcode() or 0), "GET_RANGE" except HTTPError as err: return int(err.code), "GET_RANGE" except URLError: return 0, "NETWORK_ERROR" except Exception: return 0, "ERROR" def iter_registry_items(registry, checkpoints_only=False, name_filter=None): categories = ["checkpoints"] if checkpoints_only else CATEGORY_SPECS pattern = re.compile(name_filter, re.IGNORECASE) if name_filter else None for category in categories: items = registry.get(category, []) if not isinstance(items, list): continue for item in items: if isinstance(item, dict): if pattern: name = str(item.get("name", "")) if not pattern.search(name): continue yield category, item def sync_urls( registry_path, dry_run=False, offline=False, checkpoints_only=False, check_live=False, timeout=12, name_filter=None, ): registry = load_registry(registry_path) if not registry: print(f"Registry not found at: {registry_path}") return 1 civitai_cache = {} scan_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_path = os.path.join(os.path.dirname(registry_path), URL_LOG_FILE) stats = { "scanned": 0, "changed": 0, "live_checked": 0, "live_ok": 0, "live_bad": 0, "unresolved": 0, } change_lines = [] status_lines = [] unresolved_lines = [] for category, item in iter_registry_items( registry, checkpoints_only=checkpoints_only, name_filter=name_filter, ): name = str(item.get("name", "")).strip() old_url = str(item.get("url", "")).strip() if not name or not old_url: continue stats["scanned"] += 1 new_url = old_url reason = "unchanged" if is_huggingface_url(old_url): new_url = normalize_huggingface_url(old_url, name) if new_url != old_url: reason = "huggingface_normalized" elif is_civitai_url(old_url): model_id = extract_civitai_model_id(old_url) if model_id: canonical = canonical_civitai_model_url(model_id) if canonical != old_url: new_url = canonical reason = "civitai_canonicalized" elif not offline: resolved, resolve_reason = resolve_civitai_model_url( old_url, name, timeout=timeout, cache=civitai_cache, ) new_url = resolved reason = f"civitai_{resolve_reason}" if resolved == old_url and resolve_reason == "unresolved_slug": stats["unresolved"] += 1 unresolved_lines.append(f"[{category}] {name}: {old_url}") else: reason = "unknown_provider" if new_url != old_url: stats["changed"] += 1 item["url"] = new_url change_lines.append(f"[{category}] {name}: {old_url} -> {new_url} ({reason})") print(f"[URL FIXED] [{category}] {name}") if check_live: code, method = check_url_status(new_url, timeout=timeout) stats["live_checked"] += 1 ok = 200 <= code < 400 if ok: stats["live_ok"] += 1 else: stats["live_bad"] += 1 status_lines.append(f"[{category}] {name}: {code} via {method} | {new_url}") if dry_run: print("\nDry run complete. No changes written.") elif stats["changed"] > 0: save_registry(registry_path, registry) print(f"\nUpdated registry URLs: {stats['changed']} changes written.") else: print("\nNo URL changes needed.") with open(log_path, "w", encoding="utf-8") as log: log.write(f"Scan Date: {scan_time}\n") log.write(f"Registry: {registry_path}\n") log.write(f"Dry Run: {dry_run}\n") log.write(f"Offline: {offline}\n") log.write(f"Check Live: {check_live}\n") log.write(f"Checkpoints Only: {checkpoints_only}\n") log.write(f"Scanned: {stats['scanned']}\n") log.write(f"Changed: {stats['changed']}\n") log.write(f"Unresolved Slugs: {stats['unresolved']}\n") log.write(f"Live Checked: {stats['live_checked']}\n") log.write(f"Live OK: {stats['live_ok']}\n") log.write(f"Live Bad: {stats['live_bad']}\n") log.write("\n== URL Changes ==\n") for line in change_lines: log.write(f"{line}\n") log.write("\n== Live Status ==\n") for line in status_lines: log.write(f"{line}\n") log.write("\n== Unresolved Civitai Slugs ==\n") for line in unresolved_lines: log.write(f"{line}\n") print(f"URL scan log written to: {log_path}") print( "Summary: " f"scanned={stats['scanned']}, " f"changed={stats['changed']}, " f"unresolved={stats['unresolved']}, " f"live_checked={stats['live_checked']}, " f"live_bad={stats['live_bad']}" ) return 0 def parse_args(): parser = argparse.ArgumentParser( description="Validate and repair source download URLs in model_registry.json." ) parser.add_argument("--registry", default=REGISTRY_FILE, help="Path to registry JSON.") parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing.") parser.add_argument( "--offline", action="store_true", help="Skip network lookups (only normalize existing URL patterns).", ) parser.add_argument( "--check-live", action="store_true", help="Check HTTP status for each final URL (HEAD + GET range fallback).", ) parser.add_argument( "--checkpoints-only", action="store_true", help="Process checkpoints category only.", ) parser.add_argument( "--timeout", type=int, default=12, help="HTTP timeout in seconds for URL lookup/check requests.", ) parser.add_argument( "--name-filter", default="", help="Optional regex filter for model names (example: nightvision).", ) return parser.parse_args() if __name__ == "__main__": args = parse_args() registry_path = args.registry if not os.path.isabs(registry_path): registry_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), registry_path) rc = sync_urls( registry_path=registry_path, dry_run=args.dry_run, offline=args.offline, checkpoints_only=args.checkpoints_only, check_live=args.check_live, timeout=max(3, int(args.timeout)), name_filter=args.name_filter.strip() or None, ) sys.exit(rc)