Files
ComfyUI-Lora-Manager/py/utils/civitai_utils.py
T
Will Miao ec5fef512b fix(update): read model-page prices from a host that answers
End-to-end verification against the live site found the price capture broken for
a whole class of users: the civitai page hosts are not interchangeable, and the
user's civitai_host preference was silently fatal. With civitai_host=civitai.red
the update DB held zero prices even with tracking enabled.

- civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge,
  403 for any User-Agent, aiohttp and httpx alike), while civitai.com and
  civitai.green answer normally for anonymously visible models and 404 for
  mature ones. An earlier manual check with curl passed on TLS fingerprint luck,
  which is why this was missed.
- get_model_prices now tries the configured host first, then the others, and
  takes the first parseable payload. The host that worked is remembered, and a
  host that refuses outright is parked for 15 minutes so a library full of
  mature models does not pay three requests each; a 404 is model-specific and
  does not park the host. Links keep using the configured host, which is where
  the user's own browser has clearance.
- Mature models still have no price source anywhere, so that is now stated
  instead of silent: price_check_attempted_at separates "tried and unreadable"
  from "never looked", gated versions show a muted "Price unavailable" badge,
  and the alerts panel reports unavailableCount.
- Failures are logged at warning level, once per host per TTL, with the
  per-host reason, instead of only at debug level.
- The recorded alternatives (internal tRPC with the user's API key, or an
  extension-assisted fetch from the user's browser) and the strengthened
  upstream ask for a public price field are documented in the plan.
2026-10-04 20:00:29 +08:00

397 lines
12 KiB
Python

"""Utilities for working with Civitai assets."""
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = {
"allowNoCredit": True,
"allowCommercialUse": _DEFAULT_ALLOW_COMMERCIAL_USE,
"allowDerivatives": True,
"allowDifferentLicense": True,
}
_COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
_COMMERCIAL_SHIFT = 1
def is_supported_civitai_page_host(hostname: str | None) -> bool:
"""Return whether the hostname is a supported Civitai page domain."""
if not hostname:
return False
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
def normalize_civitai_page_host(hostname: str | None) -> str:
"""Return a supported Civitai page host or the default host."""
if not isinstance(hostname, str):
return DEFAULT_CIVITAI_PAGE_HOST
normalized = hostname.strip().lower()
if is_supported_civitai_page_host(normalized):
return normalized
return DEFAULT_CIVITAI_PAGE_HOST
def civitai_page_host_candidates(hostname: str | None = None) -> tuple[str, ...]:
"""Ordered page hosts to try, the preferred one first.
The hosts are not interchangeable: ``civitai.red`` serves mature model pages
that ``civitai.com`` hides from anonymous visitors, but it also sits behind a
Cloudflare challenge that rejects non-browser HTTP clients outright. Trying the
others when the preferred host refuses a request is what keeps a user's
``civitai_host`` preference from disabling page reads entirely.
"""
preferred = normalize_civitai_page_host(hostname)
rest = sorted(_SUPPORTED_CIVITAI_PAGE_HOSTS - {preferred})
return (preferred, *rest)
def build_civitai_model_page_url(
model_id: str | int | None,
version_id: str | int | None = None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai model or model-version page URL."""
normalized_host = normalize_civitai_page_host(host)
normalized_model_id = str(model_id).strip() if model_id is not None else ""
normalized_version_id = str(version_id).strip() if version_id is not None else ""
if normalized_model_id:
path = f"/models/{normalized_model_id}"
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
return urlunparse(("https", normalized_host, path, "", query, ""))
if normalized_version_id:
return urlunparse(
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
)
return None
_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)")
def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None:
"""Extract the numeric image ID from a Cloudflare CDN image URL.
CivitAI image CDN URLs follow the pattern::
https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext}
The image database ID is always the last path segment (minus extension)
because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly
in the model-versions REST API response.
"""
if not url:
return None
match = _RE_CDN_IMAGE_ID.search(url)
return match.group(1) if match else None
def build_civitai_image_page_url(
image_id: str | int | None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai image page URL.
Returns something like ``https://civitai.com/images/12345``.
The host is resolved through :func:`normalize_civitai_page_host` and
therefore respects the user's ``civitai_host`` setting.
"""
if not image_id:
return None
normalized_host = normalize_civitai_page_host(host)
normalized_id = str(image_id).strip()
if not normalized_id:
return None
return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", ""))
def _parse_supported_civitai_page_url(url: str | None):
if not url:
return None
try:
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme not in {"http", "https"}:
return None
if not is_supported_civitai_page_host(parsed.hostname):
return None
return parsed
def extract_civitai_model_url_parts(
url: str | None,
) -> tuple[str | None, str | None]:
"""Extract model and version identifiers from a supported Civitai model URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None, None
path_match = re.search(r"/models/(\d+)", parsed.path)
if not path_match:
return None, None
model_id = path_match.group(1)
query_params = parse_qs(parsed.query)
version_values = query_params.get("modelVersionId") or []
version_id = version_values[0] if version_values else None
return model_id, version_id
def extract_civitai_image_id(url: str | None) -> str | None:
"""Extract the image identifier from a supported Civitai image page URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
path_match = re.search(r"/images/(\d+)", parsed.path)
if not path_match:
return None
return path_match.group(1)
def normalize_civitai_download_url(url: str | None) -> str | None:
"""Rewrite Civitai download URLs to the canonical authenticated host."""
if not url:
return url
try:
parsed = urlparse(url)
except ValueError:
return url
hostname = parsed.hostname.lower() if parsed.hostname else None
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
return url
return urlunparse(parsed._replace(netloc="civitai.com"))
def extract_civitai_page_host(url: str | None) -> str | None:
"""Extract the supported Civitai page host from a URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
return parsed.hostname.lower() if parsed.hostname else None
def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""Return a normalized list of commercial permissions preserving source values."""
def _split_aggregate(value_str: str) -> list[str]:
stripped = value_str.strip()
looks_aggregate = "," in stripped or (
stripped.startswith("{") and stripped.endswith("}")
)
if not looks_aggregate:
return [value_str]
trimmed = stripped
if trimmed.startswith("{") and trimmed.endswith("}"):
trimmed = trimmed[1:-1]
parts = [part.strip() for part in trimmed.split(",")]
result = [part for part in parts if part]
return result or [value_str]
if value is None:
return list(_DEFAULT_ALLOW_COMMERCIAL_USE)
if isinstance(value, str):
return _split_aggregate(value)
if isinstance(value, Iterable):
result = []
for item in value:
if item is None:
continue
if isinstance(item, str):
result.extend(_split_aggregate(item))
continue
result.append(str(item))
if result:
return result
try:
if len(value) == 0: # pyright: ignore[reportArgumentType]
return []
except TypeError:
pass
return list(_DEFAULT_ALLOW_COMMERCIAL_USE)
def _to_bool(value: Any, fallback: bool) -> bool:
if value is None:
return fallback
return bool(value)
def resolve_license_payload(model_data: Mapping[str, Any] | None) -> Dict[str, Any]:
"""Extract license fields from model metadata applying documented defaults."""
payload: Dict[str, Any] = {}
allow_no_credit = payload["allowNoCredit"] = _to_bool(
(model_data or {}).get("allowNoCredit"),
_LICENSE_DEFAULTS["allowNoCredit"],
)
commercial = _normalize_commercial_values(
(model_data or {}).get("allowCommercialUse"),
)
payload["allowCommercialUse"] = list(commercial)
allow_derivatives = payload["allowDerivatives"] = _to_bool(
(model_data or {}).get("allowDerivatives"),
_LICENSE_DEFAULTS["allowDerivatives"],
)
allow_different_license = payload["allowDifferentLicense"] = _to_bool(
(model_data or {}).get("allowDifferentLicense"),
_LICENSE_DEFAULTS["allowDifferentLicense"],
)
# Ensure booleans are plain bool instances
payload["allowNoCredit"] = bool(allow_no_credit)
payload["allowDerivatives"] = bool(allow_derivatives)
payload["allowDifferentLicense"] = bool(allow_different_license)
return payload
def _resolve_commercial_bits(values: Sequence[str]) -> int:
normalized_values = set()
for value in values:
normalized = str(value).strip().lower().replace("_", "").replace("-", "")
if normalized in _COMMERCIAL_ALLOWED_VALUES:
normalized_values.add(normalized)
has_sell = "sell" in normalized_values
has_rent = "rent" in normalized_values
has_rentcivit = "rentcivit" in normalized_values
has_image = "image" in normalized_values
commercial_bits = (
(1 if has_sell else 0) << 3
| (1 if has_rent else 0) << 2
| (1 if has_rentcivit else 0) << 1
| (1 if has_image else 0)
)
return commercial_bits << _COMMERCIAL_SHIFT
def build_license_flags(payload: Mapping[str, Any] | None) -> int:
"""Encode license payload into a compact bitset for cache storage."""
resolved = resolve_license_payload(payload or {})
flags = 0
if resolved.get("allowNoCredit", True):
flags |= 1 << 0
commercial_bits = _resolve_commercial_bits(resolved.get("allowCommercialUse", ()))
flags |= commercial_bits
if resolved.get("allowDerivatives", True):
flags |= 1 << 5
if resolved.get("allowDifferentLicense", True):
flags |= 1 << 6
return flags
def resolve_license_info(
model_data: Mapping[str, Any] | None,
) -> tuple[Dict[str, Any], int]:
"""Return normalized license payload and its encoded bitset."""
payload = resolve_license_payload(model_data)
return payload, build_license_flags(payload)
def rewrite_preview_url(
source_url: str | None, media_type: str | None = None
) -> tuple[str | None, bool]:
"""Rewrite Civitai preview URLs to use optimized renditions.
Args:
source_url: Original preview URL from the Civitai API.
media_type: Optional media type hint (e.g. ``"image"`` or ``"video"``).
Returns:
A tuple of the potentially rewritten URL and a flag indicating whether the
replacement occurred. When the URL is not rewritten, the original value is
returned with ``False``.
"""
if not source_url:
return source_url, False
try:
parsed = urlparse(source_url)
except ValueError:
return source_url, False
hostname = parsed.hostname
if hostname is None:
return source_url, False
hostname = hostname.lower()
if hostname == "civitai.com" or not hostname.endswith(".civitai.com"):
return source_url, False
replacement = "/width=450,optimized=true"
if (media_type or "").lower() == "video":
replacement = "/transcode=true,width=450,optimized=true"
if "/original=true" not in parsed.path:
return source_url, False
updated_path = parsed.path.replace("/original=true", replacement, 1)
if updated_path == parsed.path:
return source_url, False
rewritten = urlunparse(parsed._replace(path=updated_path))
return rewritten, True
__all__ = [
"build_civitai_image_page_url",
"build_license_flags",
"extract_civitai_image_id",
"extract_civitai_image_id_from_cdn_url",
"extract_civitai_page_host",
"extract_civitai_model_url_parts",
"is_supported_civitai_page_host",
"resolve_license_payload",
"resolve_license_info",
"rewrite_preview_url",
]