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.
- riftbound_scraper/__init__.py +3 -0
- riftbound_scraper/application/__init__.py +1 -0
- riftbound_scraper/application/assets/__init__.py +1 -0
- riftbound_scraper/application/assets/card_asset_sync_service.py +118 -0
- riftbound_scraper/application/normalization/__init__.py +1 -0
- riftbound_scraper/application/normalization/ability_text_normalizer.py +38 -0
- riftbound_scraper/application/normalization/card_variant_detector.py +44 -0
- riftbound_scraper/application/normalization/gallery_snapshot_normalizer.py +61 -0
- riftbound_scraper/application/normalization/normalized_gallery_result.py +37 -0
- riftbound_scraper/application/normalization/raw_card_normalizer.py +217 -0
- riftbound_scraper/application/normalization/source_asset_mapper.py +36 -0
- riftbound_scraper/application/sync/__init__.py +1 -0
- riftbound_scraper/application/sync/gallery_sync_service.py +115 -0
- riftbound_scraper/application/sync/local_stats_service.py +64 -0
- riftbound_scraper/application/sync/local_stats_snapshot.py +46 -0
- riftbound_scraper/application/sync/local_verification_service.py +111 -0
- riftbound_scraper/application/sync/set_catalog_service.py +33 -0
- riftbound_scraper/application/sync/set_not_found_error.py +17 -0
- riftbound_scraper/application/sync/sync_execution_report.py +75 -0
- riftbound_scraper/application/sync/verification_issue.py +24 -0
- riftbound_scraper/application/sync/verification_report.py +27 -0
- riftbound_scraper/cli/__init__.py +1 -0
- riftbound_scraper/cli/cli_dependency_factory.py +162 -0
- riftbound_scraper/cli/cli_exit_code.py +11 -0
- riftbound_scraper/cli/cli_output_formatter.py +98 -0
- riftbound_scraper/cli/riftbound_cli_app.py +150 -0
- riftbound_scraper/domain/__init__.py +1 -0
- riftbound_scraper/domain/assets/__init__.py +1 -0
- riftbound_scraper/domain/assets/asset_download_gateway.py +29 -0
- riftbound_scraper/domain/assets/asset_download_result.py +60 -0
- riftbound_scraper/domain/assets/asset_download_status.py +11 -0
- riftbound_scraper/domain/assets/asset_download_task.py +31 -0
- riftbound_scraper/domain/assets/asset_kind.py +15 -0
- riftbound_scraper/domain/card/__init__.py +1 -0
- riftbound_scraper/domain/card/asset_reference.py +34 -0
- riftbound_scraper/domain/card/card_artist.py +31 -0
- riftbound_scraper/domain/card/card_audit_metadata.py +42 -0
- riftbound_scraper/domain/card/card_domain.py +31 -0
- riftbound_scraper/domain/card/card_rarity.py +31 -0
- riftbound_scraper/domain/card/card_set.py +29 -0
- riftbound_scraper/domain/card/card_type_entry.py +36 -0
- riftbound_scraper/domain/card/card_variant_kind.py +18 -0
- riftbound_scraper/domain/card/normalized_card.py +126 -0
- riftbound_scraper/domain/dto/__init__.py +1 -0
- riftbound_scraper/domain/dto/raw_card_dto.py +117 -0
- riftbound_scraper/domain/dto/raw_set_dto.py +46 -0
- riftbound_scraper/domain/persistence/__init__.py +1 -0
- riftbound_scraper/domain/persistence/card_persistence_gateway.py +31 -0
- riftbound_scraper/domain/persistence/persisted_card_result.py +34 -0
- riftbound_scraper/domain/source/__init__.py +1 -0
- riftbound_scraper/domain/source/cards_count_mismatch.py +45 -0
- riftbound_scraper/domain/source/raw_gallery_metadata.py +31 -0
- riftbound_scraper/domain/source/raw_gallery_snapshot.py +66 -0
- riftbound_scraper/infrastructure/__init__.py +1 -0
- riftbound_scraper/infrastructure/assets/__init__.py +1 -0
- riftbound_scraper/infrastructure/assets/asset_content_hasher.py +19 -0
- riftbound_scraper/infrastructure/assets/asset_download_error.py +30 -0
- riftbound_scraper/infrastructure/assets/asset_extension_resolver.py +44 -0
- riftbound_scraper/infrastructure/assets/asset_http_downloader.py +63 -0
- riftbound_scraper/infrastructure/assets/asset_path_builder.py +47 -0
- riftbound_scraper/infrastructure/assets/asset_record_download_repository.py +100 -0
- riftbound_scraper/infrastructure/assets/asset_storage_config.py +30 -0
- riftbound_scraper/infrastructure/assets/local_asset_download_gateway.py +172 -0
- riftbound_scraper/infrastructure/persistence/__init__.py +1 -0
- riftbound_scraper/infrastructure/persistence/config/riftbound_database_bootstrap.py +50 -0
- riftbound_scraper/infrastructure/persistence/config/riftbound_database_config.py +37 -0
- riftbound_scraper/infrastructure/persistence/config/riftbound_database_context.py +51 -0
- riftbound_scraper/infrastructure/persistence/models/__init__.py +25 -0
- riftbound_scraper/infrastructure/persistence/models/asset_record.py +35 -0
- riftbound_scraper/infrastructure/persistence/models/card_artist_link_record.py +23 -0
- riftbound_scraper/infrastructure/persistence/models/card_artist_record.py +20 -0
- riftbound_scraper/infrastructure/persistence/models/card_domain_link_record.py +23 -0
- riftbound_scraper/infrastructure/persistence/models/card_domain_record.py +20 -0
- riftbound_scraper/infrastructure/persistence/models/card_rarity_record.py +20 -0
- riftbound_scraper/infrastructure/persistence/models/card_record.py +45 -0
- riftbound_scraper/infrastructure/persistence/models/card_set_record.py +20 -0
- riftbound_scraper/infrastructure/persistence/models/card_type_link_record.py +23 -0
- riftbound_scraper/infrastructure/persistence/models/card_type_record.py +23 -0
- riftbound_scraper/infrastructure/persistence/repositories/asset_record_repository.py +67 -0
- riftbound_scraper/infrastructure/persistence/repositories/sqlalchemy_card_persistence_gateway.py +320 -0
- riftbound_scraper/infrastructure/source/__init__.py +1 -0
- riftbound_scraper/infrastructure/source/gallery_blade_not_found_error.py +23 -0
- riftbound_scraper/infrastructure/source/gallery_source_config.py +33 -0
- riftbound_scraper/infrastructure/source/next_data_extractor.py +53 -0
- riftbound_scraper/infrastructure/source/next_data_not_found_error.py +21 -0
- riftbound_scraper/infrastructure/source/raw_gallery_payload_mapper.py +107 -0
- riftbound_scraper/infrastructure/source/riftbound_source_client.py +92 -0
- riftbound_scraper/infrastructure/source/source_fetch_error.py +15 -0
- riftbound_scraper/infrastructure/source/source_http_error.py +26 -0
- riftbound_scraper/infrastructure/source/source_json_error.py +18 -0
- riftbound_scraper-1.0.0.dist-info/METADATA +133 -0
- riftbound_scraper-1.0.0.dist-info/RECORD +94 -0
- riftbound_scraper-1.0.0.dist-info/WHEEL +4 -0
- riftbound_scraper-1.0.0.dist-info/entry_points.txt +2 -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)]
|