o-browser-vivatech 0.1.0__tar.gz

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,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .eggs/
5
+ venv/
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: o-browser-vivatech
3
+ Version: 0.1.0
4
+ Summary: VivaTech exhibitors directory adapter for o-browser
5
+ Author: Alexis Laporte
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: o-browser>=0.3.0
@@ -0,0 +1,228 @@
1
+ """
2
+ VivaTechClient — scraper de l'annuaire exposants VivaTech (vivatech.com).
3
+
4
+ Le site est un Next.js App Router (données inwink). Il n'expose pas d'API REST :
5
+ - la **liste** (scroll infini) passe par une Server Action `POST /exhibitors?page=N`
6
+ (header `next-action`), réponse RSC dont la ligne `1:{"data":[...]}` porte 20 exposants ;
7
+ - la **fiche** n'a pas d'endpoint JSON : l'objet complet (49 champs) est SSR dans le HTML
8
+ du document `/exhibitors/<slug>`, dans le payload flight Next.
9
+
10
+ Deux contraintes dictent la méthode :
11
+ 1. Anti-bot **GTAB** : un Chrome headless reçoit 403. Il faut `headless=False` (défaut ici)
12
+ + idéalement un profil persistant. Une 403 lève une erreur explicite.
13
+ 2. Le `next-action` est **lié au build** (change à chaque redeploy). On le **capture
14
+ dynamiquement** (un scroll déclenche un POST qu'on intercepte) plutôt que de le coder en dur.
15
+
16
+ Les requêtes sont rejouées **depuis l'intérieur de la page** (`page.evaluate(fetch(...))`)
17
+ pour hériter des cookies + fingerprint et passer GTAB.
18
+
19
+ Adapter o-browser (distribution `o-browser-vivatech`, entry-point `o_browser.sites:vivatech`).
20
+
21
+ Exemple :
22
+
23
+ from o_browser import load_site
24
+ VivaTechClient = load_site("vivatech")
25
+ async with VivaTechClient(profile_path="~/.config/browser/vivatech") as vt:
26
+ exhibitors = await vt.scrape(enrich=True, progress=print)
27
+ """
28
+
29
+ import json
30
+ from typing import Callable, Dict, List, Optional
31
+
32
+ from o_browser import BrowserClient
33
+
34
+ BASE = "https://vivatech.com"
35
+
36
+ # Boucle la Server Action page par page jusqu'à épuisement ; renvoie toutes les lignes.
37
+ _LIST_JS = r"""
38
+ async (cfg) => {
39
+ const {action, tree, filters, maxPages} = cfg;
40
+ const all = [];
41
+ let page = 1;
42
+ for (; page <= maxPages; page++) {
43
+ const res = await fetch('/exhibitors?page=' + page, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'accept': 'text/x-component',
47
+ 'content-type': 'text/plain;charset=UTF-8',
48
+ 'next-action': action,
49
+ 'next-router-state-tree': tree,
50
+ },
51
+ body: JSON.stringify([Object.assign(
52
+ {page: page, search: '$undefined', sectors: '$undefined', company_type: '$undefined',
53
+ fundraising: '$undefined', tags: '$undefined', label: '$undefined'}, filters || {}, {page: page})]),
54
+ });
55
+ if (res.status !== 200) return {error: 'status ' + res.status + ' @ page ' + page, all};
56
+ const text = await res.text();
57
+ let rows = null;
58
+ for (const line of text.split('\n')) {
59
+ const m = line.match(/^[0-9a-f]+:(.*)$/);
60
+ if (!m) continue;
61
+ try { const o = JSON.parse(m[1]); if (o && Array.isArray(o.data)) { rows = o.data; break; } } catch (e) {}
62
+ }
63
+ if (!rows || rows.length === 0) break;
64
+ all.push(...rows);
65
+ await new Promise(r => setTimeout(r, 120));
66
+ }
67
+ return {all, lastPage: page};
68
+ }
69
+ """
70
+
71
+ # Fetch in-page d'un lot de fiches ; renvoie l'objet exposant échappé (slice du flight HTML).
72
+ _DETAIL_JS = r"""
73
+ async (cfg) => {
74
+ const {slugs, delay} = cfg;
75
+ const out = [];
76
+ for (const slug of slugs) {
77
+ let rec = null, status = 0, err = null;
78
+ try {
79
+ const res = await fetch('/exhibitors/' + slug, {headers: {'accept': 'text/html'}});
80
+ status = res.status;
81
+ if (status === 200) {
82
+ const html = await res.text();
83
+ const marker = html.indexOf(',\\"isPreview\\"');
84
+ if (marker > 0) {
85
+ let i = marker - 1;
86
+ while (i > 0 && html[i] !== '}') i--;
87
+ let depth = 0, start = -1;
88
+ for (let j = i; j >= 0; j--) {
89
+ const c = html[j];
90
+ if (c === '}') depth++;
91
+ else if (c === '{') { depth--; if (depth === 0) { start = j; break; } }
92
+ }
93
+ if (start >= 0) rec = html.slice(start, i + 1);
94
+ }
95
+ }
96
+ } catch (e) { err = e.message; }
97
+ out.push({slug, status, rec, err});
98
+ if (delay) await new Promise(r => setTimeout(r, delay));
99
+ }
100
+ return out;
101
+ }
102
+ """
103
+
104
+
105
+ def _unescape_object(escaped: str) -> dict:
106
+ """Le flight encode l'objet en chaîne JS (quotes en \\"). Double json.loads → dict (UTF-8 sûr)."""
107
+ return json.loads(json.loads('"' + escaped + '"'))
108
+
109
+
110
+ class VivaTechClient(BrowserClient):
111
+ """Client de scraping de l'annuaire exposants VivaTech, par-dessus BrowserClient."""
112
+
113
+ def __init__(self, *args, **kwargs):
114
+ # L'anti-bot GTAB bloque le headless : on impose le mode visible par défaut.
115
+ kwargs.setdefault("headless", False)
116
+ super().__init__(*args, **kwargs)
117
+
118
+ async def open_directory(self) -> None:
119
+ """Charge l'annuaire et lève si l'anti-bot répond 403."""
120
+ await self.goto(f"{BASE}/exhibitors", timeout=45000)
121
+ await self.wait(3)
122
+ text = await self.get_text()
123
+ if "FORBIDDEN" in text.upper() and "403" in text:
124
+ raise RuntimeError(
125
+ "VivaTech anti-bot (GTAB) a renvoyé 403. Utiliser headless=False "
126
+ "avec un profil persistant (idéalement authentifié)."
127
+ )
128
+
129
+ async def _capture_action(self) -> tuple[str, str]:
130
+ """Capture le `next-action` + state-tree en déclenchant un POST de pagination via scroll."""
131
+ box: Dict[str, str] = {}
132
+
133
+ async def grab(request):
134
+ if box:
135
+ return
136
+ try:
137
+ if request.method != "POST" or "/exhibitors" not in request.url:
138
+ return
139
+ headers = await request.all_headers()
140
+ if "next-action" in headers:
141
+ box["action"] = headers["next-action"]
142
+ box["tree"] = headers.get("next-router-state-tree", "")
143
+ except Exception:
144
+ pass
145
+
146
+ self.page.on("request", grab)
147
+ try:
148
+ for _ in range(8):
149
+ await self.evaluate("window.scrollTo(0, document.body.scrollHeight)")
150
+ await self.wait(1.5)
151
+ if box:
152
+ break
153
+ finally:
154
+ self.page.remove_listener("request", grab)
155
+
156
+ if "action" not in box:
157
+ raise RuntimeError(
158
+ "Impossible de capturer la Server Action VivaTech (structure de page changée ?)."
159
+ )
160
+ return box["action"], box["tree"]
161
+
162
+ async def list_exhibitors(
163
+ self, filters: Optional[Dict[str, str]] = None, max_pages: int = 500
164
+ ) -> List[dict]:
165
+ """Liste complète des exposants (dédupliquée par id) via la Server Action paginée.
166
+
167
+ `filters` : surcharge les filtres de l'action (`search`, `sectors`, `company_type`,
168
+ `fundraising`, `tags`, `label`). Par défaut tous à `$undefined` = annuaire complet.
169
+ """
170
+ await self.open_directory()
171
+ action, tree = await self._capture_action()
172
+ result = await self.page.evaluate(
173
+ _LIST_JS, {"action": action, "tree": tree, "filters": filters or {}, "maxPages": max_pages}
174
+ )
175
+ if result.get("error"):
176
+ raise RuntimeError(f"Pagination VivaTech échouée : {result['error']}")
177
+ seen: Dict[str, dict] = {}
178
+ for row in result.get("all", []):
179
+ seen[row.get("id")] = row
180
+ return list(seen.values())
181
+
182
+ async def get_exhibitor(self, slug: str) -> Optional[dict]:
183
+ """Objet complet (49 champs) d'un exposant, ou None si la fiche n'a pas l'objet standard.
184
+
185
+ Le fetch in-page utilise une URL relative : on s'assure d'être sur le domaine VivaTech.
186
+ """
187
+ if not (self.page.url or "").startswith(BASE):
188
+ await self.open_directory()
189
+ recs = await self.enrich([slug])
190
+ return recs.get(slug)
191
+
192
+ async def enrich(
193
+ self,
194
+ slugs: List[str],
195
+ batch: int = 25,
196
+ delay_ms: int = 80,
197
+ progress: Optional[Callable[[int, int], None]] = None,
198
+ ) -> Dict[str, dict]:
199
+ """Récupère l'objet complet de chaque slug (fetch in-page batché). Renvoie slug -> record.
200
+
201
+ Les slugs dont la fiche n'expose pas l'objet standard (ex. placeholders) sont omis.
202
+ """
203
+ out: Dict[str, dict] = {}
204
+ total = len(slugs)
205
+ for bi in range(0, total, batch):
206
+ chunk = slugs[bi:bi + batch]
207
+ rows = await self.page.evaluate(_DETAIL_JS, {"slugs": chunk, "delay": delay_ms})
208
+ for r in rows:
209
+ if r.get("status") == 200 and r.get("rec"):
210
+ try:
211
+ out[r["slug"]] = _unescape_object(r["rec"])
212
+ except Exception:
213
+ pass
214
+ if progress:
215
+ progress(min(bi + batch, total), total)
216
+ return out
217
+
218
+ async def scrape(
219
+ self, enrich: bool = False, progress: Optional[Callable] = None
220
+ ) -> List[dict]:
221
+ """Annuaire complet. Si `enrich=True`, chaque exposant est remplacé par sa fiche complète
222
+ (49 champs) ; les exposants sans fiche standard gardent leur enregistrement de liste."""
223
+ listing = await self.list_exhibitors()
224
+ if not enrich:
225
+ return listing
226
+ slugs = [e["slug"] for e in listing]
227
+ details = await self.enrich(slugs, progress=progress)
228
+ return [details.get(e["slug"], e) for e in listing]
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "o-browser-vivatech"
7
+ version = "0.1.0"
8
+ description = "VivaTech exhibitors directory adapter for o-browser"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Alexis Laporte" }
13
+ ]
14
+ dependencies = [
15
+ "o-browser>=0.3.0",
16
+ ]
17
+
18
+ # Discovered by o-browser core via `load_site("vivatech")`.
19
+ [project.entry-points."o_browser.sites"]
20
+ vivatech = "o_browser_vivatech:VivaTechClient"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["o_browser_vivatech"]