lbxtoolkit 2.3.1__tar.gz → 2.4.0__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.
@@ -1,13 +1,14 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: lbxtoolkit
3
- Version: 2.3.1
4
- Summary:
3
+ Version: 2.4.0
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)
@@ -79,6 +79,8 @@ Classe de miscelâneas/diversos
79
79
  - `maximize_console` Maxima a janela do console (cmd.exe)
80
80
  - `cor` Altera cores de fonte e funto e estilos (negrito/sublinhado)
81
81
 
82
+ ### TODO: adicionar novas classes (chrome_session, locators, webdriver_handler)
83
+
82
84
  ## Instalação e uso:
83
85
 
84
86
  ### Instalação
@@ -100,6 +102,9 @@ from .auth_entra_id import auth_EntraID
100
102
  from .postgresql import postgreSQL
101
103
  from .api_rest import api_rest
102
104
  from .misc import misc
105
+ from .chrome_session import ChromeSessionConfig, ChromeSessionManager
106
+ from .locators import WebLocator, load_locators_ini, find_element
107
+ from .webdrive_handler import webdrive_handler
103
108
 
104
109
  locale.setlocale(locale.LC_ALL, 'pt_BR.UTF-8')
105
110
 
@@ -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
@@ -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).")