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.
@@ -0,0 +1,3 @@
1
+ """MagiaStream package principal."""
2
+
3
+ __version__ = "0.1.0"
@@ -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"]