Files
2026-09-20 12:12:56 +02:00

83 lines
3.2 KiB
Python

"""MakerWorld URL parsing and canonicalisation.
Extracts ``(model_id, profile_id_or_None)`` from model URLs and builds the
stable dedupe key used as the library ``source_url``. Rejects non-makerworld
hosts — this is the input-validation surface, so it is deliberately strict.
"""
from __future__ import annotations
import re
from urllib.parse import urlparse
from backend.app.services.model_providers.base import ProviderResourceRef
from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
_MODEL_ID_RE = re.compile(r"/models/(\d+)")
_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
def parse_url(url: str) -> ProviderResourceRef:
"""Extract a :class:`ProviderResourceRef` from a MakerWorld URL.
Accepts any of:
- ``https://makerworld.com/en/models/1400373``
- ``https://makerworld.com/en/models/1400373-slug-with-dashes``
- ``https://makerworld.com/en/models/1400373#profileId-1452154``
- ``makerworld.com/models/1400373`` (scheme optional)
Rejects non-makerworld hosts.
"""
if not url or not isinstance(url, str):
raise MakerWorldUrlError("URL is empty or not a string")
candidate = url.strip()
if "://" not in candidate:
candidate = "https://" + candidate
try:
parsed = urlparse(candidate)
except ValueError as exc:
raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
host = (parsed.hostname or "").lower()
if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
model_match = _MODEL_ID_RE.search(parsed.path)
if not model_match:
raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
model_id = int(model_match.group(1))
profile_id: int | None = None
if parsed.fragment:
profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
if profile_match:
profile_id = int(profile_match.group(1))
return ProviderResourceRef(
source_type="makerworld",
external_id=str(model_id),
sub_id=str(profile_id) if profile_id is not None else None,
original_url=url,
)
def canonical_url(ref: ProviderResourceRef) -> str:
"""Build a stable dedupe key for a MakerWorld resource.
Dedupe is keyed per *plate* (profile) rather than per model, since the
download returns a specific plate — not the full multi-plate zip — so two
different plates of the same design should become two separate library
entries. Canonical shape uses the locale-free path with the
``#profileId-`` fragment so all URL variants of the same plate still
collapse (e.g. ``/en/models/123-slug?from=search#profileId-456`` and
``/de/models/123#profileId-456`` both map to
``https://makerworld.com/models/123#profileId-456``). Plate-less imports
(legacy or whole-design) keep the old model-only shape for backwards
compatibility with existing rows.
"""
if ref.sub_id:
return f"https://makerworld.com/models/{ref.external_id}#profileId-{ref.sub_id}"
return f"https://makerworld.com/models/{ref.external_id}"