• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kimata / my-py-lib / 20670164784

03 Jan 2026 01:17AM UTC coverage: 65.051% (-0.8%) from 65.835%
20670164784

push

github

kimata
feat: グレースフルシャットダウン管理モジュールを追加

Ctrl+C (SIGINT) によるグレースフルシャットダウンを管理する
graceful_shutdown.py を新規追加。
- シングルトンパターンでシャットダウン状態を管理
- Rich Live 表示のpause/resume対応
- 便利関数でデフォルトインスタンスを使用可能

また、with_retry の on_retry コールバックを拡張し、
False を返すとリトライを中止できるように変更。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

0 of 59 new or added lines in 2 files covered. (0.0%)

1 existing line in 1 file now uncovered.

3168 of 4870 relevant lines covered (65.05%)

0.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.96
/src/my_lib/selenium_util.py
1
#!/usr/bin/env python3
2
"""
3
Selenium を Chrome Driver を使って動かします。
4

5
Usage:
6
  selenium_util.py [-c CONFIG] [-D]
7

8
Options:
9
  -c CONFIG         : CONFIG を設定ファイルとして読み込んで実行します。[default: tests/fixtures/config.example.yaml]
10
  -D                : デバッグモードで動作します。
11
"""
12

13
from __future__ import annotations
1✔
14

15
import datetime
1✔
16
import inspect
1✔
17
import json
1✔
18
import logging
1✔
19
import os
1✔
20
import pathlib
1✔
21
import random
1✔
22
import re
1✔
23
import shutil
1✔
24
import signal
1✔
25
import sqlite3
1✔
26
import subprocess
1✔
27
import time
1✔
28
import io
1✔
29
from collections.abc import Callable
1✔
30
from dataclasses import dataclass
1✔
31
from typing import TYPE_CHECKING, Any, TypeVar
1✔
32

33
T = TypeVar("T")
1✔
34

35
import PIL.Image
1✔
36
import psutil
1✔
37
import selenium
1✔
38
import selenium.common.exceptions
1✔
39
import selenium.webdriver.chrome.options
1✔
40
import selenium.webdriver.chrome.service
1✔
41
import selenium.webdriver.common.action_chains
1✔
42
import selenium.webdriver.common.by
1✔
43
import selenium.webdriver.common.keys
1✔
44
import selenium.webdriver.support.expected_conditions
1✔
45
import undetected_chromedriver
1✔
46

47
if TYPE_CHECKING:
48
    from selenium.webdriver.remote.webdriver import WebDriver
49
    from selenium.webdriver.support.wait import WebDriverWait
50

51
WAIT_RETRY_COUNT: int = 1
1✔
52

53

54
class SeleniumError(Exception):
1✔
55
    """Selenium 関連エラーの基底クラス"""
56

57

58
def _get_chrome_version() -> int | None:
1✔
59
    try:
1✔
60
        result = subprocess.run(
1✔
61
            ["google-chrome", "--version"],
62
            capture_output=True,
63
            text=True,
64
            timeout=10,
65
        )
66
        match = re.search(r"(\d+)\.", result.stdout)
1✔
67
        if match:
1✔
68
            return int(match.group(1))
1✔
69
    except Exception:
1✔
70
        logging.warning("Failed to detect Chrome version")
1✔
71
    return None
1✔
72

73

74
def _create_driver_impl(
1✔
75
    profile_name: str,
76
    data_path: pathlib.Path,
77
    is_headless: bool,
78
    use_subprocess: bool = True,
79
) -> WebDriver:  # noqa: ARG001
80
    chrome_data_path = data_path / "chrome"
×
81
    log_path = data_path / "log"
×
82

83
    # NOTE: Pytest を並列実行できるようにする
84
    suffix = os.environ.get("PYTEST_XDIST_WORKER", None)
×
85
    if suffix is not None:
×
86
        profile_name += "." + suffix
×
87

88
    chrome_data_path.mkdir(parents=True, exist_ok=True)
×
89
    log_path.mkdir(parents=True, exist_ok=True)
×
90

91
    options = selenium.webdriver.chrome.options.Options()
×
92

93
    if is_headless:
×
94
        options.add_argument("--headless=new")
×
95

96
    options.add_argument("--no-sandbox")  # for Docker
×
97
    options.add_argument("--disable-dev-shm-usage")  # for Docker
×
98
    options.add_argument("--disable-gpu")
×
99

100
    options.add_argument("--disable-popup-blocking")
×
101
    options.add_argument("--disable-plugins")
×
102

103
    options.add_argument("--no-first-run")
×
104

105
    options.add_argument("--lang=ja-JP")
×
106
    options.add_argument("--window-size=1920,1080")
×
107

108
    # NOTE: Accept-Language ヘッダーを日本語優先に設定
109
    options.add_experimental_option("prefs", {"intl.accept_languages": "ja-JP,ja,en-US,en"})
×
110

111
    options.add_argument("--user-data-dir=" + str(chrome_data_path / profile_name))
×
112

113
    options.add_argument("--enable-logging")
×
114
    options.add_argument("--v=1")
×
115

116
    chrome_log_file = log_path / f"chrome_{profile_name}.log"
×
117
    options.add_argument(f"--log-file={chrome_log_file!s}")
×
118

119
    if not is_headless:
×
120
        options.add_argument("--auto-open-devtools-for-tabs")
×
121

122
    service = selenium.webdriver.chrome.service.Service(
×
123
        service_args=["--verbose", f"--log-path={str(log_path / 'webdriver.log')!s}"],
124
    )
125

126
    chrome_version = _get_chrome_version()
×
127

128
    # NOTE: user_multi_procs=True は既存の chromedriver ファイルが存在することを前提としているため、
129
    # ファイルが存在しない場合(CI環境の初回実行など)は False にする
130
    uc_data_path = pathlib.Path("~/.local/share/undetected_chromedriver").expanduser()
×
131
    use_multi_procs = uc_data_path.exists() and any(uc_data_path.glob("*chromedriver*"))
×
132

133
    driver = undetected_chromedriver.Chrome(
×
134
        service=service,
135
        options=options,
136
        use_subprocess=use_subprocess,
137
        version_main=chrome_version,
138
        user_multi_procs=use_multi_procs,
139
    )
140

141
    driver.set_page_load_timeout(30)
×
142

143
    return driver
×
144

145

146
@dataclass
1✔
147
class _ProfileHealthResult:
1✔
148
    """プロファイル健全性チェックの結果"""
149

150
    is_healthy: bool
1✔
151
    errors: list[str]
1✔
152
    has_lock_files: bool = False
1✔
153
    has_corrupted_json: bool = False
1✔
154
    has_corrupted_db: bool = False
1✔
155

156

157
def _check_json_file(file_path: pathlib.Path) -> str | None:
1✔
158
    """JSON ファイルの整合性をチェック
159

160
    Returns:
161
        エラーメッセージ(正常な場合は None)
162
    """
163
    if not file_path.exists():
1✔
164
        return None
1✔
165

166
    try:
1✔
167
        content = file_path.read_text(encoding="utf-8")
1✔
168
        json.loads(content)
1✔
169
        return None
1✔
170
    except json.JSONDecodeError as e:
1✔
171
        return f"{file_path.name} is corrupted: {e}"
1✔
172
    except Exception as e:
×
173
        return f"{file_path.name} read error: {e}"
×
174

175

176
def _check_sqlite_db(db_path: pathlib.Path) -> str | None:
1✔
177
    """SQLite データベースの整合性をチェック
178

179
    Returns:
180
        エラーメッセージ(正常な場合は None)
181
    """
182
    if not db_path.exists():
1✔
183
        return None
1✔
184

185
    try:
1✔
186
        conn = sqlite3.connect(str(db_path), timeout=5)
1✔
187
        result = conn.execute("PRAGMA integrity_check").fetchone()
1✔
188
        conn.close()
1✔
189
        if result[0] != "ok":
1✔
190
            return f"{db_path.name} database is corrupted: {result[0]}"
×
191
        return None
1✔
192
    except sqlite3.DatabaseError as e:
1✔
193
        return f"{db_path.name} database error: {e}"
1✔
194
    except Exception as e:
×
195
        return f"{db_path.name} check error: {e}"
×
196

197

198
def _check_profile_health(profile_path: pathlib.Path) -> _ProfileHealthResult:
1✔
199
    """Chrome プロファイルの健全性をチェック
200

201
    Args:
202
        profile_path: Chrome プロファイルのディレクトリパス
203

204
    Returns:
205
        ProfileHealthResult: チェック結果
206
    """
207
    errors: list[str] = []
1✔
208
    has_lock_files = False
1✔
209
    has_corrupted_json = False
1✔
210
    has_corrupted_db = False
1✔
211

212
    if not profile_path.exists():
1✔
213
        # プロファイルが存在しない場合は健全(新規作成される)
214
        return _ProfileHealthResult(is_healthy=True, errors=[])
1✔
215

216
    default_path = profile_path / "Default"
1✔
217

218
    # 1. ロックファイルのチェック
219
    lock_files = ["SingletonLock", "SingletonSocket", "SingletonCookie"]
1✔
220
    existing_locks = []
1✔
221
    for lock_file in lock_files:
1✔
222
        lock_path = profile_path / lock_file
1✔
223
        if lock_path.exists() or lock_path.is_symlink():
1✔
224
            existing_locks.append(lock_file)
1✔
225
            has_lock_files = True
1✔
226
    if existing_locks:
1✔
227
        errors.append(f"Lock files exist: {', '.join(existing_locks)}")
1✔
228

229
    # 2. Local State の JSON チェック
230
    local_state_error = _check_json_file(profile_path / "Local State")
1✔
231
    if local_state_error:
1✔
232
        errors.append(local_state_error)
1✔
233
        has_corrupted_json = True
1✔
234

235
    # 3. Preferences の JSON チェック
236
    if default_path.exists():
1✔
237
        prefs_error = _check_json_file(default_path / "Preferences")
1✔
238
        if prefs_error:
1✔
239
            errors.append(prefs_error)
×
240
            has_corrupted_json = True
×
241

242
        # 4. SQLite データベースの整合性チェック
243
        for db_name in ["Cookies", "History", "Web Data"]:
1✔
244
            db_error = _check_sqlite_db(default_path / db_name)
1✔
245
            if db_error:
1✔
246
                errors.append(db_error)
1✔
247
                has_corrupted_db = True
1✔
248

249
    is_healthy = len(errors) == 0
1✔
250

251
    return _ProfileHealthResult(
1✔
252
        is_healthy=is_healthy,
253
        errors=errors,
254
        has_lock_files=has_lock_files,
255
        has_corrupted_json=has_corrupted_json,
256
        has_corrupted_db=has_corrupted_db,
257
    )
258

259

260
def _recover_corrupted_profile(profile_path: pathlib.Path) -> bool:
1✔
261
    """破損したプロファイルをバックアップして新規作成を可能にする
262

263
    Args:
264
        profile_path: Chrome プロファイルのディレクトリパス
265

266
    Returns:
267
        bool: リカバリが成功したかどうか
268
    """
269
    if not profile_path.exists():
1✔
270
        return True
1✔
271

272
    # バックアップ先を決定(タイムスタンプ付き)
273
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
1✔
274
    backup_path = profile_path.parent / f"{profile_path.name}.corrupted.{timestamp}"
1✔
275

276
    try:
1✔
277
        shutil.move(str(profile_path), str(backup_path))
1✔
278
        logging.warning(
1✔
279
            "Corrupted profile moved to backup: %s -> %s",
280
            profile_path,
281
            backup_path,
282
        )
283
        return True
1✔
284
    except Exception as e:
×
285
        logging.exception("Failed to backup corrupted profile: %s", e)
×
286
        return False
×
287

288

289
def _cleanup_profile_lock(profile_path: pathlib.Path) -> None:
1✔
290
    """プロファイルのロックファイルを削除する"""
291
    lock_files = ["SingletonLock", "SingletonSocket", "SingletonCookie"]
1✔
292
    found_locks = []
1✔
293
    for lock_file in lock_files:
1✔
294
        lock_path = profile_path / lock_file
1✔
295
        if lock_path.exists() or lock_path.is_symlink():
1✔
296
            found_locks.append(lock_path)
1✔
297

298
    if found_locks:
1✔
299
        logging.warning("Profile lock files found: %s", ", ".join(str(p.name) for p in found_locks))
1✔
300
        for lock_path in found_locks:
1✔
301
            try:
1✔
302
                lock_path.unlink()
1✔
303
            except OSError as e:
×
304
                logging.warning("Failed to remove lock file %s: %s", lock_path, e)
×
305

306

307
def _is_running_in_container() -> bool:
1✔
308
    """コンテナ内で実行中かどうかを判定"""
309
    return os.path.exists("/.dockerenv")
1✔
310

311

312
def _cleanup_orphaned_chrome_processes_in_container() -> None:
1✔
313
    """コンテナ内で実行中の場合のみ、残った Chrome プロセスをクリーンアップ
314

315
    NOTE: プロセスツリーに関係なくプロセス名で一律終了するのはコンテナ内限定
316
    """
317
    if not _is_running_in_container():
×
318
        return
×
319

320
    for proc in psutil.process_iter(["pid", "name"]):
×
321
        try:
×
322
            proc_name = proc.info["name"].lower() if proc.info["name"] else ""
×
323
            if "chrome" in proc_name:
×
324
                logging.info("Terminating orphaned Chrome process: PID %d", proc.info["pid"])
×
325
                os.kill(proc.info["pid"], signal.SIGTERM)
×
326
        except (psutil.NoSuchProcess, psutil.AccessDenied, ProcessLookupError, OSError):
×
327
            pass
×
328
    time.sleep(1)
×
329

330

331
def _get_actual_profile_name(profile_name: str) -> str:
1✔
332
    """PYTEST_XDIST_WORKER を考慮した実際のプロファイル名を取得"""
333
    suffix = os.environ.get("PYTEST_XDIST_WORKER", None)
×
334
    return profile_name + ("." + suffix if suffix is not None else "")
×
335

336

337
def delete_profile(profile_name: str, data_path: pathlib.Path) -> bool:
1✔
338
    """Chrome プロファイルを削除する
339

340
    Args:
341
        profile_name: プロファイル名
342
        data_path: データディレクトリのパス
343

344
    Returns:
345
        bool: 削除が成功したかどうか
346
    """
347
    actual_profile_name = _get_actual_profile_name(profile_name)
×
348
    profile_path = data_path / "chrome" / actual_profile_name
×
349

350
    if not profile_path.exists():
×
351
        logging.info("Profile does not exist: %s", profile_path)
×
352
        return True
×
353

354
    try:
×
355
        shutil.rmtree(profile_path)
×
356
        logging.warning("Deleted Chrome profile: %s", profile_path)
×
357
        return True
×
358
    except Exception:
×
359
        logging.exception("Failed to delete Chrome profile: %s", profile_path)
×
360
        return False
×
361

362

363
def create_driver(
1✔
364
    profile_name: str,
365
    data_path: pathlib.Path,
366
    is_headless: bool = True,
367
    clean_profile: bool = False,
368
    auto_recover: bool = True,
369
    use_subprocess: bool = True,
370
) -> WebDriver:
371
    """Chrome WebDriver を作成する
372

373
    Args:
374
        profile_name: プロファイル名
375
        data_path: データディレクトリのパス
376
        is_headless: ヘッドレスモードで起動するか
377
        clean_profile: 起動前にロックファイルを削除するか
378
        auto_recover: プロファイル破損時に自動リカバリするか
379
        use_subprocess: サブプロセスで Chrome を起動するか
380
    """
381
    # NOTE: ルートロガーの出力レベルを変更した場合でも Selenium 関係は抑制する
382
    logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
×
383
    logging.getLogger("selenium.webdriver.common.selenium_manager").setLevel(logging.WARNING)
×
384
    logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING)
×
385

386
    actual_profile_name = _get_actual_profile_name(profile_name)
×
387
    profile_path = data_path / "chrome" / actual_profile_name
×
388

389
    # プロファイル健全性チェック
390
    health = _check_profile_health(profile_path)
×
391
    if not health.is_healthy:
×
392
        logging.warning("Profile health check failed: %s", ", ".join(health.errors))
×
393

394
        if health.has_lock_files and not (health.has_corrupted_json or health.has_corrupted_db):
×
395
            # ロックファイルのみの問題なら削除して続行
396
            logging.info("Cleaning up lock files only")
×
397
            _cleanup_profile_lock(profile_path)
×
398
        elif auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
399
            # JSON または DB が破損している場合はプロファイルをリカバリ
400
            logging.warning("Profile is corrupted, attempting recovery")
×
401
            if _recover_corrupted_profile(profile_path):
×
402
                logging.info("Profile recovery successful, will create new profile")
×
403
            else:
404
                logging.error("Profile recovery failed")
×
405

406
    if clean_profile:
×
407
        _cleanup_profile_lock(profile_path)
×
408

409
    # NOTE: 1回だけ自動リトライ
410
    try:
×
411
        return _create_driver_impl(profile_name, data_path, is_headless, use_subprocess)
×
412
    except Exception as e:
×
413
        logging.warning("First attempt to create driver failed: %s", e)
×
414

415
        # コンテナ内で実行中の場合のみ、残った Chrome プロセスをクリーンアップ
416
        _cleanup_orphaned_chrome_processes_in_container()
×
417

418
        # プロファイルのロックファイルを削除
419
        _cleanup_profile_lock(profile_path)
×
420

421
        # 再度健全性チェック
422
        health = _check_profile_health(profile_path)
×
423
        if not health.is_healthy and auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
424
            logging.warning("Profile still corrupted after first attempt, recovering")
×
425
            _recover_corrupted_profile(profile_path)
×
426

427
        return _create_driver_impl(profile_name, data_path, is_headless, use_subprocess)
×
428

429

430
def xpath_exists(driver: WebDriver, xpath: str) -> bool:
1✔
431
    return len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0
×
432

433

434
def get_text(
1✔
435
    driver: WebDriver,
436
    xpath: str,
437
    safe_text: str,
438
    wait: WebDriverWait[WebDriver] | None = None,
439
) -> str:
440
    if wait is not None:
×
441
        wait.until(
×
442
            selenium.webdriver.support.expected_conditions.presence_of_all_elements_located(
443
                (selenium.webdriver.common.by.By.XPATH, xpath)
444
            )
445
        )
446

447
    if len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0:
×
448
        return driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).text.strip()
×
449
    else:
450
        return safe_text
×
451

452

453
def input_xpath(
1✔
454
    driver: WebDriver,
455
    xpath: str,
456
    text: str,
457
    wait: WebDriverWait[WebDriver] | None = None,
458
    is_warn: bool = True,
459
) -> bool:
460
    if wait is not None:
×
461
        wait.until(
×
462
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
463
                (selenium.webdriver.common.by.By.XPATH, xpath)
464
            )
465
        )
466
        time.sleep(0.05)
×
467

468
    if xpath_exists(driver, xpath):
×
469
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).send_keys(text)
×
470
        return True
×
471
    else:
472
        if is_warn:
×
473
            logging.warning("Element is not found: %s", xpath)
×
474
        return False
×
475

476

477
def click_xpath(
1✔
478
    driver: WebDriver,
479
    xpath: str,
480
    wait: WebDriverWait[WebDriver] | None = None,
481
    is_warn: bool = True,
482
    move: bool = False,
483
) -> bool:
484
    if wait is not None:
×
485
        wait.until(
×
486
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
487
                (selenium.webdriver.common.by.By.XPATH, xpath)
488
            )
489
        )
490
        time.sleep(0.05)
×
491

492
    if xpath_exists(driver, xpath):
×
493
        elem = driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath)
×
494
        if move:
×
495
            action = selenium.webdriver.common.action_chains.ActionChains(driver)
×
496
            action.move_to_element(elem)
×
497
            action.perform()
×
498

499
        elem.click()
×
500
        return True
×
501
    else:
502
        if is_warn:
×
503
            logging.warning("Element is not found: %s", xpath)
×
504
        return False
×
505

506

507
def is_display(driver: WebDriver, xpath: str) -> bool:
1✔
508
    return (len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0) and (
×
509
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).is_displayed()
510
    )
511

512

513
def random_sleep(sec: float) -> None:
1✔
514
    RATIO = 0.8
1✔
515

516
    time.sleep((sec * RATIO) + (sec * (1 - RATIO) * 2) * random.random())  # noqa: S311
1✔
517

518

519
def with_retry(
1✔
520
    func: Callable[[], T],
521
    max_retries: int = 3,
522
    delay: float = 1.0,
523
    exceptions: tuple[type[Exception], ...] = (Exception,),
524
    on_retry: Callable[[int, Exception], bool | None] | None = None,
525
) -> T:
526
    """リトライ付きで関数を実行
527

528
    全て失敗した場合は最後の例外を再スロー。
529
    呼び出し側で try/except してエラー処理を行う。
530

531
    Args:
532
        func: 実行する関数
533
        max_retries: 最大リトライ回数
534
        delay: リトライ間の待機秒数
535
        exceptions: リトライ対象の例外タプル
536
        on_retry: リトライ時のコールバック (attempt, exception)
537
            - None または True を返すとリトライを継続
538
            - False を返すとリトライを中止して例外を再スロー
539

540
    Returns:
541
        成功時は関数の戻り値
542

543
    Raises:
544
        最後の例外を再スロー
545
    """
546
    last_exception: Exception | None = None
×
547

548
    for attempt in range(max_retries):
×
549
        try:
×
550
            return func()
×
551
        except exceptions as e:
×
552
            last_exception = e
×
553
            if attempt < max_retries - 1:
×
554
                if on_retry:
×
NEW
555
                    should_continue = on_retry(attempt + 1, e)
×
NEW
556
                    if should_continue is False:
×
NEW
557
                        raise
×
UNCOV
558
                time.sleep(delay)
×
559

560
    if last_exception:
×
561
        raise last_exception
×
562
    raise RuntimeError("Unexpected state in with_retry")
×
563

564

565
def wait_patiently(
1✔
566
    driver: WebDriver,
567
    wait: WebDriverWait[WebDriver],
568
    target: Any,
569
) -> None:
570
    error: selenium.common.exceptions.TimeoutException | None = None
×
571
    for i in range(WAIT_RETRY_COUNT + 1):
×
572
        try:
×
573
            wait.until(target)
×
574
            return
×
575
        except selenium.common.exceptions.TimeoutException as e:  # noqa: PERF203
×
576
            logging.warning(
×
577
                "タイムアウトが発生しました。(%s in %s line %d)",
578
                inspect.stack()[1].function,
579
                inspect.stack()[1].filename,
580
                inspect.stack()[1].lineno,
581
            )
582
            error = e
×
583

584
            logging.info(i)
×
585
            if i != WAIT_RETRY_COUNT:
×
586
                logging.info("refresh")
×
587
                driver.refresh()
×
588

589
    if error is not None:
×
590
        raise error
×
591

592

593
def dump_page(
1✔
594
    driver: WebDriver,
595
    index: int,
596
    dump_path: pathlib.Path,
597
    stack_index: int = 1,
598
) -> None:
599
    name = inspect.stack()[stack_index].function.replace("<", "").replace(">", "")
×
600

601
    dump_path.mkdir(parents=True, exist_ok=True)
×
602

603
    png_path = dump_path / f"{name}_{index:02d}.png"
×
604
    htm_path = dump_path / f"{name}_{index:02d}.htm"
×
605

606
    driver.save_screenshot(str(png_path))
×
607

608
    with htm_path.open("w", encoding="utf-8") as f:
×
609
        f.write(driver.page_source)
×
610

611
    logging.info(
×
612
        "page dump: %02d from %s in %s line %d",
613
        index,
614
        inspect.stack()[stack_index].function,
615
        inspect.stack()[stack_index].filename,
616
        inspect.stack()[stack_index].lineno,
617
    )
618

619

620
def clear_cache(driver: WebDriver) -> None:
1✔
621
    driver.execute_cdp_cmd("Network.clearBrowserCache", {})
×
622

623

624
def clean_dump(dump_path: pathlib.Path, keep_days: int = 1) -> None:
1✔
625
    if not dump_path.exists():
1✔
626
        return
1✔
627

628
    time_threshold = datetime.timedelta(keep_days)
1✔
629

630
    for item in dump_path.iterdir():
1✔
631
        if not item.is_file():
1✔
632
            continue
1✔
633
        try:
1✔
634
            time_diff = datetime.datetime.now(datetime.timezone.utc) - datetime.datetime.fromtimestamp(
1✔
635
                item.stat().st_mtime, datetime.timezone.utc
636
            )
637
        except FileNotFoundError:
×
638
            # ファイルが別プロセスにより削除された場合(SQLiteの一時ファイルなど)
639
            continue
×
640
        if time_diff > time_threshold:
1✔
641
            logging.warning("remove %s [%s day(s) old].", item.absolute(), f"{time_diff.days:,}")
1✔
642

643
            item.unlink(missing_ok=True)
1✔
644

645

646
def get_memory_info(driver: WebDriver) -> dict[str, Any]:
1✔
647
    """ブラウザのメモリ使用量を取得(単位: KB)"""
648
    total_bytes = subprocess.Popen(  # noqa: S602
×
649
        "smem -t -c pss -P chrome | tail -n 1",  # noqa: S607
650
        shell=True,
651
        stdout=subprocess.PIPE,
652
    ).communicate()[0]
653
    total = int(str(total_bytes, "utf-8").strip())  # smem の出力は KB 単位
×
654

655
    try:
×
656
        memory_info = driver.execute_cdp_cmd("Memory.getAllTimeSamplingProfile", {})
×
657
        heap_usage = driver.execute_cdp_cmd("Runtime.getHeapUsage", {})
×
658

659
        heap_used = heap_usage.get("usedSize", 0) // 1024  # bytes → KB
×
660
        heap_total = heap_usage.get("totalSize", 0) // 1024  # bytes → KB
×
661
    except Exception as e:
×
662
        logging.debug("Failed to get memory usage: %s", e)
×
663

664
        memory_info = None
×
665
        heap_used = 0
×
666
        heap_total = 0
×
667

668
    return {
×
669
        "total": total,
670
        "heap_used": heap_used,
671
        "heap_total": heap_total,
672
        "memory_info": memory_info,
673
    }
674

675

676
def log_memory_usage(driver: WebDriver) -> None:
1✔
677
    mem_info = get_memory_info(driver)
×
678
    logging.info(
×
679
        "Chrome memory: %s MB (JS heap: %s MB)",
680
        f"""{mem_info["total"] // 1024:,}""",
681
        f"""{mem_info["heap_used"] // 1024:,}""",
682
    )
683

684

685
def _warmup(
1✔
686
    driver: WebDriver,
687
    keyword: str,
688
    url_pattern: str,
689
    sleep_sec: int = 3,
690
) -> None:
691
    # NOTE: ダミーアクセスを行って BOT ではないと思わせる。(効果なさそう...)
692
    driver.get("https://www.yahoo.co.jp/")
×
693
    time.sleep(sleep_sec)
×
694

695
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(keyword)
×
696
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(
×
697
        selenium.webdriver.common.keys.Keys.ENTER
698
    )
699

700
    time.sleep(sleep_sec)
×
701

702
    driver.find_element(
×
703
        selenium.webdriver.common.by.By.XPATH, f'//a[contains(@href, "{url_pattern}")]'
704
    ).click()
705

706
    time.sleep(sleep_sec)
×
707

708

709
class browser_tab:  # noqa: N801
1✔
710
    def __init__(self, driver: WebDriver, url: str) -> None:  # noqa: D107
1✔
711
        self.driver = driver
1✔
712
        self.url = url
1✔
713
        self.original_window: str | None = None
1✔
714

715
    def __enter__(self) -> None:  # noqa: D105
1✔
716
        self.original_window = self.driver.current_window_handle
1✔
717
        self.driver.execute_script("window.open('');")
1✔
718
        self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
719
        try:
1✔
720
            self.driver.get(self.url)
1✔
721
        except Exception:
×
722
            # NOTE: URL読み込みに失敗した場合もクリーンアップしてから例外を再送出
723
            self._cleanup()
×
724
            raise
×
725

726
    def _cleanup(self) -> None:
1✔
727
        """タブを閉じて元のウィンドウに戻る"""
728
        try:
1✔
729
            # 余分なタブを閉じる
730
            while len(self.driver.window_handles) > 1:
1✔
731
                self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
732
                self.driver.close()
1✔
733
            if self.original_window is not None:
1✔
734
                self.driver.switch_to.window(self.original_window)
1✔
735
            time.sleep(0.1)
1✔
736
        except Exception:
1✔
737
            # NOTE: Chromeがクラッシュした場合は無視(既に終了しているため操作不可)
738
            logging.exception("タブのクリーンアップに失敗しました(Chromeがクラッシュした可能性があります)")
1✔
739

740
    def _recover_from_error(self) -> None:
1✔
741
        """エラー後にブラウザの状態を回復する"""
742
        try:
×
743
            # ページロードタイムアウトをリセット(負の値になっている可能性があるため)
744
            self.driver.set_page_load_timeout(30)
×
745

746
            # about:blank に移動してレンダラーの状態をリセット
747
            self.driver.get("about:blank")
×
748
            time.sleep(0.5)
×
749
        except Exception:
×
750
            logging.warning("ブラウザの回復に失敗しました")
×
751

752
    def __exit__(
1✔
753
        self,
754
        exception_type: type[BaseException] | None,
755
        exception_value: BaseException | None,
756
        traceback: Any,
757
    ) -> None:  # noqa: D105
758
        self._cleanup()
1✔
759

760
        # 例外が発生した場合はブラウザの状態を回復
761
        if exception_type is not None:
1✔
762
            self._recover_from_error()
×
763

764

765
class error_handler:  # noqa: N801
1✔
766
    """Selenium操作時のエラーハンドリング用コンテキストマネージャ
767

768
    エラー発生時に自動でログ出力、スクリーンショット取得、コールバック呼び出しを行う。
769

770
    Args:
771
        driver: WebDriver インスタンス
772
        message: ログに出力するエラーメッセージ
773
        on_error: エラー時に呼ばれるコールバック関数 (exception, screenshot: PIL.Image.Image | None) -> None
774
        capture_screenshot: スクリーンショットを自動取得するか(デフォルト: True)
775
        reraise: 例外を再送出するか(デフォルト: True)
776

777
    Attributes:
778
        exception: 発生した例外(エラーがなければ None)
779
        screenshot: 取得したスクリーンショット(PIL.Image.Image、取得失敗時は None)
780

781
    Examples:
782
        基本的な使用方法::
783

784
            with my_lib.selenium_util.error_handler(driver, message="ログイン処理に失敗") as handler:
785
                driver.get(login_url)
786
                driver.find_element(...).click()
787

788
        コールバック付き(Slack通知など)::
789

790
            def notify(exc, screenshot):
791
                slack.error("エラー発生", str(exc), screenshot)
792

793
            with my_lib.selenium_util.error_handler(
794
                driver,
795
                message="クロール処理に失敗",
796
                on_error=notify,
797
            ):
798
                crawl_page(driver)
799

800
        例外を抑制して続行::
801

802
            with my_lib.selenium_util.error_handler(driver, reraise=False) as handler:
803
                risky_operation()
804

805
            if handler.exception:
806
                logging.warning("処理をスキップしました")
807
    """
808

809
    def __init__(
1✔
810
        self,
811
        driver: WebDriver,
812
        message: str = "Selenium operation failed",
813
        on_error: Callable[[Exception, PIL.Image.Image | None], None] | None = None,
814
        capture_screenshot: bool = True,
815
        reraise: bool = True,
816
    ) -> None:
817
        self.driver = driver
1✔
818
        self.message = message
1✔
819
        self.on_error = on_error
1✔
820
        self.capture_screenshot = capture_screenshot
1✔
821
        self.reraise = reraise
1✔
822
        self.exception: Exception | None = None
1✔
823
        self.screenshot: PIL.Image.Image | None = None
1✔
824

825
    def __enter__(self) -> "error_handler":
1✔
826
        return self
1✔
827

828
    def __exit__(
1✔
829
        self,
830
        exception_type: type[BaseException] | None,
831
        exception_value: BaseException | None,
832
        traceback: Any,
833
    ) -> bool:
834
        if exception_value is None:
1✔
835
            return False
1✔
836

837
        # 例外を記録
838
        if isinstance(exception_value, Exception):
1✔
839
            self.exception = exception_value
1✔
840
        else:
841
            # BaseException(KeyboardInterrupt など)は処理せず再送出
842
            return False
×
843

844
        # ログ出力
845
        logging.exception(self.message)
1✔
846

847
        # スクリーンショット取得
848
        if self.capture_screenshot:
1✔
849
            try:
1✔
850
                screenshot_bytes = self.driver.get_screenshot_as_png()
1✔
851
                self.screenshot = PIL.Image.open(io.BytesIO(screenshot_bytes))
1✔
852
            except Exception:
1✔
853
                logging.debug("Failed to capture screenshot for error handling")
1✔
854

855
        # コールバック呼び出し
856
        if self.on_error is not None:
1✔
857
            try:
1✔
858
                self.on_error(self.exception, self.screenshot)
1✔
859
            except Exception:
×
860
                logging.exception("Error in on_error callback")
×
861

862
        # reraise=False なら例外を抑制
863
        return not self.reraise
1✔
864

865

866
def _is_chrome_related_process(process: psutil.Process) -> bool:
1✔
867
    """プロセスがChrome関連かどうかを判定"""
868
    try:
1✔
869
        process_name = process.name().lower()
1✔
870
        # Chrome関連のプロセス名パターン
871
        chrome_patterns = ["chrome", "chromium", "google-chrome", "undetected_chro"]
1✔
872
        # chromedriverは除外
873
        if "chromedriver" in process_name:
1✔
874
            return False
1✔
875
        return any(pattern in process_name for pattern in chrome_patterns)
1✔
876
    except (psutil.NoSuchProcess, psutil.AccessDenied):
1✔
877
        return False
1✔
878

879

880
def _get_chrome_processes_by_pgid(chromedriver_pid: int, existing_pids: set[int]) -> list[int]:
1✔
881
    """プロセスグループIDで追加のChrome関連プロセスを取得"""
882
    additional_pids = []
×
883
    try:
×
884
        pgid = os.getpgid(chromedriver_pid)
×
885
        for proc in psutil.process_iter(["pid", "name", "ppid"]):
×
886
            if proc.info["pid"] in existing_pids:
×
887
                continue
×
888
            try:
×
889
                if os.getpgid(proc.info["pid"]) == pgid:
×
890
                    proc_obj = psutil.Process(proc.info["pid"])
×
891
                    if _is_chrome_related_process(proc_obj):
×
892
                        additional_pids.append(proc.info["pid"])
×
893
                        logging.debug(
×
894
                            "Found Chrome-related process by pgid: PID %d, name: %s",
895
                            proc.info["pid"],
896
                            proc.info["name"],
897
                        )
898
            except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
×
899
                pass
×
900
    except (OSError, psutil.NoSuchProcess):
×
901
        logging.debug("Failed to get process group ID for chromedriver")
×
902
    return additional_pids
×
903

904

905
def _get_chrome_related_processes(driver: WebDriver) -> list[int]:
1✔
906
    """Chrome関連の全子プロセスを取得
907

908
    undetected_chromedriver 使用時、Chrome プロセスは chromedriver の子ではなく
909
    Python プロセスの直接の子として起動されることがあるため、両方を検索する。
910
    """
911
    chrome_pids = set()
×
912

913
    # 1. driver.service.process の子プロセスを検索
914
    try:
×
915
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "process"):  # type: ignore[attr-defined]
×
916
            process = driver.service.process  # type: ignore[attr-defined]
×
917
            if process and hasattr(process, "pid"):
×
918
                chromedriver_pid = process.pid
×
919

920
                # psutilでプロセス階層を取得
921
                parent_process = psutil.Process(chromedriver_pid)
×
922
                children = parent_process.children(recursive=True)
×
923

924
                for child in children:
×
925
                    chrome_pids.add(child.pid)
×
926
                    logging.debug(
×
927
                        "Found Chrome-related process (service child): PID %d, name: %s",
928
                        child.pid,
929
                        child.name(),
930
                    )
931
    except Exception:
×
932
        logging.exception("Failed to get Chrome-related processes from service")
×
933

934
    # 2. 現在の Python プロセスの全子孫から Chrome 関連プロセスを検索
935
    try:
×
936
        current_process = psutil.Process()
×
937
        all_children = current_process.children(recursive=True)
×
938

939
        for child in all_children:
×
940
            if child.pid in chrome_pids:
×
941
                continue
×
942
            try:
×
943
                if _is_chrome_related_process(child):
×
944
                    chrome_pids.add(child.pid)
×
945
                    logging.debug(
×
946
                        "Found Chrome-related process (python child): PID %d, name: %s",
947
                        child.pid,
948
                        child.name(),
949
                    )
950
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
951
                pass
×
952
    except Exception:
×
953
        logging.exception("Failed to get Chrome-related processes from python children")
×
954

955
    return list(chrome_pids)
×
956

957

958
def _send_signal_to_processes(pids: list[int], sig: signal.Signals, signal_name: str) -> None:
1✔
959
    """プロセスリストに指定されたシグナルを送信"""
960
    errors = []
×
961
    for pid in pids:
×
962
        try:
×
963
            # プロセス名を取得
964
            try:
×
965
                process = psutil.Process(pid)
×
966
                process_name = process.name()
×
967
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
968
                process_name = "unknown"
×
969

970
            if sig == signal.SIGKILL:
×
971
                # プロセスがまだ存在するかチェック
972
                os.kill(pid, 0)  # シグナル0は存在確認
×
973
            os.kill(pid, sig)
×
974
            logging.info("Sent %s to process: PID %d (%s)", signal_name, pid, process_name)
×
975
        except (ProcessLookupError, OSError) as e:  # noqa: PERF203
×
976
            # プロセスが既に終了している場合は無視
977
            errors.append((pid, e))
×
978

979
    # エラーが発生した場合はまとめてログ出力
980
    if errors:
×
981
        logging.debug("Failed to send %s to some processes: %s", signal_name, errors)
×
982

983

984
def _terminate_chrome_processes(chrome_pids: list[int], timeout: float = 5.0) -> None:
1✔
985
    """Chrome関連プロセスを段階的に終了
986

987
    Args:
988
        chrome_pids: 終了対象のプロセスIDリスト
989
        timeout: SIGTERM後にプロセス終了を待機する最大時間(秒)
990
    """
991
    if not chrome_pids:
×
992
        return
×
993

994
    # 優雅な終了(SIGTERM)
995
    _send_signal_to_processes(chrome_pids, signal.SIGTERM, "SIGTERM")
×
996

997
    # プロセスの終了を待機(ポーリング)
998
    remaining_pids = list(chrome_pids)
×
999
    poll_interval = 0.2
×
1000
    elapsed = 0.0
×
1001

1002
    while remaining_pids and elapsed < timeout:
×
1003
        time.sleep(poll_interval)
×
1004
        elapsed += poll_interval
×
1005

1006
        # まだ生存しているプロセスをチェック
1007
        still_alive = []
×
1008
        for pid in remaining_pids:
×
1009
            try:
×
1010
                if psutil.pid_exists(pid):
×
1011
                    process = psutil.Process(pid)
×
1012
                    if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
1013
                        still_alive.append(pid)
×
1014
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1015
                pass
×
1016

1017
        remaining_pids = still_alive
×
1018

1019
    # タイムアウト後もまだ残っているプロセスにのみ SIGKILL を送信
1020
    if remaining_pids:
×
1021
        logging.warning(
×
1022
            "Chrome processes still alive after %.1fs, sending SIGKILL to %d processes",
1023
            elapsed,
1024
            len(remaining_pids),
1025
        )
1026
        _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1027

1028

1029
def _reap_single_process(pid: int) -> None:
1✔
1030
    """単一プロセスをwaitpidで回収"""
1031
    try:
×
1032
        # ノンブロッキングでwaitpid
1033
        result_pid, status = os.waitpid(pid, os.WNOHANG)
×
1034
        if result_pid == pid:
×
1035
            # プロセス名を取得
1036
            try:
×
1037
                process = psutil.Process(pid)
×
1038
                process_name = process.name()
×
1039
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1040
                process_name = "unknown"
×
1041
            logging.debug("Reaped Chrome process: PID %d (%s)", pid, process_name)
×
1042
    except (ChildProcessError, OSError):
×
1043
        # 子プロセスでない場合や既に回収済みの場合は無視
1044
        pass
×
1045

1046

1047
def _reap_chrome_processes(chrome_pids: list[int]) -> None:
1✔
1048
    """Chrome関連プロセスを明示的に回収してゾンビ化を防ぐ"""
1049
    for pid in chrome_pids:
×
1050
        _reap_single_process(pid)
×
1051

1052

1053
def _get_remaining_chrome_pids(chrome_pids: list[int]) -> list[int]:
1✔
1054
    """指定されたPIDリストから、まだ生存しているChrome関連プロセスを取得"""
1055
    remaining = []
×
1056
    for pid in chrome_pids:
×
1057
        try:
×
1058
            if psutil.pid_exists(pid):
×
1059
                process = psutil.Process(pid)
×
1060
                if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
1061
                    if _is_chrome_related_process(process):
×
1062
                        remaining.append(pid)
×
1063
        except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1064
            pass
×
1065
    return remaining
×
1066

1067

1068
def _wait_for_processes_with_check(
1✔
1069
    chrome_pids: list[int],
1070
    timeout: float,
1071
    poll_interval: float = 0.2,
1072
    log_interval: float = 1.0,
1073
) -> list[int]:
1074
    """プロセスの終了を待機しつつ、残存プロセスをチェック
1075

1076
    Args:
1077
        chrome_pids: 監視対象のプロセスIDリスト
1078
        timeout: 最大待機時間(秒)
1079
        poll_interval: チェック間隔(秒)
1080
        log_interval: ログ出力間隔(秒)
1081

1082
    Returns:
1083
        タイムアウト後も残存しているプロセスIDのリスト
1084
    """
1085
    elapsed = 0.0
×
1086
    last_log_time = 0.0
×
1087
    remaining_pids = list(chrome_pids)
×
1088

1089
    while remaining_pids and elapsed < timeout:
×
1090
        time.sleep(poll_interval)
×
1091
        elapsed += poll_interval
×
1092
        remaining_pids = _get_remaining_chrome_pids(remaining_pids)
×
1093

1094
        if remaining_pids and (elapsed - last_log_time) >= log_interval:
×
1095
            logging.info(
×
1096
                "Found %d remaining Chrome processes after %.0fs",
1097
                len(remaining_pids),
1098
                elapsed,
1099
            )
1100
            last_log_time = elapsed
×
1101

1102
    return remaining_pids
×
1103

1104

1105
def quit_driver_gracefully(
1✔
1106
    driver: WebDriver | None,
1107
    wait_sec: float = 5.0,
1108
    sigterm_wait_sec: float = 5.0,
1109
    sigkill_wait_sec: float = 5.0,
1110
) -> None:  # noqa: C901, PLR0912
1111
    """Chrome WebDriverを確実に終了する
1112

1113
    終了フロー:
1114
    1. driver.quit() を呼び出し
1115
    2. wait_sec 秒待機しつつプロセス終了をチェック
1116
    3. 残存プロセスがあれば SIGTERM を送信
1117
    4. sigterm_wait_sec 秒待機しつつプロセス終了をチェック
1118
    5. 残存プロセスがあれば SIGKILL を送信
1119
    6. sigkill_wait_sec 秒待機
1120

1121
    Args:
1122
        driver: 終了する WebDriver インスタンス
1123
        wait_sec: quit 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1124
        sigterm_wait_sec: SIGTERM 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1125
        sigkill_wait_sec: SIGKILL 後にプロセス回収を待機する秒数(デフォルト: 5秒)
1126
    """
1127
    if driver is None:
1✔
1128
        return
1✔
1129

1130
    # quit前にChrome関連プロセスを記録
1131
    chrome_pids_before = _get_chrome_related_processes(driver)
1✔
1132

1133
    try:
1✔
1134
        # WebDriverの正常終了を試行(これがタブのクローズも含む)
1135
        driver.quit()
1✔
1136
        logging.info("WebDriver quit successfully")
1✔
1137
    except Exception:
1✔
1138
        logging.warning("Failed to quit driver normally", exc_info=True)
1✔
1139
    finally:
1140
        # undetected_chromedriver の __del__ がシャットダウン時に再度呼ばれるのを防ぐ
1141
        if hasattr(driver, "_has_quit"):
1✔
1142
            driver._has_quit = True  # type: ignore[attr-defined]
1✔
1143

1144
    # ChromeDriverサービスの停止を試行
1145
    try:
1✔
1146
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "stop"):  # type: ignore[attr-defined]
1✔
1147
            driver.service.stop()  # type: ignore[attr-defined]
×
1148
    except (ConnectionResetError, OSError):
×
1149
        # Chrome が既に終了している場合は無視
1150
        logging.debug("Chrome service already stopped")
×
1151
    except Exception:
×
1152
        logging.warning("Failed to stop Chrome service", exc_info=True)
×
1153

1154
    # Step 1: quit 後に wait_sec 秒待機しつつプロセス終了をチェック
1155
    remaining_pids = _wait_for_processes_with_check(chrome_pids_before, wait_sec)
1✔
1156

1157
    if not remaining_pids:
1✔
1158
        logging.debug("All Chrome processes exited normally")
1✔
1159
        return
1✔
1160

1161
    # Step 2: 残存プロセスに SIGTERM を送信
1162
    logging.info(
×
1163
        "Found %d remaining Chrome processes after %.0fs, sending SIGTERM",
1164
        len(remaining_pids),
1165
        wait_sec,
1166
    )
1167
    _send_signal_to_processes(remaining_pids, signal.SIGTERM, "SIGTERM")
×
1168

1169
    # Step 3: SIGTERM 後に sigterm_wait_sec 秒待機しつつプロセス終了をチェック
1170
    remaining_pids = _wait_for_processes_with_check(remaining_pids, sigterm_wait_sec)
×
1171

1172
    if not remaining_pids:
×
1173
        logging.info("All Chrome processes exited after SIGTERM")
×
1174
        _reap_chrome_processes(chrome_pids_before)
×
1175
        return
×
1176

1177
    # Step 4: 残存プロセスに SIGKILL を送信
1178
    logging.warning(
×
1179
        "Chrome processes still alive after SIGTERM + %.1fs, sending SIGKILL to %d processes",
1180
        sigterm_wait_sec,
1181
        len(remaining_pids),
1182
    )
1183
    _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1184

1185
    # Step 5: SIGKILL 後に sigkill_wait_sec 秒待機してプロセス回収
1186
    time.sleep(sigkill_wait_sec)
×
1187
    _reap_chrome_processes(chrome_pids_before)
×
1188

1189
    # 最終チェック:まだ残っているプロセスがあるか確認
1190
    still_remaining = _get_remaining_chrome_pids(remaining_pids)
×
1191

1192
    # 回収できなかったプロセスについて警告
1193
    if still_remaining:
×
1194
        for pid in still_remaining:
×
1195
            try:
×
1196
                process = psutil.Process(pid)
×
1197
                logging.warning("Failed to collect Chrome-related process: PID %d (%s)", pid, process.name())
×
1198
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1199
                pass
×
1200

1201

1202
if __name__ == "__main__":
1203
    import pathlib
1204

1205
    import docopt
1206
    import selenium.webdriver.support.wait
1207

1208
    import my_lib.config
1209
    import my_lib.logger
1210

1211
    assert __doc__ is not None
1212
    args = docopt.docopt(__doc__)
1213

1214
    config_file = args["-c"]
1215
    debug_mode = args["-D"]
1216

1217
    my_lib.logger.init("test", level=logging.DEBUG if debug_mode else logging.INFO)
1218

1219
    config = my_lib.config.load(config_file)
1220

1221
    driver = create_driver("test", pathlib.Path(config["data"]["selenium"]))
1222
    wait = selenium.webdriver.support.wait.WebDriverWait(driver, 5)
1223

1224
    driver.get("https://www.google.com/")
1225
    wait.until(
1226
        selenium.webdriver.support.expected_conditions.presence_of_element_located(
1227
            (selenium.webdriver.common.by.By.XPATH, '//input[contains(@value, "Google")]')
1228
        )
1229
    )
1230

1231
    quit_driver_gracefully(driver)
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc