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.
- cli.py +1017 -0
- compiler.py +948 -0
- core/__init__.py +19 -0
- core/api_keys.py +523 -0
- core/auto_repair.py +682 -0
- core/base_client.py +165 -0
- core/consent.py +169 -0
- core/form_automation.py +1044 -0
- core/hitl_queue.py +886 -0
- core/onboarding.py +1457 -0
- core/token_store.py +223 -0
- healer.py +2074 -0
- master_mcp.py +193 -0
- ownapi/__init__.py +94 -0
- ownapi/cli.py +309 -0
- ownapi/sdk.py +436 -0
- ownapi-1.0.0.dist-info/METADATA +287 -0
- ownapi-1.0.0.dist-info/RECORD +62 -0
- ownapi-1.0.0.dist-info/WHEEL +5 -0
- ownapi-1.0.0.dist-info/entry_points.txt +4 -0
- ownapi-1.0.0.dist-info/top_level.txt +10 -0
- recorder.py +486 -0
- registry/coingecko/__init__.py +5 -0
- registry/coingecko/client.py +84 -0
- registry/coingecko/schema.json +23 -0
- registry/edevlet/__init__.py +5 -0
- registry/edevlet/client.py +54 -0
- registry/edevlet/schema.json +14 -0
- registry/enuygun/__init__.py +5 -0
- registry/enuygun/client.py +78 -0
- registry/enuygun/schema.json +13 -0
- registry/github_trending/__init__.py +5 -0
- registry/github_trending/client.py +90 -0
- registry/github_trending/schema.json +31 -0
- registry/hackernews/__init__.py +5 -0
- registry/hackernews/client.py +90 -0
- registry/hackernews/schema.json +38 -0
- registry/hepsiburada/__init__.py +5 -0
- registry/hepsiburada/client.py +32 -0
- registry/hepsiburada/schema.json +23 -0
- registry/linkedin_jobs/__init__.py +5 -0
- registry/linkedin_jobs/client.py +241 -0
- registry/linkedin_jobs/schema.json +46 -0
- registry/pegasus/__init__.py +5 -0
- registry/pegasus/client.py +141 -0
- registry/pegasus/schema.json +76 -0
- registry/thy/__init__.py +5 -0
- registry/thy/client.py +1796 -0
- registry/thy/schema.json +46 -0
- registry/trendyol/__init__.py +5 -0
- registry/trendyol/client.py +228 -0
- registry/trendyol/schema.json +30 -0
- registry/ucak_bileti/client.py +94 -0
- registry/ucak_bileti/schema.json +47 -0
- registry/wikipedia/__init__.py +5 -0
- registry/wikipedia/client.py +64 -0
- registry/wikipedia/schema.json +31 -0
- registry/yahoo_finance/__init__.py +5 -0
- registry/yahoo_finance/client.py +98 -0
- registry/yahoo_finance/schema.json +22 -0
- server.py +891 -0
- static/index.html +2401 -0
core/form_automation.py
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qs, unquote_plus, urlparse
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console(stderr=True, legacy_windows=False)
|
|
12
|
+
|
|
13
|
+
# Havalimanı kodu <-> Şehir eşleştirmeleri (Autocomplete listesinde hem IATA kodu hem şehir adı eşleşmesi için)
|
|
14
|
+
AIRPORT_CITY_ALIASES: dict[str, list[str]] = {
|
|
15
|
+
"IST": ["IST", "İstanbul", "Istanbul", "İstanbul Havalimanı"],
|
|
16
|
+
"SAW": ["SAW", "Sabiha Gökçen", "İstanbul", "Istanbul"],
|
|
17
|
+
"ESB": ["ESB", "Ankara", "Esenboğa", "Esenboga"],
|
|
18
|
+
"ADB": ["ADB", "İzmir", "Izmir", "Adnan Menderes"],
|
|
19
|
+
"AYT": ["AYT", "Antalya"],
|
|
20
|
+
"TZX": ["TZX", "Trabzon"],
|
|
21
|
+
"ADA": ["ADA", "Adana", "Şakirpaşa", "Çukurova"],
|
|
22
|
+
"DLM": ["DLM", "Dalaman", "Muğla"],
|
|
23
|
+
"BJV": ["BJV", "Bodrum", "Milas", "Muğla"],
|
|
24
|
+
"GZT": ["GZT", "Gaziantep"],
|
|
25
|
+
"ASR": ["ASR", "Kayseri"],
|
|
26
|
+
"VAN": ["VAN", "Van"],
|
|
27
|
+
"DIY": ["DIY", "Diyarbakır", "Diyarbakir"],
|
|
28
|
+
"ERZ": ["ERZ", "Erzurum"],
|
|
29
|
+
"SZF": ["SZF", "Samsun"],
|
|
30
|
+
"ECN": ["ECN", "Lefkoşa", "Ercan", "Kıbrıs"],
|
|
31
|
+
"LHR": ["LHR", "London", "Londra", "Heathrow"],
|
|
32
|
+
"CDG": ["CDG", "Paris", "Charles de Gaulle"],
|
|
33
|
+
"FRA": ["FRA", "Frankfurt"],
|
|
34
|
+
"AMS": ["AMS", "Amsterdam"],
|
|
35
|
+
"JFK": ["JFK", "New York"],
|
|
36
|
+
"250_0": ["İstanbul", "IST"],
|
|
37
|
+
"1912_170": ["Ankara", "ESB"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Semantik alan anahtar kelimeleri (Türkçe + İngilizce, normalize edilmiş küçük harf)
|
|
41
|
+
SEMANTIC_FIELD_KEYWORDS: dict[str, list[str]] = {
|
|
42
|
+
"origin": [
|
|
43
|
+
"nereden",
|
|
44
|
+
"kalkis",
|
|
45
|
+
"kalkis yeri",
|
|
46
|
+
"kalkis havalimani",
|
|
47
|
+
"departure port",
|
|
48
|
+
"departure airport",
|
|
49
|
+
"departure city",
|
|
50
|
+
"departureport",
|
|
51
|
+
"originairport",
|
|
52
|
+
"originport",
|
|
53
|
+
"origin",
|
|
54
|
+
"from airport",
|
|
55
|
+
"flying from",
|
|
56
|
+
"from",
|
|
57
|
+
"hareket",
|
|
58
|
+
],
|
|
59
|
+
"destination": [
|
|
60
|
+
"nereye",
|
|
61
|
+
"varis",
|
|
62
|
+
"varis yeri",
|
|
63
|
+
"varis havalimani",
|
|
64
|
+
"arrival port",
|
|
65
|
+
"arrival airport",
|
|
66
|
+
"arrival city",
|
|
67
|
+
"arrivalport",
|
|
68
|
+
"destinationairport",
|
|
69
|
+
"destinationport",
|
|
70
|
+
"destination",
|
|
71
|
+
"to airport",
|
|
72
|
+
"flying to",
|
|
73
|
+
"to",
|
|
74
|
+
"inis",
|
|
75
|
+
],
|
|
76
|
+
"departure_date": [
|
|
77
|
+
"gidis tarihi",
|
|
78
|
+
"gidis",
|
|
79
|
+
"kalkis tarihi",
|
|
80
|
+
"hareket tarihi",
|
|
81
|
+
"departure date",
|
|
82
|
+
"departuredate",
|
|
83
|
+
"depart date",
|
|
84
|
+
"outbound date",
|
|
85
|
+
"start date",
|
|
86
|
+
"depart",
|
|
87
|
+
"check-in",
|
|
88
|
+
"checkin",
|
|
89
|
+
],
|
|
90
|
+
"return_date": [
|
|
91
|
+
"donus tarihi",
|
|
92
|
+
"donus",
|
|
93
|
+
"varis tarihi",
|
|
94
|
+
"return date",
|
|
95
|
+
"returndate",
|
|
96
|
+
"inbound date",
|
|
97
|
+
"end date",
|
|
98
|
+
"return",
|
|
99
|
+
"check-out",
|
|
100
|
+
"checkout",
|
|
101
|
+
],
|
|
102
|
+
"query": [
|
|
103
|
+
"urun, kategori veya marka ara",
|
|
104
|
+
"arama yap",
|
|
105
|
+
"is ara",
|
|
106
|
+
"kelime veya is unvani",
|
|
107
|
+
"search jobs",
|
|
108
|
+
"keywords",
|
|
109
|
+
"search",
|
|
110
|
+
"ara",
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
SEARCH_BUTTON_KEYWORDS: list[str] = [
|
|
115
|
+
"ucus ara",
|
|
116
|
+
"ucus bul",
|
|
117
|
+
"ucuslari listele",
|
|
118
|
+
"ucuslari goster",
|
|
119
|
+
"bilet bul",
|
|
120
|
+
"bilet ara",
|
|
121
|
+
"ucak bileti bul",
|
|
122
|
+
"seferleri goster",
|
|
123
|
+
"sefer ara",
|
|
124
|
+
"search flights",
|
|
125
|
+
"find flights",
|
|
126
|
+
"show flights",
|
|
127
|
+
"ara",
|
|
128
|
+
"bul",
|
|
129
|
+
"sorgula",
|
|
130
|
+
"listele",
|
|
131
|
+
"search",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
NEGATIVE_BUTTON_KEYWORDS: list[str] = [
|
|
135
|
+
"iptal",
|
|
136
|
+
"kapat",
|
|
137
|
+
"temizle",
|
|
138
|
+
"vazgec",
|
|
139
|
+
"geri",
|
|
140
|
+
"onceki",
|
|
141
|
+
"sonraki",
|
|
142
|
+
"cerez",
|
|
143
|
+
"cookie",
|
|
144
|
+
"reddet",
|
|
145
|
+
"kabul et",
|
|
146
|
+
"üye ol",
|
|
147
|
+
"uye ol",
|
|
148
|
+
"giris yap",
|
|
149
|
+
"login",
|
|
150
|
+
"sign in",
|
|
151
|
+
"sign up",
|
|
152
|
+
"close",
|
|
153
|
+
"cancel",
|
|
154
|
+
"clear",
|
|
155
|
+
"reset",
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _normalize_tr(text: str) -> str:
|
|
160
|
+
"""Türkçe karakterleri ASCII karşılıklarına dönüştürür ve küçük harfe çevirir."""
|
|
161
|
+
if not text:
|
|
162
|
+
return ""
|
|
163
|
+
tr_map = str.maketrans("ıİğĞüÜşŞöÖçÇ", "iigguussoocc")
|
|
164
|
+
cleaned = str(text).translate(tr_map).lower()
|
|
165
|
+
return re.sub(r"\s+", " ", cleaned).strip()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _expand_aliases(value: str) -> list[str]:
|
|
169
|
+
"""Havalimanı kodu veya şehir değeri için arama ve autocomplete eşleşme adaylarını döndürür."""
|
|
170
|
+
clean = str(value or "").strip()
|
|
171
|
+
if not clean:
|
|
172
|
+
return []
|
|
173
|
+
upper = clean.upper()
|
|
174
|
+
aliases = list(AIRPORT_CITY_ALIASES.get(upper, []))
|
|
175
|
+
if clean not in aliases:
|
|
176
|
+
aliases.insert(0, clean)
|
|
177
|
+
return aliases
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _format_date_variants(date_str: str) -> list[str]:
|
|
181
|
+
"""
|
|
182
|
+
Farklı sitelerin bekleyebileceği tarih formatı varyasyonlarını üretir:
|
|
183
|
+
(orijinal, DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD).
|
|
184
|
+
"""
|
|
185
|
+
clean = str(date_str or "").strip()
|
|
186
|
+
if not clean:
|
|
187
|
+
return []
|
|
188
|
+
variants: list[str] = [clean]
|
|
189
|
+
|
|
190
|
+
# YYYYMMDD (örn. 20270707)
|
|
191
|
+
if len(clean) == 8 and clean.isdigit():
|
|
192
|
+
yyyy, mm, dd = clean[:4], clean[4:6], clean[6:8]
|
|
193
|
+
for v in (f"{dd}.{mm}.{yyyy}", f"{dd}-{mm}-{yyyy}", f"{yyyy}-{mm}-{dd}", f"{dd}/{mm}/{yyyy}"):
|
|
194
|
+
if v not in variants:
|
|
195
|
+
variants.append(v)
|
|
196
|
+
return variants
|
|
197
|
+
|
|
198
|
+
for sep in ("-", ".", "/"):
|
|
199
|
+
if sep in clean:
|
|
200
|
+
parts = clean.split(sep)
|
|
201
|
+
if len(parts) == 3:
|
|
202
|
+
if len(parts[0]) == 4:
|
|
203
|
+
yyyy, mm, dd = parts[0], parts[1].zfill(2), parts[2].zfill(2)
|
|
204
|
+
else:
|
|
205
|
+
dd, mm, yyyy = parts[0].zfill(2), parts[1].zfill(2), parts[2]
|
|
206
|
+
for v in (
|
|
207
|
+
f"{dd}.{mm}.{yyyy}",
|
|
208
|
+
f"{dd}-{mm}-{yyyy}",
|
|
209
|
+
f"{yyyy}-{mm}-{dd}",
|
|
210
|
+
f"{dd}/{mm}/{yyyy}",
|
|
211
|
+
):
|
|
212
|
+
if v not in variants:
|
|
213
|
+
variants.append(v)
|
|
214
|
+
break
|
|
215
|
+
return variants
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def extract_semantic_form_params(
|
|
219
|
+
json_payload: Any = None,
|
|
220
|
+
params: dict[str, Any] | None = None,
|
|
221
|
+
url: str | None = None,
|
|
222
|
+
target_url: str | None = None,
|
|
223
|
+
explicit_form_params: dict[str, Any] | None = None,
|
|
224
|
+
) -> dict[str, str]:
|
|
225
|
+
"""
|
|
226
|
+
İstek gövdesi (`json_payload`), sorgu parametreleri (`params`), hedef URL (`url` / `target_url`)
|
|
227
|
+
veya doğrudan verilen sözlükten semantik form alanlarını (`origin`, `destination`,
|
|
228
|
+
`departure_date`, `return_date`, `query`, `location`) otonom olarak çıkarır.
|
|
229
|
+
"""
|
|
230
|
+
extracted: dict[str, str] = {}
|
|
231
|
+
|
|
232
|
+
if isinstance(explicit_form_params, dict):
|
|
233
|
+
for k, v in explicit_form_params.items():
|
|
234
|
+
if v is not None and str(v).strip():
|
|
235
|
+
extracted[str(k).strip()] = str(v).strip()
|
|
236
|
+
|
|
237
|
+
def _scan_obj(obj: Any) -> None:
|
|
238
|
+
if isinstance(obj, str):
|
|
239
|
+
s = obj.strip()
|
|
240
|
+
if s.startswith("{") or s.startswith("["):
|
|
241
|
+
try:
|
|
242
|
+
_scan_obj(json.loads(s))
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if isinstance(obj, list):
|
|
248
|
+
# Çoklu uçuş segmenti listesi (örn. originDestinationInformationList veya flightSearchList)
|
|
249
|
+
if len(obj) >= 1 and isinstance(obj[0], dict):
|
|
250
|
+
seg0 = obj[0]
|
|
251
|
+
for ok in ("originAirportCode", "origin_airport_code", "departurePort", "departure_port", "origin", "from"):
|
|
252
|
+
if seg0.get(ok) and not extracted.get("origin"):
|
|
253
|
+
extracted["origin"] = str(seg0[ok]).strip()
|
|
254
|
+
for dk in ("destinationAirportCode", "destination_airport_code", "arrivalPort", "arrival_port", "destination", "to"):
|
|
255
|
+
if seg0.get(dk) and not extracted.get("destination"):
|
|
256
|
+
extracted["destination"] = str(seg0[dk]).strip()
|
|
257
|
+
for dep_k in ("departureDate", "departure_date", "date", "depDate"):
|
|
258
|
+
if seg0.get(dep_k) and not extracted.get("departure_date"):
|
|
259
|
+
extracted["departure_date"] = str(seg0[dep_k]).strip()
|
|
260
|
+
for ret_k in ("returnDate", "return_date", "retDate"):
|
|
261
|
+
if seg0.get(ret_k) and not extracted.get("return_date"):
|
|
262
|
+
extracted["return_date"] = str(seg0[ret_k]).strip()
|
|
263
|
+
|
|
264
|
+
if len(obj) >= 2 and isinstance(obj[1], dict) and not extracted.get("return_date"):
|
|
265
|
+
seg1 = obj[1]
|
|
266
|
+
for dep_k in ("departureDate", "departure_date", "returnDate", "return_date", "date"):
|
|
267
|
+
if seg1.get(dep_k):
|
|
268
|
+
extracted["return_date"] = str(seg1[dep_k]).strip()
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
for item in obj:
|
|
272
|
+
_scan_obj(item)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if isinstance(obj, dict):
|
|
276
|
+
# Önce iç listeleri kontrol et
|
|
277
|
+
for list_key in ("originDestinationInformationList", "flightSearchList", "segments", "legs", "itineraries"):
|
|
278
|
+
if isinstance(obj.get(list_key), list):
|
|
279
|
+
_scan_obj(obj[list_key])
|
|
280
|
+
|
|
281
|
+
for k, v in obj.items():
|
|
282
|
+
if v is None or isinstance(v, (dict, list, bool)):
|
|
283
|
+
if isinstance(v, (dict, list)):
|
|
284
|
+
_scan_obj(v)
|
|
285
|
+
continue
|
|
286
|
+
val_str = str(v).strip()
|
|
287
|
+
if not val_str:
|
|
288
|
+
continue
|
|
289
|
+
kl = re.sub(r"[_\-\s]", "", str(k).lower())
|
|
290
|
+
|
|
291
|
+
if not extracted.get("origin") and kl in (
|
|
292
|
+
"origin",
|
|
293
|
+
"originairportcode",
|
|
294
|
+
"originairport",
|
|
295
|
+
"origincitycode",
|
|
296
|
+
"originid",
|
|
297
|
+
"departureport",
|
|
298
|
+
"departureairport",
|
|
299
|
+
"from",
|
|
300
|
+
"fromport",
|
|
301
|
+
"fromairport",
|
|
302
|
+
"kalkis",
|
|
303
|
+
"nereden",
|
|
304
|
+
):
|
|
305
|
+
extracted["origin"] = val_str
|
|
306
|
+
elif not extracted.get("destination") and kl in (
|
|
307
|
+
"destination",
|
|
308
|
+
"destinationairportcode",
|
|
309
|
+
"destinationairport",
|
|
310
|
+
"destinationcitycode",
|
|
311
|
+
"destinationid",
|
|
312
|
+
"arrivalport",
|
|
313
|
+
"arrivalairport",
|
|
314
|
+
"to",
|
|
315
|
+
"toport",
|
|
316
|
+
"toairport",
|
|
317
|
+
"varis",
|
|
318
|
+
"nereye",
|
|
319
|
+
):
|
|
320
|
+
extracted["destination"] = val_str
|
|
321
|
+
elif not extracted.get("departure_date") and kl in (
|
|
322
|
+
"departuredate",
|
|
323
|
+
"depdate",
|
|
324
|
+
"gidistarihi",
|
|
325
|
+
"outbounddate",
|
|
326
|
+
"startdate",
|
|
327
|
+
):
|
|
328
|
+
extracted["departure_date"] = val_str
|
|
329
|
+
elif not extracted.get("return_date") and kl in (
|
|
330
|
+
"returndate",
|
|
331
|
+
"retdate",
|
|
332
|
+
"donustarihi",
|
|
333
|
+
"inbounddate",
|
|
334
|
+
"enddate",
|
|
335
|
+
):
|
|
336
|
+
extracted["return_date"] = val_str
|
|
337
|
+
elif not extracted.get("query") and kl in (
|
|
338
|
+
"keywords",
|
|
339
|
+
"query",
|
|
340
|
+
"q",
|
|
341
|
+
"search",
|
|
342
|
+
"keyword",
|
|
343
|
+
):
|
|
344
|
+
extracted["query"] = val_str
|
|
345
|
+
elif not extracted.get("location") and kl in ("location", "city"):
|
|
346
|
+
extracted["location"] = val_str
|
|
347
|
+
|
|
348
|
+
_scan_obj(json_payload)
|
|
349
|
+
_scan_obj(params)
|
|
350
|
+
|
|
351
|
+
# URL query parametrelerinden çıkarım
|
|
352
|
+
for u in (target_url, url):
|
|
353
|
+
if not u:
|
|
354
|
+
continue
|
|
355
|
+
try:
|
|
356
|
+
parsed = urlparse(str(u))
|
|
357
|
+
qs = parse_qs(parsed.query)
|
|
358
|
+
flat_qs = {k: unquote_plus(str(v[0])).strip() for k, v in qs.items() if v}
|
|
359
|
+
_scan_obj(flat_qs)
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
return extracted
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class GenericFormAutomator:
|
|
367
|
+
"""
|
|
368
|
+
Tüm konektörlerde (`registry/*`) ve `SessionHealer` (`healer.py`) içinde ortak çalışan
|
|
369
|
+
Genel Amaçlı Akıllı Form Otomasyonu (Generic Form Automation) Katmanı:
|
|
370
|
+
1. Sayfa yüklendiğinde havalimanı/şehir (`origin` / `destination`), tarih (`departure` / `return`)
|
|
371
|
+
veya arama (`query`) input'larını `placeholder`, `name`, `id`, `aria-label`, `data-testid` ve `<label>`
|
|
372
|
+
etiketlerinden semantik olarak bulup otonom doldurur.
|
|
373
|
+
2. Autocomplete (şehir/havalimanı öneri listesi) açılırsa ilk eşleşen sonuca otonom tıklar.
|
|
374
|
+
3. Formun arama / uçuş bul butonunu otomatik tespit edip tıklayarak SPA state'ini (frontend oturumunu)
|
|
375
|
+
tam anlamıyla oluşturur.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self, settle_wait_sec: float = 2.5) -> None:
|
|
379
|
+
self.settle_wait_sec = settle_wait_sec
|
|
380
|
+
|
|
381
|
+
async def automate_page_form(
|
|
382
|
+
self,
|
|
383
|
+
page: Any,
|
|
384
|
+
form_params: dict[str, str] | None = None,
|
|
385
|
+
json_payload: Any = None,
|
|
386
|
+
params: dict[str, Any] | None = None,
|
|
387
|
+
url: str | None = None,
|
|
388
|
+
target_url: str | None = None,
|
|
389
|
+
submit_form: bool = True,
|
|
390
|
+
) -> dict[str, Any]:
|
|
391
|
+
"""
|
|
392
|
+
Verilen Playwright `page` üzerinde semantik form otomasyonunu yürütür ve
|
|
393
|
+
yapılan işlemlerin özet raporunu döndürür.
|
|
394
|
+
"""
|
|
395
|
+
resolved = extract_semantic_form_params(
|
|
396
|
+
json_payload=json_payload,
|
|
397
|
+
params=params,
|
|
398
|
+
url=url,
|
|
399
|
+
target_url=target_url,
|
|
400
|
+
explicit_form_params=form_params,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
report: dict[str, Any] = {
|
|
404
|
+
"executed": False,
|
|
405
|
+
"detected_params": resolved,
|
|
406
|
+
"filled_fields": {},
|
|
407
|
+
"autocomplete_selected": {},
|
|
408
|
+
"submitted": False,
|
|
409
|
+
"submit_button": None,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if not resolved:
|
|
413
|
+
return report
|
|
414
|
+
|
|
415
|
+
has_flight_fields = bool(
|
|
416
|
+
resolved.get("origin")
|
|
417
|
+
or resolved.get("destination")
|
|
418
|
+
or resolved.get("departure_date")
|
|
419
|
+
or resolved.get("return_date")
|
|
420
|
+
)
|
|
421
|
+
has_search_query = bool(resolved.get("query"))
|
|
422
|
+
|
|
423
|
+
if not has_flight_fields and not has_search_query:
|
|
424
|
+
return report
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
# Çerez / bildirim banner'ı formu kapatıyorsa sessizce kapatmayı dene
|
|
428
|
+
await self._dismiss_blocking_banners(page)
|
|
429
|
+
|
|
430
|
+
# 1. Origin (Nereden / Kalkış Havalimanı veya Şehir)
|
|
431
|
+
if resolved.get("origin"):
|
|
432
|
+
orig_val = resolved["origin"]
|
|
433
|
+
orig_ok, orig_ac = await self._fill_location_field(
|
|
434
|
+
page=page,
|
|
435
|
+
role_key="origin",
|
|
436
|
+
value=orig_val,
|
|
437
|
+
)
|
|
438
|
+
if orig_ok:
|
|
439
|
+
report["executed"] = True
|
|
440
|
+
report["filled_fields"]["origin"] = orig_val
|
|
441
|
+
report["autocomplete_selected"]["origin"] = orig_ac
|
|
442
|
+
|
|
443
|
+
# 2. Destination (Nereye / Varış Havalimanı veya Şehir)
|
|
444
|
+
if resolved.get("destination"):
|
|
445
|
+
dest_val = resolved["destination"]
|
|
446
|
+
dest_ok, dest_ac = await self._fill_location_field(
|
|
447
|
+
page=page,
|
|
448
|
+
role_key="destination",
|
|
449
|
+
value=dest_val,
|
|
450
|
+
)
|
|
451
|
+
if dest_ok:
|
|
452
|
+
report["executed"] = True
|
|
453
|
+
report["filled_fields"]["destination"] = dest_val
|
|
454
|
+
report["autocomplete_selected"]["destination"] = dest_ac
|
|
455
|
+
|
|
456
|
+
# 3. Departure Date (Gidiş Tarihi)
|
|
457
|
+
if resolved.get("departure_date"):
|
|
458
|
+
dep_val = resolved["departure_date"]
|
|
459
|
+
dep_ok = await self._fill_date_field(
|
|
460
|
+
page=page,
|
|
461
|
+
role_key="departure_date",
|
|
462
|
+
date_str=dep_val,
|
|
463
|
+
)
|
|
464
|
+
if dep_ok:
|
|
465
|
+
report["executed"] = True
|
|
466
|
+
report["filled_fields"]["departure_date"] = dep_val
|
|
467
|
+
|
|
468
|
+
# 4. Return Date (Dönüş Tarihi)
|
|
469
|
+
if resolved.get("return_date"):
|
|
470
|
+
ret_val = resolved["return_date"]
|
|
471
|
+
ret_ok = await self._fill_date_field(
|
|
472
|
+
page=page,
|
|
473
|
+
role_key="return_date",
|
|
474
|
+
date_str=ret_val,
|
|
475
|
+
)
|
|
476
|
+
if ret_ok:
|
|
477
|
+
report["executed"] = True
|
|
478
|
+
report["filled_fields"]["return_date"] = ret_val
|
|
479
|
+
|
|
480
|
+
# 5. Genel Arama Input'u (Eğer origin/destination yoksa ama query varsa)
|
|
481
|
+
if not has_flight_fields and has_search_query:
|
|
482
|
+
q_val = resolved["query"]
|
|
483
|
+
q_ok, q_ac = await self._fill_location_field(
|
|
484
|
+
page=page,
|
|
485
|
+
role_key="query",
|
|
486
|
+
value=q_val,
|
|
487
|
+
)
|
|
488
|
+
if q_ok:
|
|
489
|
+
report["executed"] = True
|
|
490
|
+
report["filled_fields"]["query"] = q_val
|
|
491
|
+
report["autocomplete_selected"]["query"] = q_ac
|
|
492
|
+
|
|
493
|
+
# 6. Arama / Uçuş Bul Butonunu Otomatik Tespit Et ve Tıkla
|
|
494
|
+
if submit_form and report["filled_fields"]:
|
|
495
|
+
btn_info = await self._click_search_submit_button(page)
|
|
496
|
+
if btn_info.get("clicked"):
|
|
497
|
+
report["submitted"] = True
|
|
498
|
+
report["submit_button"] = btn_info.get("label")
|
|
499
|
+
console.print(
|
|
500
|
+
f"[bold green]🤖 [Generic Form Automation] Form dolduruldu "
|
|
501
|
+
f"({report['filled_fields']}) ve '{btn_info.get('label')}' butonuna tıklandı![/bold green]"
|
|
502
|
+
)
|
|
503
|
+
await asyncio.sleep(self.settle_wait_sec)
|
|
504
|
+
else:
|
|
505
|
+
console.print(
|
|
506
|
+
f"[cyan]🤖 [Generic Form Automation] Alanlar dolduruldu: {report['filled_fields']}[/cyan]"
|
|
507
|
+
)
|
|
508
|
+
await asyncio.sleep(1.0)
|
|
509
|
+
except Exception as exc:
|
|
510
|
+
console.print(f"[dim]Generic Form Automation bildirimi: {exc}[/dim]")
|
|
511
|
+
|
|
512
|
+
return report
|
|
513
|
+
|
|
514
|
+
async def _dismiss_blocking_banners(self, page: Any) -> None:
|
|
515
|
+
"""Form elemanlarının tıklanmasını engelleyebilecek çerez/modal pencerelerini sessizce kapatır."""
|
|
516
|
+
try:
|
|
517
|
+
await page.evaluate(
|
|
518
|
+
"""() => {
|
|
519
|
+
const selectors = [
|
|
520
|
+
'#onetrust-accept-btn-handler',
|
|
521
|
+
'[id*="cookie" i] button[id*="accept" i]',
|
|
522
|
+
'[class*="cookie" i] button[class*="accept" i]',
|
|
523
|
+
'button[data-testid*="cookie-accept" i]'
|
|
524
|
+
];
|
|
525
|
+
for (const sel of selectors) {
|
|
526
|
+
const el = document.querySelector(sel);
|
|
527
|
+
if (el && el.offsetParent !== null) {
|
|
528
|
+
el.click();
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}"""
|
|
533
|
+
)
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
async def _locate_semantic_element(self, page: Any, role_key: str) -> str | None:
|
|
538
|
+
"""
|
|
539
|
+
Sayfadaki görünür input/combobox elemanlarını `placeholder`, `name`, `id`, `aria-label`,
|
|
540
|
+
`data-testid` ve `<label>` metinlerine göre puanlayarak en uygun elemana geçici bir
|
|
541
|
+
`data-ownapi-field="<role_key>"` özniteliği atar ve CSS seçicisini döndürür.
|
|
542
|
+
"""
|
|
543
|
+
keywords = SEMANTIC_FIELD_KEYWORDS.get(role_key, [])
|
|
544
|
+
if not keywords:
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
attr_val = f"ownapi-{role_key}"
|
|
548
|
+
found = await page.evaluate(
|
|
549
|
+
"""({ roleKey, keywords, attrVal }) => {
|
|
550
|
+
const norm = (s) => (s || '')
|
|
551
|
+
.toString()
|
|
552
|
+
.replace(/[ıİ]/g, 'i')
|
|
553
|
+
.replace(/[ğĞ]/g, 'g')
|
|
554
|
+
.replace(/[üÜ]/g, 'u')
|
|
555
|
+
.replace(/[şŞ]/g, 's')
|
|
556
|
+
.replace(/[öÖ]/g, 'o')
|
|
557
|
+
.replace(/[çÇ]/g, 'c')
|
|
558
|
+
.toLowerCase()
|
|
559
|
+
.replace(/\\s+/g, ' ')
|
|
560
|
+
.trim();
|
|
561
|
+
|
|
562
|
+
const isVisible = (el) => {
|
|
563
|
+
if (!el) return false;
|
|
564
|
+
const style = window.getComputedStyle(el);
|
|
565
|
+
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
const r = el.getBoundingClientRect();
|
|
569
|
+
return r.width > 8 && r.height > 8;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// Önceki işareti temizle
|
|
573
|
+
document.querySelectorAll(`[data-ownapi-target="${attrVal}"]`).forEach(
|
|
574
|
+
el => el.removeAttribute('data-ownapi-target')
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
578
|
+
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]), ' +
|
|
579
|
+
'[role="combobox"], [role="searchbox"], [contenteditable="true"], ' +
|
|
580
|
+
'button[aria-haspopup="listbox"], button[aria-haspopup="dialog"], ' +
|
|
581
|
+
'[data-testid*="origin" i], [data-testid*="destination" i], [data-testid*="departure" i], [data-testid*="return" i], ' +
|
|
582
|
+
'[class*="port-input" i], [class*="airport-input" i]'
|
|
583
|
+
));
|
|
584
|
+
|
|
585
|
+
let bestEl = null;
|
|
586
|
+
let bestScore = 0;
|
|
587
|
+
|
|
588
|
+
for (const el of candidates) {
|
|
589
|
+
if (!isVisible(el)) continue;
|
|
590
|
+
// Zaten başka bir role atanmışsa atla
|
|
591
|
+
const existingRole = el.getAttribute('data-ownapi-assigned');
|
|
592
|
+
if (existingRole && existingRole !== roleKey) continue;
|
|
593
|
+
|
|
594
|
+
const placeholder = norm(el.getAttribute('placeholder'));
|
|
595
|
+
const name = norm(el.getAttribute('name'));
|
|
596
|
+
const id = norm(el.getAttribute('id'));
|
|
597
|
+
const ariaLabel = norm(el.getAttribute('aria-label'));
|
|
598
|
+
const testId = norm(el.getAttribute('data-testid') || el.getAttribute('data-cy') || el.getAttribute('data-qa'));
|
|
599
|
+
const title = norm(el.getAttribute('title'));
|
|
600
|
+
|
|
601
|
+
let labelText = '';
|
|
602
|
+
if (el.id) {
|
|
603
|
+
const lbl = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
604
|
+
if (lbl) labelText += ' ' + norm(lbl.innerText || lbl.textContent);
|
|
605
|
+
}
|
|
606
|
+
const parentLabel = el.closest('label');
|
|
607
|
+
if (parentLabel) {
|
|
608
|
+
labelText += ' ' + norm(parentLabel.innerText || parentLabel.textContent);
|
|
609
|
+
}
|
|
610
|
+
const fieldWrap = el.closest('[class*="field" i], [class*="input" i], [class*="port" i], [class*="date" i], [class*="search" i]');
|
|
611
|
+
if (fieldWrap) {
|
|
612
|
+
const subLbl = fieldWrap.querySelector('label, [class*="label" i], [class*="title" i]');
|
|
613
|
+
if (subLbl) labelText += ' ' + norm(subLbl.innerText || subLbl.textContent);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const primaryBlob = `${placeholder} ${ariaLabel} ${name} ${id} ${testId} ${title}`;
|
|
617
|
+
const fullBlob = `${primaryBlob} ${labelText}`;
|
|
618
|
+
|
|
619
|
+
let score = 0;
|
|
620
|
+
for (let i = 0; i < keywords.length; i++) {
|
|
621
|
+
const kw = keywords[i];
|
|
622
|
+
const weight = Math.max(10, 40 - i * 2);
|
|
623
|
+
if (placeholder === kw || ariaLabel === kw || name === kw || id === kw) {
|
|
624
|
+
score += weight + 35;
|
|
625
|
+
} else if (primaryBlob.includes(kw)) {
|
|
626
|
+
score += weight + 15;
|
|
627
|
+
} else if (fullBlob.includes(kw)) {
|
|
628
|
+
score += weight;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Input etiketine ek bonus
|
|
633
|
+
if (el.tagName.toLowerCase() === 'input') {
|
|
634
|
+
score += 8;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Karşıt rol kelimeleri geçiyorsa puan kır (örn. origin ararken nereye/destination geçmesi)
|
|
638
|
+
if (roleKey === 'origin' && (primaryBlob.includes('nereye') || primaryBlob.includes('destination') || primaryBlob.includes('arrival') || primaryBlob.includes('varis'))) {
|
|
639
|
+
score -= 60;
|
|
640
|
+
}
|
|
641
|
+
if (roleKey === 'destination' && (primaryBlob.includes('nereden') || primaryBlob.includes('origin') || primaryBlob.includes('departure') || primaryBlob.includes('kalkis'))) {
|
|
642
|
+
score -= 60;
|
|
643
|
+
}
|
|
644
|
+
if (roleKey === 'departure_date' && (primaryBlob.includes('donus') || primaryBlob.includes('return'))) {
|
|
645
|
+
score -= 60;
|
|
646
|
+
}
|
|
647
|
+
if (roleKey === 'return_date' && (primaryBlob.includes('gidis') || primaryBlob.includes('departure') || primaryBlob.includes('kalkis'))) {
|
|
648
|
+
score -= 60;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (score > bestScore) {
|
|
652
|
+
bestScore = score;
|
|
653
|
+
bestEl = el;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (bestEl && bestScore >= 12) {
|
|
658
|
+
bestEl.setAttribute('data-ownapi-target', attrVal);
|
|
659
|
+
bestEl.setAttribute('data-ownapi-assigned', roleKey);
|
|
660
|
+
return `[data-ownapi-target="${attrVal}"]`;
|
|
661
|
+
}
|
|
662
|
+
return null;
|
|
663
|
+
}""",
|
|
664
|
+
{"roleKey": role_key, "keywords": keywords, "attrVal": attr_val},
|
|
665
|
+
)
|
|
666
|
+
return found
|
|
667
|
+
|
|
668
|
+
async def _fill_location_field(
|
|
669
|
+
self,
|
|
670
|
+
page: Any,
|
|
671
|
+
role_key: str,
|
|
672
|
+
value: str,
|
|
673
|
+
) -> tuple[bool, bool]:
|
|
674
|
+
"""
|
|
675
|
+
Havalimanı/şehir (`origin` / `destination`) veya arama (`query`) input'unu bulur,
|
|
676
|
+
değeri yazar ve Autocomplete listesi açılırsa ilk eşleşen sonuca tıklar.
|
|
677
|
+
Dönüş: `(filled_ok, autocomplete_clicked)`
|
|
678
|
+
"""
|
|
679
|
+
selector = await self._locate_semantic_element(page, role_key)
|
|
680
|
+
if not selector:
|
|
681
|
+
return False, False
|
|
682
|
+
|
|
683
|
+
aliases = _expand_aliases(value)
|
|
684
|
+
type_value = aliases[0] if aliases else str(value).strip()
|
|
685
|
+
norm_aliases = [_normalize_tr(a) for a in aliases if a]
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
loc = page.locator(selector).first
|
|
689
|
+
await loc.click(timeout=3000)
|
|
690
|
+
await asyncio.sleep(0.2)
|
|
691
|
+
|
|
692
|
+
# Eğer tıklanan öğe bir combobox/button ise ve tıklama sonrası aktif bir <input> açıldıysa onu yakala
|
|
693
|
+
active_input_sel = await page.evaluate(
|
|
694
|
+
"""(origSel) => {
|
|
695
|
+
document.querySelectorAll('[data-ownapi-active-input]').forEach(
|
|
696
|
+
el => el.removeAttribute('data-ownapi-active-input')
|
|
697
|
+
);
|
|
698
|
+
const orig = document.querySelector(origSel);
|
|
699
|
+
if (orig && orig.tagName && orig.tagName.toLowerCase() === 'input') {
|
|
700
|
+
return origSel;
|
|
701
|
+
}
|
|
702
|
+
const active = document.activeElement;
|
|
703
|
+
if (active && active.tagName && active.tagName.toLowerCase() === 'input') {
|
|
704
|
+
active.setAttribute('data-ownapi-active-input', '1');
|
|
705
|
+
return '[data-ownapi-active-input="1"]';
|
|
706
|
+
}
|
|
707
|
+
if (orig) {
|
|
708
|
+
const innerInput = orig.querySelector('input') || document.querySelector('input:focus, [class*="modal" i] input, [class*="dropdown" i] input, [class*="popup" i] input, [class*="search" i] input');
|
|
709
|
+
if (innerInput) {
|
|
710
|
+
innerInput.setAttribute('data-ownapi-active-input', '1');
|
|
711
|
+
return '[data-ownapi-active-input="1"]';
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return origSel;
|
|
715
|
+
}""",
|
|
716
|
+
selector,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
target_loc = page.locator(active_input_sel).first
|
|
720
|
+
try:
|
|
721
|
+
await target_loc.fill("", timeout=2000)
|
|
722
|
+
except Exception:
|
|
723
|
+
await page.keyboard.press("Control+A")
|
|
724
|
+
await page.keyboard.press("Backspace")
|
|
725
|
+
|
|
726
|
+
await target_loc.type(type_value, delay=45)
|
|
727
|
+
await asyncio.sleep(0.45)
|
|
728
|
+
|
|
729
|
+
# Autocomplete listesi açıldıysa ilk eşleşen sonuca tıkla
|
|
730
|
+
ac_clicked = await self._select_first_autocomplete_match(
|
|
731
|
+
page=page,
|
|
732
|
+
norm_aliases=norm_aliases,
|
|
733
|
+
input_selector=active_input_sel,
|
|
734
|
+
)
|
|
735
|
+
return True, ac_clicked
|
|
736
|
+
except Exception:
|
|
737
|
+
return False, False
|
|
738
|
+
|
|
739
|
+
async def _select_first_autocomplete_match(
|
|
740
|
+
self,
|
|
741
|
+
page: Any,
|
|
742
|
+
norm_aliases: list[str],
|
|
743
|
+
input_selector: str,
|
|
744
|
+
) -> bool:
|
|
745
|
+
"""
|
|
746
|
+
Açılan şehir/havalimanı Autocomplete listesinden (`[role="option"]`, `listbox`, `.autocomplete-item` vb.)
|
|
747
|
+
ilk eşleşen görünür öğeyi bulup tıklar.
|
|
748
|
+
"""
|
|
749
|
+
try:
|
|
750
|
+
clicked = await page.evaluate(
|
|
751
|
+
"""({ aliases, inputSel }) => {
|
|
752
|
+
const norm = (s) => (s || '')
|
|
753
|
+
.toString()
|
|
754
|
+
.replace(/[ıİ]/g, 'i')
|
|
755
|
+
.replace(/[ğĞ]/g, 'g')
|
|
756
|
+
.replace(/[üÜ]/g, 'u')
|
|
757
|
+
.replace(/[şŞ]/g, 's')
|
|
758
|
+
.replace(/[öÖ]/g, 'o')
|
|
759
|
+
.replace(/[çÇ]/g, 'c')
|
|
760
|
+
.toLowerCase()
|
|
761
|
+
.replace(/\\s+/g, ' ')
|
|
762
|
+
.trim();
|
|
763
|
+
|
|
764
|
+
const isVisible = (el) => {
|
|
765
|
+
if (!el) return false;
|
|
766
|
+
const style = window.getComputedStyle(el);
|
|
767
|
+
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
const r = el.getBoundingClientRect();
|
|
771
|
+
return r.width > 10 && r.height > 10;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const inputEl = document.querySelector(inputSel);
|
|
775
|
+
const optionSelectors = [
|
|
776
|
+
'[role="option"]',
|
|
777
|
+
'[role="listbox"] li',
|
|
778
|
+
'[role="listbox"] [role="button"]',
|
|
779
|
+
'[class*="autocomplete" i] li',
|
|
780
|
+
'[class*="autocomplete" i] [class*="item" i]',
|
|
781
|
+
'[class*="suggestion" i] li',
|
|
782
|
+
'[class*="suggestion" i] [class*="item" i]',
|
|
783
|
+
'[class*="port-list" i] li',
|
|
784
|
+
'[class*="airport-list" i] li',
|
|
785
|
+
'[class*="dropdown-menu" i] li',
|
|
786
|
+
'[class*="search-result" i] li',
|
|
787
|
+
'[data-testid*="option" i]',
|
|
788
|
+
'[data-testid*="suggestion" i]',
|
|
789
|
+
'[data-testid*="airport" i]',
|
|
790
|
+
'.ui-menu-item'
|
|
791
|
+
];
|
|
792
|
+
|
|
793
|
+
const rawOptions = Array.from(document.querySelectorAll(optionSelectors.join(', ')));
|
|
794
|
+
const visibleOptions = rawOptions.filter(el => {
|
|
795
|
+
if (!isVisible(el)) return false;
|
|
796
|
+
if (inputEl && (el === inputEl || el.contains(inputEl))) return false;
|
|
797
|
+
const txt = norm(el.innerText || el.textContent);
|
|
798
|
+
return txt.length >= 2 && txt.length <= 250;
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
if (visibleOptions.length === 0) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 1. Önce kelime sınırı (word-boundary) ile tam kod veya şehir eşleşen ilk seçeneği ara
|
|
806
|
+
for (const alias of aliases) {
|
|
807
|
+
if (!alias) continue;
|
|
808
|
+
const escaped = alias.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
|
|
809
|
+
const wordRe = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i');
|
|
810
|
+
for (const opt of visibleOptions) {
|
|
811
|
+
const txt = norm(opt.innerText || opt.textContent);
|
|
812
|
+
if (wordRe.test(txt)) {
|
|
813
|
+
opt.click();
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// 2. Alt dizge (substring) eşleşmesi ara
|
|
820
|
+
for (const alias of aliases) {
|
|
821
|
+
if (!alias) continue;
|
|
822
|
+
for (const opt of visibleOptions) {
|
|
823
|
+
const txt = norm(opt.innerText || opt.textContent);
|
|
824
|
+
if (txt.includes(alias)) {
|
|
825
|
+
opt.click();
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 3. Eşleşme yoksa açılan listedeki ilk geçerli öneriye tıkla
|
|
832
|
+
visibleOptions[0].click();
|
|
833
|
+
return true;
|
|
834
|
+
}""",
|
|
835
|
+
{"aliases": norm_aliases, "inputSel": input_selector},
|
|
836
|
+
)
|
|
837
|
+
if clicked:
|
|
838
|
+
await asyncio.sleep(0.25)
|
|
839
|
+
return True
|
|
840
|
+
|
|
841
|
+
# DOM üzerinden bulunamadıysa klavye ile ilk öneriyi seçmeyi dene
|
|
842
|
+
await page.keyboard.press("ArrowDown")
|
|
843
|
+
await asyncio.sleep(0.1)
|
|
844
|
+
await page.keyboard.press("Enter")
|
|
845
|
+
await asyncio.sleep(0.2)
|
|
846
|
+
return True
|
|
847
|
+
except Exception:
|
|
848
|
+
return False
|
|
849
|
+
|
|
850
|
+
async def _fill_date_field(
|
|
851
|
+
self,
|
|
852
|
+
page: Any,
|
|
853
|
+
role_key: str,
|
|
854
|
+
date_str: str,
|
|
855
|
+
) -> bool:
|
|
856
|
+
"""
|
|
857
|
+
Gidiş (`departure_date`) veya dönüş (`return_date`) tarih alanını semantik olarak bulur,
|
|
858
|
+
hem input değerini (`input`/`change` event'leriyle) ayarlar hem de açık takvim hücresi varsa seçer.
|
|
859
|
+
"""
|
|
860
|
+
selector = await self._locate_semantic_element(page, role_key)
|
|
861
|
+
if not selector:
|
|
862
|
+
return False
|
|
863
|
+
|
|
864
|
+
variants = _format_date_variants(date_str)
|
|
865
|
+
if not variants:
|
|
866
|
+
return False
|
|
867
|
+
|
|
868
|
+
primary_val = variants[0]
|
|
869
|
+
iso_val = next((v for v in variants if len(v) == 10 and v[4] == "-"), primary_val)
|
|
870
|
+
|
|
871
|
+
try:
|
|
872
|
+
filled = await page.evaluate(
|
|
873
|
+
"""({ sel, primaryVal, isoVal, allVariants }) => {
|
|
874
|
+
const el = document.querySelector(sel);
|
|
875
|
+
if (!el) return false;
|
|
876
|
+
el.click();
|
|
877
|
+
el.focus();
|
|
878
|
+
|
|
879
|
+
const targetInput = (el.tagName && el.tagName.toLowerCase() === 'input')
|
|
880
|
+
? el
|
|
881
|
+
: (el.querySelector('input') || el);
|
|
882
|
+
|
|
883
|
+
if (targetInput && targetInput.tagName && targetInput.tagName.toLowerCase() === 'input') {
|
|
884
|
+
const inType = (targetInput.getAttribute('type') || 'text').toLowerCase();
|
|
885
|
+
const valToSet = inType === 'date' ? isoVal : primaryVal;
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
targetInput.removeAttribute('readonly');
|
|
889
|
+
} catch (_) {}
|
|
890
|
+
|
|
891
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
892
|
+
window.HTMLInputElement.prototype,
|
|
893
|
+
'value'
|
|
894
|
+
)?.set;
|
|
895
|
+
if (nativeSetter) {
|
|
896
|
+
nativeSetter.call(targetInput, valToSet);
|
|
897
|
+
} else {
|
|
898
|
+
targetInput.value = valToSet;
|
|
899
|
+
}
|
|
900
|
+
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
901
|
+
targetInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
902
|
+
targetInput.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Takvim popover açıksa eşleşen gün hücresine tıkla
|
|
906
|
+
for (const v of allVariants) {
|
|
907
|
+
const cell = document.querySelector(
|
|
908
|
+
`[data-date="${v}"], [data-day="${v}"], [aria-label*="${v}"], td[title*="${v}"]`
|
|
909
|
+
);
|
|
910
|
+
if (cell && cell.offsetParent !== null) {
|
|
911
|
+
cell.click();
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return true;
|
|
916
|
+
}""",
|
|
917
|
+
{
|
|
918
|
+
"sel": selector,
|
|
919
|
+
"primaryVal": primary_val,
|
|
920
|
+
"isoVal": iso_val,
|
|
921
|
+
"allVariants": variants,
|
|
922
|
+
},
|
|
923
|
+
)
|
|
924
|
+
await asyncio.sleep(0.25)
|
|
925
|
+
return bool(filled)
|
|
926
|
+
except Exception:
|
|
927
|
+
return False
|
|
928
|
+
|
|
929
|
+
async def _click_search_submit_button(self, page: Any) -> dict[str, Any]:
|
|
930
|
+
"""
|
|
931
|
+
Formun arama / uçuş bul butonunu (`Uçuş Ara`, `Uçuş Bul`, `Bilet Bul`, `Ara`, `Search` vb.)
|
|
932
|
+
otomatik tespit edip tıklayarak SPA state'ini başlatır.
|
|
933
|
+
"""
|
|
934
|
+
try:
|
|
935
|
+
result = await page.evaluate(
|
|
936
|
+
"""({ posKeywords, negKeywords }) => {
|
|
937
|
+
const norm = (s) => (s || '')
|
|
938
|
+
.toString()
|
|
939
|
+
.replace(/[ıİ]/g, 'i')
|
|
940
|
+
.replace(/[ğĞ]/g, 'g')
|
|
941
|
+
.replace(/[üÜ]/g, 'u')
|
|
942
|
+
.replace(/[şŞ]/g, 's')
|
|
943
|
+
.replace(/[öÖ]/g, 'o')
|
|
944
|
+
.replace(/[çÇ]/g, 'c')
|
|
945
|
+
.toLowerCase()
|
|
946
|
+
.replace(/\\s+/g, ' ')
|
|
947
|
+
.trim();
|
|
948
|
+
|
|
949
|
+
const isVisible = (el) => {
|
|
950
|
+
if (!el || el.disabled) return false;
|
|
951
|
+
const style = window.getComputedStyle(el);
|
|
952
|
+
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
const r = el.getBoundingClientRect();
|
|
956
|
+
return r.width > 16 && r.height > 16;
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
960
|
+
'button, input[type="submit"], input[type="button"], [role="button"], a[class*="btn" i], a[class*="button" i], a[class*="search" i]'
|
|
961
|
+
));
|
|
962
|
+
|
|
963
|
+
let bestBtn = null;
|
|
964
|
+
let bestScore = 0;
|
|
965
|
+
let bestLabel = '';
|
|
966
|
+
|
|
967
|
+
for (const btn of candidates) {
|
|
968
|
+
if (!isVisible(btn)) continue;
|
|
969
|
+
|
|
970
|
+
const rawText = (btn.innerText || btn.value || btn.getAttribute('aria-label') || btn.getAttribute('title') || '').trim();
|
|
971
|
+
const txt = norm(rawText);
|
|
972
|
+
const aria = norm(btn.getAttribute('aria-label'));
|
|
973
|
+
const testId = norm(btn.getAttribute('data-testid') || btn.getAttribute('data-cy') || btn.getAttribute('id') || btn.getAttribute('name'));
|
|
974
|
+
const cls = norm(btn.className);
|
|
975
|
+
const blob = `${txt} ${aria} ${testId} ${cls}`;
|
|
976
|
+
|
|
977
|
+
if (!blob.trim()) continue;
|
|
978
|
+
if (negKeywords.some(neg => txt === neg || blob.includes(neg))) {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
let score = 0;
|
|
983
|
+
for (let i = 0; i < posKeywords.length; i++) {
|
|
984
|
+
const kw = posKeywords[i];
|
|
985
|
+
const weight = Math.max(12, 50 - i * 2);
|
|
986
|
+
if (txt === kw || aria === kw) {
|
|
987
|
+
score += weight + 40;
|
|
988
|
+
} else if (txt.includes(kw) || aria.includes(kw)) {
|
|
989
|
+
score += weight + 20;
|
|
990
|
+
} else if (testId.includes(kw) || cls.includes(kw)) {
|
|
991
|
+
score += weight;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if ((btn.getAttribute('type') || '').toLowerCase() === 'submit') {
|
|
996
|
+
score += 25;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (score > bestScore) {
|
|
1000
|
+
bestScore = score;
|
|
1001
|
+
bestBtn = btn;
|
|
1002
|
+
bestLabel = rawText || btn.getAttribute('aria-label') || btn.getAttribute('id') || 'submit';
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (bestBtn && bestScore >= 20) {
|
|
1007
|
+
bestBtn.click();
|
|
1008
|
+
return { clicked: true, label: bestLabel.slice(0, 60), score: bestScore };
|
|
1009
|
+
}
|
|
1010
|
+
return { clicked: false, label: null, score: 0 };
|
|
1011
|
+
}""",
|
|
1012
|
+
{
|
|
1013
|
+
"posKeywords": SEARCH_BUTTON_KEYWORDS,
|
|
1014
|
+
"negKeywords": NEGATIVE_BUTTON_KEYWORDS,
|
|
1015
|
+
},
|
|
1016
|
+
)
|
|
1017
|
+
if isinstance(result, dict) and result.get("clicked"):
|
|
1018
|
+
return result
|
|
1019
|
+
except Exception:
|
|
1020
|
+
pass
|
|
1021
|
+
return {"clicked": False, "label": None, "score": 0}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
async def run_generic_form_automation(
|
|
1025
|
+
page: Any,
|
|
1026
|
+
form_params: dict[str, str] | None = None,
|
|
1027
|
+
json_payload: Any = None,
|
|
1028
|
+
params: dict[str, Any] | None = None,
|
|
1029
|
+
url: str | None = None,
|
|
1030
|
+
target_url: str | None = None,
|
|
1031
|
+
submit_form: bool = True,
|
|
1032
|
+
settle_wait_sec: float = 2.5,
|
|
1033
|
+
) -> dict[str, Any]:
|
|
1034
|
+
"""Kısayol yardımcı fonksiyon: Verilen Playwright sayfasında GenericFormAutomator çalıştırır."""
|
|
1035
|
+
automator = GenericFormAutomator(settle_wait_sec=settle_wait_sec)
|
|
1036
|
+
return await automator.automate_page_form(
|
|
1037
|
+
page=page,
|
|
1038
|
+
form_params=form_params,
|
|
1039
|
+
json_payload=json_payload,
|
|
1040
|
+
params=params,
|
|
1041
|
+
url=url,
|
|
1042
|
+
target_url=target_url,
|
|
1043
|
+
submit_form=submit_form,
|
|
1044
|
+
)
|