riftbound-scraper 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. riftbound_scraper/__init__.py +3 -0
  2. riftbound_scraper/application/__init__.py +1 -0
  3. riftbound_scraper/application/assets/__init__.py +1 -0
  4. riftbound_scraper/application/assets/card_asset_sync_service.py +118 -0
  5. riftbound_scraper/application/normalization/__init__.py +1 -0
  6. riftbound_scraper/application/normalization/ability_text_normalizer.py +38 -0
  7. riftbound_scraper/application/normalization/card_variant_detector.py +44 -0
  8. riftbound_scraper/application/normalization/gallery_snapshot_normalizer.py +61 -0
  9. riftbound_scraper/application/normalization/normalized_gallery_result.py +37 -0
  10. riftbound_scraper/application/normalization/raw_card_normalizer.py +217 -0
  11. riftbound_scraper/application/normalization/source_asset_mapper.py +36 -0
  12. riftbound_scraper/application/sync/__init__.py +1 -0
  13. riftbound_scraper/application/sync/gallery_sync_service.py +115 -0
  14. riftbound_scraper/application/sync/local_stats_service.py +64 -0
  15. riftbound_scraper/application/sync/local_stats_snapshot.py +46 -0
  16. riftbound_scraper/application/sync/local_verification_service.py +111 -0
  17. riftbound_scraper/application/sync/set_catalog_service.py +33 -0
  18. riftbound_scraper/application/sync/set_not_found_error.py +17 -0
  19. riftbound_scraper/application/sync/sync_execution_report.py +75 -0
  20. riftbound_scraper/application/sync/verification_issue.py +24 -0
  21. riftbound_scraper/application/sync/verification_report.py +27 -0
  22. riftbound_scraper/cli/__init__.py +1 -0
  23. riftbound_scraper/cli/cli_dependency_factory.py +162 -0
  24. riftbound_scraper/cli/cli_exit_code.py +11 -0
  25. riftbound_scraper/cli/cli_output_formatter.py +98 -0
  26. riftbound_scraper/cli/riftbound_cli_app.py +150 -0
  27. riftbound_scraper/domain/__init__.py +1 -0
  28. riftbound_scraper/domain/assets/__init__.py +1 -0
  29. riftbound_scraper/domain/assets/asset_download_gateway.py +29 -0
  30. riftbound_scraper/domain/assets/asset_download_result.py +60 -0
  31. riftbound_scraper/domain/assets/asset_download_status.py +11 -0
  32. riftbound_scraper/domain/assets/asset_download_task.py +31 -0
  33. riftbound_scraper/domain/assets/asset_kind.py +15 -0
  34. riftbound_scraper/domain/card/__init__.py +1 -0
  35. riftbound_scraper/domain/card/asset_reference.py +34 -0
  36. riftbound_scraper/domain/card/card_artist.py +31 -0
  37. riftbound_scraper/domain/card/card_audit_metadata.py +42 -0
  38. riftbound_scraper/domain/card/card_domain.py +31 -0
  39. riftbound_scraper/domain/card/card_rarity.py +31 -0
  40. riftbound_scraper/domain/card/card_set.py +29 -0
  41. riftbound_scraper/domain/card/card_type_entry.py +36 -0
  42. riftbound_scraper/domain/card/card_variant_kind.py +18 -0
  43. riftbound_scraper/domain/card/normalized_card.py +126 -0
  44. riftbound_scraper/domain/dto/__init__.py +1 -0
  45. riftbound_scraper/domain/dto/raw_card_dto.py +117 -0
  46. riftbound_scraper/domain/dto/raw_set_dto.py +46 -0
  47. riftbound_scraper/domain/persistence/__init__.py +1 -0
  48. riftbound_scraper/domain/persistence/card_persistence_gateway.py +31 -0
  49. riftbound_scraper/domain/persistence/persisted_card_result.py +34 -0
  50. riftbound_scraper/domain/source/__init__.py +1 -0
  51. riftbound_scraper/domain/source/cards_count_mismatch.py +45 -0
  52. riftbound_scraper/domain/source/raw_gallery_metadata.py +31 -0
  53. riftbound_scraper/domain/source/raw_gallery_snapshot.py +66 -0
  54. riftbound_scraper/infrastructure/__init__.py +1 -0
  55. riftbound_scraper/infrastructure/assets/__init__.py +1 -0
  56. riftbound_scraper/infrastructure/assets/asset_content_hasher.py +19 -0
  57. riftbound_scraper/infrastructure/assets/asset_download_error.py +30 -0
  58. riftbound_scraper/infrastructure/assets/asset_extension_resolver.py +44 -0
  59. riftbound_scraper/infrastructure/assets/asset_http_downloader.py +63 -0
  60. riftbound_scraper/infrastructure/assets/asset_path_builder.py +47 -0
  61. riftbound_scraper/infrastructure/assets/asset_record_download_repository.py +100 -0
  62. riftbound_scraper/infrastructure/assets/asset_storage_config.py +30 -0
  63. riftbound_scraper/infrastructure/assets/local_asset_download_gateway.py +172 -0
  64. riftbound_scraper/infrastructure/persistence/__init__.py +1 -0
  65. riftbound_scraper/infrastructure/persistence/config/riftbound_database_bootstrap.py +50 -0
  66. riftbound_scraper/infrastructure/persistence/config/riftbound_database_config.py +37 -0
  67. riftbound_scraper/infrastructure/persistence/config/riftbound_database_context.py +51 -0
  68. riftbound_scraper/infrastructure/persistence/models/__init__.py +25 -0
  69. riftbound_scraper/infrastructure/persistence/models/asset_record.py +35 -0
  70. riftbound_scraper/infrastructure/persistence/models/card_artist_link_record.py +23 -0
  71. riftbound_scraper/infrastructure/persistence/models/card_artist_record.py +20 -0
  72. riftbound_scraper/infrastructure/persistence/models/card_domain_link_record.py +23 -0
  73. riftbound_scraper/infrastructure/persistence/models/card_domain_record.py +20 -0
  74. riftbound_scraper/infrastructure/persistence/models/card_rarity_record.py +20 -0
  75. riftbound_scraper/infrastructure/persistence/models/card_record.py +45 -0
  76. riftbound_scraper/infrastructure/persistence/models/card_set_record.py +20 -0
  77. riftbound_scraper/infrastructure/persistence/models/card_type_link_record.py +23 -0
  78. riftbound_scraper/infrastructure/persistence/models/card_type_record.py +23 -0
  79. riftbound_scraper/infrastructure/persistence/repositories/asset_record_repository.py +67 -0
  80. riftbound_scraper/infrastructure/persistence/repositories/sqlalchemy_card_persistence_gateway.py +320 -0
  81. riftbound_scraper/infrastructure/source/__init__.py +1 -0
  82. riftbound_scraper/infrastructure/source/gallery_blade_not_found_error.py +23 -0
  83. riftbound_scraper/infrastructure/source/gallery_source_config.py +33 -0
  84. riftbound_scraper/infrastructure/source/next_data_extractor.py +53 -0
  85. riftbound_scraper/infrastructure/source/next_data_not_found_error.py +21 -0
  86. riftbound_scraper/infrastructure/source/raw_gallery_payload_mapper.py +107 -0
  87. riftbound_scraper/infrastructure/source/riftbound_source_client.py +92 -0
  88. riftbound_scraper/infrastructure/source/source_fetch_error.py +15 -0
  89. riftbound_scraper/infrastructure/source/source_http_error.py +26 -0
  90. riftbound_scraper/infrastructure/source/source_json_error.py +18 -0
  91. riftbound_scraper-1.0.0.dist-info/METADATA +133 -0
  92. riftbound_scraper-1.0.0.dist-info/RECORD +94 -0
  93. riftbound_scraper-1.0.0.dist-info/WHEEL +4 -0
  94. riftbound_scraper-1.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,3 @@
1
+ """Package principal de l'application CLI Riftbound Scraper."""
2
+
3
+ __version__: str = "1.0.0"
@@ -0,0 +1 @@
1
+ """Couche application : cas d'usage et orchestration."""
@@ -0,0 +1 @@
1
+ """Cas d'usage de synchronisation des assets."""
@@ -0,0 +1,118 @@
1
+ """Synchronisation des assets liés à une carte normalisée."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from baobab_database.core.unit_of_work import UnitOfWork
6
+ from sqlalchemy.orm import Session
7
+
8
+ from riftbound_scraper.domain.assets.asset_download_gateway import AssetDownloadGateway
9
+ from riftbound_scraper.domain.assets.asset_download_result import AssetDownloadResult
10
+ from riftbound_scraper.domain.assets.asset_download_task import AssetDownloadTask
11
+ from riftbound_scraper.domain.assets.asset_kind import AssetKind
12
+ from riftbound_scraper.domain.card.asset_reference import AssetReference
13
+ from riftbound_scraper.domain.card.normalized_card import NormalizedCard
14
+ from riftbound_scraper.infrastructure.persistence.repositories import (
15
+ asset_record_repository,
16
+ )
17
+
18
+
19
+ class CardAssetSyncService:
20
+ """Orchestre le téléchargement des assets d'une carte persistée."""
21
+
22
+ def __init__(
23
+ self,
24
+ unit_of_work: UnitOfWork,
25
+ database_name: str,
26
+ download_gateway: AssetDownloadGateway,
27
+ asset_repository: asset_record_repository.AssetRecordRepository | None = None,
28
+ ) -> None:
29
+ self._unit_of_work = unit_of_work
30
+ self._database_name = database_name
31
+ self._download_gateway = download_gateway
32
+ self._asset_repository = (
33
+ asset_repository or asset_record_repository.AssetRecordRepository()
34
+ )
35
+
36
+ def sync_card_assets(
37
+ self,
38
+ card: NormalizedCard,
39
+ ) -> list[AssetDownloadResult]:
40
+ """Télécharge les assets liés à une carte normalisée.
41
+
42
+ :param card: Carte dont les références assets doivent être matérialisées.
43
+ :type card: NormalizedCard
44
+ :return: Résultats unitaires par asset traité.
45
+ :rtype: list[AssetDownloadResult]
46
+ """
47
+ with self._unit_of_work.begin(self._database_name) as session:
48
+ tasks = self._build_tasks(session, card)
49
+ return [
50
+ self._download_gateway.ensure_downloaded(session, task)
51
+ for task in tasks
52
+ ]
53
+
54
+ def _build_tasks(
55
+ self,
56
+ session: Session,
57
+ card: NormalizedCard,
58
+ ) -> list[AssetDownloadTask]:
59
+ tasks: list[AssetDownloadTask] = []
60
+ self._append_task(
61
+ tasks,
62
+ session,
63
+ card.card_image,
64
+ AssetKind.CARD_IMAGE,
65
+ card.source_id,
66
+ )
67
+ self._append_task(
68
+ tasks,
69
+ session,
70
+ card.rarity.icon,
71
+ AssetKind.RARITY_ICON,
72
+ card.rarity.rarity_id,
73
+ )
74
+ for domain in card.domains:
75
+ self._append_task(
76
+ tasks,
77
+ session,
78
+ domain.icon,
79
+ AssetKind.DOMAIN_ICON,
80
+ domain.domain_id,
81
+ )
82
+ for card_type in card.types:
83
+ self._append_task(
84
+ tasks,
85
+ session,
86
+ card_type.icon,
87
+ AssetKind.TYPE_ICON,
88
+ card_type.type_id,
89
+ )
90
+ for artist in card.artists:
91
+ self._append_task(
92
+ tasks,
93
+ session,
94
+ artist.icon,
95
+ AssetKind.ARTIST_ICON,
96
+ artist.artist_id,
97
+ )
98
+ return tasks
99
+
100
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
101
+ def _append_task(
102
+ self,
103
+ tasks: list[AssetDownloadTask],
104
+ session: Session,
105
+ asset: AssetReference | None,
106
+ kind: AssetKind,
107
+ entity_slug: str,
108
+ ) -> None:
109
+ asset_id = self._asset_repository.get_id_by_url(session, asset)
110
+ if asset_id is None:
111
+ return
112
+ tasks.append(
113
+ AssetDownloadTask(
114
+ asset_id=asset_id,
115
+ kind=kind,
116
+ entity_slug=entity_slug,
117
+ ),
118
+ )
@@ -0,0 +1 @@
1
+ """Cas d'usage de normalisation des données source."""
@@ -0,0 +1,38 @@
1
+ """Convertit le HTML de compétence en texte lisible multiligne."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+
8
+ from bs4 import BeautifulSoup, NavigableString, Tag
9
+
10
+
11
+ class AbilityTextNormalizer:
12
+ """Normalise le contenu HTML des compétences en texte avec retours ligne."""
13
+
14
+ def normalize(self, ability_html: str | None) -> str | None:
15
+ """Convertit le HTML source en texte lisible.
16
+
17
+ :param ability_html: HTML brut de compétence.
18
+ :type ability_html: str | None
19
+ :return: Texte normalisé ou ``None`` si absent.
20
+ :rtype: str | None
21
+ """
22
+ if ability_html is None or not ability_html.strip():
23
+ return None
24
+ soup = BeautifulSoup(ability_html, "html.parser")
25
+ self._replace_breaks_with_newlines(soup)
26
+ raw_text = soup.get_text()
27
+ decoded = html.unescape(raw_text)
28
+ lines = [line.strip() for line in decoded.splitlines()]
29
+ compact = "\n".join(line for line in lines if line)
30
+ normalized = re.sub(r"\n{3,}", "\n\n", compact)
31
+ return normalized or None
32
+
33
+ def _replace_breaks_with_newlines(self, root: Tag) -> None:
34
+ for break_tag in root.find_all("br"):
35
+ break_tag.replace_with(NavigableString("\n"))
36
+ for paragraph in root.find_all("p"):
37
+ if paragraph.contents:
38
+ paragraph.append(NavigableString("\n"))
@@ -0,0 +1,44 @@
1
+ """Détection des variantes de cartes depuis le code public."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from riftbound_scraper.domain.card.card_variant_kind import CardVariantKind
8
+
9
+
10
+ class CardVariantDetector:
11
+ """Détecte le type de variante à partir du ``public_code`` source."""
12
+
13
+ _VARIANT_PATTERN = re.compile(r"-(\d+)([a-z*]+)/", re.IGNORECASE)
14
+
15
+ def detect(self, public_code: str) -> tuple[CardVariantKind, str | None]:
16
+ """Analyse le code public et retourne le type de variante.
17
+
18
+ :param public_code: Code public source (ex. ``OGN-066a/298``).
19
+ :type public_code: str
20
+ :return: Type de variante et suffixe éventuel.
21
+ :rtype: tuple[CardVariantKind, str | None]
22
+ """
23
+ match = self._VARIANT_PATTERN.search(public_code)
24
+ if match is not None:
25
+ suffix = match.group(2)
26
+ if suffix == "*":
27
+ return CardVariantKind.SHOWCASE, "*"
28
+ return CardVariantKind.ALPHABETIC, suffix.lower()
29
+ if "*" in public_code:
30
+ return CardVariantKind.SHOWCASE, "*"
31
+ return CardVariantKind.STANDARD, None
32
+
33
+ def extract_collector_number_label(self, public_code: str) -> str:
34
+ """Extrait le numéro de collection affiché depuis le code public.
35
+
36
+ :param public_code: Code public source.
37
+ :type public_code: str
38
+ :return: Numéro affiché incluant variante (ex. ``066a``, ``227*``).
39
+ :rtype: str
40
+ """
41
+ match = re.search(r"-([^/]+)/", public_code)
42
+ if match is not None:
43
+ return match.group(1)
44
+ return public_code
@@ -0,0 +1,61 @@
1
+ """Normalise un snapshot galerie complet en sets et cartes métier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from riftbound_scraper.application.normalization.normalized_gallery_result import (
6
+ NormalizedGalleryResult,
7
+ )
8
+ from riftbound_scraper.application.normalization.raw_card_normalizer import (
9
+ RawCardNormalizer,
10
+ )
11
+ from riftbound_scraper.domain.card.card_set import CardSet
12
+ from riftbound_scraper.domain.dto.raw_card_dto import RawCardDto
13
+ from riftbound_scraper.domain.dto.raw_set_dto import RawSetDto
14
+ from riftbound_scraper.domain.source.raw_gallery_snapshot import RawGallerySnapshot
15
+
16
+
17
+ class GallerySnapshotNormalizer:
18
+ """Orchestre la normalisation d'un ``RawGallerySnapshot`` complet."""
19
+
20
+ def __init__(
21
+ self,
22
+ card_normalizer: RawCardNormalizer | None = None,
23
+ default_locale: str = "fr-fr",
24
+ ) -> None:
25
+ self._card_normalizer: RawCardNormalizer = (
26
+ card_normalizer or RawCardNormalizer()
27
+ )
28
+ self._default_locale: str = default_locale
29
+
30
+ def normalize(self, snapshot: RawGallerySnapshot) -> NormalizedGalleryResult:
31
+ """Normalise sets et cartes d'un snapshot source.
32
+
33
+ :param snapshot: Snapshot brut issu du client source.
34
+ :type snapshot: RawGallerySnapshot
35
+ :return: Résultat normalisé prêt pour la persistence.
36
+ :rtype: NormalizedGalleryResult
37
+ """
38
+ normalized_sets = [
39
+ self._normalize_set(RawSetDto(raw_set)) for raw_set in snapshot.raw_sets
40
+ ]
41
+ normalized_cards = [
42
+ self._card_normalizer.normalize(
43
+ RawCardDto(raw_card),
44
+ source_page_url=snapshot.source_url,
45
+ source_locale=self._default_locale,
46
+ )
47
+ for raw_card in snapshot.raw_cards
48
+ ]
49
+ return NormalizedGalleryResult(
50
+ sets=normalized_sets,
51
+ cards=normalized_cards,
52
+ source_url=snapshot.source_url,
53
+ source_locale=self._default_locale,
54
+ )
55
+
56
+ def _normalize_set(self, raw_set: RawSetDto) -> CardSet:
57
+ return CardSet(
58
+ set_id=raw_set.set_id,
59
+ name=raw_set.name,
60
+ collector_number_max=raw_set.collector_number_max,
61
+ )
@@ -0,0 +1,37 @@
1
+ """Résultat de normalisation d'un snapshot galerie complet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from riftbound_scraper.domain.card.card_set import CardSet
6
+ from riftbound_scraper.domain.card.normalized_card import NormalizedCard
7
+
8
+
9
+ class NormalizedGalleryResult:
10
+ """Ensemble des sets et cartes normalisés depuis un snapshot source.
11
+
12
+ :param sets: Sets normalisés.
13
+ :type sets: list[CardSet]
14
+ :param cards: Cartes normalisées.
15
+ :type cards: list[NormalizedCard]
16
+ :param source_url: URL source utilisée.
17
+ :type source_url: str
18
+ :param source_locale: Locale source déduite ou fournie.
19
+ :type source_locale: str | None
20
+ """
21
+
22
+ sets: list[CardSet]
23
+ cards: list[NormalizedCard]
24
+ source_url: str
25
+ source_locale: str | None
26
+
27
+ def __init__(
28
+ self,
29
+ sets: list[CardSet],
30
+ cards: list[NormalizedCard],
31
+ source_url: str,
32
+ source_locale: str | None = None,
33
+ ) -> None:
34
+ self.sets = sets
35
+ self.cards = cards
36
+ self.source_url = source_url
37
+ self.source_locale = source_locale
@@ -0,0 +1,217 @@
1
+ """Normalise une carte brute en modèle métier ``NormalizedCard``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from riftbound_scraper.application.normalization.ability_text_normalizer import (
8
+ AbilityTextNormalizer,
9
+ )
10
+ from riftbound_scraper.application.normalization.card_variant_detector import (
11
+ CardVariantDetector,
12
+ )
13
+ from riftbound_scraper.application.normalization.source_asset_mapper import (
14
+ SourceAssetMapper,
15
+ )
16
+ from riftbound_scraper.domain.card.card_artist import CardArtist
17
+ from riftbound_scraper.domain.card.card_audit_metadata import CardAuditMetadata
18
+ from riftbound_scraper.domain.card.card_domain import CardDomain
19
+ from riftbound_scraper.domain.card.card_rarity import CardRarity
20
+ from riftbound_scraper.domain.card.card_set import CardSet
21
+ from riftbound_scraper.domain.card.card_type_entry import CardTypeEntry
22
+ from riftbound_scraper.domain.card.normalized_card import NormalizedCard
23
+ from riftbound_scraper.domain.dto.raw_card_dto import RawCardDto
24
+
25
+
26
+ class RawCardNormalizer:
27
+ """Transforme un ``RawCardDto`` en ``NormalizedCard`` stable."""
28
+
29
+ def __init__(
30
+ self,
31
+ ability_text_normalizer: AbilityTextNormalizer | None = None,
32
+ variant_detector: CardVariantDetector | None = None,
33
+ asset_mapper: SourceAssetMapper | None = None,
34
+ ) -> None:
35
+ self._ability_text_normalizer: AbilityTextNormalizer = (
36
+ ability_text_normalizer or AbilityTextNormalizer()
37
+ )
38
+ self._variant_detector: CardVariantDetector = (
39
+ variant_detector or CardVariantDetector()
40
+ )
41
+ self._asset_mapper: SourceAssetMapper = asset_mapper or SourceAssetMapper()
42
+
43
+ def normalize(
44
+ self,
45
+ raw_card: RawCardDto,
46
+ source_page_url: str | None = None,
47
+ source_locale: str | None = None,
48
+ ) -> NormalizedCard:
49
+ """Normalise une carte source en modèle métier.
50
+
51
+ :param raw_card: DTO carte brute.
52
+ :type raw_card: RawCardDto
53
+ :param source_page_url: URL page galerie pour audit.
54
+ :type source_page_url: str | None
55
+ :param source_locale: Locale source pour audit.
56
+ :type source_locale: str | None
57
+ :return: Carte normalisée persistable.
58
+ :rtype: NormalizedCard
59
+ """
60
+ ability_html = raw_card.get_ability_html()
61
+ variant_kind, variant_suffix = self._variant_detector.detect(
62
+ raw_card.public_code
63
+ )
64
+ collector_label = self._variant_detector.extract_collector_number_label(
65
+ raw_card.public_code
66
+ )
67
+ audit = CardAuditMetadata(
68
+ raw_payload=raw_card.payload,
69
+ source_locale=source_locale,
70
+ source_page_url=source_page_url,
71
+ orientation=raw_card.orientation,
72
+ ability_html=ability_html,
73
+ )
74
+ return NormalizedCard(
75
+ source_id=raw_card.source_id,
76
+ public_code=raw_card.public_code,
77
+ name=raw_card.name,
78
+ collector_number_label=collector_label,
79
+ collector_number_base=raw_card.collector_number_base,
80
+ card_set=self._map_set(raw_card),
81
+ rarity=self._map_rarity(raw_card),
82
+ domains=self._map_domains(raw_card),
83
+ types=self._map_types(raw_card),
84
+ artists=self._map_artists(raw_card),
85
+ card_image=self._asset_mapper.map_optional(
86
+ raw_card.get_section("cardImage")
87
+ ),
88
+ energy=raw_card.get_int_stat("energy"),
89
+ runic_essence=raw_card.get_int_stat("power"),
90
+ might=raw_card.get_int_stat("might"),
91
+ ability_text=self._ability_text_normalizer.normalize(ability_html),
92
+ variant_kind=variant_kind,
93
+ variant_suffix=variant_suffix,
94
+ audit=audit,
95
+ )
96
+
97
+ def _map_set(self, raw_card: RawCardDto) -> CardSet:
98
+ set_section = raw_card.get_section("set")
99
+ if set_section is None:
100
+ return CardSet(set_id="", name="")
101
+ value = set_section.get("value")
102
+ if not isinstance(value, dict):
103
+ return CardSet(set_id="", name="")
104
+ set_id = str(value.get("id", ""))
105
+ name = str(value.get("label", ""))
106
+ return CardSet(set_id=set_id, name=name)
107
+
108
+ def _map_rarity(self, raw_card: RawCardDto) -> CardRarity:
109
+ rarity_section = raw_card.get_section("rarity")
110
+ if rarity_section is None:
111
+ return CardRarity(rarity_id="", label="")
112
+ value = rarity_section.get("value")
113
+ if not isinstance(value, dict):
114
+ return CardRarity(rarity_id="", label="")
115
+ icon_payload = value.get("icon")
116
+ icon = self._asset_mapper.map_optional(
117
+ cast(dict[str, Any], icon_payload)
118
+ if isinstance(icon_payload, dict)
119
+ else None
120
+ )
121
+ return CardRarity(
122
+ rarity_id=str(value.get("id", "")),
123
+ label=str(value.get("label", "")),
124
+ icon=icon,
125
+ )
126
+
127
+ def _map_domains(self, raw_card: RawCardDto) -> list[CardDomain]:
128
+ domain_section = raw_card.get_section("domain")
129
+ if domain_section is None:
130
+ return []
131
+ values = domain_section.get("values")
132
+ if not isinstance(values, list):
133
+ return []
134
+ domains: list[CardDomain] = []
135
+ for entry in values:
136
+ if not isinstance(entry, dict):
137
+ continue
138
+ icon_payload = entry.get("icon")
139
+ domains.append(
140
+ CardDomain(
141
+ domain_id=str(entry.get("id", "")),
142
+ label=str(entry.get("label", "")),
143
+ icon=self._asset_mapper.map_optional(
144
+ cast(dict[str, Any], icon_payload)
145
+ if isinstance(icon_payload, dict)
146
+ else None
147
+ ),
148
+ )
149
+ )
150
+ return domains
151
+
152
+ def _map_types(self, raw_card: RawCardDto) -> list[CardTypeEntry]:
153
+ card_type_section = raw_card.get_section("cardType")
154
+ if card_type_section is None:
155
+ return []
156
+ types: list[CardTypeEntry] = []
157
+ types.extend(
158
+ self._map_type_entries(card_type_section.get("type"), is_super_type=False)
159
+ )
160
+ types.extend(
161
+ self._map_type_entries(
162
+ card_type_section.get("superType"),
163
+ is_super_type=True,
164
+ )
165
+ )
166
+ return types
167
+
168
+ def _map_type_entries(
169
+ self,
170
+ entries: object,
171
+ is_super_type: bool,
172
+ ) -> list[CardTypeEntry]:
173
+ if not isinstance(entries, list):
174
+ return []
175
+ mapped: list[CardTypeEntry] = []
176
+ for entry in entries:
177
+ if not isinstance(entry, dict):
178
+ continue
179
+ icon_payload = entry.get("icon")
180
+ mapped.append(
181
+ CardTypeEntry(
182
+ type_id=str(entry.get("id", "")),
183
+ label=str(entry.get("label", "")),
184
+ icon=self._asset_mapper.map_optional(
185
+ cast(dict[str, Any], icon_payload)
186
+ if isinstance(icon_payload, dict)
187
+ else None
188
+ ),
189
+ is_super_type=is_super_type,
190
+ )
191
+ )
192
+ return mapped
193
+
194
+ def _map_artists(self, raw_card: RawCardDto) -> list[CardArtist]:
195
+ illustrator_section = raw_card.get_section("illustrator")
196
+ if illustrator_section is None:
197
+ return []
198
+ values = illustrator_section.get("values")
199
+ if not isinstance(values, list):
200
+ return []
201
+ artists: list[CardArtist] = []
202
+ for entry in values:
203
+ if not isinstance(entry, dict):
204
+ continue
205
+ icon_payload = entry.get("icon")
206
+ artists.append(
207
+ CardArtist(
208
+ artist_id=str(entry.get("id", "")),
209
+ name=str(entry.get("label", "")),
210
+ icon=self._asset_mapper.map_optional(
211
+ cast(dict[str, Any], icon_payload)
212
+ if isinstance(icon_payload, dict)
213
+ else None
214
+ ),
215
+ )
216
+ )
217
+ return artists
@@ -0,0 +1,36 @@
1
+ """Mappe les structures image/icône source vers ``AssetReference``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from riftbound_scraper.domain.card.asset_reference import AssetReference
8
+
9
+
10
+ class SourceAssetMapper:
11
+ """Convertit les objets image du payload source en références normalisées."""
12
+
13
+ def map_optional(self, payload: dict[str, Any] | None) -> AssetReference | None:
14
+ """Mappe un objet image source optionnel.
15
+
16
+ :param payload: Objet image source.
17
+ :type payload: dict[str, Any] | None
18
+ :return: Référence asset ou ``None``.
19
+ :rtype: AssetReference | None
20
+ """
21
+ if payload is None:
22
+ return None
23
+ url = payload.get("url")
24
+ if not isinstance(url, str) or not url:
25
+ return None
26
+ provider = payload.get("provider")
27
+ mime_type = payload.get("mimeType")
28
+ accessibility = payload.get("accessibilityText")
29
+ return AssetReference(
30
+ url=url,
31
+ provider=str(provider) if isinstance(provider, str) else None,
32
+ mime_type=str(mime_type) if isinstance(mime_type, str) else None,
33
+ accessibility_text=(
34
+ str(accessibility) if isinstance(accessibility, str) else None
35
+ ),
36
+ )
@@ -0,0 +1 @@
1
+ """Cas d'usage de synchronisation et vérification locale."""
@@ -0,0 +1,115 @@
1
+ """Orchestration source → normalisation → persistence → assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from riftbound_scraper.application.assets.card_asset_sync_service import (
6
+ CardAssetSyncService,
7
+ )
8
+ from riftbound_scraper.application.normalization.gallery_snapshot_normalizer import (
9
+ GallerySnapshotNormalizer,
10
+ )
11
+ from riftbound_scraper.application.sync.set_not_found_error import SetNotFoundError
12
+ from riftbound_scraper.application.sync.sync_execution_report import (
13
+ SyncExecutionReport,
14
+ )
15
+ from riftbound_scraper.domain.card.card_set import CardSet
16
+ from riftbound_scraper.domain.card.normalized_card import NormalizedCard
17
+ from riftbound_scraper.domain.persistence.card_persistence_gateway import (
18
+ CardPersistenceGateway,
19
+ )
20
+ from riftbound_scraper.domain.source.raw_gallery_snapshot import RawGallerySnapshot
21
+ from riftbound_scraper.infrastructure.source.riftbound_source_client import (
22
+ RiftboundSourceClient,
23
+ )
24
+
25
+
26
+ class GallerySyncService:
27
+ """Synchronise sets, cartes et assets depuis la galerie Riftbound."""
28
+
29
+ def __init__(
30
+ self,
31
+ source_client: RiftboundSourceClient,
32
+ snapshot_normalizer: GallerySnapshotNormalizer,
33
+ card_gateway: CardPersistenceGateway,
34
+ asset_sync_service: CardAssetSyncService,
35
+ ) -> None:
36
+ self._source_client = source_client
37
+ self._snapshot_normalizer = snapshot_normalizer
38
+ self._card_gateway = card_gateway
39
+ self._asset_sync_service = asset_sync_service
40
+
41
+ def sync_all(self) -> SyncExecutionReport:
42
+ """Synchronise tous les sets et cartes détectés.
43
+
44
+ :return: Rapport d'exécution détaillé.
45
+ :rtype: SyncExecutionReport
46
+ """
47
+ return self._sync(target_set_id=None)
48
+
49
+ def sync_set(self, set_id: str) -> SyncExecutionReport:
50
+ """Synchronise un set identifié.
51
+
52
+ :param set_id: Identifiant métier du set (ex. ``UNL``).
53
+ :type set_id: str
54
+ :return: Rapport d'exécution détaillé.
55
+ :rtype: SyncExecutionReport
56
+ :raises SetNotFoundError: Si le set est absent du snapshot source.
57
+ """
58
+ return self._sync(target_set_id=set_id)
59
+
60
+ def _sync(self, target_set_id: str | None) -> SyncExecutionReport:
61
+ snapshot = self._source_client.fetch_snapshot()
62
+ normalized = self._snapshot_normalizer.normalize(snapshot)
63
+ warnings = self._build_warnings(snapshot)
64
+ sets, cards = self._filter_targets(
65
+ normalized.sets,
66
+ normalized.cards,
67
+ target_set_id,
68
+ )
69
+ report = SyncExecutionReport(
70
+ target_set_id=target_set_id,
71
+ warnings=warnings,
72
+ )
73
+ for card_set in sets:
74
+ self._card_gateway.upsert_set(card_set)
75
+ report.sets_processed += 1
76
+ for card in cards:
77
+ persisted = self._card_gateway.upsert_card(card)
78
+ report.cards_processed += 1
79
+ if persisted.created:
80
+ report.cards_created += 1
81
+ else:
82
+ report.cards_updated += 1
83
+ asset_results = self._asset_sync_service.sync_card_assets(card)
84
+ for asset_result in asset_results:
85
+ if asset_result.error_message:
86
+ report.assets_failed += 1
87
+ elif asset_result.downloaded:
88
+ report.assets_downloaded += 1
89
+ elif asset_result.skipped:
90
+ report.assets_skipped += 1
91
+ return report
92
+
93
+ def _filter_targets(
94
+ self,
95
+ sets: list[CardSet],
96
+ cards: list[NormalizedCard],
97
+ target_set_id: str | None,
98
+ ) -> tuple[list[CardSet], list[NormalizedCard]]:
99
+ if target_set_id is None:
100
+ return sets, cards
101
+ filtered_sets = [
102
+ card_set for card_set in sets if card_set.set_id == target_set_id
103
+ ]
104
+ if not filtered_sets:
105
+ raise SetNotFoundError(target_set_id)
106
+ filtered_cards = [
107
+ card for card in cards if card.card_set.set_id == target_set_id
108
+ ]
109
+ return filtered_sets, filtered_cards
110
+
111
+ def _build_warnings(self, snapshot: RawGallerySnapshot) -> list[str]:
112
+ mismatch = snapshot.cards_count_mismatch
113
+ if mismatch is None or not mismatch.has_mismatch:
114
+ return []
115
+ return [str(mismatch)]