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

kimata / my-py-lib / 21517187477

30 Jan 2026 01:18PM UTC coverage: 63.555% (+0.2%) from 63.353%
21517187477

push

github

kimata
fix: ty check エラーとテスト失敗を修正

- selenium_util.py: 不要な type: ignore コメントを削除
- sensor_data.py: pyright 用の ignore コメントに変更
- webapp/base.py: flask.abort() 後の型ナローイングを追加
- test_webapp_event.py: DB監視テストで2回状態変更するよう修正

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

1 of 6 new or added lines in 3 files covered. (16.67%)

33 existing lines in 5 files now uncovered.

3608 of 5677 relevant lines covered (63.55%)

0.64 hits per line

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

36.13
/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 を設定ファイルとして読み込んで実行します。
10
                      [default: tests/fixtures/config.example.yaml]
11
  -D                : デバッグモードで動作します。
12
"""
13

14
from __future__ import annotations
1✔
15

16
import datetime
1✔
17
import inspect
1✔
18
import io
1✔
19
import logging
1✔
20
import os
1✔
21
import pathlib
1✔
22
import random
1✔
23
import re
1✔
24
import signal
1✔
25
import subprocess
1✔
26
import time
1✔
27
from typing import TYPE_CHECKING, Any, Self, TypeVar
1✔
28

29
import PIL.Image
1✔
30
import psutil
1✔
31
import selenium
1✔
32
import selenium.common.exceptions
1✔
33
import selenium.webdriver.chrome.options
1✔
34
import selenium.webdriver.chrome.service
1✔
35
import selenium.webdriver.common.action_chains
1✔
36
import selenium.webdriver.common.by
1✔
37
import selenium.webdriver.common.keys
1✔
38
import selenium.webdriver.support.expected_conditions
1✔
39

40
import my_lib.chrome_util
1✔
41
import my_lib.time
1✔
42

43
if TYPE_CHECKING:
44
    import types
45
    from collections.abc import Callable
46

47
    from selenium.webdriver.remote.webdriver import WebDriver
48
    from selenium.webdriver.support.wait import WebDriverWait
49

50
T = TypeVar("T")
1✔
51

52
WAIT_RETRY_COUNT: int = 1
1✔
53

54

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

58

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

75

76
def _create_chrome_options(
1✔
77
    profile_name: str,
78
    chrome_data_path: pathlib.Path,
79
    log_path: pathlib.Path,
80
    is_headless: bool,
81
) -> selenium.webdriver.chrome.options.Options:
82
    """Chrome オプションを作成する
83

84
    Args:
85
        profile_name: プロファイル名
86
        chrome_data_path: Chrome データディレクトリのパス
87
        log_path: ログディレクトリのパス
88
        is_headless: ヘッドレスモードで起動するか
89

90
    Returns:
91
        設定済みの Chrome オプション
92

93
    """
94
    options = selenium.webdriver.chrome.options.Options()
×
95

96
    if is_headless:
×
97
        options.add_argument("--headless=new")
×
98

99
    options.add_argument("--no-sandbox")  # for Docker
×
100
    options.add_argument("--disable-dev-shm-usage")  # for Docker
×
101
    options.add_argument("--disable-gpu")
×
102

103
    options.add_argument("--disable-popup-blocking")
×
104
    options.add_argument("--disable-plugins")
×
105

106
    options.add_argument("--no-first-run")
×
107

108
    options.add_argument("--lang=ja-JP")
×
109
    options.add_argument("--window-size=1920,1080")
×
110

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

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

116
    options.add_argument("--enable-logging")
×
117
    options.add_argument("--v=1")
×
118

119
    chrome_log_file = log_path / f"chrome_{profile_name}.log"
×
120
    options.add_argument(f"--log-file={chrome_log_file!s}")
×
121

122
    if not is_headless:
×
123
        options.add_argument("--auto-open-devtools-for-tabs")
×
124

125
    return options
×
126

127

128
def _create_driver_impl(
1✔
129
    profile_name: str,
130
    data_path: pathlib.Path,
131
    is_headless: bool,
132
    use_subprocess: bool,
133
    use_undetected: bool,
134
    stealth_mode: bool,
135
) -> WebDriver:
136
    """WebDriver を作成する内部実装
137

138
    Args:
139
        profile_name: プロファイル名
140
        data_path: データディレクトリのパス
141
        is_headless: ヘッドレスモードで起動するか
142
        use_subprocess: サブプロセスで Chrome を起動するか(undetected_chromedriver のみ)
143
        use_undetected: undetected_chromedriver を使用するか
144
        stealth_mode: ボット検出回避のための User-Agent 偽装を行うか
145

146
    Returns:
147
        WebDriver インスタンス
148

149
    """
150
    chrome_data_path = data_path / "chrome"
×
151
    log_path = data_path / "log"
×
152

153
    # NOTE: Pytest を並列実行できるようにする
154
    suffix = os.environ.get("PYTEST_XDIST_WORKER", None)
×
155
    if suffix is not None:
×
156
        profile_name += "." + suffix
×
157

158
    chrome_data_path.mkdir(parents=True, exist_ok=True)
×
159
    log_path.mkdir(parents=True, exist_ok=True)
×
160

161
    options = _create_chrome_options(profile_name, chrome_data_path, log_path, is_headless)
×
162

163
    service = selenium.webdriver.chrome.service.Service(
×
164
        service_args=["--verbose", f"--log-path={str(log_path / 'webdriver.log')!s}"],
165
    )
166

167
    if use_undetected:
×
168
        import undetected_chromedriver
×
169

170
        chrome_version = _get_chrome_version()
×
171

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

177
        driver = undetected_chromedriver.Chrome(
×
178
            service=service,
179
            options=options,
180
            use_subprocess=use_subprocess,
181
            version_main=chrome_version,
182
            user_multi_procs=use_multi_procs,
183
        )
184
    else:
185
        driver = selenium.webdriver.Chrome(
×
186
            service=service,
187
            options=options,
188
        )
189

190
    driver.set_page_load_timeout(30)
×
191

192
    # CDP を使って日本語ロケールを強制設定
193
    set_japanese_locale(driver)
×
194

195
    # ボット検出回避のための設定を適用(オプション)
196
    if stealth_mode:
×
197
        set_stealth_mode(driver)
×
198

199
    return driver
×
200

201

202
def create_driver(
1✔
203
    profile_name: str,
204
    data_path: pathlib.Path,
205
    is_headless: bool = True,
206
    clean_profile: bool = False,
207
    auto_recover: bool = True,
208
    use_undetected: bool = True,
209
    use_subprocess: bool = False,
210
    stealth_mode: bool = True,
211
) -> WebDriver:
212
    """Chrome WebDriver を作成する
213

214
    Args:
215
        profile_name: プロファイル名
216
        data_path: データディレクトリのパス
217
        is_headless: ヘッドレスモードで起動するか
218
        clean_profile: 起動前にロックファイルを削除するか
219
        auto_recover: プロファイル破損時に自動リカバリするか
220
        use_undetected: undetected_chromedriver を使用するか(デフォルト: True)
221
        use_subprocess: サブプロセスで Chrome を起動するか(undetected_chromedriver のみ)
222
        stealth_mode: ボット検出回避のための User-Agent 偽装を行うか(デフォルト: True)
223

224
    Raises:
225
        ValueError: use_undetected=False かつ use_subprocess=True の場合
226

227
    """
228
    if not use_undetected and use_subprocess:
×
229
        raise ValueError("use_subprocess=True は use_undetected=True の場合のみ有効です")
×
230

231
    # NOTE: ルートロガーの出力レベルを変更した場合でも Selenium 関係は抑制する
232
    logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
×
233
    logging.getLogger("selenium.webdriver.common.selenium_manager").setLevel(logging.WARNING)
×
234
    logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING)
×
235

236
    # NOTE: chrome_util の内部関数を使用(同一パッケージ内での使用は許容)
237
    actual_profile_name = my_lib.chrome_util._get_actual_profile_name(profile_name)
×
238
    profile_path = data_path / "chrome" / actual_profile_name
×
239

240
    # プロファイル健全性チェック
241
    health = my_lib.chrome_util._check_profile_health(profile_path)
×
242
    if not health.is_healthy:
×
243
        logging.warning("Profile health check failed: %s", ", ".join(health.errors))
×
244

245
        if health.has_lock_files and not (health.has_corrupted_json or health.has_corrupted_db):
×
246
            # ロックファイルのみの問題なら削除して続行
247
            logging.info("Cleaning up lock files only")
×
248
            my_lib.chrome_util._cleanup_profile_lock(profile_path)
×
249
        elif auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
250
            # JSON または DB が破損している場合はプロファイルをリカバリ
251
            logging.warning("Profile is corrupted, attempting recovery")
×
252
            if my_lib.chrome_util._recover_corrupted_profile(profile_path):
×
253
                logging.info("Profile recovery successful, will create new profile")
×
254
            else:
255
                logging.error("Profile recovery failed")
×
256

257
    if clean_profile:
×
258
        my_lib.chrome_util._cleanup_profile_lock(profile_path)
×
259

260
    # NOTE: 1回だけ自動リトライ
261
    try:
×
262
        return _create_driver_impl(
×
263
            profile_name, data_path, is_headless, use_subprocess, use_undetected, stealth_mode
264
        )
265
    except Exception as e:
×
266
        logging.warning("First attempt to create driver failed: %s", e)
×
267

268
        # コンテナ内で実行中の場合のみ、残った Chrome プロセスをクリーンアップ
269
        my_lib.chrome_util._cleanup_orphaned_chrome_processes_in_container()
×
270

271
        # プロファイルのロックファイルを削除
272
        my_lib.chrome_util._cleanup_profile_lock(profile_path)
×
273

274
        # 再度健全性チェック
275
        health = my_lib.chrome_util._check_profile_health(profile_path)
×
276
        if not health.is_healthy and auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
277
            logging.warning("Profile still corrupted after first attempt, recovering")
×
278
            my_lib.chrome_util._recover_corrupted_profile(profile_path)
×
279

280
        return _create_driver_impl(
×
281
            profile_name, data_path, is_headless, use_subprocess, use_undetected, stealth_mode
282
        )
283

284

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

288

289
def get_text(
1✔
290
    driver: WebDriver,
291
    xpath: str,
292
    safe_text: str,
293
    wait: WebDriverWait[WebDriver] | None = None,
294
) -> str:
295
    if wait is not None:
×
296
        wait.until(
×
297
            selenium.webdriver.support.expected_conditions.presence_of_all_elements_located(
298
                (selenium.webdriver.common.by.By.XPATH, xpath)
299
            )
300
        )
301

302
    if len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0:
×
303
        return driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).text.strip()
×
304
    else:
305
        return safe_text
×
306

307

308
def input_xpath(
1✔
309
    driver: WebDriver,
310
    xpath: str,
311
    text: str,
312
    wait: WebDriverWait[WebDriver] | None = None,
313
    is_warn: bool = True,
314
) -> bool:
315
    if wait is not None:
×
316
        wait.until(
×
317
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
318
                (selenium.webdriver.common.by.By.XPATH, xpath)
319
            )
320
        )
321
        time.sleep(0.05)
×
322

323
    if xpath_exists(driver, xpath):
×
324
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).send_keys(text)
×
325
        return True
×
326
    else:
327
        if is_warn:
×
328
            logging.warning("Element is not found: %s", xpath)
×
329
        return False
×
330

331

332
def click_xpath(
1✔
333
    driver: WebDriver,
334
    xpath: str,
335
    wait: WebDriverWait[WebDriver] | None = None,
336
    is_warn: bool = True,
337
    move: bool = False,
338
) -> bool:
339
    if wait is not None:
×
340
        wait.until(
×
341
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
342
                (selenium.webdriver.common.by.By.XPATH, xpath)
343
            )
344
        )
345
        time.sleep(0.05)
×
346

347
    if xpath_exists(driver, xpath):
×
348
        elem = driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath)
×
349
        if move:
×
350
            action = selenium.webdriver.common.action_chains.ActionChains(driver)
×
351
            action.move_to_element(elem)
×
352
            action.perform()
×
353

354
        elem.click()
×
355
        return True
×
356
    else:
357
        if is_warn:
×
358
            logging.warning("Element is not found: %s", xpath)
×
359
        return False
×
360

361

362
def is_display(driver: WebDriver, xpath: str) -> bool:
1✔
363
    return (len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0) and (
×
364
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).is_displayed()
365
    )
366

367

368
def random_sleep(sec: float) -> None:
1✔
369
    RATIO = 0.8
1✔
370

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

373

374
def with_retry(
1✔
375
    func: Callable[[], T],
376
    max_retries: int = 3,
377
    delay: float = 1.0,
378
    exceptions: tuple[type[Exception], ...] = (Exception,),
379
    on_retry: Callable[[int, Exception], bool | None] | None = None,
380
) -> T:
381
    """リトライ付きで関数を実行
382

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

386
    Args:
387
        func: 実行する関数
388
        max_retries: 最大リトライ回数
389
        delay: リトライ間の待機秒数
390
        exceptions: リトライ対象の例外タプル
391
        on_retry: リトライ時のコールバック (attempt, exception)
392
            - None または True を返すとリトライを継続
393
            - False を返すとリトライを中止して例外を再スロー
394

395
    Returns:
396
        成功時は関数の戻り値
397

398
    Raises:
399
        最後の例外を再スロー
400

401
    """
402
    last_exception: Exception | None = None
1✔
403

404
    for attempt in range(max_retries):
1✔
405
        try:
1✔
406
            return func()
1✔
407
        except exceptions as e:
1✔
408
            last_exception = e
1✔
409
            if attempt < max_retries - 1:
1✔
410
                if on_retry:
1✔
411
                    should_continue = on_retry(attempt + 1, e)
1✔
412
                    if should_continue is False:
1✔
413
                        raise
1✔
414
                time.sleep(delay)
1✔
415

416
    if last_exception:
1✔
417
        raise last_exception
1✔
418
    raise RuntimeError("Unexpected state in with_retry")
×
419

420

421
def with_session_retry(
1✔
422
    func: Callable[[], T],
423
    driver_name: str,
424
    data_dir: pathlib.Path,
425
    *,
426
    max_retries: int = 1,
427
    clear_profile_on_error: bool = True,
428
    on_retry: Callable[[int, int], None] | None = None,
429
    before_retry: Callable[[], None] | None = None,
430
) -> T:
431
    """InvalidSessionIdException 発生時にプロファイル削除してリトライ
432

433
    ブラウザのセッションが無効になった場合(クラッシュ等)、
434
    プロファイルを削除して再起動することで復旧を試みる。
435

436
    Args:
437
        func: 実行する関数
438
        driver_name: Chrome プロファイル名
439
        data_dir: Selenium データディレクトリ
440
        max_retries: 最大リトライ回数 (デフォルト: 1)
441
        clear_profile_on_error: エラー時にプロファイルを削除するか
442
        on_retry: リトライ時のコールバック (attempt, max_retries) - ステータス更新用
443
        before_retry: リトライ前のコールバック - quit_selenium() 呼び出し用
444

445
    Returns:
446
        成功時は関数の戻り値
447

448
    Raises:
449
        InvalidSessionIdException: リトライ上限に達した場合
450

451
    """
452

453
    def retry_handler(attempt: int, exception: Exception) -> bool | None:
1✔
454
        if before_retry:
1✔
455
            before_retry()
1✔
456

457
        if attempt <= max_retries and clear_profile_on_error:
1✔
458
            logging.warning(
1✔
459
                "セッションエラーが発生しました。プロファイルを削除してリトライします(%d/%d)",
460
                attempt,
461
                max_retries,
462
            )
463
            if on_retry:
1✔
464
                on_retry(attempt, max_retries)
1✔
465
            my_lib.chrome_util.delete_profile(driver_name, data_dir)
1✔
466
            return True
1✔
467
        return False
1✔
468

469
    return with_retry(
1✔
470
        func,
471
        max_retries=max_retries + 1,
472
        delay=0,
473
        exceptions=(selenium.common.exceptions.InvalidSessionIdException,),
474
        on_retry=retry_handler,
475
    )
476

477

478
def wait_patiently(
1✔
479
    driver: WebDriver,
480
    wait: WebDriverWait[WebDriver],
481
    target: Any,
482
) -> None:
483
    error: selenium.common.exceptions.TimeoutException | None = None
×
484
    for i in range(WAIT_RETRY_COUNT + 1):
×
485
        try:
×
486
            wait.until(target)
×
487
            return
×
488
        except selenium.common.exceptions.TimeoutException as e:
×
489
            logging.warning(
×
490
                "タイムアウトが発生しました。(%s in %s line %d)",
491
                inspect.stack()[1].function,
492
                inspect.stack()[1].filename,
493
                inspect.stack()[1].lineno,
494
            )
495
            error = e
×
496

497
            logging.info(i)
×
498
            if i != WAIT_RETRY_COUNT:
×
499
                logging.info("refresh")
×
500
                driver.refresh()
×
501

502
    if error is not None:
×
503
        raise error
×
504

505

506
def dump_page(
1✔
507
    driver: WebDriver,
508
    index: int,
509
    dump_path: pathlib.Path,
510
    stack_index: int = 1,
511
) -> None:
512
    name = inspect.stack()[stack_index].function.replace("<", "").replace(">", "")
×
513

514
    dump_path.mkdir(parents=True, exist_ok=True)
×
515

516
    png_path = dump_path / f"{name}_{index:02d}.png"
×
517
    htm_path = dump_path / f"{name}_{index:02d}.htm"
×
518

519
    driver.save_screenshot(str(png_path))
×
520

521
    with htm_path.open("w", encoding="utf-8") as f:
×
522
        f.write(driver.page_source)
×
523

524
    logging.info(
×
525
        "page dump: %02d from %s in %s line %d",
526
        index,
527
        inspect.stack()[stack_index].function,
528
        inspect.stack()[stack_index].filename,
529
        inspect.stack()[stack_index].lineno,
530
    )
531

532

533
def clear_cache(driver: WebDriver) -> None:
1✔
534
    driver.execute_cdp_cmd("Network.clearBrowserCache", {})
×
535

536

537
def set_japanese_locale(driver: WebDriver) -> None:
1✔
538
    """CDP を使って日本語ロケールを強制設定
539

540
    Chrome の起動オプションだけでは言語設定が変わってしまうことがあるため、
541
    CDP を使って Accept-Language ヘッダーとロケールを強制的に日本語に設定する。
542
    """
543
    try:
×
544
        # NOTE: Network.setExtraHTTPHeaders は Network.enable を先に呼ばないと機能しない
545
        driver.execute_cdp_cmd("Network.enable", {})
×
546
        driver.execute_cdp_cmd(
×
547
            "Network.setExtraHTTPHeaders",
548
            {"headers": {"Accept-Language": "ja-JP,ja;q=0.9"}},
549
        )
550
        driver.execute_cdp_cmd(
×
551
            "Emulation.setLocaleOverride",
552
            {"locale": "ja-JP"},
553
        )
554
        logging.debug("Japanese locale set via CDP")
×
555
    except Exception:
×
556
        logging.warning("Failed to set Japanese locale via CDP")
×
557

558

559
def _get_stealth_user_agent(driver: WebDriver) -> str:
1✔
560
    """ブラウザの User-Agent を取得し、ボット検出回避用に修正.
561

562
    - OS 部分を Windows に変更
563
    - HeadlessChrome を Chrome に変更
564

565
    Args:
566
        driver: WebDriver インスタンス
567

568
    Returns:
569
        修正された User-Agent 文字列
570

571
    """
572
    original_ua = driver.execute_script("return navigator.userAgent")
×
573
    logging.debug("Original User-Agent: %s", original_ua)
×
574

575
    # OS 部分を Windows に変更
576
    pattern = r"\([^)]*(?:Linux|Macintosh|X11)[^)]*\)"
×
577
    replacement = "(Windows NT 10.0; Win64; x64)"
×
578
    modified_ua = re.sub(pattern, replacement, original_ua)
×
579

580
    # HeadlessChrome を Chrome に変更
581
    modified_ua = modified_ua.replace("HeadlessChrome", "Chrome")
×
582

583
    logging.debug("Modified User-Agent: %s", modified_ua)
×
584
    return modified_ua
×
585

586

587
def set_stealth_mode(driver: WebDriver) -> None:
1✔
588
    """ボット検出回避のための CDP 設定を適用.
589

590
    ヨドバシ等のボット検出は User-Agent をチェックしているため、
591
    OS を Windows に偽装し、HeadlessChrome を Chrome に変更することで回避できる。
592

593
    Args:
594
        driver: WebDriver インスタンス
595

596
    """
597
    try:
×
598
        modified_ua = _get_stealth_user_agent(driver)
×
599
        driver.execute_cdp_cmd(
×
600
            "Network.setUserAgentOverride",
601
            {
602
                "userAgent": modified_ua,
603
                "acceptLanguage": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
604
                "platform": "Win32",
605
            },
606
        )
607
        logging.debug("Stealth mode enabled (User-Agent override applied)")
×
608
    except Exception:
×
609
        logging.warning("Failed to enable stealth mode")
×
610

611

612
def clean_dump(dump_path: pathlib.Path, keep_days: int = 1) -> None:
1✔
613
    if not dump_path.exists():
1✔
614
        return
1✔
615

616
    time_threshold = datetime.timedelta(keep_days)
1✔
617

618
    for item in dump_path.iterdir():
1✔
619
        if not item.is_file():
1✔
620
            continue
1✔
621
        try:
1✔
622
            time_diff = datetime.datetime.now(datetime.UTC) - datetime.datetime.fromtimestamp(
1✔
623
                item.stat().st_mtime, datetime.UTC
624
            )
625
        except FileNotFoundError:
×
626
            # ファイルが別プロセスにより削除された場合(SQLiteの一時ファイルなど)
627
            continue
×
628
        if time_diff > time_threshold:
1✔
629
            logging.warning("remove %s [%s day(s) old].", item.absolute(), f"{time_diff.days:,}")
1✔
630

631
            item.unlink(missing_ok=True)
1✔
632

633

634
def get_memory_info(driver: WebDriver) -> dict[str, Any]:
1✔
635
    """ブラウザのメモリ使用量を取得(単位: KB)"""
636
    total_bytes = subprocess.Popen(  # noqa: S602
×
637
        "smem -t -c pss -P chrome | tail -n 1",  # noqa: S607
638
        shell=True,
639
        stdout=subprocess.PIPE,
640
    ).communicate()[0]
641
    total = int(str(total_bytes, "utf-8").strip())  # smem の出力は KB 単位
×
642

643
    try:
×
644
        memory_info = driver.execute_cdp_cmd("Memory.getAllTimeSamplingProfile", {})
×
645
        heap_usage = driver.execute_cdp_cmd("Runtime.getHeapUsage", {})
×
646

647
        heap_used = heap_usage.get("usedSize", 0) // 1024  # bytes → KB
×
648
        heap_total = heap_usage.get("totalSize", 0) // 1024  # bytes → KB
×
649
    except Exception as e:
×
650
        logging.debug("Failed to get memory usage: %s", e)
×
651

652
        memory_info = None
×
653
        heap_used = 0
×
654
        heap_total = 0
×
655

656
    return {
×
657
        "total": total,
658
        "heap_used": heap_used,
659
        "heap_total": heap_total,
660
        "memory_info": memory_info,
661
    }
662

663

664
def log_memory_usage(driver: WebDriver) -> None:
1✔
665
    mem_info = get_memory_info(driver)
×
666
    logging.info(
×
667
        "Chrome memory: %s MB (JS heap: %s MB)",
668
        f"""{mem_info["total"] // 1024:,}""",
669
        f"""{mem_info["heap_used"] // 1024:,}""",
670
    )
671

672

673
def _warmup(
1✔
674
    driver: WebDriver,
675
    keyword: str,
676
    url_pattern: str,
677
    sleep_sec: int = 3,
678
) -> None:
679
    # NOTE: ダミーアクセスを行って BOT ではないと思わせる。(効果なさそう...)
680
    driver.get("https://www.yahoo.co.jp/")
×
681
    time.sleep(sleep_sec)
×
682

683
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(keyword)
×
684
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(
×
685
        selenium.webdriver.common.keys.Keys.ENTER
686
    )
687

688
    time.sleep(sleep_sec)
×
689

690
    driver.find_element(
×
691
        selenium.webdriver.common.by.By.XPATH, f'//a[contains(@href, "{url_pattern}")]'
692
    ).click()
693

694
    time.sleep(sleep_sec)
×
695

696

697
class browser_tab:
1✔
698
    """新しいブラウザタブで URL を開くコンテキストマネージャ"""
699

700
    def __init__(self, driver: WebDriver, url: str) -> None:
1✔
701
        """初期化
702

703
        Args:
704
            driver: WebDriver インスタンス
705
            url: 開く URL
706

707
        """
708
        self.driver = driver
1✔
709
        self.url = url
1✔
710
        self.original_window: str | None = None
1✔
711

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

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

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

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

750
    def __exit__(
1✔
751
        self,
752
        exception_type: type[BaseException] | None,
753
        exception_value: BaseException | None,
754
        traceback: types.TracebackType | None,
755
    ) -> None:
756
        """タブを閉じて元のウィンドウに戻る"""
757
        self._cleanup()
1✔
758

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

763

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

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

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

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

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

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

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

791
            def notify(exc, screenshot, page_source):
792
                slack.notify_error_with_page(config, "エラー発生", exc, screenshot, page_source)
793

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

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

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

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

809
    """
810

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

829
    def __enter__(self) -> Self:
1✔
830
        """コンテキストマネージャの開始"""
831
        return self
1✔
832

833
    def __exit__(
1✔
834
        self,
835
        exception_type: type[BaseException] | None,
836
        exception_value: BaseException | None,
837
        traceback: types.TracebackType | None,
838
    ) -> bool:
839
        """コンテキストマネージャの終了、エラー処理を実行"""
840
        if exception_value is None:
1✔
841
            return False
1✔
842

843
        # 例外を記録
844
        if isinstance(exception_value, Exception):
1✔
845
            self.exception = exception_value
1✔
846
        else:
847
            # BaseException(KeyboardInterrupt など)は処理せず再送出
848
            return False
×
849

850
        # ログ出力
851
        logging.exception(self.message)
1✔
852

853
        # スクリーンショット・ページソース取得
854
        if self.capture_screenshot:
1✔
855
            try:
1✔
856
                self.page_source = self.driver.page_source
1✔
857
            except Exception:
×
858
                logging.debug("Failed to capture page source for error handling")
×
859
            try:
1✔
860
                screenshot_bytes = self.driver.get_screenshot_as_png()
1✔
861
                self.screenshot = PIL.Image.open(io.BytesIO(screenshot_bytes))
1✔
862
            except Exception:
1✔
863
                logging.debug("Failed to capture screenshot for error handling")
1✔
864

865
        # コールバック呼び出し
866
        if self.on_error is not None:
1✔
867
            try:
1✔
868
                self.on_error(self.exception, self.screenshot, self.page_source)
1✔
869
            except Exception:
×
870
                logging.exception("Error in on_error callback")
×
871

872
        # reraise=False なら例外を抑制
873
        return not self.reraise
1✔
874

875

876
def _is_chrome_related_process(process: psutil.Process) -> bool:
1✔
877
    """プロセスがChrome関連かどうかを判定"""
878
    try:
1✔
879
        process_name = process.name().lower()
1✔
880
        # Chrome関連のプロセス名パターン
881
        chrome_patterns = ["chrome", "chromium", "google-chrome", "undetected_chro"]
1✔
882
        # chromedriverは除外
883
        if "chromedriver" in process_name:
1✔
884
            return False
1✔
885
        return any(pattern in process_name for pattern in chrome_patterns)
1✔
886
    except (psutil.NoSuchProcess, psutil.AccessDenied):
1✔
887
        return False
1✔
888

889

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

914

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

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

923
    # 1. driver.service.process の子プロセスを検索
924
    # NOTE: Chrome/Firefox WebDriver には service 属性があるが、型スタブでは定義されていない
925
    try:
×
NEW
926
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "process"):
×
NEW
927
            process = driver.service.process
×
928
            if process and hasattr(process, "pid"):
×
929
                chromedriver_pid = process.pid
×
930

931
                # psutilでプロセス階層を取得
932
                parent_process = psutil.Process(chromedriver_pid)
×
933
                children = parent_process.children(recursive=True)
×
934

935
                for child in children:
×
936
                    chrome_pids.add(child.pid)
×
937
                    logging.debug(
×
938
                        "Found Chrome-related process (service child): PID %d, name: %s",
939
                        child.pid,
940
                        child.name(),
941
                    )
942
    except Exception:
×
943
        logging.exception("Failed to get Chrome-related processes from service")
×
944

945
    # 2. 現在の Python プロセスの全子孫から Chrome 関連プロセスを検索
946
    try:
×
947
        current_process = psutil.Process()
×
948
        all_children = current_process.children(recursive=True)
×
949

950
        for child in all_children:
×
951
            if child.pid in chrome_pids:
×
952
                continue
×
953
            try:
×
954
                if _is_chrome_related_process(child):
×
955
                    chrome_pids.add(child.pid)
×
956
                    logging.debug(
×
957
                        "Found Chrome-related process (python child): PID %d, name: %s",
958
                        child.pid,
959
                        child.name(),
960
                    )
961
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
962
                pass
×
963
    except Exception:
×
964
        logging.exception("Failed to get Chrome-related processes from python children")
×
965

966
    return list(chrome_pids)
×
967

968

969
def _send_signal_to_processes(pids: list[int], sig: signal.Signals, signal_name: str) -> None:
1✔
970
    """プロセスリストに指定されたシグナルを送信"""
971
    errors = []
×
972
    for pid in pids:
×
973
        try:
×
974
            # プロセス名を取得
975
            try:
×
976
                process = psutil.Process(pid)
×
977
                process_name = process.name()
×
978
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
979
                process_name = "unknown"
×
980

981
            if sig == signal.SIGKILL:
×
982
                # プロセスがまだ存在するかチェック
983
                os.kill(pid, 0)  # シグナル0は存在確認
×
984
            os.kill(pid, sig)
×
985
            logging.info("Sent %s to process: PID %d (%s)", signal_name, pid, process_name)
×
986
        except (ProcessLookupError, OSError) as e:
×
987
            # プロセスが既に終了している場合は無視
988
            errors.append((pid, e))
×
989

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

994

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

998
    Args:
999
        chrome_pids: 終了対象のプロセスIDリスト
1000
        timeout: SIGTERM後にプロセス終了を待機する最大時間(秒)
1001

1002
    """
1003
    if not chrome_pids:
×
1004
        return
×
1005

1006
    # 優雅な終了(SIGTERM)
1007
    _send_signal_to_processes(chrome_pids, signal.SIGTERM, "SIGTERM")
×
1008

1009
    # プロセスの終了を待機(ポーリング)
1010
    remaining_pids = list(chrome_pids)
×
1011
    poll_interval = 0.2
×
1012
    elapsed = 0.0
×
1013

1014
    while remaining_pids and elapsed < timeout:
×
1015
        time.sleep(poll_interval)
×
1016
        elapsed += poll_interval
×
1017

1018
        # まだ生存しているプロセスをチェック
1019
        still_alive = []
×
1020
        for pid in remaining_pids:
×
1021
            try:
×
1022
                if psutil.pid_exists(pid):
×
1023
                    process = psutil.Process(pid)
×
1024
                    if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
1025
                        still_alive.append(pid)
×
1026
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1027
                pass
×
1028

1029
        remaining_pids = still_alive
×
1030

1031
    # タイムアウト後もまだ残っているプロセスにのみ SIGKILL を送信
1032
    if remaining_pids:
×
1033
        logging.warning(
×
1034
            "Chrome processes still alive after %.1fs, sending SIGKILL to %d processes",
1035
            elapsed,
1036
            len(remaining_pids),
1037
        )
1038
        _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1039

1040

1041
def _reap_single_process(pid: int) -> None:
1✔
1042
    """単一プロセスをwaitpidで回収"""
1043
    try:
×
1044
        # ノンブロッキングでwaitpid
1045
        result_pid, status = os.waitpid(pid, os.WNOHANG)
×
1046
        if result_pid == pid:
×
1047
            # プロセス名を取得
1048
            try:
×
1049
                process = psutil.Process(pid)
×
1050
                process_name = process.name()
×
1051
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1052
                process_name = "unknown"
×
1053
            logging.debug("Reaped Chrome process: PID %d (%s)", pid, process_name)
×
1054
    except (ChildProcessError, OSError):
×
1055
        # 子プロセスでない場合や既に回収済みの場合は無視
1056
        pass
×
1057

1058

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

1064

1065
def _get_remaining_chrome_pids(chrome_pids: list[int]) -> list[int]:
1✔
1066
    """指定されたPIDリストから、まだ生存しているChrome関連プロセスを取得"""
1067
    remaining = []
×
1068
    for pid in chrome_pids:
×
1069
        try:
×
1070
            if psutil.pid_exists(pid):
×
1071
                process = psutil.Process(pid)
×
1072
                if (
×
1073
                    process.is_running()
1074
                    and process.status() != psutil.STATUS_ZOMBIE
1075
                    and _is_chrome_related_process(process)
1076
                ):
1077
                    remaining.append(pid)
×
1078
        except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1079
            pass
×
1080
    return remaining
×
1081

1082

1083
def _wait_for_processes_with_check(
1✔
1084
    chrome_pids: list[int],
1085
    timeout: float,
1086
    poll_interval: float = 0.2,
1087
    log_interval: float = 1.0,
1088
) -> list[int]:
1089
    """プロセスの終了を待機しつつ、残存プロセスをチェック
1090

1091
    Args:
1092
        chrome_pids: 監視対象のプロセスIDリスト
1093
        timeout: 最大待機時間(秒)
1094
        poll_interval: チェック間隔(秒)
1095
        log_interval: ログ出力間隔(秒)
1096

1097
    Returns:
1098
        タイムアウト後も残存しているプロセスIDのリスト
1099

1100
    """
1101
    elapsed = 0.0
×
1102
    last_log_time = 0.0
×
1103
    remaining_pids = list(chrome_pids)
×
1104

1105
    while remaining_pids and elapsed < timeout:
×
1106
        time.sleep(poll_interval)
×
1107
        elapsed += poll_interval
×
1108
        remaining_pids = _get_remaining_chrome_pids(remaining_pids)
×
1109

1110
        if remaining_pids and (elapsed - last_log_time) >= log_interval:
×
1111
            logging.info(
×
1112
                "Found %d remaining Chrome processes after %.0fs",
1113
                len(remaining_pids),
1114
                elapsed,
1115
            )
1116
            last_log_time = elapsed
×
1117

1118
    return remaining_pids
×
1119

1120

1121
def quit_driver_gracefully(
1✔
1122
    driver: WebDriver | None,
1123
    wait_sec: float = 5.0,
1124
    sigterm_wait_sec: float = 5.0,
1125
    sigkill_wait_sec: float = 5.0,
1126
) -> None:
1127
    """Chrome WebDriverを確実に終了する
1128

1129
    終了フロー:
1130
    1. driver.quit() を呼び出し
1131
    2. wait_sec 秒待機しつつプロセス終了をチェック
1132
    3. 残存プロセスがあれば SIGTERM を送信
1133
    4. sigterm_wait_sec 秒待機しつつプロセス終了をチェック
1134
    5. 残存プロセスがあれば SIGKILL を送信
1135
    6. sigkill_wait_sec 秒待機
1136

1137
    Args:
1138
        driver: 終了する WebDriver インスタンス
1139
        wait_sec: quit 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1140
        sigterm_wait_sec: SIGTERM 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1141
        sigkill_wait_sec: SIGKILL 後にプロセス回収を待機する秒数(デフォルト: 5秒)
1142

1143
    """
1144
    if driver is None:
1✔
1145
        return
1✔
1146

1147
    # quit前にChrome関連プロセスを記録
1148
    chrome_pids_before = _get_chrome_related_processes(driver)
1✔
1149

1150
    try:
1✔
1151
        # WebDriverの正常終了を試行(これがタブのクローズも含む)
1152
        driver.quit()
1✔
1153
        logging.info("WebDriver quit successfully")
1✔
1154
    except Exception:
1✔
1155
        logging.warning("Failed to quit driver normally", exc_info=True)
1✔
1156
    finally:
1157
        # undetected_chromedriver の __del__ がシャットダウン時に再度呼ばれるのを防ぐ
1158
        if hasattr(driver, "_has_quit"):
1✔
1159
            driver._has_quit = True  # type: ignore[attr-defined]
1✔
1160

1161
    # ChromeDriverサービスの停止を試行
1162
    try:
1✔
1163
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "stop"):
1✔
NEW
1164
            driver.service.stop()  # type: ignore[call-non-callable]
×
UNCOV
1165
    except (ConnectionResetError, OSError):
×
1166
        # Chrome が既に終了している場合は無視
1167
        logging.debug("Chrome service already stopped")
×
1168
    except Exception:
×
1169
        logging.warning("Failed to stop Chrome service", exc_info=True)
×
1170

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

1174
    if not remaining_pids:
1✔
1175
        logging.debug("All Chrome processes exited normally")
1✔
1176
        return
1✔
1177

1178
    # Step 2: 残存プロセスに SIGTERM を送信
1179
    logging.info(
×
1180
        "Found %d remaining Chrome processes after %.0fs, sending SIGTERM",
1181
        len(remaining_pids),
1182
        wait_sec,
1183
    )
1184
    _send_signal_to_processes(remaining_pids, signal.SIGTERM, "SIGTERM")
×
1185

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

1189
    if not remaining_pids:
×
1190
        logging.info("All Chrome processes exited after SIGTERM")
×
1191
        _reap_chrome_processes(chrome_pids_before)
×
1192
        return
×
1193

1194
    # Step 4: 残存プロセスに SIGKILL を送信
1195
    logging.warning(
×
1196
        "Chrome processes still alive after SIGTERM + %.1fs, sending SIGKILL to %d processes",
1197
        sigterm_wait_sec,
1198
        len(remaining_pids),
1199
    )
1200
    _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1201

1202
    # Step 5: SIGKILL 後に sigkill_wait_sec 秒待機してプロセス回収
1203
    time.sleep(sigkill_wait_sec)
×
1204
    _reap_chrome_processes(chrome_pids_before)
×
1205

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

1209
    # 回収できなかったプロセスについて警告
1210
    if still_remaining:
×
1211
        for pid in still_remaining:
×
1212
            try:
×
1213
                process = psutil.Process(pid)
×
1214
                logging.warning("Failed to collect Chrome-related process: PID %d (%s)", pid, process.name())
×
1215
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1216
                pass
×
1217

1218

1219
if __name__ == "__main__":
1220
    import pathlib
1221

1222
    import docopt
1223
    import selenium.webdriver.support.wait
1224

1225
    import my_lib.config
1226
    import my_lib.logger
1227

1228
    assert __doc__ is not None  # noqa: S101
1229
    args = docopt.docopt(__doc__)
1230

1231
    config_file = args["-c"]
1232
    debug_mode = args["-D"]
1233

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

1236
    config = my_lib.config.load(config_file)
1237

1238
    driver = create_driver("test", pathlib.Path(config["data"]["selenium"]))
1239
    wait = selenium.webdriver.support.wait.WebDriverWait(driver, 5)
1240

1241
    driver.get("https://www.google.com/")
1242
    wait.until(
1243
        selenium.webdriver.support.expected_conditions.presence_of_element_located(
1244
            (selenium.webdriver.common.by.By.XPATH, '//input[contains(@value, "Google")]')
1245
        )
1246
    )
1247

1248
    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