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
registry/thy/client.py ADDED
@@ -0,0 +1,1796 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import pathlib
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ if hasattr(sys.stdout, "reconfigure"):
13
+ try:
14
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
15
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
16
+ except Exception:
17
+ pass
18
+
19
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
20
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
21
+ from healer import SessionHealer, update_payload_deep
22
+ from core.token_store import TokenStore
23
+ from core.base_client import BaseShadowClient
24
+
25
+
26
+ def _normalize_thy_date(date_str: str) -> str:
27
+ """Tarih dizgesini THY'nin güncel API şemasının beklediği DD-MM-YYYY formatına dönüştürür."""
28
+ clean = str(date_str).strip()
29
+ for sep in ("-", ".", "/"):
30
+ if sep in clean:
31
+ parts = clean.split(sep)
32
+ if len(parts) == 3:
33
+ # YYYY-MM-DD -> DD-MM-YYYY
34
+ if len(parts[0]) == 4:
35
+ return f"{parts[2].zfill(2)}-{parts[1].zfill(2)}-{parts[0]}"
36
+ # DD-MM-YYYY
37
+ return f"{parts[0].zfill(2)}-{parts[1].zfill(2)}-{parts[2]}"
38
+ return clean
39
+
40
+
41
+ THY_HOME_URL = "https://www.turkishairlines.com/tr-tr/"
42
+ THY_AVAILABILITY_URL = "https://www.turkishairlines.com/api/v1/availability"
43
+
44
+ THY_AIRPORT_META: dict[str, dict[str, Any]] = {
45
+ "IST": {"city_code": "IST", "city": "İstanbul", "country": "TR", "multi_port": True, "domestic": True},
46
+ "SAW": {"city_code": "IST", "city": "İstanbul", "country": "TR", "multi_port": False, "domestic": True},
47
+ "ESB": {"city_code": "ANK", "city": "Ankara", "country": "TR", "multi_port": False, "domestic": True},
48
+ "ADB": {"city_code": "IZM", "city": "İzmir", "country": "TR", "multi_port": False, "domestic": True},
49
+ "AYT": {"city_code": "AYT", "city": "Antalya", "country": "TR", "multi_port": False, "domestic": True},
50
+ "TZX": {"city_code": "TZX", "city": "Trabzon", "country": "TR", "multi_port": False, "domestic": True},
51
+ "ADA": {"city_code": "ADA", "city": "Adana", "country": "TR", "multi_port": False, "domestic": True},
52
+ "DLM": {"city_code": "DLM", "city": "Muğla", "country": "TR", "multi_port": False, "domestic": True},
53
+ "BJV": {"city_code": "BJV", "city": "Muğla", "country": "TR", "multi_port": False, "domestic": True},
54
+ "GZT": {"city_code": "GZT", "city": "Gaziantep", "country": "TR", "multi_port": False, "domestic": True},
55
+ "ASR": {"city_code": "ASR", "city": "Kayseri", "country": "TR", "multi_port": False, "domestic": True},
56
+ }
57
+
58
+
59
+ def _airport_info(code: str) -> dict[str, Any]:
60
+ clean = (code or "IST").upper().strip()
61
+ return THY_AIRPORT_META.get(
62
+ clean,
63
+ {"city_code": clean, "city": clean, "country": "TR", "multi_port": clean == "IST", "domestic": True},
64
+ )
65
+
66
+
67
+ def _load_captured_availability_template() -> dict[str, Any]:
68
+ """THY /api/v1/availability POST isteğinin güncel ve doğrulanmış DTO şablonunu döndürür."""
69
+ return {
70
+ "selectedBookerSearch": "R",
71
+ "selectedCabinClass": "ECONOMY",
72
+ "moduleType": "TICKETING",
73
+ "passengerTypeList": [
74
+ {
75
+ "quantity": 1,
76
+ "code": "ADULT",
77
+ }
78
+ ],
79
+ "originDestinationInformationList": [
80
+ {
81
+ "originAirportCode": "IST",
82
+ "originCountryCode": "TR",
83
+ "originMultiPort": True,
84
+ "originDomestic": True,
85
+ "destinationAirportCode": "ESB",
86
+ "destinationCountryCode": "TR",
87
+ "destinationMultiPort": False,
88
+ "destinationDomestic": True,
89
+ "departureDate": "02-03-2027",
90
+ "originCityCode": "IST",
91
+ "destinationCityCode": "ANK",
92
+ "originCity": "İstanbul",
93
+ "destinationCity": "Ankara",
94
+ },
95
+ {
96
+ "originAirportCode": "ESB",
97
+ "originCountryCode": "TR",
98
+ "originMultiPort": False,
99
+ "originDomestic": True,
100
+ "destinationAirportCode": "IST",
101
+ "destinationCountryCode": "TR",
102
+ "destinationMultiPort": True,
103
+ "destinationDomestic": True,
104
+ "departureDate": "09-03-2027",
105
+ "originCityCode": "ANK",
106
+ "destinationCityCode": "IST",
107
+ "originCity": "Ankara",
108
+ "destinationCity": "İstanbul",
109
+ },
110
+ ],
111
+ "savedDate": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
112
+ "preselectedOptionDetails": [],
113
+ }
114
+
115
+
116
+ def build_thy_availability_payload(
117
+ departure_date: str = "02-03-2027",
118
+ return_date: str | None = "09-03-2027",
119
+ origin_airport_code: str = "IST",
120
+ destination_airport_code: str = "ESB",
121
+ cabin_class: str = "ECONOMY",
122
+ adults: int = 1,
123
+ ) -> dict[str, Any]:
124
+ """
125
+ Parametre eksikliğinden kaynaklı 400/404 hatalarını önlemek için THY'nin güncel
126
+ /api/v1/availability arama şemasına birebir uyumlu JSON gövdesini (body) oluşturur.
127
+ """
128
+ is_round_trip = bool(return_date and str(return_date).strip())
129
+ norm_departure_date = _normalize_thy_date(departure_date)
130
+ orig_code = (origin_airport_code or "IST").upper().strip()
131
+ dest_code = (destination_airport_code or "ESB").upper().strip()
132
+ orig_meta = _airport_info(orig_code)
133
+ dest_meta = _airport_info(dest_code)
134
+
135
+ outbound_segment: dict[str, Any] = {
136
+ "originAirportCode": orig_code,
137
+ "originCountryCode": orig_meta["country"],
138
+ "originMultiPort": bool(orig_meta["multi_port"]),
139
+ "originDomestic": bool(orig_meta["domestic"]),
140
+ "destinationAirportCode": dest_code,
141
+ "destinationCountryCode": dest_meta["country"],
142
+ "destinationMultiPort": bool(dest_meta["multi_port"]),
143
+ "destinationDomestic": bool(dest_meta["domestic"]),
144
+ "departureDate": norm_departure_date,
145
+ "originCityCode": orig_meta["city_code"],
146
+ "destinationCityCode": dest_meta["city_code"],
147
+ "originCity": orig_meta["city"],
148
+ "destinationCity": dest_meta["city"],
149
+ }
150
+
151
+ segments: list[dict[str, Any]] = [outbound_segment]
152
+ if is_round_trip and return_date:
153
+ inbound_segment: dict[str, Any] = {
154
+ "originAirportCode": dest_code,
155
+ "originCountryCode": dest_meta["country"],
156
+ "originMultiPort": bool(dest_meta["multi_port"]),
157
+ "originDomestic": bool(dest_meta["domestic"]),
158
+ "destinationAirportCode": orig_code,
159
+ "destinationCountryCode": orig_meta["country"],
160
+ "destinationMultiPort": bool(orig_meta["multi_port"]),
161
+ "destinationDomestic": bool(orig_meta["domestic"]),
162
+ "departureDate": _normalize_thy_date(return_date),
163
+ "originCityCode": dest_meta["city_code"],
164
+ "destinationCityCode": orig_meta["city_code"],
165
+ "originCity": dest_meta["city"],
166
+ "destinationCity": orig_meta["city"],
167
+ }
168
+ segments.append(inbound_segment)
169
+
170
+ return {
171
+ "selectedBookerSearch": "R" if is_round_trip else "O",
172
+ "selectedCabinClass": (cabin_class or "ECONOMY").upper().strip(),
173
+ "moduleType": "TICKETING",
174
+ "passengerTypeList": [
175
+ {
176
+ "quantity": max(1, int(adults or 1)),
177
+ "code": "ADULT",
178
+ }
179
+ ],
180
+ "originDestinationInformationList": segments,
181
+ "savedDate": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
182
+ "preselectedOptionDetails": [],
183
+ }
184
+
185
+
186
+ def _build_validate_payload(p_load: Any) -> dict[str, Any]:
187
+ """/api/v1/availability/validate uç noktası için oturum durumunu başlatan DTO payload'unu üretir."""
188
+ if not isinstance(p_load, dict) or not p_load.get("originDestinationInformationList"):
189
+ p_load = build_thy_availability_payload()
190
+ val = {
191
+ "selectedBookerSearch": p_load.get("selectedBookerSearch", "R"),
192
+ "selectedCabinClass": p_load.get("selectedCabinClass", "ECONOMY"),
193
+ "moduleType": p_load.get("moduleType", "TICKETING"),
194
+ "passengerTypeList": p_load.get("passengerTypeList", [{"quantity": 1, "code": "ADULT"}]),
195
+ "originDestinationInformationList": [],
196
+ }
197
+ for s in p_load.get("originDestinationInformationList", []):
198
+ if isinstance(s, dict):
199
+ val["originDestinationInformationList"].append({
200
+ "originAirportCode": s.get("originAirportCode"),
201
+ "originMultiPort": s.get("originMultiPort", False),
202
+ "destinationAirportCode": s.get("destinationAirportCode"),
203
+ "destinationMultiPort": s.get("destinationMultiPort", False),
204
+ "departureDate": s.get("departureDate"),
205
+ })
206
+ return val
207
+
208
+
209
+ def build_thy_search_url(
210
+ departure_date: str = "02-03-2027",
211
+ return_date: str | None = "09-03-2027",
212
+ origin_airport_code: str = "IST",
213
+ destination_airport_code: str = "ESB",
214
+ cabin_class: str = "ECONOMY",
215
+ adults: int = 1,
216
+ cid: str | None = None,
217
+ ) -> str:
218
+ """
219
+ THY frontend uygulamasının sayfa/oturum state'ini başlatması için gereken
220
+ gidiş-dönüş (veya tek yön) arama parametrelerini ve cId'yi içeren tam arama URL'sini üretir.
221
+ """
222
+ from urllib.parse import urlencode
223
+
224
+ active_cid = cid or str(uuid.uuid4())
225
+ orig_clean = (origin_airport_code or "IST").upper().strip()
226
+ dest_clean = (destination_airport_code or "ESB").upper().strip()
227
+ orig_meta = _airport_info(orig_clean)
228
+ dest_meta = _airport_info(dest_clean)
229
+ is_domestic = bool(orig_meta.get("domestic") and dest_meta.get("domestic"))
230
+
231
+ base_path = (
232
+ "/tr-tr/ucak-bileti/rezervasyon/ic-hat-musaitlik/"
233
+ if is_domestic
234
+ else "/tr-tr/ucak-bileti/rezervasyon/rezervasyon-durumu/"
235
+ )
236
+ dep_norm = _normalize_thy_date(departure_date)
237
+ is_round_trip = bool(return_date and str(return_date).strip())
238
+ ret_norm = _normalize_thy_date(return_date) if is_round_trip and return_date else None
239
+
240
+ query_params: dict[str, Any] = {
241
+ "cId": active_cid,
242
+ "origin": orig_clean,
243
+ "destination": dest_clean,
244
+ "departureDate": dep_norm,
245
+ }
246
+ if ret_norm:
247
+ query_params["returnDate"] = ret_norm
248
+ query_params["tripType"] = "R" if is_round_trip else "O"
249
+ query_params["cabinClass"] = (cabin_class or "ECONOMY").upper()
250
+ query_params["ADT"] = max(1, int(adults or 1))
251
+
252
+ return f"https://www.turkishairlines.com{base_path}?{urlencode(query_params)}"
253
+
254
+
255
+ def build_thy_search_url_from_payload(
256
+ payload: dict[str, Any] | None,
257
+ cid: str | None = None,
258
+ ) -> str:
259
+ """
260
+ /api/v1/availability payload sözlüğündeki originDestinationInformationList verisinden
261
+ gidiş-dönüş arama URL'sini oluşturur.
262
+ """
263
+ if not isinstance(payload, dict):
264
+ return build_thy_search_url(cid=cid)
265
+
266
+ od_list = payload.get("originDestinationInformationList")
267
+ orig_code = "IST"
268
+ dest_code = "ESB"
269
+ dep_date = "02-03-2027"
270
+ ret_date: str | None = "09-03-2027"
271
+
272
+ if isinstance(od_list, list) and len(od_list) >= 1 and isinstance(od_list[0], dict):
273
+ first_seg = od_list[0]
274
+ orig_code = str(first_seg.get("originAirportCode") or orig_code)
275
+ dest_code = str(first_seg.get("destinationAirportCode") or dest_code)
276
+ dep_date = str(first_seg.get("departureDate") or dep_date)
277
+ if len(od_list) >= 2 and isinstance(od_list[1], dict):
278
+ ret_date = str(od_list[1].get("departureDate") or "") or None
279
+ elif str(payload.get("selectedBookerSearch", "R")).upper() == "O":
280
+ ret_date = None
281
+
282
+ cabin_class = str(payload.get("selectedCabinClass") or "ECONOMY")
283
+ adults = 1
284
+ pax_list = payload.get("passengerTypeList")
285
+ if isinstance(pax_list, list):
286
+ for p in pax_list:
287
+ if isinstance(p, dict) and str(p.get("code", "")).upper() in ("ADULT", "ADT"):
288
+ try:
289
+ adults = max(1, int(p.get("quantity", 1)))
290
+ except Exception:
291
+ adults = 1
292
+ break
293
+
294
+ return build_thy_search_url(
295
+ departure_date=dep_date,
296
+ return_date=ret_date,
297
+ origin_airport_code=orig_code,
298
+ destination_airport_code=dest_code,
299
+ cabin_class=cabin_class,
300
+ adults=adults,
301
+ cid=cid,
302
+ )
303
+
304
+
305
+ def search_flights(
306
+ departure_date: str = '02-03-2027',
307
+ return_date: str | None = '09-03-2027',
308
+ origin_airport_code: str = 'IST',
309
+ destination_airport_code: str = 'ESB',
310
+ cid: str | None = None,
311
+ tenant_id: str = "default",
312
+ mode: str = "local",
313
+ non_interactive: bool | None = None,
314
+ ) -> dict[str, Any]:
315
+ """
316
+ Türk Hava Yolları (THY) İstanbul - Ankara gidiş-dönüş veya tek yön uçuş seferlerini ve müsaitlik/fiyat bilgilerini
317
+ doğrudan User-Delegated In-Browser Fetch tüneli üzerinden sorgular.
318
+ Önce THY ana sayfasında ve ilgili gidiş-dönüş arama URL'sinde sayfa/oturum state'ini oluşturur,
319
+ ardından Referer ve Origin başlıklarıyla /api/v1/availability POST çağrısını yürütür.
320
+ """
321
+ from urllib.parse import urlparse
322
+
323
+ params: dict[str, Any] = {}
324
+ headers: dict[str, str] = {
325
+ "Accept": "application/json, text/plain, */*",
326
+ "Content-Type": "application/json",
327
+ "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
328
+ "Referer": "https://www.turkishairlines.com/",
329
+ "Origin": "https://www.turkishairlines.com",
330
+ "referer": "https://www.turkishairlines.com/",
331
+ "origin": "https://www.turkishairlines.com",
332
+ }
333
+
334
+ payload = build_thy_availability_payload(
335
+ departure_date=departure_date,
336
+ return_date=return_date,
337
+ origin_airport_code=origin_airport_code,
338
+ destination_airport_code=destination_airport_code,
339
+ )
340
+
341
+ healer = SessionHealer(
342
+ target_url=THY_HOME_URL,
343
+ tenant_id=tenant_id,
344
+ service="thy",
345
+ mode=mode,
346
+ non_interactive=bool(non_interactive) if non_interactive is not None else False,
347
+ )
348
+
349
+ # TokenStore (.sessions/{tenant_id}/thy_tokens.json) içindeki gerçek çerezleri ve User-Agent başlığını yükle
350
+ saved_tokens = healer.load_tokens(tenant_id=tenant_id, service="thy")
351
+ real_cookie = saved_tokens.get("cookie") or saved_tokens.get("headers", {}).get("cookie", "")
352
+ real_ua = saved_tokens.get("user-agent") or saved_tokens.get("headers", {}).get("user-agent", "")
353
+
354
+ if saved_tokens:
355
+ tok_headers = saved_tokens.get("headers", {})
356
+ if isinstance(tok_headers, dict):
357
+ for k in ("authorization", "x-session-id", "x-session", "tafsessionid", "x-requested-with", "accept-language"):
358
+ if tok_headers.get(k):
359
+ headers[k] = str(tok_headers[k])
360
+
361
+ if real_cookie:
362
+ headers["cookie"] = real_cookie
363
+ headers["Cookie"] = real_cookie
364
+ if real_ua:
365
+ headers["user-agent"] = real_ua
366
+
367
+ # Kalıcı profil ve PerimeterX (_px...) çerezlerini istek başlıklarına bağla
368
+ headers, params, payload = healer.apply_to_request(
369
+ headers,
370
+ params,
371
+ payload,
372
+ endpoint_url=THY_AVAILABILITY_URL,
373
+ )
374
+
375
+ for k in list(headers.keys()):
376
+ if k.lower().startswith("sec-") or k.lower() == "accept-encoding":
377
+ del headers[k]
378
+
379
+ # Dinamik cId parametresini belirle
380
+ if not cid:
381
+ raw_referer = headers.get("referer", "")
382
+ if "cid=" in raw_referer.lower():
383
+ try:
384
+ from urllib.parse import parse_qs
385
+ qs = parse_qs(urlparse(raw_referer).query)
386
+ found = qs.get("cId", qs.get("cid", [None]))[0]
387
+ if found:
388
+ cid = found
389
+ except Exception:
390
+ pass
391
+ if not cid:
392
+ cid = str(uuid.uuid4())
393
+
394
+ # Backend DTO JSON şemasını bozmamak için payload gövdesindeki fazla alanları temizle
395
+ ALLOWED_PAYLOAD_KEYS = {
396
+ "selectedBookerSearch",
397
+ "selectedCabinClass",
398
+ "moduleType",
399
+ "passengerTypeList",
400
+ "originDestinationInformationList",
401
+ "savedDate",
402
+ "preselectedOptionDetails",
403
+ }
404
+ for k in list(payload.keys()):
405
+ if k not in ALLOWED_PAYLOAD_KEYS:
406
+ del payload[k]
407
+
408
+ # Gidiş-dönüş parametrelerini içeren arama URL'sini oluştur
409
+ search_url = build_thy_search_url_from_payload(payload, cid=cid)
410
+ parsed_search = urlparse(search_url)
411
+ route_page = (
412
+ f"{parsed_search.path}?{parsed_search.query}"
413
+ if parsed_search.query
414
+ else parsed_search.path
415
+ )
416
+
417
+ # Zorunlu Referer ve Origin başlıklarını ve THY özel başlıklarını sabitle
418
+ headers["accept"] = "application/json, text/plain, */*"
419
+ headers["content-type"] = "application/json"
420
+ headers["Referer"] = search_url
421
+ headers["Origin"] = "https://www.turkishairlines.com"
422
+ headers["referer"] = search_url
423
+ headers["origin"] = "https://www.turkishairlines.com"
424
+ headers["x-current-page-url"] = route_page
425
+ headers["x-client-id"] = cid
426
+ headers["x-clientid"] = cid
427
+ headers["x-country"] = "TR"
428
+ headers["x-language"] = "tr"
429
+
430
+ # TAF / Session ID başlıkları
431
+ for cookie_part in headers.get("cookie", "").split(";"):
432
+ if "TAFSessionId=" in cookie_part:
433
+ taf_val = cookie_part.split("TAFSessionId=")[1].strip()
434
+ if taf_val:
435
+ headers.setdefault("tafsessionid", taf_val)
436
+ headers.setdefault("x-session-id", taf_val)
437
+ break
438
+
439
+ # User-Delegated Browser Middleware:
440
+ # Önce tarayıcıda ana sayfaya ve gidiş-dönüş arama URL'sine (search_url) gidip Generic Form Automation
441
+ # ve sayfa state'ini oluştur, ardından fetch tüneli üzerinden /api/v1/availability POST isteğini yürüt.
442
+ raw_response = healer.execute_in_browser(
443
+ method="POST",
444
+ url=THY_AVAILABILITY_URL,
445
+ headers=headers,
446
+ params=params if params else None,
447
+ json_payload=payload,
448
+ target_url=search_url,
449
+ tenant_id=tenant_id,
450
+ mode=mode,
451
+ non_interactive=non_interactive,
452
+ form_params={
453
+ "origin": origin_airport_code,
454
+ "destination": destination_airport_code,
455
+ "departure_date": departure_date,
456
+ "return_date": return_date or "",
457
+ },
458
+ )
459
+
460
+ # Hata Detayı: Eğer 400 Bad Request dönerse gönderilen payload ve dönen yanıt detayını yazdır
461
+ if isinstance(raw_response, dict):
462
+ status_code = raw_response.get("status_code")
463
+ if raw_response.get("status") == 400 or status_code == 400:
464
+ print("\n" + "=" * 80)
465
+ print("❌ [Shadow-API] 400 Bad Request Hatası Alındı!")
466
+ print(f"HTTP Status: {status_code}")
467
+ print("Gönderilen Payload (JSON):")
468
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
469
+ print("Sunucu Hata Yanıtı (response.text):")
470
+ print(raw_response.get("text") or raw_response)
471
+ print("=" * 80 + "\n")
472
+
473
+ if raw_response.get("success") is True:
474
+ data_obj = raw_response.get("data")
475
+ if isinstance(data_obj, dict):
476
+ print(f"\n[Shadow-API] 🔑 API 'success: True' döndürdü. 'data' anahtarları: {list(data_obj.keys())}")
477
+ elif isinstance(data_obj, list):
478
+ print(f"\n[Shadow-API] 🔑 API 'success: True' döndürdü. 'data' (liste) uzunluğu: {len(data_obj)}")
479
+ raw_response["parsed"] = parse_thy_availability(raw_response)
480
+ return raw_response
481
+
482
+
483
+ _PRICE_KEYS_EXACT = (
484
+ "amount",
485
+ "totalAmount",
486
+ "price",
487
+ "totalPrice",
488
+ "value",
489
+ "fare",
490
+ "totalFare",
491
+ "baseFare",
492
+ "startingPrice",
493
+ "starting_price",
494
+ "amountInPreferredCurrency",
495
+ "fareAmount",
496
+ "cashAmount",
497
+ "netAmount",
498
+ "cheapestPrice",
499
+ "lowestPrice",
500
+ "minPrice",
501
+ "bestFare",
502
+ "economyStartingPrice",
503
+ "businessStartingPrice",
504
+ )
505
+
506
+ _NON_PRICE_KEYS = {
507
+ "decimalplaces",
508
+ "quantity",
509
+ "seatcount",
510
+ "availableseatcount",
511
+ "availableseats",
512
+ "seats",
513
+ "flightpagelimit",
514
+ "minflightcountforsorting",
515
+ "minflightcountfortimerangefiltering",
516
+ "minflightcountforoptionsfiltering",
517
+ "maximumdaysforavailability",
518
+ "datedifference",
519
+ "duration",
520
+ "flightduration",
521
+ "flightnumber",
522
+ "carrierflightnumber",
523
+ "id",
524
+ "index",
525
+ "status",
526
+ "status_code",
527
+ "statuscode",
528
+ "maxage",
529
+ "minage",
530
+ "maxcount",
531
+ "numberofextensions",
532
+ "countdowntime",
533
+ "pagestimeout",
534
+ "prefetcheditemcount",
535
+ }
536
+
537
+ _THY_BRAND_ALIASES: dict[str, str] = {
538
+ "ef": "EcoFly",
539
+ "ec": "EcoFly",
540
+ "eco": "EcoFly",
541
+ "ecofly": "EcoFly",
542
+ "eco_fly": "EcoFly",
543
+ "eco fly": "EcoFly",
544
+ "brand.ecofly": "EcoFly",
545
+ "ecojet": "EcoFly",
546
+ "xf": "ExtraFly",
547
+ "ex": "ExtraFly",
548
+ "fl": "ExtraFly",
549
+ "ef2": "ExtraFly",
550
+ "extrafly": "ExtraFly",
551
+ "extra_fly": "ExtraFly",
552
+ "extra fly": "ExtraFly",
553
+ "brand.extrafly": "ExtraFly",
554
+ "flexfly": "ExtraFly",
555
+ "flex_fly": "ExtraFly",
556
+ "flex fly": "ExtraFly",
557
+ "brand.flexfly": "ExtraFly",
558
+ "extra": "ExtraFly",
559
+ "pf": "PrimeFly",
560
+ "pr": "PrimeFly",
561
+ "primefly": "PrimeFly",
562
+ "prime_fly": "PrimeFly",
563
+ "prime fly": "PrimeFly",
564
+ "brand.primefly": "PrimeFly",
565
+ "prime": "PrimeFly",
566
+ "bu": "Business",
567
+ "bc": "Business",
568
+ "bl": "Business",
569
+ "bus": "Business",
570
+ "business": "Business",
571
+ "businessfly": "Business",
572
+ "business_fly": "Business",
573
+ "business fly": "Business",
574
+ "businessprime": "Business",
575
+ "business_prime": "Business",
576
+ "brand.business": "Business",
577
+ }
578
+
579
+
580
+ def _normalize_thy_package_name(
581
+ raw_name: Any,
582
+ cabin_class: str = "",
583
+ idx_in_cabin: int | None = None,
584
+ ) -> str:
585
+ """
586
+ THY marka/paket kodlarını ve isimlerini ('EF', 'XF', 'FL', 'PF', 'BU', 'brand.ecoFly' vb.)
587
+ standart 'EcoFly', 'ExtraFly', 'PrimeFly' veya 'Business' paket adlarına dönüştürür.
588
+ """
589
+ if isinstance(raw_name, list) and raw_name:
590
+ raw_name = raw_name[0]
591
+ clean = str(raw_name or "").strip()
592
+ low = clean.lower().replace("-", "_").strip()
593
+ if low.startswith("brand."):
594
+ low_no_prefix = low[6:].strip()
595
+ else:
596
+ low_no_prefix = low
597
+
598
+ if low in _THY_BRAND_ALIASES:
599
+ return _THY_BRAND_ALIASES[low]
600
+ if low_no_prefix in _THY_BRAND_ALIASES:
601
+ return _THY_BRAND_ALIASES[low_no_prefix]
602
+
603
+ for key_sub, canonical in (
604
+ ("ecofly", "EcoFly"),
605
+ ("eco_fly", "EcoFly"),
606
+ ("extrafly", "ExtraFly"),
607
+ ("extra_fly", "ExtraFly"),
608
+ ("flexfly", "ExtraFly"),
609
+ ("primefly", "PrimeFly"),
610
+ ("prime_fly", "PrimeFly"),
611
+ ("business", "Business"),
612
+ ):
613
+ if key_sub in low:
614
+ return canonical
615
+
616
+ cabin_up = str(cabin_class or "").upper().strip()
617
+ if cabin_up == "BUSINESS" and (not clean or low in ("business", "standart", "standard", "default")):
618
+ return "Business"
619
+
620
+ if cabin_up in ("ECONOMY", "") and (not clean or low in ("economy", "standart", "standard", "default")):
621
+ if idx_in_cabin == 0:
622
+ return "EcoFly"
623
+ if idx_in_cabin == 1:
624
+ return "ExtraFly"
625
+ if idx_in_cabin == 2:
626
+ return "PrimeFly"
627
+ if cabin_up == "ECONOMY" and idx_in_cabin is None:
628
+ return "EcoFly"
629
+
630
+ return clean or "EcoFly"
631
+
632
+
633
+ def _parse_numeric_price_str(val_str: str) -> tuple[float | None, str]:
634
+ """Metin formatındaki fiyat dizgesinden ('1.671,71 TRY', '₺1671.71' vb.) tutar ve para birimini ayıklar."""
635
+ import re
636
+
637
+ s = str(val_str or "").strip()
638
+ if not s:
639
+ return None, ""
640
+ curr = ""
641
+ s_up = s.upper()
642
+ for code, norm_c in (("TRY", "TRY"), ("TL", "TRY"), ("₺", "TRY"), ("USD", "USD"), ("$", "USD"), ("EUR", "EUR"), ("€", "EUR"), ("GBP", "GBP")):
643
+ if code in s_up or code in s:
644
+ curr = norm_c
645
+ break
646
+
647
+ m = re.search(r"[-+]?\d[\d.,]*", s)
648
+ if not m:
649
+ return None, curr
650
+ num_tok = m.group(0)
651
+ try:
652
+ if "," in num_tok and "." in num_tok:
653
+ if num_tok.rfind(",") > num_tok.rfind("."):
654
+ num_tok = num_tok.replace(".", "").replace(",", ".")
655
+ else:
656
+ num_tok = num_tok.replace(",", "")
657
+ elif "," in num_tok:
658
+ parts = num_tok.split(",")
659
+ if len(parts[-1]) == 3 and len(parts) > 1 and all(p.isdigit() for p in parts):
660
+ num_tok = "".join(parts)
661
+ else:
662
+ num_tok = num_tok.replace(",", ".")
663
+ amt = float(num_tok)
664
+ return (amt if amt > 0 else None), curr
665
+ except ValueError:
666
+ return None, curr
667
+
668
+
669
+ def _extract_amount_and_currency(obj: Any, _depth: int = 0) -> tuple[float | None, str]:
670
+ """Herhangi bir fiyat/tutar nesnesinden sayısal tutar ve para birimini derinlemesine ayıklar."""
671
+ if obj is None or _depth > 6:
672
+ return None, ""
673
+ if isinstance(obj, bool):
674
+ return None, ""
675
+ if isinstance(obj, (int, float)):
676
+ val = float(obj)
677
+ return (val if val > 0 else None), "TRY"
678
+ if isinstance(obj, str):
679
+ amt, curr = _parse_numeric_price_str(obj)
680
+ return amt, (curr or "TRY") if amt is not None else ""
681
+ if isinstance(obj, list):
682
+ best_amt: float | None = None
683
+ best_curr = ""
684
+ for item in obj:
685
+ sub_amt, sub_curr = _extract_amount_and_currency(item, _depth + 1)
686
+ if sub_amt is not None and sub_amt > 0:
687
+ if best_amt is None or sub_amt < best_amt:
688
+ best_amt = sub_amt
689
+ best_curr = sub_curr or best_curr
690
+ return best_amt, str(best_curr or "TRY") if best_amt is not None else ""
691
+ if isinstance(obj, dict):
692
+ curr_raw = (
693
+ obj.get("currency")
694
+ or obj.get("currencyCode")
695
+ or obj.get("currency_code")
696
+ or obj.get("currencySign")
697
+ or ""
698
+ )
699
+ curr = "TRY" if str(curr_raw).strip() == "₺" else str(curr_raw or "").strip()
700
+
701
+ amt: float | None = None
702
+ for k in _PRICE_KEYS_EXACT:
703
+ if k not in obj:
704
+ continue
705
+ v = obj.get(k)
706
+ if v is None or isinstance(v, bool):
707
+ continue
708
+ if isinstance(v, (int, float)):
709
+ if float(v) > 0:
710
+ amt = float(v)
711
+ break
712
+ elif isinstance(v, str):
713
+ p_amt, p_curr = _parse_numeric_price_str(v)
714
+ if p_amt is not None and p_amt > 0:
715
+ amt = p_amt
716
+ curr = curr or p_curr
717
+ break
718
+ elif isinstance(v, (dict, list)):
719
+ sub_amt, sub_curr = _extract_amount_and_currency(v, _depth + 1)
720
+ if sub_amt is not None and sub_amt > 0:
721
+ amt = sub_amt
722
+ curr = curr or sub_curr
723
+ break
724
+
725
+ if amt is None:
726
+ for k, v in obj.items():
727
+ k_low = str(k).lower().strip()
728
+ if k_low in _NON_PRICE_KEYS:
729
+ continue
730
+ if any(pk in k_low for pk in ("price", "amount", "fare", "cost", "total", "tutar", "fiyat", "ecofly", "extrafly", "primefly")):
731
+ sub_amt, sub_curr = _extract_amount_and_currency(v, _depth + 1)
732
+ if sub_amt is not None and sub_amt > 0:
733
+ if amt is None or sub_amt < amt:
734
+ amt = sub_amt
735
+ curr = curr or sub_curr
736
+
737
+ return amt, str(curr or "TRY") if amt is not None else ""
738
+ return None, ""
739
+
740
+
741
+ def _unwrap_thy_root(data: Any) -> Any:
742
+ """
743
+ Gelen yanıt sözlük, liste veya JSON metni (hatta kesilmiş JSON metni) olsa dahi
744
+ içindeki esas veri kökünü (`root`) güvenli şekilde çözümler.
745
+ """
746
+ import re
747
+
748
+ if isinstance(data, str):
749
+ raw_str = data.strip()
750
+ if not raw_str:
751
+ return {}
752
+ try:
753
+ return _unwrap_thy_root(json.loads(raw_str))
754
+ except Exception:
755
+ # Kesilmiş (truncated) JSON dizgesinden anlamlı alanları kurtar
756
+ recovered: dict[str, Any] = {"_raw_truncated_text": raw_str}
757
+ for key_name in ("economyStartingPrice", "businessStartingPrice", "bestFare"):
758
+ m = re.search(rf'"{key_name}"\s*:\s*(\{{[^{{}}]*\{{[^{{}}]*\}}[^{{}}]*\}}|\{{[^{{}}]*\}})', raw_str)
759
+ if m:
760
+ try:
761
+ recovered[key_name] = json.loads(m.group(1))
762
+ except Exception:
763
+ pass
764
+ return recovered
765
+
766
+ if isinstance(data, dict):
767
+ for wrapper_key in ("data", "result", "response", "payload", "body"):
768
+ inner = data.get(wrapper_key)
769
+ if isinstance(inner, (dict, list)) and inner:
770
+ return inner
771
+ if isinstance(inner, str) and inner.strip().startswith(("{", "[")):
772
+ parsed_inner = _unwrap_thy_root(inner)
773
+ if parsed_inner:
774
+ return parsed_inner
775
+
776
+ for text_key in ("text", "response_sample", "raw_text"):
777
+ raw_txt = data.get(text_key)
778
+ if isinstance(raw_txt, str) and raw_txt.strip().startswith(("{", "[")):
779
+ parsed_txt = _unwrap_thy_root(raw_txt)
780
+ if isinstance(parsed_txt, (dict, list)) and parsed_txt:
781
+ return parsed_txt
782
+
783
+ return data
784
+
785
+
786
+ def _extract_packages_deep(f_opt: Any, default_cabin: str = "ECONOMY") -> list[dict[str, Any]]:
787
+ """
788
+ Bir uçuş seçeneği (veya alt JSON ağacı) içindeki tüm paketleri (EcoFly, ExtraFly, PrimeFly, Business)
789
+ standart anahtarlara bağlı kalmaksızın derinlemesine (deep search) tarayarak ayıklar.
790
+ """
791
+ fares_out: list[dict[str, Any]] = []
792
+ seen_pkg_keys: set[tuple[str, float]] = set()
793
+
794
+ def _add_fare(
795
+ raw_pkg_name: Any,
796
+ cabin_name: str,
797
+ amt: float | None,
798
+ curr: str,
799
+ seats: Any = None,
800
+ idx_in_cabin: int | None = None,
801
+ ) -> None:
802
+ if amt is None or amt <= 0:
803
+ return
804
+ norm_name = _normalize_thy_package_name(raw_pkg_name, cabin_class=cabin_name, idx_in_cabin=idx_in_cabin)
805
+ norm_cabin = str(cabin_name or ("BUSINESS" if norm_name == "Business" else default_cabin)).upper().strip() or "ECONOMY"
806
+ dedup_key = (norm_name, round(float(amt), 2))
807
+ if dedup_key in seen_pkg_keys:
808
+ return
809
+ seen_pkg_keys.add(dedup_key)
810
+ fares_out.append({
811
+ "package_name": norm_name,
812
+ "cabin_class": norm_cabin,
813
+ "amount": round(float(amt), 2),
814
+ "currency": str(curr or "TRY"),
815
+ "available_seats": seats,
816
+ })
817
+
818
+ if not isinstance(f_opt, (dict, list)):
819
+ return fares_out
820
+
821
+ # 1. Standart fareCategory yapısı
822
+ if isinstance(f_opt, dict):
823
+ fare_category = f_opt.get("fareCategory") or f_opt.get("fareCategories") or f_opt.get("cabins")
824
+ if isinstance(fare_category, dict):
825
+ for cabin_name, cabin_obj in fare_category.items():
826
+ if isinstance(cabin_obj, dict):
827
+ b_list = (
828
+ cabin_obj.get("bookingPriceInfoList")
829
+ or cabin_obj.get("brandList")
830
+ or cabin_obj.get("packages")
831
+ or cabin_obj.get("fares")
832
+ or cabin_obj.get("subGroupList")
833
+ or []
834
+ )
835
+ if isinstance(b_list, list) and b_list:
836
+ for b_idx, b_info in enumerate(b_list):
837
+ if not isinstance(b_info, dict):
838
+ continue
839
+ raw_brand = (
840
+ b_info.get("brandName")
841
+ or b_info.get("brandKey")
842
+ or b_info.get("brandCode")
843
+ or b_info.get("brandCodeList")
844
+ or b_info.get("fareFamilyCode")
845
+ or b_info.get("subGroupName")
846
+ or b_info.get("name")
847
+ or b_info.get("code")
848
+ )
849
+ pkg_amt, pkg_curr = _extract_amount_and_currency(
850
+ b_info.get("startingPrice")
851
+ or b_info.get("price")
852
+ or b_info.get("totalPrice")
853
+ or b_info.get("fare")
854
+ or b_info
855
+ )
856
+ seat_count = (
857
+ b_info.get("availableSeatCount")
858
+ or b_info.get("seatCount")
859
+ or b_info.get("availableSeats")
860
+ or b_info.get("seats")
861
+ )
862
+ _add_fare(raw_brand, str(cabin_name), pkg_amt, pkg_curr, seat_count, idx_in_cabin=b_idx)
863
+ else:
864
+ c_amt, c_curr = _extract_amount_and_currency(cabin_obj)
865
+ _add_fare(cabin_name, str(cabin_name), c_amt, c_curr, idx_in_cabin=0)
866
+
867
+ # 2. Derinlemesine (Deep Search) paket/marka taraması
868
+ def _scan_packages(node: Any, current_cabin: str = default_cabin) -> None:
869
+ if isinstance(node, dict):
870
+ node_cabin = str(
871
+ node.get("cabinClass")
872
+ or node.get("cabin")
873
+ or node.get("cabinType")
874
+ or current_cabin
875
+ ).upper().strip() or default_cabin
876
+
877
+ # Sözlük anahtarları doğrudan EcoFly / ExtraFly / PrimeFly / Business veya EF / XF / PF ise
878
+ for k, v in node.items():
879
+ k_str = str(k).strip()
880
+ k_low = k_str.lower().replace("-", "_").replace(" ", "_")
881
+ if k_low in (
882
+ "ecofly", "eco_fly", "extrafly", "extra_fly", "flexfly", "flex_fly",
883
+ "primefly", "prime_fly", "business", "businessfly",
884
+ "ecofly_price", "extrafly_price", "primefly_price", "business_price",
885
+ ) or k_str in ("EF", "XF", "FL", "PF", "BU"):
886
+ p_amt, p_curr = _extract_amount_and_currency(v)
887
+ seats = v.get("availableSeatCount") or v.get("seatCount") or v.get("seats") if isinstance(v, dict) else None
888
+ _add_fare(k_str, node_cabin, p_amt, p_curr, seats)
889
+
890
+ # Eğer düğümün kendisi bir brand / paket nesnesiyse
891
+ has_brand_field = any(
892
+ bk in node
893
+ for bk in (
894
+ "brandCode", "brandName", "brandKey", "brandCodeList",
895
+ "subGroupName", "fareFamilyCode", "packageName", "package_name",
896
+ "brand", "tier", "tariffName",
897
+ )
898
+ )
899
+ if has_brand_field:
900
+ raw_b = (
901
+ node.get("brandName")
902
+ or node.get("brandKey")
903
+ or node.get("brandCode")
904
+ or node.get("brandCodeList")
905
+ or node.get("subGroupName")
906
+ or node.get("fareFamilyCode")
907
+ or node.get("packageName")
908
+ or node.get("package_name")
909
+ or node.get("brand")
910
+ or node.get("tier")
911
+ or node.get("tariffName")
912
+ )
913
+ p_amt, p_curr = _extract_amount_and_currency(
914
+ node.get("startingPrice")
915
+ or node.get("price")
916
+ or node.get("totalPrice")
917
+ or node.get("fare")
918
+ or node
919
+ )
920
+ seats = (
921
+ node.get("availableSeatCount")
922
+ or node.get("seatCount")
923
+ or node.get("availableSeats")
924
+ or node.get("seats")
925
+ )
926
+ _add_fare(raw_b, node_cabin, p_amt, p_curr, seats)
927
+
928
+ # Alt liste anahtarlarında sıralı paket listesi varsa
929
+ for list_key in (
930
+ "bookingPriceInfoList", "fareSubGroupList", "subGroupList",
931
+ "fares", "fareList", "brandList", "brands", "packages",
932
+ "packageList", "tariffs", "bundles", "priceOptions",
933
+ ):
934
+ sub_list = node.get(list_key)
935
+ if isinstance(sub_list, list):
936
+ for idx_item, sub_item in enumerate(sub_list):
937
+ if isinstance(sub_item, dict):
938
+ raw_b = (
939
+ sub_item.get("brandName")
940
+ or sub_item.get("brandKey")
941
+ or sub_item.get("brandCode")
942
+ or sub_item.get("brandCodeList")
943
+ or sub_item.get("subGroupName")
944
+ or sub_item.get("fareFamilyCode")
945
+ or sub_item.get("packageName")
946
+ or sub_item.get("package_name")
947
+ or sub_item.get("name")
948
+ or sub_item.get("code")
949
+ )
950
+ item_cabin = str(sub_item.get("cabinClass") or sub_item.get("cabin") or node_cabin)
951
+ p_amt, p_curr = _extract_amount_and_currency(
952
+ sub_item.get("startingPrice")
953
+ or sub_item.get("price")
954
+ or sub_item.get("totalPrice")
955
+ or sub_item.get("fare")
956
+ or sub_item
957
+ )
958
+ seats = (
959
+ sub_item.get("availableSeatCount")
960
+ or sub_item.get("seatCount")
961
+ or sub_item.get("availableSeats")
962
+ or sub_item.get("seats")
963
+ )
964
+ _add_fare(raw_b, item_cabin, p_amt, p_curr, seats, idx_in_cabin=idx_item)
965
+
966
+ for k, v in node.items():
967
+ if k in ("flightSegmentList", "segments"):
968
+ continue
969
+ next_cabin = k if str(k).upper() in ("ECONOMY", "BUSINESS") else node_cabin
970
+ _scan_packages(v, next_cabin)
971
+ elif isinstance(node, list):
972
+ for elem in node:
973
+ _scan_packages(elem, current_cabin)
974
+
975
+ _scan_packages(f_opt, default_cabin)
976
+
977
+ # Eğer hiç paket bulunamadıysa ve f_opt üzerinde doğrudan bir fiyat varsa
978
+ if not fares_out and isinstance(f_opt, dict):
979
+ opt_amt, opt_curr = _extract_amount_and_currency(
980
+ f_opt.get("price")
981
+ or f_opt.get("cheapestPrice")
982
+ or f_opt.get("totalPrice")
983
+ or f_opt.get("startingPrice")
984
+ or f_opt.get("lowestPrice")
985
+ or f_opt.get("fare")
986
+ )
987
+ if opt_amt is not None and opt_amt > 0:
988
+ _add_fare("EcoFly", default_cabin, opt_amt, opt_curr, idx_in_cabin=0)
989
+
990
+ # Paketleri EcoFly -> ExtraFly -> PrimeFly -> Business sırasıyla ve fiyata göre diz
991
+ pkg_order = {"EcoFly": 0, "ExtraFly": 1, "PrimeFly": 2, "Business": 3}
992
+ fares_out.sort(key=lambda x: (pkg_order.get(x["package_name"], 99), x.get("amount") or 0.0))
993
+ return fares_out
994
+
995
+
996
+ def _looks_like_flight_segment_dict(d: dict[str, Any]) -> bool:
997
+ """Bir sözlüğün uçuş segmenti veya uçuş seçeneği olup olmadığını yapısal olarak tespit eder."""
998
+ if not isinstance(d, dict):
999
+ return False
1000
+ if any(k in d for k in ("flightSegmentList", "fareCategory", "bookingPriceInfoList", "fareSubGroupList")):
1001
+ return True
1002
+ has_flight_id = any(k in d for k in ("flightNumber", "carrierFlightNumber", "flightNo", "flight_number", "flightCode", "marketingFlightNumber"))
1003
+ has_time = any(k in d for k in ("departureDateTime", "departureTime", "arrivalDateTime", "arrivalTime", "departure", "arrival"))
1004
+ has_route = any(k in d for k in ("originAirportCode", "departureAirportCode", "destinationAirportCode", "arrivalAirportCode"))
1005
+ has_price_or_pkg = any(k in d for k in ("packages", "fares", "brands", "price", "startingPrice", "EcoFly", "ecofly", "EF"))
1006
+ return bool((has_flight_id and (has_time or has_route or has_price_or_pkg)) or (has_time and has_price_or_pkg))
1007
+
1008
+
1009
+ def _parse_single_flight_option(
1010
+ f_opt: dict[str, Any],
1011
+ fallback_orig: str = "",
1012
+ fallback_dest: str = "",
1013
+ fallback_packages: list[dict[str, Any]] | None = None,
1014
+ ) -> dict[str, Any]:
1015
+ """Tek bir uçuş seçeneği/segment düğümünden uçuş numarası, saatler, paketler ve en düşük fiyatı çıkarır."""
1016
+ seg_list = (
1017
+ f_opt.get("flightSegmentList")
1018
+ or f_opt.get("segments")
1019
+ or f_opt.get("legs")
1020
+ or f_opt.get("flightLegs")
1021
+ or []
1022
+ )
1023
+ flight_no = ""
1024
+ dep_time = ""
1025
+ arr_time = ""
1026
+ dur_str = ""
1027
+ orig_code = fallback_orig
1028
+ dest_code = fallback_dest
1029
+ is_direct = True
1030
+
1031
+ if isinstance(seg_list, list) and seg_list and isinstance(seg_list[0], dict):
1032
+ is_direct = len(seg_list) == 1
1033
+ first_seg = seg_list[0]
1034
+ last_seg = seg_list[-1] if isinstance(seg_list[-1], dict) else first_seg
1035
+
1036
+ fn = (
1037
+ first_seg.get("flightNumber")
1038
+ or first_seg.get("carrierFlightNumber")
1039
+ or first_seg.get("marketingFlightNumber")
1040
+ or first_seg.get("flightNo")
1041
+ or first_seg.get("flight_number")
1042
+ or first_seg.get("flightCode")
1043
+ or ""
1044
+ )
1045
+ op_air = first_seg.get("operatingAirline") or first_seg.get("marketingAirline") or {}
1046
+ carrier = (
1047
+ (op_air.get("code") if isinstance(op_air, dict) else None)
1048
+ or first_seg.get("carrierCode")
1049
+ or first_seg.get("airlineCode")
1050
+ or "TK"
1051
+ )
1052
+ fn_str = str(fn).strip()
1053
+ if fn_str:
1054
+ flight_no = fn_str if fn_str.upper().startswith(str(carrier).upper()) else f"{carrier} {fn_str}".strip()
1055
+
1056
+ dep_time = str(
1057
+ first_seg.get("departureDateTime")
1058
+ or first_seg.get("departureTime")
1059
+ or first_seg.get("departureDate")
1060
+ or first_seg.get("departure")
1061
+ or ""
1062
+ )
1063
+ arr_time = str(
1064
+ last_seg.get("arrivalDateTime")
1065
+ or last_seg.get("arrivalTime")
1066
+ or last_seg.get("arrivalDate")
1067
+ or last_seg.get("arrival")
1068
+ or ""
1069
+ )
1070
+ dur_str = str(
1071
+ f_opt.get("duration")
1072
+ or first_seg.get("duration")
1073
+ or first_seg.get("flightDuration")
1074
+ or first_seg.get("journeyDuration")
1075
+ or ""
1076
+ )
1077
+ orig_code = str(
1078
+ first_seg.get("originAirportCode")
1079
+ or first_seg.get("departureAirportCode")
1080
+ or (first_seg.get("departureAirport", {}) or {}).get("code")
1081
+ or first_seg.get("origin")
1082
+ or orig_code
1083
+ )
1084
+ dest_code = str(
1085
+ last_seg.get("destinationAirportCode")
1086
+ or last_seg.get("arrivalAirportCode")
1087
+ or (last_seg.get("arrivalAirport", {}) or {}).get("code")
1088
+ or last_seg.get("destination")
1089
+ or dest_code
1090
+ )
1091
+ else:
1092
+ fn = (
1093
+ f_opt.get("flightNumber")
1094
+ or f_opt.get("carrierFlightNumber")
1095
+ or f_opt.get("marketingFlightNumber")
1096
+ or f_opt.get("flightNo")
1097
+ or f_opt.get("flight_number")
1098
+ or f_opt.get("flightCode")
1099
+ or ""
1100
+ )
1101
+ op_air = f_opt.get("operatingAirline") or f_opt.get("marketingAirline") or {}
1102
+ carrier = (
1103
+ (op_air.get("code") if isinstance(op_air, dict) else None)
1104
+ or f_opt.get("carrierCode")
1105
+ or f_opt.get("airlineCode")
1106
+ or "TK"
1107
+ )
1108
+ fn_str = str(fn).strip()
1109
+ if fn_str:
1110
+ flight_no = fn_str if fn_str.upper().startswith(str(carrier).upper()) else f"{carrier} {fn_str}".strip()
1111
+
1112
+ dep_time = str(
1113
+ f_opt.get("departureDateTime")
1114
+ or f_opt.get("departureTime")
1115
+ or f_opt.get("departureDate")
1116
+ or f_opt.get("departure")
1117
+ or ""
1118
+ )
1119
+ arr_time = str(
1120
+ f_opt.get("arrivalDateTime")
1121
+ or f_opt.get("arrivalTime")
1122
+ or f_opt.get("arrivalDate")
1123
+ or f_opt.get("arrival")
1124
+ or ""
1125
+ )
1126
+ dur_str = str(f_opt.get("duration") or f_opt.get("flightDuration") or "")
1127
+ orig_code = str(
1128
+ f_opt.get("originAirportCode")
1129
+ or f_opt.get("departureAirportCode")
1130
+ or f_opt.get("origin")
1131
+ or orig_code
1132
+ )
1133
+ dest_code = str(
1134
+ f_opt.get("destinationAirportCode")
1135
+ or f_opt.get("arrivalAirportCode")
1136
+ or f_opt.get("destination")
1137
+ or dest_code
1138
+ )
1139
+
1140
+ fares_out = _extract_packages_deep(f_opt)
1141
+ if not fares_out and fallback_packages:
1142
+ fares_out = [dict(p) for p in fallback_packages]
1143
+
1144
+ packages_by_name: dict[str, float] = {}
1145
+ for p in fares_out:
1146
+ pname = p.get("package_name")
1147
+ pamt = p.get("amount")
1148
+ if pname and pamt is not None:
1149
+ if pname not in packages_by_name or pamt < packages_by_name[pname]:
1150
+ packages_by_name[pname] = pamt
1151
+
1152
+ flight_amts = [p["amount"] for p in fares_out if p.get("amount") is not None and p["amount"] > 0]
1153
+ flight_min_price = min(flight_amts) if flight_amts else None
1154
+ flight_curr = fares_out[0]["currency"] if fares_out else "TRY"
1155
+
1156
+ return {
1157
+ "flight_number": flight_no or "TK Uçuşu",
1158
+ "origin": orig_code,
1159
+ "destination": dest_code,
1160
+ "departure": dep_time,
1161
+ "arrival": arr_time,
1162
+ "duration": dur_str,
1163
+ "is_direct": is_direct,
1164
+ "min_price": flight_min_price,
1165
+ "currency": flight_curr,
1166
+ "packages": fares_out,
1167
+ "packages_by_name": packages_by_name,
1168
+ }
1169
+
1170
+
1171
+ def parse_thy_availability(data: dict[str, Any] | Any) -> dict[str, Any]:
1172
+ """
1173
+ THY /api/v1/availability yanıtını derinlemesine (deep search) analiz eder.
1174
+ Standart anahtarlar ('originDestinationInformationList', 'originDestinationOptionList' vb.)
1175
+ bulunamasa veya farklı bir derinlikte/isimlendirmede gelse bile tüm JSON ağacını tarayarak:
1176
+ - Uçuş segmentlerini ve seferlerini,
1177
+ - EcoFly, ExtraFly, PrimeFly ve Business paket fiyatlarını,
1178
+ - Tüm keşfedilen fiyatları ('discovered_prices') ve en düşük fiyatı ('min_price' / 'lowest_price')
1179
+ kesin olarak yakalayıp döndürür.
1180
+ """
1181
+ if not isinstance(data, (dict, list, str)):
1182
+ return {
1183
+ "success": False,
1184
+ "error": "Geçersiz yanıt formatı",
1185
+ "min_price": None,
1186
+ "lowest_price": None,
1187
+ "currency": "TRY",
1188
+ "packages": {},
1189
+ "segments": [],
1190
+ "flights": [],
1191
+ "discovered_prices": [],
1192
+ }
1193
+
1194
+ root = _unwrap_thy_root(data)
1195
+
1196
+ # 1. Tüm JSON ağacında derinlemesine fiyat ve paket taraması (Deep Search)
1197
+ all_discovered_prices: list[dict[str, Any]] = []
1198
+ seen_price_paths: set[tuple[str, float]] = set()
1199
+ global_packages_candidates: list[dict[str, Any]] = _extract_packages_deep(root)
1200
+
1201
+ def _deep_search_prices(node: Any, path: str = "", parent_brand: str = "") -> None:
1202
+ if isinstance(node, dict):
1203
+ node_brand_raw = (
1204
+ node.get("brandName")
1205
+ or node.get("brandKey")
1206
+ or node.get("brandCode")
1207
+ or node.get("brandCodeList")
1208
+ or node.get("subGroupName")
1209
+ or node.get("fareFamilyCode")
1210
+ or node.get("packageName")
1211
+ or parent_brand
1212
+ )
1213
+ # Doğrudan fiyat taşıyan nesne mi?
1214
+ if any(k in node for k in ("amount", "totalAmount", "price", "totalPrice", "fare", "startingPrice", "baseFare", "totalFare", "fareAmount", "cashAmount", "lowestPrice", "minPrice")):
1215
+ a, c = _extract_amount_and_currency(node)
1216
+ if a is not None and a > 0:
1217
+ key_sig = (path, round(float(a), 2))
1218
+ if key_sig not in seen_price_paths:
1219
+ seen_price_paths.add(key_sig)
1220
+ pkg_tag = _normalize_thy_package_name(node_brand_raw) if node_brand_raw else None
1221
+ all_discovered_prices.append({
1222
+ "path": path or "root",
1223
+ "amount": round(float(a), 2),
1224
+ "currency": c or "TRY",
1225
+ "package_name": pkg_tag,
1226
+ })
1227
+
1228
+ for k, v in node.items():
1229
+ k_str = str(k)
1230
+ k_low = k_str.lower().strip()
1231
+ if k_low in _NON_PRICE_KEYS:
1232
+ continue
1233
+ child_path = f"{path}.{k_str}" if path else k_str
1234
+ inferred_brand = k_str if k_low in ("ecofly", "eco_fly", "extrafly", "extra_fly", "flexfly", "primefly", "prime_fly", "business", "ef", "xf", "fl", "pf", "bu") else (str(node_brand_raw) if node_brand_raw else "")
1235
+
1236
+ if isinstance(v, (int, float)) and not isinstance(v, bool) and float(v) > 0:
1237
+ if any(pk in k_low for pk in ("price", "amount", "fare", "cost", "total", "tutar", "fiyat", "ecofly", "extrafly", "primefly", "business")) or k_str in ("EF", "XF", "FL", "PF", "BU"):
1238
+ key_sig = (child_path, round(float(v), 2))
1239
+ if key_sig not in seen_price_paths:
1240
+ seen_price_paths.add(key_sig)
1241
+ pkg_tag = _normalize_thy_package_name(inferred_brand) if inferred_brand else None
1242
+ all_discovered_prices.append({
1243
+ "path": child_path,
1244
+ "amount": round(float(v), 2),
1245
+ "currency": "TRY",
1246
+ "package_name": pkg_tag,
1247
+ })
1248
+ elif isinstance(v, str):
1249
+ if any(pk in k_low for pk in ("price", "amount", "fare", "cost", "total", "tutar", "fiyat", "ecofly", "extrafly", "primefly", "business")) or k_str in ("EF", "XF", "FL", "PF", "BU"):
1250
+ s_amt, s_curr = _parse_numeric_price_str(v)
1251
+ if s_amt is not None and s_amt > 0:
1252
+ key_sig = (child_path, round(float(s_amt), 2))
1253
+ if key_sig not in seen_price_paths:
1254
+ seen_price_paths.add(key_sig)
1255
+ pkg_tag = _normalize_thy_package_name(inferred_brand) if inferred_brand else None
1256
+ all_discovered_prices.append({
1257
+ "path": child_path,
1258
+ "amount": round(float(s_amt), 2),
1259
+ "currency": s_curr or "TRY",
1260
+ "package_name": pkg_tag,
1261
+ })
1262
+ elif isinstance(v, (dict, list)):
1263
+ _deep_search_prices(v, child_path, inferred_brand)
1264
+ elif isinstance(node, list):
1265
+ for i, elem in enumerate(node):
1266
+ _deep_search_prices(elem, f"{path}[{i}]", parent_brand)
1267
+
1268
+ _deep_search_prices(root)
1269
+ all_discovered_prices.sort(key=lambda x: x["amount"])
1270
+
1271
+ # 2. Rota / Segment listesini bul (önce standart anahtarlar, yoksa derin tarama)
1272
+ od_list: list[dict[str, Any]] = []
1273
+ if isinstance(root, dict):
1274
+ for k in (
1275
+ "originDestinationInformationList",
1276
+ "flightAvailabilityList",
1277
+ "journeys",
1278
+ "boundList",
1279
+ "itineraries",
1280
+ "sliceList",
1281
+ "slices",
1282
+ "routes",
1283
+ "segments",
1284
+ "originDestinationOptionList",
1285
+ "flightList",
1286
+ "flightOptionList",
1287
+ "flights",
1288
+ "options",
1289
+ ):
1290
+ val = root.get(k)
1291
+ if isinstance(val, list) and val:
1292
+ if isinstance(val[0], dict) and _looks_like_flight_segment_dict(val[0]) and not any(
1293
+ ok in val[0] for ok in ("originDestinationOptionList", "flightOptionList", "flightList", "options", "flights")
1294
+ ):
1295
+ od_list = [{"originDestinationOptionList": val, "originAirportCode": "IST", "destinationAirportCode": "ESB"}]
1296
+ else:
1297
+ od_list = val
1298
+ break
1299
+
1300
+ elif isinstance(root, list) and root:
1301
+ if isinstance(root[0], dict) and any(
1302
+ ok in root[0] for ok in ("originDestinationOptionList", "flightOptionList", "flightList", "options", "flights")
1303
+ ):
1304
+ od_list = root
1305
+ elif isinstance(root[0], dict) and _looks_like_flight_segment_dict(root[0]):
1306
+ od_list = [{"originDestinationOptionList": root, "originAirportCode": "IST", "destinationAirportCode": "ESB"}]
1307
+
1308
+ air = root.get("availabilityInfoResponse") if isinstance(root, dict) else None
1309
+ if not od_list and isinstance(air, list) and air:
1310
+ for idx, item in enumerate(air):
1311
+ if isinstance(item, list) and item:
1312
+ od_list.append({
1313
+ "originDestinationOptionList": item,
1314
+ "originAirportCode": "IST" if idx == 0 else "ESB",
1315
+ "destinationAirportCode": "ESB" if idx == 0 else "IST",
1316
+ })
1317
+ elif isinstance(item, dict):
1318
+ od_list.append(item)
1319
+
1320
+ # Eğer standart anahtarlarla od_list bulunamadıysa tüm JSON ağacında derinlemesine OD / uçuş listesi ara
1321
+ if not od_list:
1322
+ deep_flight_nodes: list[dict[str, Any]] = []
1323
+ seen_node_ids: set[int] = set()
1324
+
1325
+ def _deep_find_flights(node: Any) -> None:
1326
+ if isinstance(node, dict):
1327
+ # Eğer düğüm altında bir uçuş seçeneği listesi varsa
1328
+ for k, v in node.items():
1329
+ if isinstance(v, list) and v and all(isinstance(x, dict) for x in v):
1330
+ if any(_looks_like_flight_segment_dict(x) for x in v):
1331
+ if id(node) not in seen_node_ids:
1332
+ seen_node_ids.add(id(node))
1333
+ od_list.append({
1334
+ "originAirportCode": node.get("originAirportCode") or node.get("origin") or node.get("departureAirportCode") or "",
1335
+ "destinationAirportCode": node.get("destinationAirportCode") or node.get("destination") or node.get("arrivalAirportCode") or "",
1336
+ "departureDate": node.get("departureDate") or node.get("date") or "",
1337
+ "originDestinationOptionList": v,
1338
+ })
1339
+ return
1340
+ if _looks_like_flight_segment_dict(node) and id(node) not in seen_node_ids:
1341
+ seen_node_ids.add(id(node))
1342
+ deep_flight_nodes.append(node)
1343
+ return
1344
+ for k, v in node.items():
1345
+ _deep_find_flights(v)
1346
+ elif isinstance(node, list):
1347
+ for elem in node:
1348
+ _deep_find_flights(elem)
1349
+
1350
+ _deep_find_flights(root)
1351
+ if not od_list and deep_flight_nodes:
1352
+ od_list = [{
1353
+ "originAirportCode": deep_flight_nodes[0].get("originAirportCode") or deep_flight_nodes[0].get("departureAirportCode") or deep_flight_nodes[0].get("origin") or "IST",
1354
+ "destinationAirportCode": deep_flight_nodes[0].get("destinationAirportCode") or deep_flight_nodes[0].get("arrivalAirportCode") or deep_flight_nodes[0].get("destination") or "ESB",
1355
+ "departureDate": deep_flight_nodes[0].get("departureDate") or deep_flight_nodes[0].get("date") or "",
1356
+ "originDestinationOptionList": deep_flight_nodes,
1357
+ }]
1358
+
1359
+ root_econ_amt, root_econ_curr = _extract_amount_and_currency(root.get("economyStartingPrice") if isinstance(root, dict) else None)
1360
+ root_bus_amt, root_bus_curr = _extract_amount_and_currency(root.get("businessStartingPrice") if isinstance(root, dict) else None)
1361
+
1362
+ segments_out: list[dict[str, Any]] = []
1363
+
1364
+ for idx, od in enumerate(od_list):
1365
+ if not isinstance(od, dict):
1366
+ continue
1367
+ direction = "GİDİŞ" if idx == 0 else ("DÖNÜŞ" if idx == 1 else f"SEGMENT_{idx+1}")
1368
+ orig = str(
1369
+ od.get("originAirportCode")
1370
+ or od.get("departureAirportCode")
1371
+ or od.get("originCityCode")
1372
+ or od.get("origin")
1373
+ or ("IST" if idx == 0 else ("ESB" if idx == 1 else ""))
1374
+ )
1375
+ dest = str(
1376
+ od.get("destinationAirportCode")
1377
+ or od.get("arrivalAirportCode")
1378
+ or od.get("destinationCityCode")
1379
+ or od.get("destination")
1380
+ or ("ESB" if idx == 0 else ("IST" if idx == 1 else ""))
1381
+ )
1382
+ date_val = str(od.get("departureDate") or od.get("arrivalDate") or od.get("date") or "")
1383
+
1384
+ econ_amt, econ_curr = _extract_amount_and_currency(od.get("economyStartingPrice"))
1385
+ if econ_amt is None:
1386
+ econ_amt, econ_curr = root_econ_amt, root_econ_curr
1387
+ bus_amt, bus_curr = _extract_amount_and_currency(od.get("businessStartingPrice"))
1388
+ if bus_amt is None:
1389
+ bus_amt, bus_curr = root_bus_amt, root_bus_curr
1390
+
1391
+ options = (
1392
+ od.get("originDestinationOptionList")
1393
+ or od.get("flightOptionList")
1394
+ or od.get("flightList")
1395
+ or od.get("options")
1396
+ or od.get("flights")
1397
+ or od.get("itineraries")
1398
+ or od.get("items")
1399
+ or []
1400
+ )
1401
+
1402
+ # Eğer options standart anahtarlarda yoksa od içindeki alt dizileri derinlemesine tara
1403
+ if not options:
1404
+ for k, v in od.items():
1405
+ if isinstance(v, list) and v and isinstance(v[0], dict) and _looks_like_flight_segment_dict(v[0]):
1406
+ options = v
1407
+ break
1408
+
1409
+ # Eğer od'nin kendisi doğrudan bir uçuş nesnesiyse
1410
+ if not options and _looks_like_flight_segment_dict(od):
1411
+ options = [od]
1412
+
1413
+ od_fallback_packages = _extract_packages_deep(od)
1414
+ flights_out: list[dict[str, Any]] = []
1415
+
1416
+ if isinstance(options, list):
1417
+ for f_opt in options:
1418
+ if not isinstance(f_opt, dict):
1419
+ continue
1420
+ parsed_fl = _parse_single_flight_option(
1421
+ f_opt,
1422
+ fallback_orig=orig,
1423
+ fallback_dest=dest,
1424
+ fallback_packages=od_fallback_packages or global_packages_candidates,
1425
+ )
1426
+ flights_out.append(parsed_fl)
1427
+
1428
+ # Segment bazında paket özetini (EcoFly, ExtraFly, PrimeFly, Business) derle
1429
+ seg_pkg_summary: dict[str, dict[str, Any]] = {}
1430
+ for fl in flights_out:
1431
+ for p in fl.get("packages", []):
1432
+ pname = p.get("package_name")
1433
+ pamt = p.get("amount")
1434
+ pcurr = p.get("currency", "TRY")
1435
+ if pname and pamt is not None and pamt > 0:
1436
+ if pname not in seg_pkg_summary or pamt < seg_pkg_summary[pname]["amount"]:
1437
+ seg_pkg_summary[pname] = {"amount": pamt, "currency": pcurr}
1438
+
1439
+ for p in od_fallback_packages:
1440
+ pname = p.get("package_name")
1441
+ pamt = p.get("amount")
1442
+ pcurr = p.get("currency", "TRY")
1443
+ if pname and pamt is not None and pamt > 0:
1444
+ if pname not in seg_pkg_summary or pamt < seg_pkg_summary[pname]["amount"]:
1445
+ seg_pkg_summary[pname] = {"amount": pamt, "currency": pcurr}
1446
+
1447
+ if econ_amt is not None and econ_amt > 0 and "EcoFly" not in seg_pkg_summary:
1448
+ seg_pkg_summary["EcoFly"] = {"amount": round(float(econ_amt), 2), "currency": econ_curr or "TRY"}
1449
+ if bus_amt is not None and bus_amt > 0 and "Business" not in seg_pkg_summary:
1450
+ seg_pkg_summary["Business"] = {"amount": round(float(bus_amt), 2), "currency": bus_curr or "TRY"}
1451
+
1452
+ # Eğer uçuş listesi kesilmiş/boş kalmış ama başlangıç veya paket fiyatları mevcutsa özet uçuş kaydı ekle
1453
+ if not flights_out and seg_pkg_summary:
1454
+ synth_pkgs = [
1455
+ {
1456
+ "package_name": pk_name,
1457
+ "cabin_class": "BUSINESS" if pk_name == "Business" else "ECONOMY",
1458
+ "amount": pk_info["amount"],
1459
+ "currency": pk_info["currency"],
1460
+ "available_seats": None,
1461
+ }
1462
+ for pk_name, pk_info in seg_pkg_summary.items()
1463
+ ]
1464
+ synth_amts = [p["amount"] for p in synth_pkgs if p.get("amount")]
1465
+ flights_out.append({
1466
+ "flight_number": "TK Uçuşu",
1467
+ "origin": orig or "IST",
1468
+ "destination": dest or "ESB",
1469
+ "departure": date_val,
1470
+ "arrival": date_val,
1471
+ "duration": "",
1472
+ "is_direct": True,
1473
+ "min_price": min(synth_amts) if synth_amts else None,
1474
+ "currency": synth_pkgs[0]["currency"] if synth_pkgs else "TRY",
1475
+ "packages": synth_pkgs,
1476
+ "packages_by_name": {p["package_name"]: p["amount"] for p in synth_pkgs},
1477
+ })
1478
+
1479
+ all_amts = [
1480
+ f["amount"]
1481
+ for fl in flights_out
1482
+ for f in fl.get("packages", [])
1483
+ if f.get("amount") is not None and f["amount"] > 0
1484
+ ]
1485
+ if econ_amt is not None and econ_amt > 0:
1486
+ all_amts.append(econ_amt)
1487
+ if bus_amt is not None and bus_amt > 0:
1488
+ all_amts.append(bus_amt)
1489
+ for pk_info in seg_pkg_summary.values():
1490
+ if pk_info.get("amount"):
1491
+ all_amts.append(pk_info["amount"])
1492
+
1493
+ min_price = round(min(all_amts), 2) if all_amts else None
1494
+ curr_code = (
1495
+ econ_curr
1496
+ or bus_curr
1497
+ or (flights_out[0]["packages"][0]["currency"] if flights_out and flights_out[0].get("packages") else "TRY")
1498
+ )
1499
+
1500
+ segments_out.append({
1501
+ "direction": direction,
1502
+ "origin": orig,
1503
+ "destination": dest,
1504
+ "date": date_val,
1505
+ "min_price": min_price,
1506
+ "lowest_price": min_price,
1507
+ "currency": curr_code,
1508
+ "economy_starting_price": econ_amt,
1509
+ "business_starting_price": bus_amt,
1510
+ "packages": seg_pkg_summary,
1511
+ "flights": flights_out,
1512
+ })
1513
+
1514
+ # 3. Eğer od_list hiç bulunamadıysa fakat derin aramada fiyatlar veya paketler yakalandıysa sentetik segment oluştur
1515
+ if not segments_out and (global_packages_candidates or all_discovered_prices or root_econ_amt or root_bus_amt):
1516
+ synth_pkgs_list: list[dict[str, Any]] = list(global_packages_candidates)
1517
+ if not synth_pkgs_list:
1518
+ for dp in all_discovered_prices:
1519
+ pname = dp.get("package_name") or "EcoFly"
1520
+ synth_pkgs_list.append({
1521
+ "package_name": pname,
1522
+ "cabin_class": "BUSINESS" if pname == "Business" else "ECONOMY",
1523
+ "amount": dp["amount"],
1524
+ "currency": dp["currency"],
1525
+ "available_seats": None,
1526
+ })
1527
+
1528
+ seg_pkg_map: dict[str, dict[str, Any]] = {}
1529
+ for p in synth_pkgs_list:
1530
+ pname = p["package_name"]
1531
+ pamt = p["amount"]
1532
+ if pname not in seg_pkg_map or pamt < seg_pkg_map[pname]["amount"]:
1533
+ seg_pkg_map[pname] = {"amount": pamt, "currency": p.get("currency", "TRY")}
1534
+
1535
+ all_candidate_amts = [dp["amount"] for dp in all_discovered_prices if dp.get("amount")] + [
1536
+ p["amount"] for p in synth_pkgs_list if p.get("amount")
1537
+ ]
1538
+ if root_econ_amt:
1539
+ all_candidate_amts.append(root_econ_amt)
1540
+ if root_bus_amt:
1541
+ all_candidate_amts.append(root_bus_amt)
1542
+ seg_min = round(min(all_candidate_amts), 2) if all_candidate_amts else None
1543
+ seg_curr = (
1544
+ all_discovered_prices[0]["currency"]
1545
+ if all_discovered_prices
1546
+ else (root_econ_curr or root_bus_curr or "TRY")
1547
+ )
1548
+
1549
+ segments_out.append({
1550
+ "direction": "GİDİŞ",
1551
+ "origin": "IST",
1552
+ "destination": "ESB",
1553
+ "date": "",
1554
+ "min_price": seg_min,
1555
+ "lowest_price": seg_min,
1556
+ "currency": seg_curr,
1557
+ "economy_starting_price": root_econ_amt or seg_min,
1558
+ "business_starting_price": root_bus_amt,
1559
+ "packages": seg_pkg_map,
1560
+ "flights": [
1561
+ {
1562
+ "flight_number": "TK Uçuşu",
1563
+ "origin": "IST",
1564
+ "destination": "ESB",
1565
+ "departure": "",
1566
+ "arrival": "",
1567
+ "duration": "",
1568
+ "is_direct": True,
1569
+ "min_price": seg_min,
1570
+ "currency": seg_curr,
1571
+ "packages": synth_pkgs_list,
1572
+ "packages_by_name": {k: v["amount"] for k, v in seg_pkg_map.items()},
1573
+ }
1574
+ ] if synth_pkgs_list else [],
1575
+ })
1576
+
1577
+ # 4. Küresel paket özeti (EcoFly, ExtraFly, PrimeFly, Business) ve genel en düşük fiyat (min_price / lowest_price)
1578
+ global_packages_summary: dict[str, dict[str, Any]] = {}
1579
+ for seg in segments_out:
1580
+ for pk_name, pk_val in (seg.get("packages") or {}).items():
1581
+ amt = pk_val.get("amount") if isinstance(pk_val, dict) else pk_val
1582
+ curr = pk_val.get("currency", seg.get("currency", "TRY")) if isinstance(pk_val, dict) else seg.get("currency", "TRY")
1583
+ if amt is not None and amt > 0:
1584
+ if pk_name not in global_packages_summary or amt < global_packages_summary[pk_name]["amount"]:
1585
+ global_packages_summary[pk_name] = {"amount": round(float(amt), 2), "currency": curr}
1586
+
1587
+ for p in global_packages_candidates:
1588
+ pname = p.get("package_name")
1589
+ pamt = p.get("amount")
1590
+ pcurr = p.get("currency", "TRY")
1591
+ if pname and pamt is not None and pamt > 0:
1592
+ if pname not in global_packages_summary or pamt < global_packages_summary[pname]["amount"]:
1593
+ global_packages_summary[pname] = {"amount": round(float(pamt), 2), "currency": pcurr}
1594
+
1595
+ for dp in all_discovered_prices:
1596
+ pname = dp.get("package_name")
1597
+ pamt = dp.get("amount")
1598
+ pcurr = dp.get("currency", "TRY")
1599
+ if pname and pamt is not None and pamt > 0:
1600
+ if pname not in global_packages_summary or pamt < global_packages_summary[pname]["amount"]:
1601
+ global_packages_summary[pname] = {"amount": round(float(pamt), 2), "currency": pcurr}
1602
+
1603
+ all_global_amounts: list[float] = [
1604
+ seg["min_price"] for seg in segments_out if seg.get("min_price") is not None and seg["min_price"] > 0
1605
+ ] + [
1606
+ dp["amount"] for dp in all_discovered_prices if dp.get("amount") is not None and dp["amount"] > 0
1607
+ ] + [
1608
+ pk["amount"] for pk in global_packages_summary.values() if pk.get("amount") is not None and pk["amount"] > 0
1609
+ ]
1610
+
1611
+ overall_min_price = round(min(all_global_amounts), 2) if all_global_amounts else None
1612
+ overall_currency = (
1613
+ segments_out[0].get("currency")
1614
+ if segments_out and segments_out[0].get("currency")
1615
+ else (all_discovered_prices[0]["currency"] if all_discovered_prices else "TRY")
1616
+ )
1617
+
1618
+ if overall_min_price is not None and "EcoFly" not in global_packages_summary:
1619
+ global_packages_summary["EcoFly"] = {"amount": overall_min_price, "currency": overall_currency}
1620
+
1621
+ all_flights_flat = [fl for seg in segments_out for fl in seg.get("flights", [])]
1622
+
1623
+ has_show_banner = isinstance(root, dict) and "showBanner" in root
1624
+ is_success = isinstance(data, dict) and data.get("success") is True
1625
+ if has_show_banner or (is_success and not segments_out and not all_discovered_prices):
1626
+ print("\n" + "=" * 80)
1627
+ if has_show_banner:
1628
+ print("⚠️ [Shadow-API] 'showBanner' içeren yanıt tespit edildi (/api/v1/availability yerine alt servis yanıtı)!")
1629
+ else:
1630
+ print("⚠️ [Shadow-API] API 'success: True' döndürdü ancak hiçbir uçuş segmenti veya fiyatı ayrıştırılamadı!")
1631
+ print("API Yanıtı ('data' / tam JSON gövdesi):")
1632
+ try:
1633
+ print(json.dumps(data, ensure_ascii=False, indent=2))
1634
+ except Exception:
1635
+ print(str(data)[:2000])
1636
+ print("=" * 80 + "\n")
1637
+
1638
+ return {
1639
+ "success": bool(segments_out or all_discovered_prices or overall_min_price is not None),
1640
+ "min_price": overall_min_price,
1641
+ "lowest_price": overall_min_price,
1642
+ "currency": overall_currency,
1643
+ "packages": global_packages_summary,
1644
+ "ecofly_min_price": global_packages_summary.get("EcoFly", {}).get("amount"),
1645
+ "extrafly_min_price": global_packages_summary.get("ExtraFly", {}).get("amount"),
1646
+ "primefly_min_price": global_packages_summary.get("PrimeFly", {}).get("amount"),
1647
+ "business_min_price": global_packages_summary.get("Business", {}).get("amount"),
1648
+ "segments": segments_out,
1649
+ "flights": all_flights_flat,
1650
+ "discovered_prices": all_discovered_prices,
1651
+ }
1652
+
1653
+
1654
+ def print_availability_summary(parsed: dict[str, Any], elapsed_ms: float = 0.0) -> None:
1655
+ """Ayrıştırılmış uçuş ve fiyat verilerini terminale ve ajana zengin, anlaşılır bir formatta yansıtır."""
1656
+ print("\n" + "=" * 80)
1657
+ print("✈ TÜRK HAVA YOLLARI (THY) UÇUŞ MÜSAİTLİK VE FİYAT RAPORU")
1658
+ if elapsed_ms > 0:
1659
+ print(f"⏱ Yanıt Süresi: {elapsed_ms:.2f} ms")
1660
+ print("=" * 80)
1661
+
1662
+ segments = parsed.get("segments", [])
1663
+ if not segments:
1664
+ discovered = parsed.get("discovered_prices", [])
1665
+ if discovered:
1666
+ print("✔ Uçuş Fiyat Verileri (Derin Tarama ile Bulundu):")
1667
+ for item in discovered[:12]:
1668
+ print(f" • {item['path']}: {item['amount']:,.2f} {item['currency']}")
1669
+ else:
1670
+ print("⚠️ Yapılandırılmış uçuş veya fiyat verisi tespit edilemedi.")
1671
+ print("=" * 80 + "\n")
1672
+ return
1673
+
1674
+ for seg in segments:
1675
+ dir_label = seg.get("direction", "UÇUŞ")
1676
+ icon = "🟢" if "GİDİŞ" in dir_label else ("🔵" if "DÖNÜŞ" in dir_label else "✈")
1677
+ orig = seg.get("origin", "")
1678
+ dest = seg.get("destination", "")
1679
+ date_str = seg.get("date", "")
1680
+ curr = seg.get("currency", "TRY")
1681
+ min_p = seg.get("min_price")
1682
+
1683
+ print(f"\n{icon} [{dir_label}] {orig} ➔ {dest} | Tarih: {date_str}")
1684
+ if min_p is not None:
1685
+ print(f" 💰 En Düşük Fiyat: {min_p:,.2f} {curr}")
1686
+ econ = seg.get("economy_starting_price")
1687
+ bus = seg.get("business_starting_price")
1688
+ price_tags: list[str] = []
1689
+ if econ is not None:
1690
+ price_tags.append(f"Economy Başlangıç: {econ:,.2f} {curr}")
1691
+ if bus is not None:
1692
+ price_tags.append(f"Business Başlangıç: {bus:,.2f} {curr}")
1693
+ if price_tags:
1694
+ print(f" ℹ {' | '.join(price_tags)}")
1695
+
1696
+ flights = seg.get("flights", [])
1697
+ if flights:
1698
+ print(" " + "-" * 74)
1699
+ for f_idx, fl in enumerate(flights, start=1):
1700
+ f_no = fl.get("flight_number", "Uçuş")
1701
+ raw_dep = fl.get("departure", "")
1702
+ raw_arr = fl.get("arrival", "")
1703
+ dep = raw_dep[-8:-3] if len(raw_dep) >= 16 else raw_dep
1704
+ arr = raw_arr[-8:-3] if len(raw_arr) >= 16 else raw_arr
1705
+ dur = fl.get("duration", "")
1706
+ mode = "Direkt" if fl.get("is_direct") else "Aktarmalı"
1707
+ time_info = f"{dep} ➔ {arr}" if dep and arr else ""
1708
+ meta = " | ".join(p for p in (time_info, f"Süre: {dur}" if dur else "", mode) if p)
1709
+
1710
+ print(f" {f_idx}. {f_no} ({meta})")
1711
+ packages = fl.get("packages", [])
1712
+ for pkg in packages:
1713
+ p_name = pkg.get("package_name", "Paket")
1714
+ p_amt = pkg.get("amount")
1715
+ p_curr = pkg.get("currency", "TRY")
1716
+ seats = pkg.get("available_seats")
1717
+ seat_info = f" ({seats} koltuk)" if seats else ""
1718
+ if p_amt is not None:
1719
+ print(f" • {p_name:<16}: {p_amt:>10,.2f} {p_curr}{seat_info}")
1720
+ print(" " + "-" * 74)
1721
+
1722
+ print("\n" + "=" * 80 + "\n")
1723
+
1724
+
1725
+ class ThyClient(BaseShadowClient):
1726
+ """THY servisi için OwnAPI User-Delegated Browser Middleware istemcisi."""
1727
+
1728
+ def __init__(
1729
+ self,
1730
+ tenant_id: str = "default",
1731
+ token_store: TokenStore | None = None,
1732
+ mode: str = "local",
1733
+ ) -> None:
1734
+ super().__init__(
1735
+ service_name="thy",
1736
+ base_url=THY_HOME_URL,
1737
+ tenant_id=tenant_id,
1738
+ token_store=token_store,
1739
+ mode=mode,
1740
+ )
1741
+
1742
+ def search_flights(
1743
+ self,
1744
+ departure_date: str = '02-03-2027',
1745
+ return_date: str | None = '09-03-2027',
1746
+ origin_airport_code: str = 'IST',
1747
+ destination_airport_code: str = 'ESB',
1748
+ cid: str | None = None,
1749
+ ) -> dict[str, Any]:
1750
+ return search_flights(
1751
+ departure_date=departure_date,
1752
+ return_date=return_date,
1753
+ origin_airport_code=origin_airport_code,
1754
+ destination_airport_code=destination_airport_code,
1755
+ cid=cid,
1756
+ tenant_id=self.tenant_id,
1757
+ mode=self.mode,
1758
+ non_interactive=self.non_interactive,
1759
+ )
1760
+
1761
+ def search(
1762
+ self,
1763
+ departure_date: str = '02-03-2027',
1764
+ return_date: str | None = '09-03-2027',
1765
+ origin_airport_code: str = 'IST',
1766
+ destination_airport_code: str = 'ESB',
1767
+ cid: str | None = None,
1768
+ ) -> dict[str, Any]:
1769
+ return self.search_flights(
1770
+ departure_date=departure_date,
1771
+ return_date=return_date,
1772
+ origin_airport_code=origin_airport_code,
1773
+ destination_airport_code=destination_airport_code,
1774
+ cid=cid,
1775
+ )
1776
+
1777
+
1778
+ THYClient = ThyClient
1779
+
1780
+
1781
+ if __name__ == "__main__":
1782
+ start_ns = time.perf_counter()
1783
+ data = search_flights()
1784
+ elapsed_ms = (time.perf_counter() - start_ns) * 1000
1785
+
1786
+ if isinstance(data, dict):
1787
+ parsed = data.get("parsed") or parse_thy_availability(data)
1788
+ print_availability_summary(parsed, elapsed_ms=elapsed_ms)
1789
+ if not parsed.get("segments") and not parsed.get("discovered_prices"):
1790
+ print(f"[Shadow-API] Üst Düzey Anahtarlar: {list(data.keys())}")
1791
+ if "data" in data and isinstance(data["data"], dict):
1792
+ print(f"[Shadow-API] Data Alt Anahtarlar: {list(data['data'].keys())[:10]}")
1793
+ print(json.dumps(data, ensure_ascii=False, indent=2)[:800])
1794
+ else:
1795
+ print(f"[Shadow-API] Yanıt Süresi: {elapsed_ms:.2f} ms")
1796
+ print(str(data)[:600])