magiastream 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.
- magia_stream/__init__.py +3 -0
- magia_stream/browser.py +199 -0
- magia_stream/cache.py +150 -0
- magia_stream/cli.py +688 -0
- magia_stream/config.py +138 -0
- magia_stream/downloader.py +426 -0
- magia_stream/exceptions.py +19 -0
- magia_stream/models.py +41 -0
- magia_stream/scraper.py +1190 -0
- magia_stream/utils.py +151 -0
- magiastream-1.0.0.dist-info/METADATA +138 -0
- magiastream-1.0.0.dist-info/RECORD +15 -0
- magiastream-1.0.0.dist-info/WHEEL +4 -0
- magiastream-1.0.0.dist-info/entry_points.txt +2 -0
- magiastream-1.0.0.dist-info/licenses/LICENSE +21 -0
magia_stream/__init__.py
ADDED
magia_stream/browser.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Gestion centralisée de Playwright pour les opérations de scraping.
|
|
2
|
+
|
|
3
|
+
Le module expose un `BrowserManager` synchrone qui encapsule :
|
|
4
|
+
- l'initialisation de Playwright,
|
|
5
|
+
- le lancement de Chromium,
|
|
6
|
+
- la création d'un `BrowserContext` standardisé,
|
|
7
|
+
- le nettoyage complet des ressources.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Iterator, Optional
|
|
16
|
+
|
|
17
|
+
try: # pragma: no cover - depends on environment
|
|
18
|
+
from playwright.sync_api import (
|
|
19
|
+
Browser,
|
|
20
|
+
BrowserContext,
|
|
21
|
+
Page,
|
|
22
|
+
Playwright,
|
|
23
|
+
sync_playwright,
|
|
24
|
+
)
|
|
25
|
+
from playwright.sync_api import (
|
|
26
|
+
Error as PlaywrightError,
|
|
27
|
+
)
|
|
28
|
+
except Exception: # pragma: no cover - fallback when Playwright is unavailable
|
|
29
|
+
sync_playwright = None # type: ignore
|
|
30
|
+
Browser = BrowserContext = Page = Playwright = object # type: ignore
|
|
31
|
+
PlaywrightError = Exception # type: ignore
|
|
32
|
+
|
|
33
|
+
try: # pragma: no cover - optional dependency
|
|
34
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
35
|
+
except Exception: # pragma: no cover - fallback without tenacity
|
|
36
|
+
|
|
37
|
+
def retry(*_args: Any, **_kwargs: Any): # type: ignore
|
|
38
|
+
def decorator(fn):
|
|
39
|
+
return fn
|
|
40
|
+
|
|
41
|
+
return decorator
|
|
42
|
+
|
|
43
|
+
def stop_after_attempt(*_args: Any, **_kwargs: Any): # type: ignore
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def wait_exponential(*_args: Any, **_kwargs: Any): # type: ignore
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def retry_if_exception_type(*_args: Any, **_kwargs: Any): # type: ignore
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
DEFAULT_USER_AGENT = (
|
|
57
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
58
|
+
)
|
|
59
|
+
DEFAULT_VIEWPORT = {"width": 1920, "height": 1080}
|
|
60
|
+
DEFAULT_LOCALE = "fr-FR"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BrowserManager:
|
|
65
|
+
"""Cycle de vie Playwright/Chromium avec un contexte standardisé.
|
|
66
|
+
|
|
67
|
+
La classe peut être utilisée comme context manager :
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
with BrowserManager() as browser_manager:
|
|
71
|
+
page = browser_manager.get_page()
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
headless: bool = False
|
|
76
|
+
user_agent: str = DEFAULT_USER_AGENT
|
|
77
|
+
viewport: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_VIEWPORT))
|
|
78
|
+
locale: str = DEFAULT_LOCALE
|
|
79
|
+
timezone_id: str = "Europe/Paris"
|
|
80
|
+
extra_http_headers: dict[str, str] = field(default_factory=lambda: {"accept-language": DEFAULT_LOCALE})
|
|
81
|
+
|
|
82
|
+
playwright: Optional[Playwright] = field(init=False, default=None)
|
|
83
|
+
browser: Optional[Browser] = field(init=False, default=None)
|
|
84
|
+
context: Optional[BrowserContext] = field(init=False, default=None)
|
|
85
|
+
_playwright_ctx: Optional[Any] = field(init=False, default=None, repr=False)
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> BrowserManager:
|
|
88
|
+
self.start()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
92
|
+
self.stop()
|
|
93
|
+
|
|
94
|
+
def start(self) -> BrowserManager:
|
|
95
|
+
"""Démarre Playwright, le navigateur Chromium et le contexte par défaut."""
|
|
96
|
+
|
|
97
|
+
if self.context is not None:
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
if sync_playwright is None:
|
|
101
|
+
raise RuntimeError("Playwright n'est pas installé dans cet environnement")
|
|
102
|
+
|
|
103
|
+
self._playwright_ctx = sync_playwright()
|
|
104
|
+
self.playwright = self._playwright_ctx.__enter__()
|
|
105
|
+
self.browser = self.playwright.chromium.launch(headless=self.headless)
|
|
106
|
+
self.context = self._create_context()
|
|
107
|
+
logger.debug("Playwright démarré (headless=%s)", self.headless)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def stop(self) -> None:
|
|
111
|
+
"""Ferme le contexte, le navigateur et l'instance Playwright."""
|
|
112
|
+
|
|
113
|
+
context = self.context
|
|
114
|
+
browser = self.browser
|
|
115
|
+
playwright_ctx = self._playwright_ctx
|
|
116
|
+
|
|
117
|
+
self.context = None
|
|
118
|
+
self.browser = None
|
|
119
|
+
self.playwright = None
|
|
120
|
+
self._playwright_ctx = None
|
|
121
|
+
|
|
122
|
+
if context is not None:
|
|
123
|
+
try:
|
|
124
|
+
context.close()
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.debug("Erreur lors de la fermeture du BrowserContext", exc_info=True)
|
|
127
|
+
|
|
128
|
+
if browser is not None:
|
|
129
|
+
try:
|
|
130
|
+
browser.close()
|
|
131
|
+
except Exception:
|
|
132
|
+
logger.debug("Erreur lors de la fermeture du navigateur", exc_info=True)
|
|
133
|
+
|
|
134
|
+
if playwright_ctx is not None:
|
|
135
|
+
try:
|
|
136
|
+
playwright_ctx.__exit__(None, None, None)
|
|
137
|
+
except Exception:
|
|
138
|
+
logger.debug("Erreur lors de l'arrêt de Playwright", exc_info=True)
|
|
139
|
+
|
|
140
|
+
def _create_context(self, **overrides: Any) -> BrowserContext:
|
|
141
|
+
if self.browser is None:
|
|
142
|
+
raise RuntimeError("Le navigateur n'est pas démarré")
|
|
143
|
+
|
|
144
|
+
context_kwargs: dict[str, Any] = {
|
|
145
|
+
"user_agent": overrides.pop("user_agent", self.user_agent),
|
|
146
|
+
"viewport": overrides.pop("viewport", dict(self.viewport)),
|
|
147
|
+
"locale": overrides.pop("locale", self.locale),
|
|
148
|
+
"timezone_id": overrides.pop("timezone_id", self.timezone_id),
|
|
149
|
+
"extra_http_headers": overrides.pop("extra_http_headers", dict(self.extra_http_headers)),
|
|
150
|
+
}
|
|
151
|
+
context_kwargs.update(overrides)
|
|
152
|
+
|
|
153
|
+
context = self.browser.new_context(**context_kwargs)
|
|
154
|
+
context.add_init_script(
|
|
155
|
+
"""
|
|
156
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
157
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['fr-FR', 'fr'] });
|
|
158
|
+
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
|
159
|
+
"""
|
|
160
|
+
)
|
|
161
|
+
return context
|
|
162
|
+
|
|
163
|
+
def new_context(self, **overrides: Any) -> BrowserContext:
|
|
164
|
+
"""Crée un nouveau contexte Playwright avec les paramètres standards."""
|
|
165
|
+
|
|
166
|
+
return self._create_context(**overrides)
|
|
167
|
+
|
|
168
|
+
def get_page(self) -> Page:
|
|
169
|
+
"""Retourne une nouvelle page issue du contexte par défaut."""
|
|
170
|
+
|
|
171
|
+
if self.context is None:
|
|
172
|
+
raise RuntimeError("Le BrowserManager doit être démarré avant get_page()")
|
|
173
|
+
return self.context.new_page()
|
|
174
|
+
|
|
175
|
+
@retry(
|
|
176
|
+
stop=stop_after_attempt(3),
|
|
177
|
+
wait=wait_exponential(multiplier=1, min=1, max=8),
|
|
178
|
+
retry=retry_if_exception_type(PlaywrightError),
|
|
179
|
+
reraise=True,
|
|
180
|
+
)
|
|
181
|
+
def goto_with_retry(self, page: Page, url: str, timeout: int = 30_000) -> None:
|
|
182
|
+
"""Navigue vers une URL en appliquant un retry exponentiel sur les erreurs Playwright."""
|
|
183
|
+
|
|
184
|
+
logger.debug("Navigation vers %s (timeout=%s)", url, timeout)
|
|
185
|
+
page.goto(url, timeout=timeout)
|
|
186
|
+
|
|
187
|
+
def close(self) -> None:
|
|
188
|
+
"""Alias de compatibilité pour `stop()`."""
|
|
189
|
+
|
|
190
|
+
self.stop()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@contextmanager
|
|
194
|
+
def managed_browser() -> Iterator[BrowserManager]:
|
|
195
|
+
"""Context manager de compatibilité pour le code existant."""
|
|
196
|
+
|
|
197
|
+
manager = BrowserManager()
|
|
198
|
+
with manager:
|
|
199
|
+
yield manager
|
magia_stream/cache.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Cache JSON local léger avec TTL.
|
|
2
|
+
|
|
3
|
+
Ce module est volontairement indépendant du reste de la base de code. Il fournit
|
|
4
|
+
une couche de cache simple basée sur un fichier JSON, avec écriture atomique et
|
|
5
|
+
nettoyage des entrées expirées.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import fcntl
|
|
20
|
+
except Exception: # pragma: no cover - Windows / environnements sans fcntl
|
|
21
|
+
fcntl = None # type: ignore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CacheManager:
|
|
25
|
+
"""Gestionnaire de cache JSON local avec TTL.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
cache_file: Chemin du fichier JSON. Par défaut, `.magia_cache.json` dans
|
|
29
|
+
le répertoire courant.
|
|
30
|
+
default_ttl_seconds: TTL utilisé si `set()` ne reçoit pas de TTL explicite.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
cache_file: str | Path | None = None,
|
|
36
|
+
default_ttl_seconds: int = 24 * 60 * 60,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.cache_file = Path(cache_file) if cache_file is not None else Path.cwd() / ".magia_cache.json"
|
|
39
|
+
self.default_ttl_seconds = int(default_ttl_seconds)
|
|
40
|
+
self._lock = threading.RLock()
|
|
41
|
+
|
|
42
|
+
def get(self, key: str) -> Any | None:
|
|
43
|
+
"""Récupère la valeur associée à `key` si elle existe et n'est pas expirée."""
|
|
44
|
+
|
|
45
|
+
with self._lock:
|
|
46
|
+
data = self._read_cache()
|
|
47
|
+
entry = data.get(key)
|
|
48
|
+
if not isinstance(entry, dict):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
expires_at = entry.get("expires_at")
|
|
52
|
+
if not isinstance(expires_at, (int, float)):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
if expires_at <= time.time():
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
return entry.get("value")
|
|
59
|
+
|
|
60
|
+
def set(self, key: str, value: Any, ttl_seconds: int = 86400) -> None:
|
|
61
|
+
"""Stocke `value` sous `key` avec une date d'expiration."""
|
|
62
|
+
|
|
63
|
+
ttl = int(ttl_seconds) if ttl_seconds is not None else self.default_ttl_seconds
|
|
64
|
+
expires_at = time.time() + max(0, ttl)
|
|
65
|
+
|
|
66
|
+
with self._lock:
|
|
67
|
+
data = self._read_cache()
|
|
68
|
+
data[key] = {
|
|
69
|
+
"value": value,
|
|
70
|
+
"expires_at": expires_at,
|
|
71
|
+
"created_at": time.time(),
|
|
72
|
+
}
|
|
73
|
+
self._write_cache(data)
|
|
74
|
+
|
|
75
|
+
def clear_expired(self) -> None:
|
|
76
|
+
"""Supprime du fichier toutes les entrées périmées."""
|
|
77
|
+
|
|
78
|
+
with self._lock:
|
|
79
|
+
data = self._read_cache()
|
|
80
|
+
now = time.time()
|
|
81
|
+
filtered = {
|
|
82
|
+
key: entry
|
|
83
|
+
for key, entry in data.items()
|
|
84
|
+
if isinstance(entry, dict)
|
|
85
|
+
and isinstance(entry.get("expires_at"), (int, float))
|
|
86
|
+
and entry["expires_at"] > now
|
|
87
|
+
}
|
|
88
|
+
self._write_cache(filtered)
|
|
89
|
+
|
|
90
|
+
def _read_cache(self) -> dict[str, Any]:
|
|
91
|
+
try:
|
|
92
|
+
if not self.cache_file.exists():
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
with self.cache_file.open("r", encoding="utf-8") as handle:
|
|
96
|
+
self._lock_file(handle)
|
|
97
|
+
try:
|
|
98
|
+
payload = json.load(handle)
|
|
99
|
+
finally:
|
|
100
|
+
self._unlock_file(handle)
|
|
101
|
+
|
|
102
|
+
if isinstance(payload, dict):
|
|
103
|
+
return payload
|
|
104
|
+
except Exception:
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def _write_cache(self, data: dict[str, Any]) -> None:
|
|
110
|
+
try:
|
|
111
|
+
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
fd, tmp_path = tempfile.mkstemp(prefix=f".{self.cache_file.name}.", dir=str(self.cache_file.parent))
|
|
113
|
+
try:
|
|
114
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
115
|
+
self._lock_file(handle)
|
|
116
|
+
try:
|
|
117
|
+
json.dump(data, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
118
|
+
handle.flush()
|
|
119
|
+
os.fsync(handle.fileno())
|
|
120
|
+
finally:
|
|
121
|
+
self._unlock_file(handle)
|
|
122
|
+
os.replace(tmp_path, self.cache_file)
|
|
123
|
+
finally:
|
|
124
|
+
try:
|
|
125
|
+
if os.path.exists(tmp_path):
|
|
126
|
+
os.unlink(tmp_path)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
except Exception:
|
|
130
|
+
# Les erreurs d'écriture restent silencieuses pour ne pas casser le flux principal.
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
def _lock_file(self, handle: Any) -> None:
|
|
134
|
+
if fcntl is None:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
138
|
+
except Exception:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def _unlock_file(self, handle: Any) -> None:
|
|
142
|
+
if fcntl is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
146
|
+
except Exception:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["CacheManager"]
|