| 44 |
44 |
ACTOR_OWNER = "gorgeous_thistle" |
| 45 |
45 |
RESIDENTIAL = {"useApifyProxy": True, "apifyProxyGroups": ["RESIDENTIAL"]} |
| 46 |
46 |
|
|
47 |
+# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) : |
|
48 |
+# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation |
|
49 |
+# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs |
|
50 |
+# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées. |
|
51 |
+MISS_PREFIXES = ("user_not_found", "private", "not_found", "no_profile", |
|
52 |
+ "no_channel", "login_wall", "invite_invalide", "http_404", |
|
53 |
+ "wall_") |
|
54 |
+ |
| 47 |
55 |
# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à |
| 48 |
56 |
# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de |
| 49 |
57 |
# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue. |
| 57 |
65 |
# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis |
| 58 |
66 |
# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j. |
| 59 |
67 |
REVISIT_DAYS = int(os.environ.get("APIFY_REVISIT_DAYS", "7")) |
|
68 |
+# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le |
|
69 |
+# bassin de chaque plateforme en un seul passage (rattrapage de couverture) |
|
70 |
+FORCE_PASS = os.environ.get("APIFY_FORCE", "") == "1" |
| 60 |
71 |
|
| 61 |
72 |
|
| 62 |
73 |
def _token() -> str: |
| 178 |
189 |
return f"{self.source_id}_at" |
| 179 |
190 |
|
| 180 |
191 |
def _stale(self, acc) -> bool: |
|
192 |
+ if FORCE_PASS: |
|
193 |
+ return True |
| 181 |
194 |
stamp = (acc.metrics or {}).get(self._stamp_key) |
| 182 |
195 |
if not stamp: |
| 183 |
196 |
return True |
| 209 |
222 |
if acc is None or not self._stale(acc): |
| 210 |
223 |
continue |
| 211 |
224 |
targets.setdefault(self.target_of(acc), []).append((cr, acc)) |
| 212 |
|
− if len(targets) >= self.cap: |
|
225 |
+ if not FORCE_PASS and len(targets) >= self.cap: |
| 213 |
226 |
break |
| 214 |
227 |
if not targets: |
| 215 |
228 |
return [] |
| 218 |
231 |
{"proxyConfiguration": RESIDENTIAL, |
| 219 |
232 |
"concurrency": RUN_CONCURRENCY, |
| 220 |
233 |
**self.extra_input()}, |
| 221 |
|
− sorted(targets)) |
|
234 |
+ sorted(targets), |
|
235 |
+ # passage forcé : tranches plus grosses (bassin entier / 32 runs) |
|
236 |
+ # → délai par run élargi en proportion |
|
237 |
+ timeout_s=7200 if FORCE_PASS else 2400) |
| 222 |
238 |
by_handle = {str(it.get("username") or "").lower(): it |
| 223 |
239 |
for it in items if it.get("kind") == "profile"} |
| 224 |
240 |
enriched: list[Creator] = [] |
| 228 |
244 |
if it is None: |
| 229 |
245 |
continue |
| 230 |
246 |
if not it.get("found"): |
| 231 |
|
− if str(it.get("error", "")).startswith(("http_5", "shell")): |
|
247 |
+ err = str(it.get("error", "")) |
|
248 |
+ if err.startswith(("http_5", "shell")): |
| 232 |
249 |
self.errors += 1 |
|
250 |
+ elif err.startswith(MISS_PREFIXES): |
|
251 |
+ for cr, acc in pairs: # miss définitif → rotation douce |
|
252 |
+ acc.metrics[self._stamp_key] = now_iso() |
|
253 |
+ acc.metrics[f"{self.source_id}_miss"] = err |
|
254 |
+ enriched.append(cr) |
| 233 |
255 |
continue |
| 234 |
256 |
for cr, acc in pairs: |
| 235 |
257 |
self.apply(cr, acc, it) |
| 252 |
274 |
if it.get(img_key): |
| 253 |
275 |
metrics[img_key] = it[img_key] |
| 254 |
276 |
metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre |
|
277 |
+ acc.metrics.pop(f"{self.source_id}_miss", None) # ressuscité |
| 255 |
278 |
acc.metrics.update(metrics) |
| 256 |
279 |
# avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la |
| 257 |
280 |
# plateforme principale (les URLs CDN signées expirent — ex. Instagram) ; |
| 362 |
385 |
"top_hashtags": "top_hashtags", "top_tweet": "top_tweet"} |
| 363 |
386 |
|
| 364 |
387 |
def extra_input(self) -> dict: |
| 365 |
|
− return {"maxTweets": 20} |
|
388 |
+ # concurrence réduite : l'endpoint syndication de X rend des 429 en |
|
389 |
+ # rafale au-delà de ~4 requêtes simultanées par run (constat 2026-08-21) |
|
390 |
+ return {"maxTweets": 20, "concurrency": 4} |
| 366 |
391 |
|
| 367 |
392 |
def bio_urls(self, it: dict) -> list[str]: |
| 368 |
393 |
return [it.get("website") or ""] |
| 369 |
394 |
|