lbxtoolkit 2.3.1__tar.gz → 2.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/PKG-INFO +4 -3
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/__init__.py +3 -2
- lbxtoolkit-2.4.1/lbxtoolkit/chrome_session.py +263 -0
- lbxtoolkit-2.4.1/lbxtoolkit/lbxtoolkit.zip +0 -0
- lbxtoolkit-2.4.1/lbxtoolkit/locators.py +178 -0
- lbxtoolkit-2.4.1/lbxtoolkit/misc.py +490 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/service.py +2 -2
- lbxtoolkit-2.4.1/lbxtoolkit/webdrive_handler.py +830 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/pyproject.toml +6 -2
- lbxtoolkit-2.3.1/lbxtoolkit/misc.py +0 -182
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/README.md +0 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/api_rest.py +0 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/auth_entra_id.py +0 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/config_manager.py +0 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/lbx_logger.py +0 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/postgresql.py +0 -0
- {lbxtoolkit-2.3.1 → lbxtoolkit-2.4.1}/lbxtoolkit/service_windows.py +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: lbxtoolkit
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.4.1
|
|
4
|
+
Summary: Caixa ferramentas da LBX para automações de processos (RPA), integrações, utilitários Windows e componentes reutilizáveis.
|
|
5
5
|
Author: Cristiano P. Ferrari
|
|
6
6
|
Author-email: cristiano.ferrari@lbx.com.br
|
|
7
7
|
Requires-Python: >=3.12,<4.0
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.12
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
12
|
Requires-Dist: msal (>=1.30.0)
|
|
12
13
|
Requires-Dist: numpy (>=2.0.1)
|
|
13
14
|
Requires-Dist: pandas (>=2.2.2)
|
|
@@ -99,7 +99,7 @@ from .service import Servicer
|
|
|
99
99
|
from .auth_entra_id import auth_EntraID
|
|
100
100
|
from .postgresql import postgreSQL
|
|
101
101
|
from .api_rest import api_rest
|
|
102
|
-
from .misc import misc
|
|
102
|
+
from .misc import misc, misc as Misc
|
|
103
103
|
|
|
104
104
|
locale.setlocale(locale.LC_ALL, 'pt_BR.UTF-8')
|
|
105
105
|
|
|
@@ -112,5 +112,6 @@ __all__ = [
|
|
|
112
112
|
"auth_EntraID",
|
|
113
113
|
"postgreSQL",
|
|
114
114
|
"api_rest",
|
|
115
|
-
"misc"
|
|
115
|
+
"misc",
|
|
116
|
+
"Misc"
|
|
116
117
|
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import psutil # type: ignore
|
|
11
|
+
|
|
12
|
+
from selenium import webdriver # type: ignore
|
|
13
|
+
from selenium.common.exceptions import SessionNotCreatedException # type: ignore
|
|
14
|
+
from selenium.webdriver.chrome.service import Service # type: ignore
|
|
15
|
+
|
|
16
|
+
from .lbx_logger import lbx_logger
|
|
17
|
+
from .misc import misc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ChromeSessionConfig:
|
|
22
|
+
chrome_exe: str
|
|
23
|
+
mode: str # reuse|new|ephemeral|default|attach (attach tratado fora deste arquivo)
|
|
24
|
+
profile_name: str
|
|
25
|
+
profile_root: Path
|
|
26
|
+
user_data_dir: Path # Chrome User Data (modo default)
|
|
27
|
+
profile_directory: str # "Default", "Profile 5", etc (modo default)
|
|
28
|
+
log_root: Path
|
|
29
|
+
headless: bool = False
|
|
30
|
+
start_maximized: bool = True
|
|
31
|
+
|
|
32
|
+
# None = auto; True/False força explicitamente
|
|
33
|
+
debugging_pipe: Optional[bool] = None
|
|
34
|
+
|
|
35
|
+
# None = auto (default -> False); True/False força explicitamente
|
|
36
|
+
stealth: Optional[bool] = None
|
|
37
|
+
|
|
38
|
+
# Se True, em caso de falha ao criar sessão, pausa e pergunta se deve tentar novamente
|
|
39
|
+
interactive_start: bool = False
|
|
40
|
+
|
|
41
|
+
# Quantas tentativas no start (quando interactive_start=False)
|
|
42
|
+
start_retries: int = 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChromeSessionManager:
|
|
46
|
+
# Ajuste conforme seu padrão (você confirmou este caminho)
|
|
47
|
+
_DEFAULT_USER_DATA_DIR = Path(r"C:\Users\cristianoferrari\AppData\Local\Google\Chrome\User Data")
|
|
48
|
+
|
|
49
|
+
def __init__(self, cfg: ChromeSessionConfig, log: lbx_logger):
|
|
50
|
+
self.Cfg = cfg
|
|
51
|
+
self.Log = log
|
|
52
|
+
self.Driver: Optional[webdriver.Chrome] = None
|
|
53
|
+
|
|
54
|
+
self._UserDataDir: Optional[Path] = None
|
|
55
|
+
self._IsEphemeral: bool = False
|
|
56
|
+
|
|
57
|
+
misc.set_dir(self.Cfg.log_root)
|
|
58
|
+
|
|
59
|
+
def _resolve_profile_dir(self) -> Tuple[Path, bool]:
|
|
60
|
+
Mode = (self.Cfg.mode or "reuse").lower().strip()
|
|
61
|
+
|
|
62
|
+
if Mode == "default":
|
|
63
|
+
return Path(self.Cfg.user_data_dir), False
|
|
64
|
+
|
|
65
|
+
Root = misc.set_dir(Path(self.Cfg.profile_root) / self.Cfg.profile_name)
|
|
66
|
+
|
|
67
|
+
if Mode == "new":
|
|
68
|
+
self.Log.aviso(f"Modo NEW: apagando profile: [{Root}]")
|
|
69
|
+
shutil.rmtree(Root, ignore_errors=True)
|
|
70
|
+
Root = misc.set_dir(Root)
|
|
71
|
+
return Root, False
|
|
72
|
+
|
|
73
|
+
if Mode == "ephemeral":
|
|
74
|
+
Base = Path(tempfile.gettempdir()) / "rpa_chrome_ephemeral" / self.Cfg.profile_name
|
|
75
|
+
RunDir = misc.set_dir(Base / datetime.now().strftime("%Y%m%d_%H%M%S"))
|
|
76
|
+
return RunDir, True
|
|
77
|
+
|
|
78
|
+
# reuse
|
|
79
|
+
return Root, False
|
|
80
|
+
|
|
81
|
+
def _bool_auto(self, v: Optional[bool], default_if_none: bool) -> bool:
|
|
82
|
+
return default_if_none if v is None else bool(v)
|
|
83
|
+
|
|
84
|
+
def _is_default_user_data_dir(self, user_data_dir: Path) -> bool:
|
|
85
|
+
try:
|
|
86
|
+
return user_data_dir.resolve() == self._DEFAULT_USER_DATA_DIR.resolve()
|
|
87
|
+
except Exception:
|
|
88
|
+
# fallback best-effort
|
|
89
|
+
return str(user_data_dir).strip().lower() == str(self._DEFAULT_USER_DATA_DIR).strip().lower()
|
|
90
|
+
|
|
91
|
+
def _list_chrome_pids(self) -> list[int]:
|
|
92
|
+
Pids: list[int] = []
|
|
93
|
+
for p in psutil.process_iter(attrs=["pid", "name"]):
|
|
94
|
+
try:
|
|
95
|
+
name = (p.info.get("name") or "").lower()
|
|
96
|
+
if name == "chrome.exe":
|
|
97
|
+
Pids.append(int(p.info["pid"]))
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
return sorted(set(Pids))
|
|
101
|
+
|
|
102
|
+
def _kill_chrome(self) -> None:
|
|
103
|
+
Pids = self._list_chrome_pids()
|
|
104
|
+
if not Pids:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
self.Log.aviso(f"Encerrando chrome.exe (PIDs={Pids})...")
|
|
108
|
+
for pid in Pids:
|
|
109
|
+
try:
|
|
110
|
+
psutil.Process(pid).kill()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def _build_options(self, user_data_dir: Path) -> webdriver.ChromeOptions:
|
|
115
|
+
Opts = webdriver.ChromeOptions()
|
|
116
|
+
Opts.binary_location = self.Cfg.chrome_exe
|
|
117
|
+
|
|
118
|
+
Mode = (self.Cfg.mode or "").lower().strip()
|
|
119
|
+
IsDefaultUD = self._is_default_user_data_dir(user_data_dir)
|
|
120
|
+
|
|
121
|
+
# profile
|
|
122
|
+
Opts.add_argument(f"--user-data-dir={str(user_data_dir)}")
|
|
123
|
+
if Mode == "default" and self.Cfg.profile_directory:
|
|
124
|
+
Opts.add_argument(f"--profile-directory={self.Cfg.profile_directory}")
|
|
125
|
+
|
|
126
|
+
# Cirúrgicas para evitar travas de restore/crash bubble (especialmente no profile real)
|
|
127
|
+
Opts.add_argument("--disable-session-crashed-bubble")
|
|
128
|
+
Opts.add_argument("--hide-crash-restore-bubble")
|
|
129
|
+
Opts.add_argument("--disable-features=InfiniteSessionRestore")
|
|
130
|
+
|
|
131
|
+
# Debugging pipe:
|
|
132
|
+
# - útil em perfis isolados (não-default)
|
|
133
|
+
# - PROIBIDO no user-data padrão (Chrome bloqueia DevTools)
|
|
134
|
+
UsePipe = self._bool_auto(self.Cfg.debugging_pipe, default_if_none=(Mode == "default"))
|
|
135
|
+
if IsDefaultUD and UsePipe:
|
|
136
|
+
self.Log.aviso(
|
|
137
|
+
"Chrome: dbg-pipe solicitado, mas user-data-dir é o padrão do Chrome. "
|
|
138
|
+
"Forçando dbg-pipe=[off] (Chrome bloqueia DevTools no User Data padrão)."
|
|
139
|
+
)
|
|
140
|
+
UsePipe = False
|
|
141
|
+
|
|
142
|
+
if UsePipe:
|
|
143
|
+
Opts.add_argument("--remote-debugging-pipe")
|
|
144
|
+
self.Log.info("Chrome: remote-debugging-pipe=[on]")
|
|
145
|
+
|
|
146
|
+
# flags básicas
|
|
147
|
+
Opts.add_argument("--no-first-run")
|
|
148
|
+
Opts.add_argument("--no-default-browser-check")
|
|
149
|
+
Opts.add_argument("--disable-gpu")
|
|
150
|
+
|
|
151
|
+
if self.Cfg.start_maximized:
|
|
152
|
+
Opts.add_argument("--start-maximized")
|
|
153
|
+
|
|
154
|
+
if self.Cfg.headless:
|
|
155
|
+
Opts.add_argument("--headless=new")
|
|
156
|
+
self.Log.aviso("Chrome em modo HEADLESS (new)")
|
|
157
|
+
|
|
158
|
+
# “Stealth” / anti-automation:
|
|
159
|
+
# No modo default (perfil real), deixe OFF por padrão.
|
|
160
|
+
DefaultStealth = (Mode != "default")
|
|
161
|
+
UseStealth = self._bool_auto(self.Cfg.stealth, default_if_none=DefaultStealth)
|
|
162
|
+
if UseStealth:
|
|
163
|
+
Opts.add_argument("--disable-blink-features=AutomationControlled")
|
|
164
|
+
Opts.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
165
|
+
Opts.add_experimental_option("useAutomationExtension", False)
|
|
166
|
+
self.Log.info("Chrome: stealth=[on]")
|
|
167
|
+
else:
|
|
168
|
+
self.Log.info("Chrome: stealth=[off]")
|
|
169
|
+
|
|
170
|
+
return Opts
|
|
171
|
+
|
|
172
|
+
def _create_driver(self, user_data_dir: Path) -> webdriver.Chrome:
|
|
173
|
+
Opts = self._build_options(user_data_dir)
|
|
174
|
+
|
|
175
|
+
ChromeDriverLog = self.Cfg.log_root / f"chromedriver_{self.Cfg.profile_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|
176
|
+
self.Log.info("Inicializando ChromeDriver...")
|
|
177
|
+
self.Log.debug(f"chromedriver_log=[{ChromeDriverLog}]")
|
|
178
|
+
|
|
179
|
+
ServiceObj = Service(log_output=str(ChromeDriverLog))
|
|
180
|
+
return webdriver.Chrome(service=ServiceObj, options=Opts)
|
|
181
|
+
|
|
182
|
+
def start(self) -> webdriver.Chrome:
|
|
183
|
+
if self.Driver is not None:
|
|
184
|
+
return self.Driver
|
|
185
|
+
|
|
186
|
+
user_data_dir, is_ephemeral = self._resolve_profile_dir()
|
|
187
|
+
self._UserDataDir = user_data_dir
|
|
188
|
+
self._IsEphemeral = is_ephemeral
|
|
189
|
+
|
|
190
|
+
Mode = (self.Cfg.mode or "").lower().strip()
|
|
191
|
+
IsDefaultUD = self._is_default_user_data_dir(user_data_dir)
|
|
192
|
+
|
|
193
|
+
# logging do contexto
|
|
194
|
+
self.Log.info("Sessão Chrome")
|
|
195
|
+
self.Log.info(f" mode : [{self.Cfg.mode}]")
|
|
196
|
+
self.Log.info(f" profile : [{self.Cfg.profile_name}]")
|
|
197
|
+
self.Log.info(f" user-data : [{user_data_dir}]")
|
|
198
|
+
if Mode == "default":
|
|
199
|
+
self.Log.info(f" profile-dir: [{self.Cfg.profile_directory}]")
|
|
200
|
+
self.Log.info(f" log_root : [{self.Cfg.log_root}]")
|
|
201
|
+
self.Log.info(f" interactive: [{'on' if self.Cfg.interactive_start else 'off'}]")
|
|
202
|
+
self.Log.info(f" retries : [{self.Cfg.start_retries}]")
|
|
203
|
+
self.Log.info(f" user-data-default: [{'yes' if IsDefaultUD else 'no'}]")
|
|
204
|
+
|
|
205
|
+
# pré-check de lock (chrome.exe rodando) — importante no modo default
|
|
206
|
+
ChromePids = self._list_chrome_pids()
|
|
207
|
+
if ChromePids:
|
|
208
|
+
self.Log.aviso(f"Detectado chrome.exe em execução (PIDs={ChromePids}). Isso pode bloquear o profile/user-data-dir.")
|
|
209
|
+
if self.Cfg.interactive_start:
|
|
210
|
+
Ans = input("Encerrar chrome.exe agora para liberar o profile? (s/N): ").strip().lower()
|
|
211
|
+
if Ans == "s":
|
|
212
|
+
self._kill_chrome()
|
|
213
|
+
ChromePids2 = self._list_chrome_pids()
|
|
214
|
+
if ChromePids2:
|
|
215
|
+
self.Log.aviso(f"Ainda há chrome.exe rodando (PIDs={ChromePids2}). Pode continuar bloqueado.")
|
|
216
|
+
else:
|
|
217
|
+
self.Log.aviso("Você optou por NÃO encerrar o Chrome. Se falhar, o motivo pode ser lock do profile.")
|
|
218
|
+
|
|
219
|
+
Attempts = max(1, int(self.Cfg.start_retries or 1))
|
|
220
|
+
|
|
221
|
+
while True:
|
|
222
|
+
last_exc: Exception | None = None
|
|
223
|
+
|
|
224
|
+
for i in range(1, Attempts + 1):
|
|
225
|
+
try:
|
|
226
|
+
self.Driver = self._create_driver(user_data_dir)
|
|
227
|
+
self.Log.info("Chrome iniciado OK.")
|
|
228
|
+
return self.Driver
|
|
229
|
+
except SessionNotCreatedException as e:
|
|
230
|
+
last_exc = e
|
|
231
|
+
self.Log.erro(f"Falha ao criar sessão (tentativa {i}/{Attempts}): [{e}]")
|
|
232
|
+
if i < Attempts and not self.Cfg.interactive_start:
|
|
233
|
+
continue
|
|
234
|
+
break
|
|
235
|
+
except Exception as e:
|
|
236
|
+
last_exc = e
|
|
237
|
+
self.Log.erro(f"Falha inesperada ao criar sessão (tentativa {i}/{Attempts}): [{e}]")
|
|
238
|
+
if i < Attempts and not self.Cfg.interactive_start:
|
|
239
|
+
continue
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
if not self.Cfg.interactive_start:
|
|
243
|
+
raise last_exc if last_exc else RuntimeError("Falha desconhecida ao criar sessão do ChromeDriver.")
|
|
244
|
+
|
|
245
|
+
# interativo: evita crash-loop e permite inspeção
|
|
246
|
+
self.Log.aviso("Start interativo: não será feito loop automático.")
|
|
247
|
+
self.Log.aviso("Se o Chrome abriu mas o WebDriver não conectou, cheque: prompts de restore, políticas, AV, perfil bloqueado.")
|
|
248
|
+
Ans = input("Tentar novamente iniciar a sessão? (s/N): ").strip().lower()
|
|
249
|
+
if Ans != "s":
|
|
250
|
+
raise last_exc if last_exc else RuntimeError("Falha desconhecida ao criar sessão do ChromeDriver.")
|
|
251
|
+
|
|
252
|
+
def stop(self) -> None:
|
|
253
|
+
try:
|
|
254
|
+
if self.Driver is not None:
|
|
255
|
+
self.Log.info("Encerrando Chrome...")
|
|
256
|
+
self.Driver.quit()
|
|
257
|
+
self.Log.info("Chrome encerrado.")
|
|
258
|
+
finally:
|
|
259
|
+
self.Driver = None
|
|
260
|
+
if self._IsEphemeral and self._UserDataDir:
|
|
261
|
+
shutil.rmtree(self._UserDataDir, ignore_errors=True)
|
|
262
|
+
self._UserDataDir = None
|
|
263
|
+
self._IsEphemeral = False
|
|
Binary file
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from selenium.webdriver.common.by import By #type: ignore
|
|
10
|
+
from selenium.webdriver.support.ui import WebDriverWait #type: ignore
|
|
11
|
+
|
|
12
|
+
from .lbx_logger import lbx_logger
|
|
13
|
+
from .misc import misc
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class WebLocator:
|
|
18
|
+
"""Locator amigável: pode ter HtmlAnchor, Xpath ou ambos."""
|
|
19
|
+
name: str
|
|
20
|
+
xpath: str = ""
|
|
21
|
+
html_anchor: str = ""
|
|
22
|
+
xpath_check: str = "" # dupla checagem opcional
|
|
23
|
+
must_contain_text: str = "" # validação opcional
|
|
24
|
+
must_have_attr: Optional[Dict[str, str]] = None # validação opcional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_locators_ini(IniPath: Path) -> Dict[str, WebLocator]:
|
|
28
|
+
Cfg = configparser.ConfigParser()
|
|
29
|
+
Cfg.read(IniPath, encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
Out: Dict[str, WebLocator] = {}
|
|
32
|
+
for Section in Cfg.sections():
|
|
33
|
+
xpath = (Cfg.get(Section, "xpath", fallback="") or "").strip()
|
|
34
|
+
html = (Cfg.get(Section, "html_anchor", fallback="") or "").strip()
|
|
35
|
+
xpath_check = (Cfg.get(Section, "xpath_check", fallback="") or "").strip()
|
|
36
|
+
must_contain_text = (Cfg.get(Section, "must_contain_text", fallback="") or "").strip()
|
|
37
|
+
|
|
38
|
+
must_have_attr_raw = (Cfg.get(Section, "must_have_attr", fallback="") or "").strip()
|
|
39
|
+
must_have_attr = None
|
|
40
|
+
if must_have_attr_raw:
|
|
41
|
+
must_have_attr = {}
|
|
42
|
+
# formato: key=value;key2=value2
|
|
43
|
+
for part in must_have_attr_raw.split(";"):
|
|
44
|
+
part = part.strip()
|
|
45
|
+
if not part:
|
|
46
|
+
continue
|
|
47
|
+
k, _, v = part.partition("=")
|
|
48
|
+
must_have_attr[k.strip()] = v.strip()
|
|
49
|
+
|
|
50
|
+
Out[Section] = WebLocator(
|
|
51
|
+
name=Section,
|
|
52
|
+
xpath=xpath,
|
|
53
|
+
html_anchor=html,
|
|
54
|
+
xpath_check=xpath_check,
|
|
55
|
+
must_contain_text=must_contain_text,
|
|
56
|
+
must_have_attr=must_have_attr,
|
|
57
|
+
)
|
|
58
|
+
return Out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_html_anchor(Anchor: str) -> Tuple[str, Dict[str, str], str]:
|
|
62
|
+
A = re.sub(r"\s+", " ", (Anchor or "").strip())
|
|
63
|
+
m = re.search(r"<\s*([a-zA-Z0-9:_-]+)", A)
|
|
64
|
+
Tag = (m.group(1) if m else "*").lower()
|
|
65
|
+
|
|
66
|
+
Attrs: Dict[str, str] = {}
|
|
67
|
+
for Key in ("id", "class", "type", "onclick", "name", "value", "formaction", "href"):
|
|
68
|
+
mm = re.search(rf"""\b{Key}\s*=\s*["']([^"']+)["']""", A)
|
|
69
|
+
if mm:
|
|
70
|
+
Attrs[Key] = mm.group(1)
|
|
71
|
+
|
|
72
|
+
Text = ""
|
|
73
|
+
tm = re.search(r">(.*)<\s*/\s*", A)
|
|
74
|
+
if tm:
|
|
75
|
+
Text = re.sub(r"<[^>]+>", " ", tm.group(1) or "")
|
|
76
|
+
Text = misc._norm(Text)
|
|
77
|
+
|
|
78
|
+
return Tag, Attrs, Text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_xpath_from_anchor(Tag: str, Attrs: Dict[str, str], Text: str) -> str:
|
|
82
|
+
Clauses: List[str] = []
|
|
83
|
+
|
|
84
|
+
if "id" in Attrs:
|
|
85
|
+
Clauses.append(f"@id='{Attrs['id']}'")
|
|
86
|
+
|
|
87
|
+
if "class" in Attrs:
|
|
88
|
+
Tokens = [t for t in re.split(r"\s+", Attrs["class"].strip()) if t]
|
|
89
|
+
for t in Tokens[:4]:
|
|
90
|
+
Clauses.append("contains(concat(' ', normalize-space(@class), ' '), ' " + t + " ')")
|
|
91
|
+
|
|
92
|
+
for k in ("href", "formaction", "onclick"):
|
|
93
|
+
if k in Attrs:
|
|
94
|
+
v = (Attrs[k] or "").replace("'", "")
|
|
95
|
+
Clauses.append(f"contains(@{k}, '{v[:80]}')")
|
|
96
|
+
|
|
97
|
+
if Text:
|
|
98
|
+
t = Text.replace("'", "")
|
|
99
|
+
Clauses.append(f"contains(normalize-space(.), '{t[:60]}')")
|
|
100
|
+
|
|
101
|
+
if not Clauses:
|
|
102
|
+
return f"//{Tag}"
|
|
103
|
+
return f"//{Tag}[" + " and ".join(Clauses) + "]"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def find_element(
|
|
107
|
+
*,
|
|
108
|
+
driver: Any,
|
|
109
|
+
locator: WebLocator,
|
|
110
|
+
log: lbx_logger,
|
|
111
|
+
timeout_s: float,
|
|
112
|
+
poll_s: float,
|
|
113
|
+
require_clickable: bool,
|
|
114
|
+
validate: bool = True,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""
|
|
117
|
+
Estratégia:
|
|
118
|
+
1) HTML-anchor -> gera XPaths candidatos e valida
|
|
119
|
+
2) fallback XPATH direto
|
|
120
|
+
3) (opcional) xpath_check como dupla checagem do candidato
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def _is_ok(el) -> bool:
|
|
124
|
+
try:
|
|
125
|
+
if require_clickable and (not el.is_displayed() or not el.is_enabled()):
|
|
126
|
+
return False
|
|
127
|
+
if validate and locator.must_contain_text:
|
|
128
|
+
if locator.must_contain_text.lower() not in misc._norm(el.text).lower():
|
|
129
|
+
return False
|
|
130
|
+
if validate and locator.must_have_attr:
|
|
131
|
+
for k, v in locator.must_have_attr.items():
|
|
132
|
+
if (el.get_attribute(k) or "") != v:
|
|
133
|
+
return False
|
|
134
|
+
if locator.xpath_check:
|
|
135
|
+
# dupla checagem: ao menos existe na página
|
|
136
|
+
try:
|
|
137
|
+
_ = driver.find_element(By.XPATH, locator.xpath_check)
|
|
138
|
+
except Exception:
|
|
139
|
+
return False
|
|
140
|
+
return True
|
|
141
|
+
except Exception:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# 1) html_anchor
|
|
145
|
+
if locator.html_anchor:
|
|
146
|
+
Tag, Attrs, Text = _parse_html_anchor(locator.html_anchor)
|
|
147
|
+
Xp = _build_xpath_from_anchor(Tag, Attrs, Text)
|
|
148
|
+
|
|
149
|
+
End = misc._p(timeout_s, 30)
|
|
150
|
+
Wait = WebDriverWait(driver, End, poll_frequency=poll_s)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
Wait.until(lambda d: len(d.find_elements(By.XPATH, Xp)) > 0)
|
|
154
|
+
Cands = driver.find_elements(By.XPATH, Xp)
|
|
155
|
+
for el in Cands:
|
|
156
|
+
if _is_ok(el):
|
|
157
|
+
log.info(f"[{locator.name}] selecionado (via html_anchor)")
|
|
158
|
+
return el
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# 2) xpath fallback
|
|
163
|
+
if locator.xpath:
|
|
164
|
+
Wait = WebDriverWait(driver, timeout_s, poll_frequency=poll_s)
|
|
165
|
+
if require_clickable:
|
|
166
|
+
el = Wait.until(lambda d: d.find_element(By.XPATH, locator.xpath))
|
|
167
|
+
if not _is_ok(el):
|
|
168
|
+
raise TimeoutError(f"[{locator.name}] encontrado via xpath, mas falhou validação.")
|
|
169
|
+
log.info(f"[{locator.name}] selecionado (via xpath)")
|
|
170
|
+
return el
|
|
171
|
+
|
|
172
|
+
el = Wait.until(lambda d: d.find_element(By.XPATH, locator.xpath))
|
|
173
|
+
if not _is_ok(el):
|
|
174
|
+
raise TimeoutError(f"[{locator.name}] encontrado via xpath, mas falhou validação.")
|
|
175
|
+
log.info(f"[{locator.name}] selecionado (via xpath)")
|
|
176
|
+
return el
|
|
177
|
+
|
|
178
|
+
raise TimeoutError(f"[{locator.name}] nenhum locator definido (sem html_anchor e sem xpath).")
|