ownapi 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.
Files changed (62) hide show
  1. cli.py +1017 -0
  2. compiler.py +948 -0
  3. core/__init__.py +19 -0
  4. core/api_keys.py +523 -0
  5. core/auto_repair.py +682 -0
  6. core/base_client.py +165 -0
  7. core/consent.py +169 -0
  8. core/form_automation.py +1044 -0
  9. core/hitl_queue.py +886 -0
  10. core/onboarding.py +1457 -0
  11. core/token_store.py +223 -0
  12. healer.py +2074 -0
  13. master_mcp.py +193 -0
  14. ownapi/__init__.py +94 -0
  15. ownapi/cli.py +309 -0
  16. ownapi/sdk.py +436 -0
  17. ownapi-1.0.0.dist-info/METADATA +287 -0
  18. ownapi-1.0.0.dist-info/RECORD +62 -0
  19. ownapi-1.0.0.dist-info/WHEEL +5 -0
  20. ownapi-1.0.0.dist-info/entry_points.txt +4 -0
  21. ownapi-1.0.0.dist-info/top_level.txt +10 -0
  22. recorder.py +486 -0
  23. registry/coingecko/__init__.py +5 -0
  24. registry/coingecko/client.py +84 -0
  25. registry/coingecko/schema.json +23 -0
  26. registry/edevlet/__init__.py +5 -0
  27. registry/edevlet/client.py +54 -0
  28. registry/edevlet/schema.json +14 -0
  29. registry/enuygun/__init__.py +5 -0
  30. registry/enuygun/client.py +78 -0
  31. registry/enuygun/schema.json +13 -0
  32. registry/github_trending/__init__.py +5 -0
  33. registry/github_trending/client.py +90 -0
  34. registry/github_trending/schema.json +31 -0
  35. registry/hackernews/__init__.py +5 -0
  36. registry/hackernews/client.py +90 -0
  37. registry/hackernews/schema.json +38 -0
  38. registry/hepsiburada/__init__.py +5 -0
  39. registry/hepsiburada/client.py +32 -0
  40. registry/hepsiburada/schema.json +23 -0
  41. registry/linkedin_jobs/__init__.py +5 -0
  42. registry/linkedin_jobs/client.py +241 -0
  43. registry/linkedin_jobs/schema.json +46 -0
  44. registry/pegasus/__init__.py +5 -0
  45. registry/pegasus/client.py +141 -0
  46. registry/pegasus/schema.json +76 -0
  47. registry/thy/__init__.py +5 -0
  48. registry/thy/client.py +1796 -0
  49. registry/thy/schema.json +46 -0
  50. registry/trendyol/__init__.py +5 -0
  51. registry/trendyol/client.py +228 -0
  52. registry/trendyol/schema.json +30 -0
  53. registry/ucak_bileti/client.py +94 -0
  54. registry/ucak_bileti/schema.json +47 -0
  55. registry/wikipedia/__init__.py +5 -0
  56. registry/wikipedia/client.py +64 -0
  57. registry/wikipedia/schema.json +31 -0
  58. registry/yahoo_finance/__init__.py +5 -0
  59. registry/yahoo_finance/client.py +98 -0
  60. registry/yahoo_finance/schema.json +22 -0
  61. server.py +891 -0
  62. static/index.html +2401 -0
healer.py ADDED
@@ -0,0 +1,2074 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import urllib.request
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, urlencode, urlparse
15
+
16
+ from playwright.async_api import Page, Request, Response, async_playwright
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+
20
+ if hasattr(sys.stdout, "reconfigure"):
21
+ try:
22
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
23
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
24
+ except Exception:
25
+ pass
26
+
27
+ from recorder import IGNORED_DOMAIN_KEYWORDS
28
+ from core.token_store import TokenStore
29
+
30
+ console = Console(stderr=True, legacy_windows=False)
31
+
32
+ # Yenilenmesi gereken kritik kimlik doğrulama, oturum ve dinamik tarayıcı başlığı önekleri
33
+ HEALABLE_HEADER_PREFIXES: tuple[str, ...] = (
34
+ "cookie",
35
+ "authorization",
36
+ "x-csrf-token",
37
+ "x-csrf",
38
+ "x-xsrf",
39
+ "csrf",
40
+ "xsrf",
41
+ "x-algolia",
42
+ "x-api-key",
43
+ "api-key",
44
+ "x-auth",
45
+ "x-session",
46
+ "x-token",
47
+ "x-requested-with",
48
+ "user-agent",
49
+ "referer",
50
+ "origin",
51
+ "sec-ch-ua",
52
+ "sec-ch-ua-mobile",
53
+ "sec-ch-ua-platform",
54
+ "sec-fetch-dest",
55
+ "sec-fetch-mode",
56
+ "sec-fetch-site",
57
+ "x-px",
58
+ "px-",
59
+ "_px",
60
+ "pxcts",
61
+ "x-perimeterx",
62
+ "tafsessionid",
63
+ "taftrackingid",
64
+ "x-current-page-url",
65
+ )
66
+
67
+ # URL query string veya JSON body içinde taşınabilen token/oturum anahtarı önekleri
68
+ HEALABLE_TOKEN_KEYS: tuple[str, ...] = (
69
+ "x-algolia",
70
+ "api_key",
71
+ "apikey",
72
+ "api-key",
73
+ "token",
74
+ "access_token",
75
+ "auth",
76
+ "csrf",
77
+ "xsrf",
78
+ "sessionid",
79
+ "session_id",
80
+ "_px",
81
+ "px",
82
+ "taf",
83
+ "amadeus",
84
+ )
85
+
86
+ PROJECT_ROOT: Path = Path(__file__).resolve().parent
87
+
88
+
89
+ def _clean_profile_dir(profile_dir: Path) -> None:
90
+ """Eski kirlenmiş Akamai blok çerezlerini ve geçersiz oturum durumunu temizler."""
91
+ if not profile_dir.exists():
92
+ profile_dir.mkdir(parents=True, exist_ok=True)
93
+ return
94
+ targets = [
95
+ profile_dir / "storage_state.json",
96
+ profile_dir / "Default" / "Network" / "Cookies",
97
+ profile_dir / "Default" / "Network" / "Cookies-journal",
98
+ profile_dir / "Default" / "Cookies",
99
+ profile_dir / "Default" / "Cookies-journal",
100
+ ]
101
+ for item in targets:
102
+ if item.exists():
103
+ try:
104
+ item.unlink()
105
+ except Exception:
106
+ pass
107
+
108
+
109
+ def _root_domain(hostname: str) -> str:
110
+ """Subdomain farklarını yoksaymak için kök domain adını döndürür (örn: web.flypgs.com -> flypgs.com)."""
111
+ parts = hostname.lower().strip(".").split(".")
112
+ if len(parts) >= 3 and parts[-2] in ("com", "org", "net", "gov", "edu", "co"):
113
+ return ".".join(parts[-3:])
114
+ if len(parts) >= 2:
115
+ return ".".join(parts[-2:])
116
+ return hostname.lower()
117
+
118
+
119
+ def _extract_response_text(res: Any) -> str:
120
+ """
121
+ Yanıt nesnesi zaten bir dict (sözlük) ise `.text` özniteliği aramaya kalkmadan
122
+ doğrudan `res.get('text')` veya `json.dumps(res)` olarak güvenli biçimde metne çevirir.
123
+ """
124
+ if res is None:
125
+ return ""
126
+ if isinstance(res, str):
127
+ return res
128
+ if isinstance(res, dict):
129
+ if "text" in res and isinstance(res["text"], str):
130
+ return res["text"]
131
+ if "response_sample" in res and isinstance(res["response_sample"], str):
132
+ return res["response_sample"]
133
+ try:
134
+ return json.dumps(res, ensure_ascii=False)
135
+ except Exception:
136
+ return str(res)
137
+ if isinstance(res, list):
138
+ try:
139
+ return json.dumps(res, ensure_ascii=False)
140
+ except Exception:
141
+ return str(res)
142
+ if hasattr(res, "text"):
143
+ attr = getattr(res, "text")
144
+ return attr() if callable(attr) else str(attr)
145
+ return str(res)
146
+
147
+
148
+ class ResponseDict(dict):
149
+ """
150
+ Hem standart Python sözlüğü (`dict`) olarak çalışan hem de istemci kodlarında
151
+ `response.text`, `response.json()` veya `response['response_sample']` eriÅŸimlerini
152
+ AttributeError / KeyError fırlatmadan destekleyen hibrit sözlük sınıfı.
153
+ """
154
+
155
+ @property
156
+ def text(self) -> str:
157
+ return _extract_response_text(dict(self))
158
+
159
+ @property
160
+ def status_code(self) -> int:
161
+ val = self.get("status_code", self.get("status", 200))
162
+ return int(val) if isinstance(val, (int, float)) else 200
163
+
164
+ @property
165
+ def ok(self) -> bool:
166
+ return bool(self.get("ok", 200 <= self.status_code < 400))
167
+
168
+ def json(self) -> dict[str, Any]:
169
+ return dict(self)
170
+
171
+ def __getitem__(self, key: Any) -> Any:
172
+ if key not in self and key in ("text", "response_sample", "content"):
173
+ return self.text
174
+ return super().__getitem__(key)
175
+
176
+
177
+ def _merge_cookie_strings(base_cookie: str, new_cookie: str) -> str:
178
+ """İki Cookie başlığı dizgesini anahtar bazında birleştirir (yeni değerler eskileri günceller)."""
179
+ cookie_map: dict[str, str] = {}
180
+ for raw in (base_cookie, new_cookie):
181
+ if not raw:
182
+ continue
183
+ for part in raw.split(";"):
184
+ part = part.strip()
185
+ if "=" in part:
186
+ k, v = part.split("=", 1)
187
+ cookie_map[k.strip()] = v.strip()
188
+ return "; ".join(f"{k}={v}" for k, v in cookie_map.items())
189
+
190
+
191
+ def update_payload_deep(payload: Any, updates: dict[str, Any]) -> Any:
192
+ """
193
+ İstek gövdesindeki (body) hem üst düzey hem de iç içe (nested, örn: flightSearchList[0])
194
+ alanları verilen anahtar-değer çiftleriyle günceller.
195
+ """
196
+ if not isinstance(payload, (dict, list)) or not updates:
197
+ return payload
198
+
199
+ updated = copy.deepcopy(payload)
200
+
201
+ def _walk(node: Any) -> bool:
202
+ matched_any = False
203
+ if isinstance(node, dict):
204
+ for k in list(node.keys()):
205
+ if k in updates and updates[k] is not None:
206
+ node[k] = updates[k]
207
+ matched_any = True
208
+ if isinstance(node[k], (dict, list)):
209
+ if _walk(node[k]):
210
+ matched_any = True
211
+ elif isinstance(node, list):
212
+ for item in node:
213
+ if isinstance(item, (dict, list)):
214
+ if _walk(item):
215
+ matched_any = True
216
+ return matched_any
217
+
218
+ _walk(updated)
219
+ # Eğer iç içe hiçbir yerde eşleşmediyse ve üst düzey dict ise üst düzeye de ekle
220
+ if isinstance(updated, dict):
221
+ for uk, uv in updates.items():
222
+ if uv is not None and uk in updated:
223
+ updated[uk] = uv
224
+ return updated
225
+
226
+
227
+ def _find_chrome_executable() -> str:
228
+ """Kullanıcının sistemindeki orijinal Google Chrome yürütülebilir dosya yolunu bulur."""
229
+ candidates = [
230
+ os.environ.get("PROGRAMFILES", r"C:\Program Files") + r"\Google\Chrome\Application\chrome.exe",
231
+ os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)") + r"\Google\Chrome\Application\chrome.exe",
232
+ os.environ.get("LOCALAPPDATA", "") + r"\Google\Chrome\Application\chrome.exe",
233
+ ]
234
+ for c in candidates:
235
+ if c and os.path.exists(c):
236
+ return c
237
+ return "chrome"
238
+
239
+
240
+ async def _ensure_cdp_chrome(
241
+ profile_dir: Path,
242
+ port: int = 9222,
243
+ initial_url: str = "https://www.turkishairlines.com/tr-tr/",
244
+ ) -> None:
245
+ """Eğer portta açık Chrome oturumu yoksa kullanıcının normal Chrome uygulamasını remote debugging portuyla başlatır."""
246
+ try:
247
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.5) as resp:
248
+ if resp.status == 200:
249
+ return
250
+ except Exception:
251
+ pass
252
+
253
+ chrome_exe = _find_chrome_executable()
254
+ profile_dir.mkdir(parents=True, exist_ok=True)
255
+ console.print(
256
+ Panel.fit(
257
+ f"[bold cyan]🚀 Kullanıcının Gerçek Google Chrome Tarayıcısı Başlatılıyor (CDP Port {port})[/bold cyan]\n"
258
+ f"[dim]Yol:[/dim] [white]{chrome_exe}[/white]\n"
259
+ f"[dim]Hedef:[/dim] [green]{initial_url}[/green]",
260
+ title="Chrome DevTools Protocol (CDP)",
261
+ border_style="cyan",
262
+ )
263
+ )
264
+ subprocess.Popen(
265
+ [
266
+ chrome_exe,
267
+ f"--remote-debugging-port={port}",
268
+ f"--user-data-dir={profile_dir.resolve()}",
269
+ "--no-first-run",
270
+ "--no-default-browser-check",
271
+ initial_url,
272
+ ],
273
+ stdout=subprocess.DEVNULL,
274
+ stderr=subprocess.DEVNULL,
275
+ )
276
+ for _ in range(30):
277
+ await asyncio.sleep(0.5)
278
+ try:
279
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.5) as resp:
280
+ if resp.status == 200:
281
+ break
282
+ except Exception:
283
+ pass
284
+
285
+
286
+ class SessionHealer:
287
+ """
288
+ Oturum çerezleri (Cookie), CSRF, Authorization veya dinamik başlıklar süresi dolduğunda
289
+ (401, 403 veya 429 hatalarında) hedef siteyi ziyaret ederek taze kimlik bilgilerini
290
+ yakalayan, session_tokens.json dosyasına kaydeden ve isteği taze oturum tüneliyle
291
+ tekrarlayan Self-Healing motoru.
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ traffic_path: Path | str = PROJECT_ROOT / "captured_traffic.json",
297
+ tokens_path: Path | str = PROJECT_ROOT / "session_tokens.json",
298
+ profile_dir: Path | str = PROJECT_ROOT / ".browser_profile",
299
+ timeout_ms: int = 15_000,
300
+ user_wait_sec: int = 5,
301
+ challenge_wait_sec: int = 45,
302
+ target_url: str | None = None,
303
+ tenant_id: str = "default",
304
+ service: str = "thy",
305
+ token_store: TokenStore | None = None,
306
+ mode: str = "local",
307
+ cloud_cdp_url: str | None = None,
308
+ non_interactive: bool = False,
309
+ ) -> None:
310
+ self.traffic_path = Path(traffic_path)
311
+ self.tokens_path = Path(tokens_path)
312
+ self.profile_dir = Path(profile_dir)
313
+ self.timeout_ms = timeout_ms
314
+ self.user_wait_sec = user_wait_sec
315
+ self.challenge_wait_sec = challenge_wait_sec
316
+ self.target_url = target_url
317
+ self.tenant_id = tenant_id or "default"
318
+ self.service = service or "thy"
319
+ self.token_store = token_store or TokenStore(legacy_tokens_path=self.tokens_path)
320
+ self.mode = (mode or os.getenv("OWNAPI_BROWSER_MODE", "local")).strip().lower()
321
+ if self.mode not in ("local", "cloud"):
322
+ self.mode = "local"
323
+ self.cloud_cdp_url = cloud_cdp_url or os.getenv("OWNAPI_CLOUD_CDP_URL", "").strip() or None
324
+ self.non_interactive = bool(
325
+ non_interactive
326
+ or os.getenv("OWNAPI_NON_INTERACTIVE", "").strip().lower() in ("1", "true", "yes")
327
+ )
328
+
329
+ @staticmethod
330
+ def _reset_window_placement(profile_path: Path) -> None:
331
+ """Kalıcı profilde kaydedilmiş ekran dışı / simge durumundaki pencere konumunu temizler."""
332
+ prefs_file = profile_path / "Default" / "Preferences"
333
+ if prefs_file.exists():
334
+ try:
335
+ prefs_data = json.loads(prefs_file.read_text(encoding="utf-8"))
336
+ if "browser" in prefs_data and "window_placement" in prefs_data["browser"]:
337
+ prefs_data["browser"].pop("window_placement", None)
338
+ prefs_file.write_text(json.dumps(prefs_data), encoding="utf-8")
339
+ except Exception:
340
+ pass
341
+
342
+ @staticmethod
343
+ async def _detect_challenge(page: Page) -> bool:
344
+ """Sayfada PerimeterX, Akamai, Cloudflare veya Captcha doğrulama ekranı olup olmadığını denetler."""
345
+ try:
346
+ content = (await page.content()).lower()
347
+ return any(
348
+ kw in content
349
+ for kw in (
350
+ "px-captcha",
351
+ "perimeterx",
352
+ "press & hold",
353
+ "basılı tutun",
354
+ "robot olmadığınızı",
355
+ "human verification",
356
+ "cpr_chlge",
357
+ "cf-turnstile",
358
+ "hcaptcha",
359
+ "recaptcha",
360
+ )
361
+ )
362
+ except Exception:
363
+ return False
364
+
365
+ async def _apply_otp_code_if_present(
366
+ self,
367
+ page: Page,
368
+ otp_code: str | None,
369
+ tenant_id: str | None = None,
370
+ ) -> bool:
371
+ """
372
+ HITL Onay Merkezi üzerinden kullanıcı `otp_code` (SMS / E-Posta / 2FA doğrulama kodu)
373
+ gönderdiğinde, açık olan Playwright `page` üzerindeki OTP input alanını otomatik bulur,
374
+ kodu yazar, gönder (submit/onayla) butonuna tıklar ve oturumu kaydeder.
375
+ """
376
+ clean_code = str(otp_code or "").strip()
377
+ if not clean_code or page is None:
378
+ return False
379
+
380
+ console.print(
381
+ f"[bold cyan]🔑 [HITL OTP] Uzaktan SMS/E-Posta doğrulama kodu ({clean_code}) sayfaya uygulanıyor...[/bold cyan]"
382
+ )
383
+ filled = False
384
+ try:
385
+ # 1. Çok kutucuklu (maxlength=1) parçalı OTP girişi kontrolü
386
+ split_boxes = page.locator('input[maxlength="1"]:visible')
387
+ split_count = await split_boxes.count()
388
+ if split_count >= 4 and len(clean_code) >= 4:
389
+ for idx, ch in enumerate(clean_code[:split_count]):
390
+ box = split_boxes.nth(idx)
391
+ await box.click(timeout=2000)
392
+ await box.fill(ch, timeout=2000)
393
+ filled = True
394
+
395
+ # 2. Tekil semantik OTP / SMS / Doğrulama Kodu input seçicileri
396
+ if not filled:
397
+ otp_selectors = [
398
+ 'input[autocomplete="one-time-code"]:visible',
399
+ 'input[name*="otp" i]:visible',
400
+ 'input[name*="sms" i]:visible',
401
+ 'input[name*="code" i]:visible',
402
+ 'input[name*="kod" i]:visible',
403
+ 'input[name*="dogrulama" i]:visible',
404
+ 'input[name*="verify" i]:visible',
405
+ 'input[name*="pin" i]:visible',
406
+ 'input[id*="otp" i]:visible',
407
+ 'input[id*="sms" i]:visible',
408
+ 'input[id*="code" i]:visible',
409
+ 'input[id*="kod" i]:visible',
410
+ 'input[id*="dogrulama" i]:visible',
411
+ 'input[id*="verify" i]:visible',
412
+ 'input[placeholder*="kod" i]:visible',
413
+ 'input[placeholder*="code" i]:visible',
414
+ 'input[placeholder*="sms" i]:visible',
415
+ 'input[placeholder*="otp" i]:visible',
416
+ 'input[placeholder*="doğrulama" i]:visible',
417
+ 'input[placeholder*="dogrulama" i]:visible',
418
+ 'input[placeholder*="onay" i]:visible',
419
+ 'input[aria-label*="kod" i]:visible',
420
+ 'input[aria-label*="code" i]:visible',
421
+ 'input[aria-label*="sms" i]:visible',
422
+ 'input[aria-label*="otp" i]:visible',
423
+ 'input[type="tel"]:visible',
424
+ 'input[type="number"]:visible',
425
+ 'input[type="text"]:visible',
426
+ ]
427
+ target_locator = None
428
+ for sel in otp_selectors:
429
+ loc = page.locator(sel)
430
+ if await loc.count() > 0:
431
+ target_locator = loc.first
432
+ break
433
+
434
+ if target_locator is not None:
435
+ await target_locator.click(timeout=3000)
436
+ await target_locator.fill(clean_code, timeout=3000)
437
+ filled = True
438
+
439
+ if not filled:
440
+ # JS tabanlı yedek doldurma
441
+ filled = bool(
442
+ await page.evaluate(
443
+ """(code) => {
444
+ const inputs = Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="checkbox"]):not([type="radio"])'));
445
+ const visible = inputs.filter(el => el.offsetParent !== null || el.getClientRects().length > 0);
446
+ const target = visible[0] || inputs[0];
447
+ if (!target) return false;
448
+ target.focus();
449
+ target.value = code;
450
+ target.dispatchEvent(new Event('input', { bubbles: true }));
451
+ target.dispatchEvent(new Event('change', { bubbles: true }));
452
+ return true;
453
+ }""",
454
+ clean_code,
455
+ )
456
+ )
457
+
458
+ if filled:
459
+ # 3. Gönder / Onayla / Doğrula / Devam Et butonunu bul ve tıkla
460
+ submit_selectors = [
461
+ 'button[type="submit"]:visible',
462
+ 'input[type="submit"]:visible',
463
+ 'button:has-text("Onayla"):visible',
464
+ 'button:has-text("Doğrula"):visible',
465
+ 'button:has-text("Dogrula"):visible',
466
+ 'button:has-text("Gönder"):visible',
467
+ 'button:has-text("Gonder"):visible',
468
+ 'button:has-text("Devam"):visible',
469
+ 'button:has-text("Giriş"):visible',
470
+ 'button:has-text("Verify"):visible',
471
+ 'button:has-text("Confirm"):visible',
472
+ 'button:has-text("Submit"):visible',
473
+ ]
474
+ clicked_submit = False
475
+ for btn_sel in submit_selectors:
476
+ btn_loc = page.locator(btn_sel)
477
+ if await btn_loc.count() > 0:
478
+ await btn_loc.first.click(timeout=3000)
479
+ clicked_submit = True
480
+ break
481
+
482
+ if not clicked_submit:
483
+ try:
484
+ await page.keyboard.press("Enter")
485
+ except Exception:
486
+ pass
487
+
488
+ await asyncio.sleep(1.0)
489
+
490
+ # 4. Güncel çerezleri ve oturumu TokenStore'a kaydet
491
+ try:
492
+ ctx = page.context
493
+ cookies = await ctx.cookies()
494
+ ua = await page.evaluate("() => navigator.userAgent")
495
+ self.token_store.save_session(
496
+ tenant_id=tenant_id or self.tenant_id,
497
+ service=self.service,
498
+ cookies=cookies,
499
+ user_agent=ua,
500
+ metadata={
501
+ "target_url": page.url,
502
+ "healed_at": datetime.now(timezone.utc).isoformat(),
503
+ "otp_verified": True,
504
+ },
505
+ )
506
+ except Exception:
507
+ pass
508
+
509
+ console.print(
510
+ f"[bold green]✔ [HITL OTP] Doğrulama kodu ({clean_code}) başarıyla girildi ve onaylandı![/bold green]"
511
+ )
512
+ return True
513
+ except Exception as exc:
514
+ console.print(f"[dim yellow]⚠️ OTP kodu uygulama uyarısı: {exc}[/dim yellow]")
515
+ return False
516
+
517
+ async def _handle_human_challenge_if_present(self, page: Page, wait_sec: int | None = None) -> None:
518
+ """
519
+ PerimeterX veya Captcha ekranı tespit edilirse kullanıcıya doğrulamayı
520
+ tamamlaması için süre tanır. Zorunlu headless=False sayesinde pencere görünürdür.
521
+ Süre dolduğunda pencereyi kapatmak yerine kullanıcı ENTER tuşuna basana kadar bekler.
522
+ """
523
+ max_wait = wait_sec if wait_sec is not None else self.challenge_wait_sec
524
+ is_challenge = await self._detect_challenge(page)
525
+ if is_challenge:
526
+ console.print(
527
+ Panel.fit(
528
+ "[bold red]🛡️ PerimeterX / Güvenlik Doğrulaması Algılandı![/bold red]\n"
529
+ "[bold white]Lütfen ekranda açılan Chrome tarayıcı penceresinde 'Basılı Tutun' veya Captcha doğrulamasını tamamlayın.[/bold white]\n"
530
+ f"[dim]Kullanıcı doğrulaması için {max_wait} saniye otomatik dinleniyor...[/dim]",
531
+ title="Doğrulama Ekranı",
532
+ border_style="red",
533
+ )
534
+ )
535
+ challenge_resolved = False
536
+ for _ in range(max_wait * 2):
537
+ await asyncio.sleep(0.5)
538
+ if not await self._detect_challenge(page):
539
+ console.print("[bold green]✔ Doğrulama otomatik olarak algılandı ve tamamlandı, veri akışına devam ediliyor![/bold green]")
540
+ challenge_resolved = True
541
+ await asyncio.sleep(1.0)
542
+ return
543
+
544
+ if not challenge_resolved:
545
+ console.print(
546
+ "\n[bold yellow]👉 Doğrulamayı tamamladıysanız devam etmek için terminalde [bold green]ENTER[/bold green] tuşuna basın...[/bold yellow]"
547
+ )
548
+ await asyncio.to_thread(input)
549
+ console.print("[bold green]✔ Kullanıcı onayı alındı, işleme devam ediliyor![/bold green]")
550
+ await asyncio.sleep(1.0)
551
+ elif self.user_wait_sec > 0:
552
+ console.print(
553
+ f"[dim yellow]👁️ Tarayıcı penceresi ekranda açık (headless=False). "
554
+ f"Kullanıcı kontrolü ve sayfa stabilizasyonu için {self.user_wait_sec} sn bekleniyor...[/dim yellow]"
555
+ )
556
+ await page.wait_for_timeout(self.user_wait_sec * 1000)
557
+
558
+ def _get_target_info(self) -> tuple[str, set[str]]:
559
+ """captured_traffic.json içinden hedef URL'yi ve API domainlerini okur."""
560
+ if not self.traffic_path.exists():
561
+ return "https://www.google.com", set()
562
+
563
+ try:
564
+ data = json.loads(self.traffic_path.read_text(encoding="utf-8"))
565
+ target_url = str(data.get("target_url", "")).strip()
566
+ api_domains: set[str] = set()
567
+ for ep in data.get("endpoints", []):
568
+ domain = ep.get("domain")
569
+ if domain:
570
+ api_domains.add(str(domain).lower())
571
+ return target_url, api_domains
572
+ except Exception:
573
+ return "", set()
574
+
575
+ @staticmethod
576
+ def _is_ignored(url: str) -> bool:
577
+ try:
578
+ parsed = urlparse(url)
579
+ hostname = (parsed.hostname or "").lower()
580
+ if not hostname:
581
+ return True
582
+ return any(kw in hostname for kw in IGNORED_DOMAIN_KEYWORDS)
583
+ except Exception:
584
+ return True
585
+
586
+ @classmethod
587
+ def _extract_healable_headers(cls, headers: dict[str, str]) -> dict[str, str]:
588
+ extracted: dict[str, str] = {}
589
+ for k, v in headers.items():
590
+ lower_k = k.lower().strip()
591
+ if any(lower_k == prefix or lower_k.startswith(prefix) for prefix in HEALABLE_HEADER_PREFIXES):
592
+ extracted[lower_k] = v
593
+ return extracted
594
+
595
+ @classmethod
596
+ def _extract_healable_query_params(cls, url: str) -> dict[str, str]:
597
+ extracted: dict[str, str] = {}
598
+ parsed = urlparse(url)
599
+ raw_params = parse_qs(parsed.query, keep_blank_values=False)
600
+ for k, values in raw_params.items():
601
+ lower_k = k.lower().strip()
602
+ if values and any(lower_k == prefix or lower_k.startswith(prefix) for prefix in HEALABLE_TOKEN_KEYS):
603
+ extracted[k] = values[-1]
604
+ return extracted
605
+
606
+ @classmethod
607
+ def _extract_healable_body_tokens(cls, request: Request) -> dict[str, Any]:
608
+ """Tüm istek gövdesi alanlarını dinamik sözlüğe bağlar."""
609
+ extracted: dict[str, Any] = {}
610
+ try:
611
+ post_data = request.post_data
612
+ if not post_data:
613
+ return extracted
614
+ parsed = json.loads(post_data)
615
+ if isinstance(parsed, dict):
616
+ for k, v in parsed.items():
617
+ extracted[k] = v
618
+ except Exception:
619
+ pass
620
+ return extracted
621
+
622
+ async def aheal(
623
+ self,
624
+ target_url: str | None = None,
625
+ retry_request: dict[str, Any] | None = None,
626
+ proxy: str | None = None,
627
+ tenant_id: str | None = None,
628
+ ) -> dict[str, Any]:
629
+ """
630
+ Playwright launch_persistent_context (headless=False,
631
+ args=['--disable-blink-features=AutomationControlled']) ile doÄŸrudan sayfaya gider.
632
+ Kullanıcı doğrulamayı geçip ENTER'a basınca tüm çerezleri (_abck, bm_sz, ak_bmsc, _px3 vb.)
633
+ ve gerçek User-Agent'ı alarak session_tokens.json ve TokenStore'a kaydeder.
634
+ """
635
+ target_goto = target_url or "https://www.turkishairlines.com/tr-tr/ucak-bileti/rezervasyon/"
636
+
637
+ console.print(
638
+ Panel.fit(
639
+ f"[bold yellow]⚡ Self-Healing & Taze Oturum Tüneli Devrede![/bold yellow]\n"
640
+ f"[cyan]Hedef Gezinme URL'si:[/cyan] {target_goto}\n"
641
+ "[dim]PerimeterX ve Akamai doğrulaması doğrudan bu sayfada çözülüyor...[/dim]",
642
+ title="Shadow-API SessionHealer",
643
+ border_style="yellow",
644
+ )
645
+ )
646
+
647
+ self.profile_dir.mkdir(parents=True, exist_ok=True)
648
+
649
+ cookies: list[dict[str, Any]] = []
650
+ user_agent: str = ""
651
+ active_tenant = tenant_id or self.tenant_id
652
+
653
+ is_headless_env = bool(
654
+ self.mode == "cloud"
655
+ or (sys.platform.startswith("linux") and not os.getenv("DISPLAY"))
656
+ )
657
+ launch_kwargs: dict[str, Any] = {
658
+ "user_data_dir": str(self.profile_dir.resolve()),
659
+ "headless": is_headless_env,
660
+ "args": [
661
+ "--disable-blink-features=AutomationControlled",
662
+ "--no-sandbox",
663
+ "--disable-setuid-sandbox",
664
+ "--disable-dev-shm-usage",
665
+ ],
666
+ }
667
+
668
+ async with async_playwright() as p:
669
+ context = await p.chromium.launch_persistent_context(**launch_kwargs)
670
+ stored_pw_cookies = self.token_store.get_playwright_cookies(
671
+ tenant_id=active_tenant,
672
+ service=self.service,
673
+ default_url=target_goto,
674
+ )
675
+ if stored_pw_cookies:
676
+ try:
677
+ await context.add_cookies(stored_pw_cookies)
678
+ except Exception:
679
+ pass
680
+
681
+ page = context.pages[0] if context.pages else await context.new_page()
682
+
683
+ console.print(f"[bold cyan]🌐 Tarayıcı açılıyor:[/bold cyan] [white]{target_goto}[/white]")
684
+ try:
685
+ await page.goto(target_goto, wait_until="domcontentloaded")
686
+ except Exception as e:
687
+ console.print(f"[dim]Sayfa gezinme bildirimi: {e}[/dim]")
688
+
689
+ await asyncio.to_thread(input, "Sayfa yüklendikten ve varsa doğrulamayı geçtikten sonra ENTER'a basın...")
690
+ console.print("[bold green]✔ Devam ediliyor... Çerezler ve gerçek User-Agent alınıyor.[/bold green]")
691
+
692
+ try:
693
+ cookies = await context.cookies()
694
+ except Exception:
695
+ cookies = []
696
+
697
+ try:
698
+ user_agent = await page.evaluate("() => navigator.userAgent")
699
+ except Exception:
700
+ user_agent = ""
701
+
702
+ try:
703
+ await context.storage_state(path=str(self.profile_dir / "storage_state.json"))
704
+ except Exception:
705
+ pass
706
+
707
+ await context.close()
708
+
709
+ cookie_parts = [f"{c['name']}={c['value']}" for c in cookies if c.get("name")]
710
+ cookie_str = "; ".join(cookie_parts)
711
+
712
+ parsed_target = urlparse(target_goto)
713
+ origin_url = f"{parsed_target.scheme}://{parsed_target.netloc}" if parsed_target.scheme and parsed_target.netloc else "https://www.turkishairlines.com"
714
+
715
+ fresh_headers = {
716
+ "cookie": cookie_str,
717
+ "user-agent": user_agent,
718
+ "accept": "application/json, text/plain, */*",
719
+ "content-type": "application/json",
720
+ "origin": origin_url,
721
+ "referer": target_goto,
722
+ }
723
+
724
+ token_bundle: dict[str, Any] = {
725
+ "target_url": target_goto,
726
+ "healed_at": datetime.now(timezone.utc).isoformat(),
727
+ "cookie": cookie_str,
728
+ "user-agent": user_agent,
729
+ "headers": fresh_headers,
730
+ "query_params": {},
731
+ "body_tokens": {},
732
+ **fresh_headers,
733
+ }
734
+
735
+ self.token_store.save_session(
736
+ tenant_id=active_tenant,
737
+ service=self.service,
738
+ cookies=cookies,
739
+ user_agent=user_agent,
740
+ metadata=token_bundle,
741
+ )
742
+
743
+ console.print(
744
+ f"[bold green]✔ Self-Healing tamamlandı![/bold green] "
745
+ f"Tenant: [cyan]{active_tenant}[/cyan] | "
746
+ f"Çerez Sayısı: [cyan]{len(cookies)}[/cyan] | "
747
+ f"User-Agent: [cyan]{user_agent[:45]}...[/cyan]"
748
+ )
749
+
750
+ return token_bundle
751
+
752
+ def heal(
753
+ self,
754
+ target_url: str | None = None,
755
+ tenant_id: str | None = None,
756
+ ) -> dict[str, Any]:
757
+ """Senkron kodlardan doğrudan çağrılabilen Self-Healing metodu."""
758
+ return self._run_sync(self.aheal(target_url=target_url, tenant_id=tenant_id))
759
+
760
+ async def aexecute_in_browser(
761
+ self,
762
+ method: str,
763
+ url: str,
764
+ headers: dict[str, str] | None = None,
765
+ params: dict[str, Any] | None = None,
766
+ json_payload: Any = None,
767
+ target_url: str | None = None,
768
+ user_wait_sec: int | None = None,
769
+ challenge_wait_sec: int | None = None,
770
+ tenant_id: str | None = None,
771
+ mode: str | None = None,
772
+ non_interactive: bool | None = None,
773
+ form_params: dict[str, Any] | None = None,
774
+ auto_fill_form: bool | None = None,
775
+ ) -> dict[str, Any]:
776
+ """
777
+ OwnAPI User-Delegated Browser Middleware:
778
+ - mode='local' (varsayılan): Kullanıcının gerçek yerel Chrome oturumunu açar
779
+ (headless=False, args=['--disable-blink-features=AutomationControlled']).
780
+ - mode='cloud': Bulut tabanlı Docker / Remote Browser (CDP / Browserless) oturumuna bağlanır
781
+ veya izole bulut konteyner bağlamında çalıştırır.
782
+ - non_interactive=True (veya OWNAPI_NON_INTERACTIVE=1): Captcha/WAF çıktığında terminalde
783
+ input() ile beklemek yerine görevi HITLQueueManager kuyruğuna ekler ve
784
+ PENDING_USER_APPROVAL yanıtı döndürür.
785
+ TokenStore'daki izole çerezleri tarayıcıya enjekte eder, hedef sayfaya gider ve
786
+ doğrudan tarayıcı içi page.evaluate(fetch) ile hedef isteği yürütür.
787
+ """
788
+ req_headers = dict(headers or {})
789
+ req_params = dict(params or {})
790
+ req_payload = copy.deepcopy(json_payload)
791
+ active_tenant = tenant_id or self.tenant_id
792
+ active_mode = (mode or self.mode or "local").strip().lower()
793
+ if active_mode not in ("local", "cloud"):
794
+ active_mode = "local"
795
+ env_ni = os.getenv("OWNAPI_NON_INTERACTIVE", "").strip().lower() in ("1", "true", "yes")
796
+ active_non_interactive = (
797
+ bool(non_interactive or env_ni)
798
+ if non_interactive is not None
799
+ else bool(self.non_interactive or env_ni)
800
+ )
801
+
802
+ full_req_url = f"{url}?{urlencode(req_params, doseq=True)}" if req_params else url
803
+ parsed_req = urlparse(full_req_url)
804
+ default_origin = (
805
+ f"{parsed_req.scheme}://{parsed_req.netloc}/"
806
+ if parsed_req.scheme and parsed_req.netloc
807
+ else "https://www.turkishairlines.com/tr-tr/"
808
+ )
809
+
810
+ is_thy_domain = (
811
+ self.service.lower() == "thy"
812
+ or "turkishairlines.com" in full_req_url.lower()
813
+ )
814
+ is_thy_availability = (
815
+ "turkishairlines.com" in full_req_url.lower()
816
+ and urlparse(full_req_url).path.rstrip("/") == "/api/v1/availability"
817
+ )
818
+
819
+ raw_visit_url = target_url or self.target_url or default_origin
820
+ if is_thy_domain:
821
+ # THY için direkt boş API linkine (/api/...) veya eski 404 rezervasyon alt sayfasına gitmek yerine
822
+ # her zaman önce ana sayfada (https://www.turkishairlines.com/tr-tr/) oturum ve çerezleri tazele
823
+ visit_url = "https://www.turkishairlines.com/tr-tr/"
824
+ else:
825
+ parsed_visit = urlparse(raw_visit_url)
826
+ if (
827
+ raw_visit_url.rstrip("/") == full_req_url.rstrip("/")
828
+ or "/api/" in parsed_visit.path.lower()
829
+ ):
830
+ # HITL veya Auto-Repair sırasında target_url yanlışlıkla API uç noktası olarak gelirse
831
+ # şemadaki target_url veya kök domain ana sayfasına yönlendir
832
+ schema_file = PROJECT_ROOT / "registry" / self.service / "schema.json"
833
+ resolved_target = default_origin
834
+ if schema_file.exists():
835
+ try:
836
+ s_data = json.loads(schema_file.read_text(encoding="utf-8"))
837
+ cand = str(s_data.get("target_url", "")).strip()
838
+ if cand and "/api/" not in urlparse(cand).path.lower():
839
+ resolved_target = cand
840
+ except Exception:
841
+ pass
842
+ visit_url = resolved_target
843
+ else:
844
+ visit_url = raw_visit_url
845
+
846
+ if is_thy_domain:
847
+ req_headers["Referer"] = "https://www.turkishairlines.com/"
848
+ req_headers["Origin"] = "https://www.turkishairlines.com"
849
+ req_headers["referer"] = "https://www.turkishairlines.com/"
850
+ req_headers["origin"] = "https://www.turkishairlines.com"
851
+ req_headers.setdefault("x-country", "TR")
852
+ req_headers.setdefault("x-language", "tr")
853
+ req_headers.setdefault("accept", "application/json, text/plain, */*")
854
+ req_headers.setdefault("content-type", "application/json")
855
+
856
+ if is_thy_availability and (
857
+ not isinstance(req_payload, dict)
858
+ or not req_payload.get("originDestinationInformationList")
859
+ ):
860
+ try:
861
+ from registry.thy.client import build_thy_availability_payload
862
+ req_payload = build_thy_availability_payload()
863
+ except Exception:
864
+ req_payload = {
865
+ "selectedBookerSearch": "R",
866
+ "selectedCabinClass": "ECONOMY",
867
+ "moduleType": "TICKETING",
868
+ "passengerTypeList": [{"quantity": 1, "code": "ADULT"}],
869
+ "originDestinationInformationList": [
870
+ {
871
+ "originAirportCode": "IST",
872
+ "originCountryCode": "TR",
873
+ "originMultiPort": True,
874
+ "originDomestic": True,
875
+ "destinationAirportCode": "ESB",
876
+ "destinationCountryCode": "TR",
877
+ "destinationMultiPort": False,
878
+ "destinationDomestic": True,
879
+ "departureDate": "02-03-2027",
880
+ "originCityCode": "IST",
881
+ "destinationCityCode": "ANK",
882
+ "originCity": "İstanbul",
883
+ "destinationCity": "Ankara",
884
+ },
885
+ {
886
+ "originAirportCode": "ESB",
887
+ "originCountryCode": "TR",
888
+ "originMultiPort": False,
889
+ "originDomestic": True,
890
+ "destinationAirportCode": "IST",
891
+ "destinationCountryCode": "TR",
892
+ "destinationMultiPort": True,
893
+ "destinationDomestic": True,
894
+ "departureDate": "09-03-2027",
895
+ "originCityCode": "ANK",
896
+ "destinationCityCode": "IST",
897
+ "originCity": "Ankara",
898
+ "destinationCity": "İstanbul",
899
+ },
900
+ ],
901
+ "savedDate": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
902
+ "preselectedOptionDetails": [],
903
+ }
904
+
905
+ mode_label = (
906
+ f"☁️ CLOUD ({self.cloud_cdp_url or 'Docker/Headless Container'})"
907
+ if active_mode == "cloud"
908
+ else "💻 LOCAL (User Chrome Session)"
909
+ )
910
+
911
+ console.print(
912
+ Panel.fit(
913
+ f"[bold cyan]🌐 User-Delegated Browser Fetch Tüneli Devrede![/bold cyan]\n"
914
+ f"[dim]Servis / Tenant:[/dim] [magenta]{self.service}[/magenta] ([cyan]{active_tenant}[/cyan])\n"
915
+ f"[dim]Çalışma Modu:[/dim] [yellow]{mode_label}[/yellow]\n"
916
+ f"[dim]Metot / Uç Nokta:[/dim] [green]{method.upper()}[/green] [white]{full_req_url}[/white]\n"
917
+ f"[dim]Oturum Sayfası:[/dim] [cyan]{visit_url}[/cyan]",
918
+ title="OwnAPI Browser Middleware",
919
+ border_style="cyan",
920
+ )
921
+ )
922
+
923
+ self.profile_dir.mkdir(parents=True, exist_ok=True)
924
+ # Kalıcı profil çerezlerini koru - _clean_profile_dir KESİNLİKLE çağrılmaz
925
+
926
+ def _build_validate_payload(p_load: Any) -> dict[str, Any]:
927
+ if not isinstance(p_load, dict):
928
+ return {}
929
+ val = {
930
+ "selectedBookerSearch": p_load.get("selectedBookerSearch", "R"),
931
+ "selectedCabinClass": p_load.get("selectedCabinClass", "ECONOMY"),
932
+ "moduleType": p_load.get("moduleType", "TICKETING"),
933
+ "passengerTypeList": p_load.get("passengerTypeList", [{"quantity": 1, "code": "ADULT"}]),
934
+ "originDestinationInformationList": [],
935
+ }
936
+ for s in p_load.get("originDestinationInformationList", []):
937
+ if isinstance(s, dict):
938
+ val["originDestinationInformationList"].append({
939
+ "originAirportCode": s.get("originAirportCode"),
940
+ "originMultiPort": s.get("originMultiPort", False),
941
+ "destinationAirportCode": s.get("destinationAirportCode"),
942
+ "destinationMultiPort": s.get("destinationMultiPort", False),
943
+ "departureDate": s.get("departureDate"),
944
+ })
945
+ return val
946
+
947
+ def _build_thy_search_state_url(
948
+ p_load: Any,
949
+ cid: str | None = None,
950
+ candidate_url: str | None = None,
951
+ ) -> str:
952
+ """
953
+ THY /api/v1/availability isteğinden önce Playwright'ın gidip sayfa state'ini
954
+ oluşturması gereken gidiş-dönüş (veya tek yön) arama URL'sini üretir.
955
+ """
956
+ active_cid = cid or str(uuid.uuid4())
957
+ if candidate_url and "turkishairlines.com" in candidate_url.lower():
958
+ parsed_c = urlparse(candidate_url)
959
+ if (
960
+ "/ucak-bileti/rezervasyon/" in parsed_c.path
961
+ and "origin=" in parsed_c.query
962
+ and "departureDate=" in parsed_c.query
963
+ ):
964
+ if not cid or f"cId={cid}" in parsed_c.query:
965
+ return candidate_url
966
+
967
+ try:
968
+ from registry.thy.client import build_thy_search_url_from_payload
969
+ return build_thy_search_url_from_payload(
970
+ p_load if isinstance(p_load, dict) else None,
971
+ cid=active_cid,
972
+ )
973
+ except Exception:
974
+ from urllib.parse import urlencode
975
+
976
+ orig = "IST"
977
+ dest = "ESB"
978
+ dep_date = "02-03-2027"
979
+ ret_date: str | None = "09-03-2027"
980
+ trip_type = "R"
981
+ cabin = "ECONOMY"
982
+ adults = 1
983
+ is_domestic = True
984
+
985
+ if isinstance(p_load, dict):
986
+ trip_type = str(p_load.get("selectedBookerSearch") or "R").upper()
987
+ cabin = str(p_load.get("selectedCabinClass") or "ECONOMY").upper()
988
+ od_l = p_load.get("originDestinationInformationList")
989
+ if isinstance(od_l, list) and len(od_l) >= 1 and isinstance(od_l[0], dict):
990
+ seg0 = od_l[0]
991
+ orig = str(seg0.get("originAirportCode") or orig)
992
+ dest = str(seg0.get("destinationAirportCode") or dest)
993
+ dep_date = str(seg0.get("departureDate") or dep_date)
994
+ is_domestic = bool(
995
+ seg0.get("originDomestic", True) and seg0.get("destinationDomestic", True)
996
+ )
997
+ if len(od_l) >= 2 and isinstance(od_l[1], dict):
998
+ ret_date = str(od_l[1].get("departureDate") or "") or None
999
+ elif trip_type == "O":
1000
+ ret_date = None
1001
+
1002
+ base_path = (
1003
+ "/tr-tr/ucak-bileti/rezervasyon/ic-hat-musaitlik/"
1004
+ if is_domestic
1005
+ else "/tr-tr/ucak-bileti/rezervasyon/rezervasyon-durumu/"
1006
+ )
1007
+ q_params: dict[str, Any] = {
1008
+ "cId": active_cid,
1009
+ "origin": orig,
1010
+ "destination": dest,
1011
+ "departureDate": dep_date,
1012
+ }
1013
+ if ret_date:
1014
+ q_params["returnDate"] = ret_date
1015
+ q_params["tripType"] = "R" if ret_date else "O"
1016
+ q_params["cabinClass"] = cabin
1017
+ q_params["ADT"] = adults
1018
+ return f"https://www.turkishairlines.com{base_path}?{urlencode(q_params)}"
1019
+
1020
+ def _is_populated_availability(json_obj_or_str: Any) -> bool:
1021
+ if isinstance(json_obj_or_str, str):
1022
+ try:
1023
+ json_obj_or_str = json.loads(json_obj_or_str)
1024
+ except Exception:
1025
+ return False
1026
+ if not isinstance(json_obj_or_str, dict):
1027
+ return False
1028
+ d = json_obj_or_str.get("data")
1029
+ if isinstance(d, dict):
1030
+ if "showBanner" in d and not d.get("originDestinationInformationList"):
1031
+ return False
1032
+ od_l = d.get("originDestinationInformationList", [])
1033
+ if isinstance(od_l, list) and od_l:
1034
+ for od_item in od_l:
1035
+ if isinstance(od_item, dict) and (od_item.get("originDestinationOptionList") or od_item.get("flightOptionList") or od_item.get("options")):
1036
+ return True
1037
+ air_l = d.get("availabilityInfoResponse")
1038
+ if isinstance(air_l, list) and any(len(sub) > 0 for sub in air_l if isinstance(sub, list)):
1039
+ return True
1040
+ if d.get("economyStartingPrice") or d.get("businessStartingPrice"):
1041
+ return True
1042
+ return False
1043
+
1044
+ validate_url = "https://www.turkishairlines.com/api/v1/availability/validate" if is_thy_availability else None
1045
+ val_payload = _build_validate_payload(req_payload) if is_thy_availability else None
1046
+
1047
+ fetch_js = """async ({ validateUrl, validateBody, url, method, headers, body }) => {
1048
+ const forbidden = [
1049
+ 'user-agent', 'cookie', 'host',
1050
+ 'content-length', 'connection', 'keep-alive', 'accept-encoding'
1051
+ ];
1052
+ const safeHeaders = {};
1053
+ for (const [k, v] of Object.entries(headers || {})) {
1054
+ const lk = k.toLowerCase().trim();
1055
+ if (!forbidden.includes(lk) && !lk.startsWith('sec-')) {
1056
+ safeHeaders[k] = v;
1057
+ }
1058
+ }
1059
+ const isThy = (url || '').toLowerCase().includes('turkishairlines.com');
1060
+ if (isThy) {
1061
+ safeHeaders['Referer'] = headers?.['Referer'] || headers?.['referer'] || 'https://www.turkishairlines.com/';
1062
+ safeHeaders['Origin'] = headers?.['Origin'] || headers?.['origin'] || 'https://www.turkishairlines.com';
1063
+ }
1064
+ const customReferrer = safeHeaders['Referer'] || safeHeaders['referer'] || (isThy ? 'https://www.turkishairlines.com/' : undefined);
1065
+ const hasBody = body !== null && body !== undefined && method !== 'GET' && method !== 'HEAD';
1066
+ if (hasBody && !safeHeaders['content-type'] && !safeHeaders['Content-Type']) {
1067
+ safeHeaders['content-type'] = 'application/json';
1068
+ }
1069
+ if (!safeHeaders['accept'] && !safeHeaders['Accept']) {
1070
+ safeHeaders['accept'] = 'application/json, text/plain, */*';
1071
+ }
1072
+
1073
+ // 1. THY ise oturum durumunu hazırlamak için /api/v1/availability/validate çağrısı
1074
+ if (validateUrl && validateBody) {
1075
+ try {
1076
+ const valOpts = {
1077
+ method: 'POST',
1078
+ headers: safeHeaders,
1079
+ credentials: 'include',
1080
+ referrer: customReferrer,
1081
+ body: typeof validateBody === 'string' ? validateBody : JSON.stringify(validateBody)
1082
+ };
1083
+ const valRes = await fetch(validateUrl, valOpts);
1084
+ const valText = await valRes.text();
1085
+ if (valText.includes('PXDqLTvkTO') || valText.includes('blockScript')) {
1086
+ return { status: valRes.status, ok: false, text: valText, step: 'validate' };
1087
+ }
1088
+ } catch (e) {
1089
+ // validate hatasını yut ve ana isteğe devam et
1090
+ }
1091
+ }
1092
+
1093
+ // 2. Ana hedef fetch() çağrısı
1094
+ const opts = {
1095
+ method: method || 'GET',
1096
+ headers: safeHeaders,
1097
+ credentials: 'include'
1098
+ };
1099
+ if (customReferrer) {
1100
+ opts.referrer = customReferrer;
1101
+ }
1102
+ if (hasBody) {
1103
+ opts.body = typeof body === 'string' ? body : JSON.stringify(body);
1104
+ }
1105
+ try {
1106
+ const res = await fetch(url, opts);
1107
+ const text = await res.text();
1108
+ return { status: res.status, ok: res.ok, text, step: 'main' };
1109
+ } catch (e) {
1110
+ try {
1111
+ const omitOpts = { ...opts, credentials: 'omit' };
1112
+ const res2 = await fetch(url, omitOpts);
1113
+ const text2 = await res2.text();
1114
+ return { status: res2.status, ok: res2.ok, text: text2, step: 'main_omit' };
1115
+ } catch (e2) {
1116
+ return { status: 0, ok: false, text: '', error: String(e), step: 'main' };
1117
+ }
1118
+ }
1119
+ }"""
1120
+
1121
+ eval_result: dict[str, Any] = {}
1122
+
1123
+ async with async_playwright() as p:
1124
+ remote_browser = None
1125
+ if active_mode == "cloud" and self.cloud_cdp_url:
1126
+ console.print(f"[bold cyan]☁️ Cloud Remote Browser bağlantısı kuruluyor:[/bold cyan] [white]{self.cloud_cdp_url}[/white]")
1127
+ remote_browser = await p.chromium.connect_over_cdp(self.cloud_cdp_url)
1128
+ context = remote_browser.contexts[0] if remote_browser.contexts else await remote_browser.new_context()
1129
+ elif active_mode == "cloud":
1130
+ # Bulut/Docker konteyner hazırlık modu (headless izole profil)
1131
+ cloud_kwargs: dict[str, Any] = {
1132
+ "user_data_dir": str(self.profile_dir.resolve()),
1133
+ "headless": True,
1134
+ "args": [
1135
+ "--disable-blink-features=AutomationControlled",
1136
+ "--no-sandbox",
1137
+ "--disable-setuid-sandbox",
1138
+ "--disable-dev-shm-usage",
1139
+ ],
1140
+ }
1141
+ context = await p.chromium.launch_persistent_context(**cloud_kwargs)
1142
+ else:
1143
+ is_headless_env = bool(
1144
+ sys.platform.startswith("linux") and not os.getenv("DISPLAY")
1145
+ )
1146
+ launch_kwargs: dict[str, Any] = {
1147
+ "user_data_dir": str(self.profile_dir.resolve()),
1148
+ "headless": is_headless_env,
1149
+ "args": [
1150
+ "--disable-blink-features=AutomationControlled",
1151
+ "--no-sandbox",
1152
+ "--disable-setuid-sandbox",
1153
+ "--disable-dev-shm-usage",
1154
+ ],
1155
+ }
1156
+ context = await p.chromium.launch_persistent_context(**launch_kwargs)
1157
+
1158
+ # TokenStore'daki kayıtlı kiracı/servis çerezlerini tarayıcı bağlamına otomatik enjekte et
1159
+ stored_pw_cookies = self.token_store.get_playwright_cookies(
1160
+ tenant_id=active_tenant,
1161
+ service=self.service,
1162
+ default_url=visit_url,
1163
+ )
1164
+ if stored_pw_cookies:
1165
+ try:
1166
+ await context.add_cookies(stored_pw_cookies)
1167
+ except Exception:
1168
+ pass
1169
+
1170
+ page = context.pages[0] if context.pages else await context.new_page()
1171
+ if active_mode == "local":
1172
+ try:
1173
+ await page.bring_to_front()
1174
+ except Exception:
1175
+ pass
1176
+
1177
+ # Tarayıcı ağ trafiğini dinleyerek asıl /api/v1/availability istek ve yanıtlarını yakala
1178
+ captured_browser_request: dict[str, Any] = {}
1179
+ captured_browser_response: dict[str, Any] = {}
1180
+
1181
+ def handle_request(req):
1182
+ try:
1183
+ p_path = urlparse(req.url).path.rstrip('/')
1184
+ if p_path == "/api/v1/availability" and req.method == "POST":
1185
+ captured_browser_request["url"] = req.url
1186
+ captured_browser_request["headers"] = dict(req.headers)
1187
+ captured_browser_request["post_data"] = req.post_data_json if req.post_data else None
1188
+ captured_browser_request["raw_post_data"] = req.post_data
1189
+ except Exception:
1190
+ pass
1191
+
1192
+ def handle_response(res):
1193
+ try:
1194
+ p_path = urlparse(res.url).path.rstrip('/')
1195
+ if p_path == "/api/v1/availability" and res.request.method == "POST":
1196
+ async def _read():
1197
+ try:
1198
+ captured_browser_response["status"] = res.status
1199
+ captured_browser_response["text"] = await res.text()
1200
+ except Exception:
1201
+ pass
1202
+ asyncio.create_task(_read())
1203
+ except Exception:
1204
+ pass
1205
+
1206
+ if is_thy_availability:
1207
+ page.on("request", handle_request)
1208
+ page.on("response", handle_response)
1209
+
1210
+ async def _prepare_thy_search_page_state(
1211
+ page_obj: Any,
1212
+ p_load: Any,
1213
+ hdrs: dict[str, Any],
1214
+ cid: str | None = None,
1215
+ ) -> str:
1216
+ """
1217
+ WAF/Captcha geçildikten veya HITL onayı (resume) alındıktan sonra,
1218
+ doğrudan /api/v1/availability isteği atmadan önce:
1219
+ 1. Tarayıcıda ön doğrulama (pax-type-codes, availability/validate) ve storage state'ini hazırlar,
1220
+ 2. İlgili gidiş-dönüş arama URL'sine (page.goto) giderek sayfa state'ini oluşturur,
1221
+ 3. Başlıkları (Referer, x-current-page-url, x-client-id) aktif arama sayfasıyla senkronize eder.
1222
+ """
1223
+ active_cid = (
1224
+ cid
1225
+ or hdrs.get("x-client-id")
1226
+ or hdrs.get("x-clientid")
1227
+ or str(uuid.uuid4())
1228
+ )
1229
+ search_state_url = _build_thy_search_state_url(
1230
+ p_load,
1231
+ cid=active_cid,
1232
+ candidate_url=raw_visit_url,
1233
+ )
1234
+ parsed_s = urlparse(search_state_url)
1235
+ route_path = (
1236
+ f"{parsed_s.path}?{parsed_s.query}"
1237
+ if parsed_s.query
1238
+ else parsed_s.path
1239
+ )
1240
+
1241
+ hdrs["x-client-id"] = active_cid
1242
+ hdrs["x-clientid"] = active_cid
1243
+ hdrs["x-current-page-url"] = route_path
1244
+ hdrs["Referer"] = search_state_url
1245
+ hdrs["referer"] = search_state_url
1246
+ hdrs["Origin"] = "https://www.turkishairlines.com"
1247
+ hdrs["origin"] = "https://www.turkishairlines.com"
1248
+
1249
+ val_body = _build_validate_payload(p_load)
1250
+
1251
+ # Adım 1: Arama sayfasına gitmeden önce session/localStorage ve validate state'ini hazırla
1252
+ try:
1253
+ await page_obj.evaluate(
1254
+ """async ({ cid, validateBody, fullPayload, routePath }) => {
1255
+ try {
1256
+ const stateObj = {
1257
+ cId: cid,
1258
+ bookingSearch: fullPayload || validateBody,
1259
+ updatedAt: new Date().toISOString()
1260
+ };
1261
+ sessionStorage.setItem('thy_booking_search_state', JSON.stringify(stateObj));
1262
+ sessionStorage.setItem('cId', cid);
1263
+ localStorage.setItem('thy_last_cid', cid);
1264
+ } catch (_) {}
1265
+
1266
+ const commonHeaders = {
1267
+ 'accept': 'application/json, text/plain, */*',
1268
+ 'content-type': 'application/json',
1269
+ 'x-country': 'TR',
1270
+ 'x-language': 'tr',
1271
+ 'x-client-id': cid,
1272
+ 'x-clientid': cid,
1273
+ 'x-current-page-url': '/tr-tr'
1274
+ };
1275
+ try {
1276
+ const odList = (validateBody && validateBody.originDestinationInformationList) || [];
1277
+ await fetch('https://www.turkishairlines.com/api/v1/passenger/pax-type-codes', {
1278
+ method: 'POST',
1279
+ headers: commonHeaders,
1280
+ credentials: 'include',
1281
+ body: JSON.stringify({
1282
+ selectedBookerSearch: (validateBody && validateBody.selectedBookerSearch) || 'R',
1283
+ moduleType: 'TICKETING',
1284
+ originDestinationInformationList: odList.slice(0, 1)
1285
+ })
1286
+ });
1287
+ } catch (_) {}
1288
+
1289
+ if (validateBody) {
1290
+ try {
1291
+ await fetch('https://www.turkishairlines.com/api/v1/availability/validate', {
1292
+ method: 'POST',
1293
+ headers: commonHeaders,
1294
+ credentials: 'include',
1295
+ body: JSON.stringify(validateBody)
1296
+ });
1297
+ } catch (_) {}
1298
+ }
1299
+ }""",
1300
+ {
1301
+ "cid": active_cid,
1302
+ "validateBody": val_body,
1303
+ "fullPayload": p_load,
1304
+ "routePath": route_path,
1305
+ },
1306
+ )
1307
+ except Exception:
1308
+ pass
1309
+
1310
+ # Adım 2: Sayfada arama formu varsa Generic Form Automation ile otonom doldur
1311
+ try:
1312
+ await _run_smart_form_automation(page_obj, p_load)
1313
+ except Exception:
1314
+ pass
1315
+
1316
+ # Adım 3: İlgili gidiş-dönüş arama URL'sine (search_state_url) giderek sayfa state'ini oluştur
1317
+ console.print(
1318
+ f"[bold cyan]🧭 THY arama URL'sine gidilerek sayfa state'i oluşturuluyor:[/bold cyan] [white]{search_state_url}[/white]"
1319
+ )
1320
+ try:
1321
+ await page_obj.goto(
1322
+ search_state_url,
1323
+ wait_until="domcontentloaded",
1324
+ timeout=15_000,
1325
+ )
1326
+ except Exception as nav_exc:
1327
+ console.print(f"[dim]THY arama sayfası gezinme bildirimi: {nav_exc}[/dim]")
1328
+
1329
+ await asyncio.sleep(3.0)
1330
+ return search_state_url
1331
+
1332
+ async def _run_smart_form_automation(
1333
+ page_obj: Any,
1334
+ p_load: Any = None,
1335
+ ) -> dict[str, Any]:
1336
+ """
1337
+ Genel Amaçlı Akıllı Form Otomasyonu (Generic Form Automation):
1338
+ 1. Sayfa yüklendiğinde origin/destination ve departure/return date input'larını
1339
+ semantik olarak (placeholder, name, aria-label, id, label) bulup otonom doldurur.
1340
+ 2. Autocomplete listesi çıkarsa ilk eşleşen sonuca tıklar.
1341
+ 3. Arama / uçuş bul butonunu otomatik tespit edip tıklayarak SPA state'ini oluşturur.
1342
+ """
1343
+ if auto_fill_form is False:
1344
+ return {"executed": False}
1345
+ try:
1346
+ from core.form_automation import (
1347
+ extract_semantic_form_params,
1348
+ run_generic_form_automation,
1349
+ )
1350
+
1351
+ active_body = p_load if p_load is not None else req_payload
1352
+ sem_params = extract_semantic_form_params(
1353
+ json_payload=active_body,
1354
+ params=req_params,
1355
+ url=full_req_url,
1356
+ target_url=raw_visit_url,
1357
+ explicit_form_params=form_params,
1358
+ )
1359
+ has_route_or_date = bool(
1360
+ sem_params.get("origin")
1361
+ or sem_params.get("destination")
1362
+ or sem_params.get("departure_date")
1363
+ or sem_params.get("return_date")
1364
+ )
1365
+ if not has_route_or_date and not auto_fill_form and not form_params:
1366
+ return {"executed": False}
1367
+
1368
+ return await run_generic_form_automation(
1369
+ page=page_obj,
1370
+ form_params=sem_params,
1371
+ json_payload=active_body,
1372
+ params=req_params,
1373
+ url=full_req_url,
1374
+ target_url=raw_visit_url,
1375
+ submit_form=True,
1376
+ )
1377
+ except Exception as fa_exc:
1378
+ console.print(f"[dim]Generic Form Automation uyarısı: {fa_exc}[/dim]")
1379
+ return {"executed": False}
1380
+
1381
+ console.print(f"[bold cyan]🌐 Tarayıcı açılıyor:[/bold cyan] [white]{visit_url}[/white]")
1382
+ try:
1383
+ await page.goto(visit_url, wait_until="domcontentloaded", timeout=12_000)
1384
+ except Exception as e:
1385
+ console.print(f"[dim]Sayfa gezinme bildirimi: {e}[/dim]")
1386
+
1387
+ wait_seconds = 3 if is_thy_availability else 1
1388
+ console.print(f"[dim]Frontend oturumunun yerleşmesi için {wait_seconds} saniye bekleniyor...[/dim]")
1389
+ await asyncio.sleep(wait_seconds)
1390
+
1391
+ from core.hitl_queue import HITLQueueManager
1392
+
1393
+ hitl_mgr = HITLQueueManager()
1394
+ active_hitl_task_id: str | None = None
1395
+ is_resuming_hitl = os.getenv("OWNAPI_HITL_RESUMING", "0").strip() == "1"
1396
+ env_otp_code = os.getenv("OWNAPI_HITL_OTP_CODE", "").strip() or None
1397
+ pause_and_wait = (
1398
+ not is_resuming_hitl
1399
+ and os.getenv("OWNAPI_HITL_PAUSE_AND_WAIT", "1").strip().lower() not in ("0", "false", "no")
1400
+ )
1401
+ pause_timeout_sec = float(os.getenv("OWNAPI_HITL_PAUSE_TIMEOUT", "300"))
1402
+
1403
+ if is_resuming_hitl and env_otp_code:
1404
+ await self._apply_otp_code_if_present(page, env_otp_code, tenant_id=active_tenant)
1405
+
1406
+ # Eğer THY availability çağrısıysa ve ana sayfada WAF engeli yoksa (veya HITL resume modundaysak),
1407
+ # doğrudan /api/v1/availability isteği atmadan ÖNCE arama URL'sine gidip sayfa state'ini oluştur!
1408
+ if is_thy_availability and (is_resuming_hitl or not await self._detect_challenge(page)):
1409
+ await _prepare_thy_search_page_state(
1410
+ page,
1411
+ req_payload,
1412
+ req_headers,
1413
+ cid=req_headers.get("x-client-id") or req_headers.get("x-clientid"),
1414
+ )
1415
+ val_payload = _build_validate_payload(req_payload)
1416
+ elif not is_thy_availability and (is_resuming_hitl or not await self._detect_challenge(page)):
1417
+ # Diğer tüm siteler (Pegasus, Enuygun, Obilet veya yeni eklenen siteler) için
1418
+ # Generic Form Automation katmanını çalıştırarak SPA state'ini oluştur!
1419
+ await _run_smart_form_automation(page, req_payload)
1420
+
1421
+ # Eğer arama URL'sine gidildiğinde sayfa kendi başına dolu /api/v1/availability yanıtı aldıysa onu kullan
1422
+ pre_captured = captured_browser_response.get("text", "")
1423
+ if is_thy_availability and pre_captured and _is_populated_availability(pre_captured):
1424
+ console.print("[bold green]✔ Arama sayfası state oluşumu sırasında dolu uçuş yanıtı yakalandı![/bold green]")
1425
+ eval_result = {
1426
+ "status": captured_browser_response.get("status", 200),
1427
+ "ok": True,
1428
+ "text": pre_captured,
1429
+ "step": "search_page_navigation",
1430
+ }
1431
+ else:
1432
+ # 1. Doğrudan açık tarayıcı sekmesi içinden fetch() isteğini tetikle
1433
+ console.print(f"[dim]Tarayıcı içi fetch({method.upper()} {full_req_url}) isteği gönderiliyor...[/dim]")
1434
+ try:
1435
+ eval_result = await asyncio.wait_for(
1436
+ page.evaluate(
1437
+ fetch_js,
1438
+ {
1439
+ "validateUrl": validate_url,
1440
+ "validateBody": val_payload,
1441
+ "url": full_req_url,
1442
+ "method": method.upper(),
1443
+ "headers": req_headers,
1444
+ "body": req_payload,
1445
+ },
1446
+ ),
1447
+ timeout=15.0,
1448
+ )
1449
+ except Exception as eval_exc:
1450
+ console.print(f"[dim]In-page fetch zaman aşımı/uyarısı ({eval_exc}), context.request.fetch kullanılıyor...[/dim]")
1451
+ eval_result = {"status": 0, "ok": False, "text": "", "error": str(eval_exc)}
1452
+
1453
+ if eval_result.get("status", 0) == 0:
1454
+ try:
1455
+ ctx_resp = await context.request.fetch(
1456
+ full_req_url,
1457
+ method=method.upper(),
1458
+ headers=req_headers,
1459
+ data=json.dumps(req_payload) if isinstance(req_payload, (dict, list)) else req_payload,
1460
+ timeout=15_000,
1461
+ )
1462
+ ctx_text = await ctx_resp.text()
1463
+ eval_result = {
1464
+ "status": ctx_resp.status,
1465
+ "ok": ctx_resp.ok,
1466
+ "text": ctx_text,
1467
+ "step": "context_request",
1468
+ }
1469
+ except Exception as ctx_err:
1470
+ console.print(f"[dim]Context request uyarısı: {ctx_err}[/dim]")
1471
+
1472
+ resp_text = _extract_response_text(
1473
+ eval_result.get("text", "") if isinstance(eval_result, dict) else eval_result
1474
+ )
1475
+ resp_status = eval_result.get("status", 0) if isinstance(eval_result, dict) else 200
1476
+ is_challenge = (
1477
+ "PXDqLTvkTO" in resp_text
1478
+ or "blockScript" in resp_text
1479
+ or resp_status in (403, 429)
1480
+ or "wrapper--notfound" in resp_text
1481
+ or "Reference Code:" in resp_text
1482
+ or (not (eval_result.get("ok") if isinstance(eval_result, dict) else True) and await self._detect_challenge(page))
1483
+ )
1484
+
1485
+ # 2. Eğer PerimeterX / Akamai / Cloudflare doğrulaması geldiyse
1486
+ if is_challenge:
1487
+ console.print("[bold yellow]⚠️ Güvenlik / İnsan doğrulaması tespit edildi![/bold yellow]")
1488
+ try:
1489
+ await page.evaluate(
1490
+ """(text) => {
1491
+ try {
1492
+ const data = JSON.parse(text);
1493
+ if (data && data.blockScript) {
1494
+ window._pxAppId = data.appId;
1495
+ window._pxJsRef = data.jsRef;
1496
+ const s = document.createElement('script');
1497
+ s.src = data.blockScript;
1498
+ document.body.appendChild(s);
1499
+ }
1500
+ } catch (_) {}
1501
+ }""",
1502
+ resp_text,
1503
+ )
1504
+ except Exception:
1505
+ pass
1506
+
1507
+ if not is_resuming_hitl:
1508
+ paused_shot_b64 = await hitl_mgr.capture_page_screenshot_b64(page)
1509
+ hitl_task = hitl_mgr.enqueue_task(
1510
+ tenant_id=active_tenant,
1511
+ service=self.service,
1512
+ method=method.upper(),
1513
+ url=url,
1514
+ params=req_params,
1515
+ headers=req_headers,
1516
+ json_payload=req_payload,
1517
+ target_url=_build_thy_search_state_url(req_payload) if is_thy_availability else visit_url,
1518
+ reason=f"Captcha / WAF / PerimeterX doğrulaması algılandı (HTTP {resp_status})",
1519
+ in_place_paused=pause_and_wait,
1520
+ execution_mode=active_mode,
1521
+ screenshot_b64=paused_shot_b64,
1522
+ )
1523
+ active_hitl_task_id = hitl_task["task_id"]
1524
+
1525
+ if not pause_and_wait:
1526
+ try:
1527
+ await context.close()
1528
+ except Exception:
1529
+ pass
1530
+ if remote_browser is not None:
1531
+ try:
1532
+ await remote_browser.close()
1533
+ except Exception:
1534
+ pass
1535
+ return ResponseDict(
1536
+ {
1537
+ "status": "PAUSED",
1538
+ "legacy_status": "PENDING_USER_APPROVAL",
1539
+ "agent_state": "paused",
1540
+ "task_id": hitl_task["task_id"],
1541
+ "service": self.service,
1542
+ "tenant_id": active_tenant,
1543
+ "reason": hitl_task["reason"],
1544
+ "message": "Ajan PAUSED durumuna alındı; Web Dashboard üzerinden onay bekleniyor",
1545
+ }
1546
+ )
1547
+
1548
+ console.print(
1549
+ f"[bold yellow]⏸️ [HITL PAUSED] Ajan görevi ({hitl_task['task_id']}) öldürülmedi, 'PAUSED' durumuna alındı.[/bold yellow]\n"
1550
+ f"[cyan]👉 Web Dashboard üzerinden onay verildiğinde kaldığı yerden ('resume') devam edecek...[/cyan]"
1551
+ )
1552
+ resumed_task = await hitl_mgr.await_task_resume(
1553
+ task_id=hitl_task["task_id"],
1554
+ poll_interval=0.5,
1555
+ timeout_sec=pause_timeout_sec,
1556
+ )
1557
+ if resumed_task is None:
1558
+ try:
1559
+ await context.close()
1560
+ except Exception:
1561
+ pass
1562
+ if remote_browser is not None:
1563
+ try:
1564
+ await remote_browser.close()
1565
+ except Exception:
1566
+ pass
1567
+ return ResponseDict(
1568
+ {
1569
+ "status": "PAUSED",
1570
+ "legacy_status": "PENDING_USER_APPROVAL",
1571
+ "agent_state": "paused",
1572
+ "task_id": hitl_task["task_id"],
1573
+ "service": self.service,
1574
+ "tenant_id": active_tenant,
1575
+ "reason": hitl_task["reason"],
1576
+ "message": "Kullanıcı doğrulaması bekleniyor (.sessions/hitl_state.json)",
1577
+ }
1578
+ )
1579
+
1580
+ console.print(
1581
+ f"[bold green]▶️ [HITL RESUMED] Kullanıcı onayı alındı ({hitl_task['task_id']}), ajan kaldığı yerden devam ediyor...[/bold green]"
1582
+ )
1583
+ resumed_otp = str(resumed_task.get("otp_code") or os.getenv("OWNAPI_HITL_OTP_CODE") or "").strip() or None
1584
+ if resumed_otp:
1585
+ await self._apply_otp_code_if_present(page, resumed_otp, tenant_id=active_tenant)
1586
+ else:
1587
+ await asyncio.sleep(2.0)
1588
+ if env_otp_code:
1589
+ await self._apply_otp_code_if_present(page, env_otp_code, tenant_id=active_tenant)
1590
+
1591
+ # WAF/Captcha geçildikten ve HITL onayı alındıktan sonra:
1592
+ # Doğrudan API isteği atmadan ÖNCE Generic Form Automation ve arama URL'si ile sayfa state'ini oluştur!
1593
+ if is_thy_availability:
1594
+ fresh_waf_cid = str(uuid.uuid4())
1595
+ await _prepare_thy_search_page_state(
1596
+ page,
1597
+ req_payload,
1598
+ req_headers,
1599
+ cid=fresh_waf_cid,
1600
+ )
1601
+ val_payload = _build_validate_payload(req_payload)
1602
+ else:
1603
+ await _run_smart_form_automation(page, req_payload)
1604
+
1605
+ captured_after_waf = captured_browser_response.get("text", "")
1606
+ if is_thy_availability and captured_after_waf and _is_populated_availability(captured_after_waf):
1607
+ console.print("[bold green]✔ WAF sonrası arama sayfası state oluşumundan dolu uçuş yanıtı yakalandı![/bold green]")
1608
+ eval_result = {
1609
+ "status": captured_browser_response.get("status", 200),
1610
+ "ok": True,
1611
+ "text": captured_after_waf,
1612
+ }
1613
+ else:
1614
+ eval_result = await page.evaluate(
1615
+ fetch_js,
1616
+ {
1617
+ "validateUrl": validate_url,
1618
+ "validateBody": val_payload,
1619
+ "url": full_req_url,
1620
+ "method": method.upper(),
1621
+ "headers": req_headers,
1622
+ "body": req_payload,
1623
+ },
1624
+ )
1625
+
1626
+ # 3. THY Özel Kontrolü: Eğer Error-DS-30006 dönerse veya availabilityInfoResponse boş ([[], []]) geldiyse
1627
+ if is_thy_availability:
1628
+ resp_text = _extract_response_text(eval_result)
1629
+ is_empty_air = False
1630
+ try:
1631
+ chk_res = json.loads(resp_text)
1632
+ if isinstance(chk_res, dict):
1633
+ data_part = chk_res.get("data")
1634
+ if isinstance(data_part, dict) and data_part.get("availabilityInfoResponse") == [[], []]:
1635
+ is_empty_air = True
1636
+ except Exception:
1637
+ pass
1638
+
1639
+ is_populated = _is_populated_availability(resp_text)
1640
+ is_ds_error = (
1641
+ "Error-DS-30006" in resp_text
1642
+ or "DS-30006" in resp_text
1643
+ or "işleminizi gerçekleştiremiyoruz" in resp_text
1644
+ or (is_empty_air and not is_populated)
1645
+ )
1646
+
1647
+ if is_ds_error or not is_populated:
1648
+ if is_empty_air:
1649
+ console.print("\n[bold yellow]⚠️ Boş availabilityInfoResponse: [[], []] alındı (oturum durumu henüz hazır değil).[/bold yellow]")
1650
+ else:
1651
+ console.print("\n[bold yellow]⚠️ Backend oturum/şema uyarısı alındı.[/bold yellow]")
1652
+
1653
+ if not is_resuming_hitl:
1654
+ paused_thy_shot_b64 = await hitl_mgr.capture_page_screenshot_b64(page)
1655
+ if active_hitl_task_id is None:
1656
+ hitl_task = hitl_mgr.enqueue_task(
1657
+ tenant_id=active_tenant,
1658
+ service=self.service,
1659
+ method=method.upper(),
1660
+ url=url,
1661
+ params=req_params,
1662
+ headers=req_headers,
1663
+ json_payload=req_payload,
1664
+ target_url=_build_thy_search_state_url(req_payload),
1665
+ reason="THY oturum/müsaitlik ön etkileşimi veya WAF doğrulaması gerekiyor",
1666
+ in_place_paused=pause_and_wait,
1667
+ execution_mode=active_mode,
1668
+ screenshot_b64=paused_thy_shot_b64,
1669
+ )
1670
+ active_hitl_task_id = hitl_task["task_id"]
1671
+ else:
1672
+ if paused_thy_shot_b64:
1673
+ hitl_mgr.update_task_state(
1674
+ active_hitl_task_id,
1675
+ status="PAUSED",
1676
+ screenshot_b64=paused_thy_shot_b64,
1677
+ execution_mode=active_mode,
1678
+ )
1679
+ hitl_task, _ = hitl_mgr.get_task(active_hitl_task_id, tenant_id=active_tenant)
1680
+ hitl_task = hitl_task or {"task_id": active_hitl_task_id, "reason": "THY doğrulaması gerekiyor"}
1681
+
1682
+ if not pause_and_wait:
1683
+ try:
1684
+ await context.close()
1685
+ except Exception:
1686
+ pass
1687
+ if remote_browser is not None:
1688
+ try:
1689
+ await remote_browser.close()
1690
+ except Exception:
1691
+ pass
1692
+ return ResponseDict(
1693
+ {
1694
+ "status": "PAUSED",
1695
+ "legacy_status": "PENDING_USER_APPROVAL",
1696
+ "agent_state": "paused",
1697
+ "task_id": hitl_task["task_id"],
1698
+ "service": self.service,
1699
+ "tenant_id": active_tenant,
1700
+ "reason": hitl_task["reason"],
1701
+ "message": "Ajan PAUSED durumuna alındı; Web Dashboard üzerinden onay bekleniyor",
1702
+ }
1703
+ )
1704
+
1705
+ console.print(
1706
+ f"[bold yellow]⏸️ [HITL PAUSED] THY görevi ({hitl_task['task_id']}) 'PAUSED' durumuna alındı.[/bold yellow]\n"
1707
+ f"[cyan]👉 Web Dashboard üzerinden onay verildiğinde kaldığı yerden ('resume') devam edecek...[/cyan]"
1708
+ )
1709
+ resumed_task = await hitl_mgr.await_task_resume(
1710
+ task_id=hitl_task["task_id"],
1711
+ poll_interval=0.5,
1712
+ timeout_sec=pause_timeout_sec,
1713
+ )
1714
+ if resumed_task is None:
1715
+ try:
1716
+ await context.close()
1717
+ except Exception:
1718
+ pass
1719
+ if remote_browser is not None:
1720
+ try:
1721
+ await remote_browser.close()
1722
+ except Exception:
1723
+ pass
1724
+ return ResponseDict(
1725
+ {
1726
+ "status": "PAUSED",
1727
+ "legacy_status": "PENDING_USER_APPROVAL",
1728
+ "agent_state": "paused",
1729
+ "task_id": hitl_task["task_id"],
1730
+ "service": self.service,
1731
+ "tenant_id": active_tenant,
1732
+ "reason": hitl_task["reason"],
1733
+ "message": "Kullanıcı doğrulaması bekleniyor (.sessions/hitl_state.json)",
1734
+ }
1735
+ )
1736
+
1737
+ console.print(
1738
+ f"[bold green]▶️ [HITL RESUMED] Kullanıcı onayı alındı ({hitl_task['task_id']}), THY görevi kaldığı yerden devam ediyor...[/bold green]"
1739
+ )
1740
+ resumed_thy_otp = str(resumed_task.get("otp_code") or os.getenv("OWNAPI_HITL_OTP_CODE") or "").strip() or None
1741
+ if resumed_thy_otp:
1742
+ await self._apply_otp_code_if_present(page, resumed_thy_otp, tenant_id=active_tenant)
1743
+ else:
1744
+ await asyncio.sleep(2.0)
1745
+ if env_otp_code:
1746
+ await self._apply_otp_code_if_present(page, env_otp_code, tenant_id=active_tenant)
1747
+
1748
+ captured_text = captured_browser_response.get("text", "")
1749
+ if captured_text and _is_populated_availability(captured_text):
1750
+ console.print("[bold green]✔ Tarayıcı trafiğinden dolu uçuş yanıtı başarıyla yakalandı ve kullanılıyor![/bold green]")
1751
+ eval_result = {
1752
+ "status": captured_browser_response.get("status", 200),
1753
+ "ok": True,
1754
+ "text": captured_text,
1755
+ }
1756
+ else:
1757
+ fresh_call_cid = str(uuid.uuid4())
1758
+ retry_headers = dict(captured_browser_request.get("headers") or req_headers)
1759
+ retry_url = captured_browser_request.get("url") or full_req_url
1760
+ retry_payload = captured_browser_request.get("post_data") or req_payload
1761
+
1762
+ # HITL resume sonrası doğrudan /api/v1/availability isteği atmadan ÖNCE
1763
+ # Playwright tarayıcısını gidiş-dönüş parametreleriyle arama URL'sine götür ve sayfa state'ini oluştur!
1764
+ await _prepare_thy_search_page_state(
1765
+ page,
1766
+ retry_payload,
1767
+ retry_headers,
1768
+ cid=fresh_call_cid,
1769
+ )
1770
+ val_payload = _build_validate_payload(retry_payload)
1771
+
1772
+ captured_after_nav = captured_browser_response.get("text", "")
1773
+ if captured_after_nav and _is_populated_availability(captured_after_nav):
1774
+ console.print("[bold green]✔ Arama sayfasına gezinme sonrası dolu uçuş yanıtı yakalandı![/bold green]")
1775
+ eval_result = {
1776
+ "status": captured_browser_response.get("status", 200),
1777
+ "ok": True,
1778
+ "text": captured_after_nav,
1779
+ }
1780
+ else:
1781
+ eval_result = await page.evaluate(
1782
+ fetch_js,
1783
+ {
1784
+ "validateUrl": validate_url,
1785
+ "validateBody": val_payload,
1786
+ "url": retry_url,
1787
+ "method": method.upper(),
1788
+ "headers": retry_headers,
1789
+ "body": retry_payload,
1790
+ },
1791
+ )
1792
+
1793
+ # 4. Taze çerezleri ve User-Agent'ı TokenStore'a (.sessions/{tenant_id}/{service}_tokens.json) kaydet
1794
+ try:
1795
+ cookies = await context.cookies()
1796
+ user_agent = await page.evaluate("() => navigator.userAgent")
1797
+ cookie_str = "; ".join(f"{c['name']}={c['value']}" for c in cookies if c.get("name"))
1798
+ parsed_visit = urlparse(visit_url)
1799
+ origin_val = f"{parsed_visit.scheme}://{parsed_visit.netloc}" if parsed_visit.scheme and parsed_visit.netloc else "https://www.turkishairlines.com"
1800
+
1801
+ token_bundle = {
1802
+ "target_url": visit_url,
1803
+ "healed_at": datetime.now(timezone.utc).isoformat(),
1804
+ "cookie": cookie_str,
1805
+ "user-agent": user_agent,
1806
+ "headers": {
1807
+ "cookie": cookie_str,
1808
+ "user-agent": user_agent,
1809
+ "accept": "application/json",
1810
+ "content-type": "application/json",
1811
+ "origin": origin_val,
1812
+ "referer": visit_url,
1813
+ },
1814
+ "query_params": {},
1815
+ "body_tokens": {},
1816
+ }
1817
+ self.token_store.save_session(
1818
+ tenant_id=active_tenant,
1819
+ service=self.service,
1820
+ cookies=cookies,
1821
+ user_agent=user_agent,
1822
+ metadata=token_bundle,
1823
+ )
1824
+ await context.storage_state(path=str(self.profile_dir / "storage_state.json"))
1825
+ except Exception:
1826
+ pass
1827
+
1828
+ try:
1829
+ await context.close()
1830
+ except Exception:
1831
+ pass
1832
+ if remote_browser is not None:
1833
+ try:
1834
+ await remote_browser.close()
1835
+ except Exception:
1836
+ pass
1837
+
1838
+ # Dönen yanıtı parse et ve döndür (dict ise .text aramaya kalkmadan doğrudan işle)
1839
+ if isinstance(eval_result, dict) and "text" not in eval_result and "status" not in eval_result:
1840
+ final_res_obj: Any = ResponseDict(eval_result)
1841
+ if active_hitl_task_id:
1842
+ hitl_mgr.update_task_state(active_hitl_task_id, status="COMPLETED", response=dict(final_res_obj))
1843
+ return final_res_obj
1844
+
1845
+ final_text = _extract_response_text(eval_result)
1846
+ status_val = eval_result.get("status", 200) if isinstance(eval_result, dict) else 200
1847
+ is_ok = bool(eval_result.get("ok", 200 <= status_val < 400)) if isinstance(eval_result, dict) else True
1848
+
1849
+ try:
1850
+ parsed_json = json.loads(final_text)
1851
+ if isinstance(parsed_json, dict):
1852
+ final_res_obj = ResponseDict(parsed_json)
1853
+ if active_hitl_task_id:
1854
+ hitl_mgr.update_task_state(active_hitl_task_id, status="COMPLETED", response=dict(final_res_obj))
1855
+ return final_res_obj
1856
+ if active_hitl_task_id:
1857
+ hitl_mgr.update_task_state(active_hitl_task_id, status="COMPLETED", response=parsed_json)
1858
+ return parsed_json
1859
+ except Exception:
1860
+ if is_ok and final_text and not (isinstance(eval_result, dict) and eval_result.get("error")):
1861
+ final_res_obj = ResponseDict(
1862
+ {
1863
+ "success": True,
1864
+ "status_code": status_val,
1865
+ "ok": True,
1866
+ "text": final_text,
1867
+ "response_sample": final_text[:3000],
1868
+ }
1869
+ )
1870
+ if active_hitl_task_id:
1871
+ hitl_mgr.update_task_state(active_hitl_task_id, status="COMPLETED", response=dict(final_res_obj))
1872
+ return final_res_obj
1873
+ final_res_obj = ResponseDict(
1874
+ {
1875
+ "status_code": status_val,
1876
+ "text": final_text,
1877
+ "response_sample": final_text[:3000],
1878
+ "error": (eval_result.get("error") if isinstance(eval_result, dict) else None)
1879
+ or "Yanıt JSON formatında ayrıştırılamadı",
1880
+ }
1881
+ )
1882
+ if active_hitl_task_id:
1883
+ hitl_mgr.update_task_state(active_hitl_task_id, status="COMPLETED", response=dict(final_res_obj))
1884
+ return final_res_obj
1885
+
1886
+
1887
+ def execute_in_browser(
1888
+ self,
1889
+ method: str,
1890
+ url: str,
1891
+ headers: dict[str, str] | None = None,
1892
+ params: dict[str, Any] | None = None,
1893
+ json_payload: Any = None,
1894
+ target_url: str | None = None,
1895
+ user_wait_sec: int | None = None,
1896
+ challenge_wait_sec: int | None = None,
1897
+ tenant_id: str | None = None,
1898
+ mode: str | None = None,
1899
+ non_interactive: bool | None = None,
1900
+ form_params: dict[str, Any] | None = None,
1901
+ auto_fill_form: bool | None = None,
1902
+ ) -> dict[str, Any]:
1903
+ """
1904
+ OwnAPI User-Delegated Browser Middleware:
1905
+ İsteği seçilen modda ('local' yerel Chrome veya 'cloud' Docker/Remote Browser)
1906
+ doğrudan tarayıcının kendi JavaScript ortamında (page.evaluate fetch) çalıştırır.
1907
+ """
1908
+ return self._run_sync(
1909
+ self.aexecute_in_browser(
1910
+ method=method,
1911
+ url=url,
1912
+ headers=headers,
1913
+ params=params,
1914
+ json_payload=json_payload,
1915
+ target_url=target_url,
1916
+ user_wait_sec=user_wait_sec,
1917
+ challenge_wait_sec=challenge_wait_sec,
1918
+ tenant_id=tenant_id,
1919
+ mode=mode,
1920
+ non_interactive=non_interactive,
1921
+ form_params=form_params,
1922
+ auto_fill_form=auto_fill_form,
1923
+ )
1924
+ )
1925
+
1926
+ def retry_with_fresh_session(
1927
+ self,
1928
+ method: str,
1929
+ url: str,
1930
+ headers: dict[str, str],
1931
+ params: dict[str, Any] | None = None,
1932
+ json_payload: Any = None,
1933
+ target_url: str | None = None,
1934
+ ) -> tuple[dict[str, str], dict[str, Any], Any, Any]:
1935
+ """
1936
+ 401/403/429 alındığında taze oturum açar, token ve başlıkları yeniler ve
1937
+ isteği taze oturum tüneli üzerinden tekrarlar.
1938
+ Dönüş: (güncel_headers, güncel_params, güncel_payload, tünel_yanıtı_veya_None)
1939
+ """
1940
+ visit_url = target_url or headers.get("referer") or headers.get("origin")
1941
+ bundle = self._run_sync(
1942
+ self.aheal(
1943
+ target_url=visit_url,
1944
+ retry_request={
1945
+ "method": method,
1946
+ "url": url,
1947
+ "headers": headers,
1948
+ "params": params,
1949
+ "json_payload": json_payload,
1950
+ },
1951
+ )
1952
+ )
1953
+ tunnel_resp = bundle.get("_tunnel_response")
1954
+ new_headers, new_params, new_payload = self.apply_to_request(
1955
+ headers,
1956
+ params,
1957
+ json_payload,
1958
+ endpoint_url=url,
1959
+ )
1960
+ return new_headers, new_params, new_payload, tunnel_resp
1961
+
1962
+ @staticmethod
1963
+ def _run_sync(coro: Any) -> Any:
1964
+ try:
1965
+ loop = asyncio.get_running_loop()
1966
+ except RuntimeError:
1967
+ loop = None
1968
+
1969
+ if loop and loop.is_running():
1970
+ import concurrent.futures
1971
+
1972
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
1973
+ future = pool.submit(asyncio.run, coro)
1974
+ return future.result()
1975
+ return asyncio.run(coro)
1976
+
1977
+ def load_tokens(self, tenant_id: str | None = None, service: str | None = None) -> dict[str, Any]:
1978
+ """TokenStore (veya geriye dönük uyumlulukla session_tokens.json) üzerinden oturum çerezlerini okur."""
1979
+ t_id = tenant_id or self.tenant_id
1980
+ svc = service or self.service
1981
+ return self.token_store.get_session(t_id, svc)
1982
+
1983
+ def load_profile_cookies(self, endpoint_url: str | None = None) -> str:
1984
+ """Kalıcı tarayıcı profilindeki (.browser_profile/storage_state.json) çerezleri okur."""
1985
+ state_file = self.profile_dir / "storage_state.json"
1986
+ if not state_file.exists():
1987
+ return ""
1988
+ try:
1989
+ state = json.loads(state_file.read_text(encoding="utf-8"))
1990
+ cookies = state.get("cookies", [])
1991
+ if not isinstance(cookies, list) or not cookies:
1992
+ return ""
1993
+ ep_host = (urlparse(endpoint_url).hostname or "").lower() if endpoint_url else ""
1994
+ ep_root = _root_domain(ep_host) if ep_host else ""
1995
+ matched: list[str] = []
1996
+ for c in cookies:
1997
+ if not isinstance(c, dict) or not c.get("name"):
1998
+ continue
1999
+ c_domain = str(c.get("domain", "")).lstrip(".").lower()
2000
+ c_root = _root_domain(c_domain) if c_domain else ""
2001
+ if not ep_root or not c_root or ep_root == c_root:
2002
+ matched.append(f"{c['name']}={c.get('value', '')}")
2003
+ return "; ".join(matched)
2004
+ except Exception:
2005
+ return ""
2006
+
2007
+ def apply_to_request(
2008
+ self,
2009
+ headers: dict[str, str],
2010
+ params: dict[str, Any] | None = None,
2011
+ payload: Any = None,
2012
+ endpoint_url: str | None = None,
2013
+ ) -> Any:
2014
+ """
2015
+ Mevcut istek başlıklarını (headers), query parametrelerini (params) ve gövdesini (payload)
2016
+ hem kalıcı profil (.browser_profile) çerezleriyle hem de session_tokens.json içindeki
2017
+ dinamik başlık ve token'larla günceller.
2018
+ Geriye uyumluluk için payload verilmemişse (headers, params), verilmişse (headers, params, payload) döndürür.
2019
+ """
2020
+ merged_headers = dict(headers)
2021
+ merged_params = dict(params) if params is not None else {}
2022
+ merged_payload = copy.deepcopy(payload)
2023
+
2024
+ profile_cookie_str = self.load_profile_cookies(endpoint_url)
2025
+ if profile_cookie_str:
2026
+ merged_headers["cookie"] = _merge_cookie_strings(
2027
+ merged_headers.get("cookie", ""),
2028
+ profile_cookie_str,
2029
+ )
2030
+
2031
+ saved = self.load_tokens()
2032
+ if saved:
2033
+ saved_target = str(saved.get("target_url", "")).lower()
2034
+ same_service = False
2035
+ if endpoint_url and saved_target:
2036
+ ep_host = (urlparse(endpoint_url).hostname or "").lower()
2037
+ target_host = (urlparse(saved_target).hostname or "").lower()
2038
+ if ep_host and target_host:
2039
+ same_service = (_root_domain(ep_host) == _root_domain(target_host)) or (
2040
+ "algolia" in ep_host and ("algolia" in target_host or "hn.algolia" in target_host)
2041
+ )
2042
+ elif not saved_target:
2043
+ same_service = True
2044
+
2045
+ if same_service:
2046
+ saved_headers = saved.get("headers")
2047
+ if isinstance(saved_headers, dict):
2048
+ for k, v in saved_headers.items():
2049
+ if not v:
2050
+ continue
2051
+ if k.lower() == "cookie":
2052
+ merged_headers["cookie"] = _merge_cookie_strings(merged_headers.get("cookie", ""), str(v))
2053
+ else:
2054
+ merged_headers[k] = str(v)
2055
+
2056
+ saved_params = saved.get("query_params")
2057
+ if isinstance(saved_params, dict):
2058
+ for k, v in saved_params.items():
2059
+ if v is not None and k in merged_params:
2060
+ merged_params[k] = v
2061
+
2062
+ saved_body_tokens = saved.get("body_tokens")
2063
+ if isinstance(saved_body_tokens, dict) and saved_body_tokens and merged_payload is not None:
2064
+ merged_payload = update_payload_deep(merged_payload, saved_body_tokens)
2065
+
2066
+ if payload is not None:
2067
+ return merged_headers, merged_params, merged_payload
2068
+ return merged_headers, merged_params
2069
+
2070
+
2071
+ if __name__ == "__main__":
2072
+ healer = SessionHealer()
2073
+ healer.heal()
2074
+