url.py 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. """MakerWorld URL parsing and canonicalisation.
  2. Extracts ``(model_id, profile_id_or_None)`` from model URLs and builds the
  3. stable dedupe key used as the library ``source_url``. Rejects non-makerworld
  4. hosts — this is the input-validation surface, so it is deliberately strict.
  5. """
  6. from __future__ import annotations
  7. import re
  8. from urllib.parse import urlparse
  9. from backend.app.services.model_providers.base import ProviderResourceRef
  10. from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
  11. MAKERWORLD_HOST = "makerworld.com" # Used only for URL parsing (input validation)
  12. _MODEL_ID_RE = re.compile(r"/models/(\d+)")
  13. _PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
  14. def parse_url(url: str) -> ProviderResourceRef:
  15. """Extract a :class:`ProviderResourceRef` from a MakerWorld URL.
  16. Accepts any of:
  17. - ``https://makerworld.com/en/models/1400373``
  18. - ``https://makerworld.com/en/models/1400373-slug-with-dashes``
  19. - ``https://makerworld.com/en/models/1400373#profileId-1452154``
  20. - ``makerworld.com/models/1400373`` (scheme optional)
  21. Rejects non-makerworld hosts.
  22. """
  23. if not url or not isinstance(url, str):
  24. raise MakerWorldUrlError("URL is empty or not a string")
  25. candidate = url.strip()
  26. if "://" not in candidate:
  27. candidate = "https://" + candidate
  28. try:
  29. parsed = urlparse(candidate)
  30. except ValueError as exc:
  31. raise MakerWorldUrlError(f"Could not parse URL: {exc}") from exc
  32. host = (parsed.hostname or "").lower()
  33. if host != MAKERWORLD_HOST and not host.endswith("." + MAKERWORLD_HOST):
  34. raise MakerWorldUrlError(f"Not a MakerWorld URL (host={host!r}); expected makerworld.com")
  35. model_match = _MODEL_ID_RE.search(parsed.path)
  36. if not model_match:
  37. raise MakerWorldUrlError("URL does not contain a /models/{id} segment")
  38. model_id = int(model_match.group(1))
  39. profile_id: int | None = None
  40. if parsed.fragment:
  41. profile_match = _PROFILE_ID_RE.search("#" + parsed.fragment)
  42. if profile_match:
  43. profile_id = int(profile_match.group(1))
  44. return ProviderResourceRef(
  45. source_type="makerworld",
  46. external_id=str(model_id),
  47. sub_id=str(profile_id) if profile_id is not None else None,
  48. original_url=url,
  49. )
  50. def canonical_url(ref: ProviderResourceRef) -> str:
  51. """Build a stable dedupe key for a MakerWorld resource.
  52. Dedupe is keyed per *plate* (profile) rather than per model, since the
  53. download returns a specific plate — not the full multi-plate zip — so two
  54. different plates of the same design should become two separate library
  55. entries. Canonical shape uses the locale-free path with the
  56. ``#profileId-`` fragment so all URL variants of the same plate still
  57. collapse (e.g. ``/en/models/123-slug?from=search#profileId-456`` and
  58. ``/de/models/123#profileId-456`` both map to
  59. ``https://makerworld.com/models/123#profileId-456``). Plate-less imports
  60. (legacy or whole-design) keep the old model-only shape for backwards
  61. compatibility with existing rows.
  62. """
  63. if ref.sub_id:
  64. return f"https://makerworld.com/models/{ref.external_id}#profileId-{ref.sub_id}"
  65. return f"https://makerworld.com/models/{ref.external_id}"