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

kimata / my-py-lib / 28693832698

04 Jul 2026 03:40AM UTC coverage: 58.906% (-1.8%) from 60.752%
28693832698

push

github

kimata
fix: 肥大化した Preferences を起動前に削除しセッション消失を防止

Chrome の既知バグ(日本語ロケール環境で内蔵拡張機能の manifest.name が
保存のたびに UTF-8 多重エンコードされる)により Preferences が指数的に
肥大化し(実測 369MB)、起動タイムアウト → プロファイル全体の退避 →
ログインセッション消失の連鎖が約1週間周期で発生していた。

Preferences のみを閾値(5MB)超過時に削除することで、Cookie 等の
セッション情報を保持したまま起動遅延を解消する。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

16 of 18 new or added lines in 2 files covered. (88.89%)

84 existing lines in 2 files now uncovered.

4180 of 7096 relevant lines covered (58.91%)

0.59 hits per line

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

34.94
/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 shutil
1✔
25
import signal
1✔
26
import subprocess
1✔
27
import time
1✔
28
from typing import TYPE_CHECKING, Any, Self, TypeVar
1✔
29

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

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

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

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

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

53
WAIT_RETRY_COUNT: int = 1
1✔
54
LOG_MAX_BYTES: int = 10 * 1024 * 1024  # 10MB
1✔
55
LOG_BACKUP_COUNT: int = 3
1✔
56

57

58
class SeleniumError(Exception):
1✔
59
    """Selenium 関連エラーの基底クラス"""
60

61

62
def _get_rotated_path(log_file: pathlib.Path, index: int) -> pathlib.Path:
1✔
63
    """ローテーションされたログファイルのパスを生成"""
64
    return log_file.parent / f"{log_file.name}.{index}"
×
65

66

67
def _rotate_log_file(
1✔
68
    log_file: pathlib.Path,
69
    max_bytes: int = LOG_MAX_BYTES,
70
    backup_count: int = LOG_BACKUP_COUNT,
71
) -> bool:
72
    """ログファイルをローテーションする(copytruncate 方式)
73

74
    Chrome がファイルを開いたまま書き込みを続けるため、
75
    ファイルをコピーしてからトランケートする方式を採用。
76

77
    Args:
78
        log_file: ローテーション対象のログファイルパス
79
        max_bytes: ローテーションを実行するファイルサイズ閾値(バイト)
80
        backup_count: 保持するバックアップ世代数
81

82
    Returns:
83
        ローテーションを実行した場合は True
84

85
    """
86
    if not log_file.exists():
×
87
        return False
×
88

89
    try:
×
90
        file_size = log_file.stat().st_size
×
91
    except OSError:
×
92
        return False
×
93

94
    if file_size <= max_bytes:
×
95
        return False
×
96

97
    logging.info(
×
98
        "Rotating log file: %s (size: %.1f MB)",
99
        log_file.name,
100
        file_size / (1024 * 1024),
101
    )
102

103
    # 最古の世代を削除し、世代をシフト
104
    for i in range(backup_count, 0, -1):
×
105
        rotated = _get_rotated_path(log_file, i)
×
106
        if i == backup_count:
×
107
            rotated.unlink(missing_ok=True)
×
108
        elif rotated.exists():
×
109
            rotated.rename(_get_rotated_path(log_file, i + 1))
×
110

111
    # 現在のファイルを .1 にコピーしてからトランケート
112
    shutil.copy2(log_file, _get_rotated_path(log_file, 1))
×
113
    log_file.open("w").close()
×
114

115
    return True
×
116

117

118
def rotate_selenium_logs(
1✔
119
    log_path: pathlib.Path,
120
    max_bytes: int = LOG_MAX_BYTES,
121
    backup_count: int = LOG_BACKUP_COUNT,
122
) -> None:
123
    """Selenium 関連のログファイルをローテーションする
124

125
    Args:
126
        log_path: ログディレクトリのパス
127
        max_bytes: ローテーションを実行するファイルサイズ閾値(バイト、デフォルト: 10MB)
128
        backup_count: 保持するバックアップ世代数(デフォルト: 3)
129

130
    """
131
    if not log_path.exists():
×
132
        return
×
133

134
    for log_file in log_path.glob("*.log"):
×
135
        _rotate_log_file(log_file, max_bytes, backup_count)
×
136

137

138
def _get_chrome_version() -> int | None:
1✔
139
    try:
1✔
140
        result = subprocess.run(
1✔
141
            ["google-chrome", "--version"],  # noqa: S607
142
            capture_output=True,
143
            text=True,
144
            timeout=10,
145
            check=False,
146
        )
147
        match = re.search(r"(\d+)\.", result.stdout)
1✔
148
        if match:
1✔
149
            return int(match.group(1))
1✔
150
    except Exception:
1✔
151
        logging.warning("Failed to detect Chrome version")
1✔
152
    return None
1✔
153

154

155
def _create_chrome_options(
1✔
156
    profile_name: str,
157
    chrome_data_path: pathlib.Path,
158
    log_path: pathlib.Path,
159
    is_headless: bool,
160
) -> selenium.webdriver.chrome.options.Options:
161
    """Chrome オプションを作成する
162

163
    Args:
164
        profile_name: プロファイル名
165
        chrome_data_path: Chrome データディレクトリのパス
166
        log_path: ログディレクトリのパス
167
        is_headless: ヘッドレスモードで起動するか
168

169
    Returns:
170
        設定済みの Chrome オプション
171

172
    """
173
    options = selenium.webdriver.chrome.options.Options()
×
174

175
    if is_headless:
×
176
        options.add_argument("--headless=new")
×
177

178
    options.add_argument("--no-sandbox")  # for Docker
×
179
    options.add_argument("--disable-dev-shm-usage")  # for Docker
×
180
    options.add_argument("--disable-gpu")
×
181

182
    options.add_argument("--disable-popup-blocking")
×
183
    options.add_argument("--disable-plugins")
×
184

185
    options.add_argument("--no-first-run")
×
186

187
    options.add_argument("--lang=ja-JP")
×
188
    options.add_argument("--window-size=1920,1080")
×
189

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

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

195
    options.add_argument("--enable-logging")
×
196
    options.add_argument("--v=1")
×
197

198
    chrome_log_file = log_path / f"chrome_{profile_name}.log"
×
199
    options.add_argument(f"--log-file={chrome_log_file!s}")
×
200

201
    if not is_headless:
×
202
        options.add_argument("--auto-open-devtools-for-tabs")
×
203

204
    return options
×
205

206

207
def _create_driver_impl(
1✔
208
    profile_name: str,
209
    data_path: pathlib.Path,
210
    is_headless: bool,
211
    use_subprocess: bool,
212
    use_undetected: bool,
213
    stealth_mode: bool,
214
) -> WebDriver:
215
    """WebDriver を作成する内部実装
216

217
    Args:
218
        profile_name: プロファイル名
219
        data_path: データディレクトリのパス
220
        is_headless: ヘッドレスモードで起動するか
221
        use_subprocess: サブプロセスで Chrome を起動するか(undetected_chromedriver のみ)
222
        use_undetected: undetected_chromedriver を使用するか
223
        stealth_mode: ボット検出回避のための User-Agent 偽装を行うか
224

225
    Returns:
226
        WebDriver インスタンス
227

228
    """
229
    chrome_data_path = data_path / "chrome"
×
230
    log_path = data_path / "log"
×
231

232
    # NOTE: Pytest を並列実行できるようにする
233
    suffix = os.environ.get("PYTEST_XDIST_WORKER", None)
×
234
    if suffix is not None:
×
235
        profile_name += "." + suffix
×
236

237
    chrome_data_path.mkdir(parents=True, exist_ok=True)
×
238
    log_path.mkdir(parents=True, exist_ok=True)
×
239

240
    rotate_selenium_logs(log_path)
×
241

242
    options = _create_chrome_options(profile_name, chrome_data_path, log_path, is_headless)
×
243

244
    service = selenium.webdriver.chrome.service.Service(
×
245
        service_args=["--verbose", f"--log-path={str(log_path / 'webdriver.log')!s}"],
246
    )
247

248
    if use_undetected:
×
249
        import undetected_chromedriver
×
250

251
        chrome_version = _get_chrome_version()
×
252

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

258
        driver = undetected_chromedriver.Chrome(
×
259
            service=service,
260
            options=options,
261
            use_subprocess=use_subprocess,
262
            version_main=chrome_version,
263
            user_multi_procs=use_multi_procs,
264
        )
265
    else:
266
        driver = selenium.webdriver.Chrome(
×
267
            service=service,
268
            options=options,
269
        )
270

271
    driver.set_page_load_timeout(30)
×
272

273
    # CDP を使って日本語ロケールを強制設定
274
    set_japanese_locale(driver)
×
275

276
    # ボット検出回避のための設定を適用(オプション)
277
    if stealth_mode:
×
278
        set_stealth_mode(driver)
×
279

280
    return driver
×
281

282

283
def create_driver(
1✔
284
    profile_name: str,
285
    data_path: pathlib.Path,
286
    is_headless: bool = True,
287
    clean_profile: bool = False,
288
    auto_recover: bool = True,
289
    use_undetected: bool = True,
290
    use_subprocess: bool = False,
291
    stealth_mode: bool = True,
292
) -> WebDriver:
293
    """Chrome WebDriver を作成する
294

295
    Args:
296
        profile_name: プロファイル名
297
        data_path: データディレクトリのパス
298
        is_headless: ヘッドレスモードで起動するか
299
        clean_profile: 起動前にロックファイルを削除するか
300
        auto_recover: プロファイル破損時に自動リカバリするか
301
        use_undetected: undetected_chromedriver を使用するか(デフォルト: True)
302
        use_subprocess: サブプロセスで Chrome を起動するか(undetected_chromedriver のみ)
303
        stealth_mode: ボット検出回避のための User-Agent 偽装を行うか(デフォルト: True)
304

305
    Raises:
306
        ValueError: use_undetected=False かつ use_subprocess=True の場合
307

308
    """
309
    if not use_undetected and use_subprocess:
×
310
        raise ValueError("use_subprocess=True は use_undetected=True の場合のみ有効です")
×
311

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

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

321
    # 肥大化した Preferences は起動前に削除する。肥大化したまま起動すると
322
    # ドライバー接続がタイムアウトし、プロファイル全体の退避(=セッション消失)に
323
    # つながるため。健全性チェックより先に実行することで、巨大 JSON のパースも回避する。
NEW
324
    if auto_recover:
×
NEW
325
        my_lib.chrome_util._cleanup_bloated_preferences(profile_path)
×
326

327
    # 過去の連続起動失敗が閾値以上に達していれば、健全性チェックを通過していても強制退避
328
    # (JSON/DB レベルの破損は検出できるが、Chrome バージョン非互換等のステルス破損は検出できないため)
329
    if auto_recover:
×
330
        failure_count = my_lib.chrome_util._read_startup_failure_count(profile_path)
×
331
        if failure_count >= my_lib.chrome_util.STARTUP_FAILURE_THRESHOLD:
×
332
            logging.warning(
×
333
                "Profile had %d consecutive startup failures (threshold: %d), forcing recovery",
334
                failure_count,
335
                my_lib.chrome_util.STARTUP_FAILURE_THRESHOLD,
336
            )
337
            my_lib.chrome_util._recover_corrupted_profile(profile_path)
×
338
            my_lib.chrome_util._clear_startup_failures(profile_path)
×
339

340
    # プロファイル健全性チェック
341
    health = my_lib.chrome_util._check_profile_health(profile_path)
×
342
    if not health.is_healthy:
×
343
        logging.warning("Profile health check failed: %s", ", ".join(health.errors))
×
344

345
        if health.has_lock_files and not (health.has_corrupted_json or health.has_corrupted_db):
×
346
            # ロックファイルのみの問題なら削除して続行
347
            logging.info("Cleaning up lock files only")
×
348
            my_lib.chrome_util._cleanup_profile_lock(profile_path)
×
349
        elif auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
350
            # JSON または DB が破損している場合はプロファイルをリカバリ
351
            logging.warning("Profile is corrupted, attempting recovery")
×
352
            if my_lib.chrome_util._recover_corrupted_profile(profile_path):
×
353
                logging.info("Profile recovery successful, will create new profile")
×
354
            else:
355
                logging.error("Profile recovery failed")
×
356

357
    if clean_profile:
×
358
        my_lib.chrome_util._cleanup_profile_lock(profile_path)
×
359

360
    # NOTE: 1回だけ自動リトライ
361
    try:
×
362
        driver = _create_driver_impl(
×
363
            profile_name, data_path, is_headless, use_subprocess, use_undetected, stealth_mode
364
        )
365
        my_lib.chrome_util._clear_startup_failures(profile_path)
×
366
        return driver
×
367
    except Exception as e:
×
368
        logging.warning("First attempt to create driver failed: %s", e)
×
369

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

373
        # プロファイルのロックファイルを削除
374
        my_lib.chrome_util._cleanup_profile_lock(profile_path)
×
375

376
        # 再度健全性チェック
377
        health = my_lib.chrome_util._check_profile_health(profile_path)
×
378
        if not health.is_healthy and auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
379
            logging.warning("Profile still corrupted after first attempt, recovering")
×
380
            my_lib.chrome_util._recover_corrupted_profile(profile_path)
×
381

382
        try:
×
383
            driver = _create_driver_impl(
×
384
                profile_name, data_path, is_headless, use_subprocess, use_undetected, stealth_mode
385
            )
386
            my_lib.chrome_util._clear_startup_failures(profile_path)
×
387
            return driver
×
388
        except Exception:
×
389
            new_count = my_lib.chrome_util._record_startup_failure(profile_path)
×
390
            logging.warning(
×
391
                "Driver creation failed (consecutive failures: %d, threshold: %d)",
392
                new_count,
393
                my_lib.chrome_util.STARTUP_FAILURE_THRESHOLD,
394
            )
395
            raise
×
396

397

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

401

402
def get_text(
1✔
403
    driver: WebDriver,
404
    xpath: str,
405
    safe_text: str,
406
    wait: WebDriverWait[WebDriver] | None = None,
407
) -> str:
408
    if wait is not None:
×
409
        wait.until(
×
410
            selenium.webdriver.support.expected_conditions.presence_of_all_elements_located(
411
                (selenium.webdriver.common.by.By.XPATH, xpath)
412
            )
413
        )
414

415
    if len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0:
×
416
        return driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).text.strip()
×
417
    else:
418
        return safe_text
×
419

420

421
def input_xpath(
1✔
422
    driver: WebDriver,
423
    xpath: str,
424
    text: str,
425
    wait: WebDriverWait[WebDriver] | None = None,
426
    is_warn: bool = True,
427
) -> bool:
428
    if wait is not None:
×
429
        wait.until(
×
430
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
431
                (selenium.webdriver.common.by.By.XPATH, xpath)
432
            )
433
        )
434
        time.sleep(0.05)
×
435

436
    if xpath_exists(driver, xpath):
×
437
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).send_keys(text)
×
438
        return True
×
439
    else:
440
        if is_warn:
×
441
            logging.warning("Element is not found: %s", xpath)
×
442
        return False
×
443

444

445
def click_xpath(
1✔
446
    driver: WebDriver,
447
    xpath: str,
448
    wait: WebDriverWait[WebDriver] | None = None,
449
    is_warn: bool = True,
450
    move: bool = False,
451
) -> bool:
452
    if wait is not None:
×
453
        wait.until(
×
454
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
455
                (selenium.webdriver.common.by.By.XPATH, xpath)
456
            )
457
        )
458
        time.sleep(0.05)
×
459

460
    if xpath_exists(driver, xpath):
×
461
        elem = driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath)
×
462
        if move:
×
463
            action = selenium.webdriver.common.action_chains.ActionChains(driver)
×
464
            action.move_to_element(elem)
×
465
            action.perform()
×
466

467
        elem.click()
×
468
        return True
×
469
    else:
470
        if is_warn:
×
471
            logging.warning("Element is not found: %s", xpath)
×
472
        return False
×
473

474

475
def is_display(driver: WebDriver, xpath: str) -> bool:
1✔
476
    return (len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0) and (
×
477
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).is_displayed()
478
    )
479

480

481
def random_sleep(sec: float) -> None:
1✔
482
    RATIO = 0.8
1✔
483

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

486

487
def with_retry(
1✔
488
    func: Callable[[], T],
489
    max_retries: int = 3,
490
    delay: float = 1.0,
491
    exceptions: tuple[type[Exception], ...] = (Exception,),
492
    on_retry: Callable[[int, Exception], bool | None] | None = None,
493
) -> T:
494
    """リトライ付きで関数を実行
495

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

499
    Args:
500
        func: 実行する関数
501
        max_retries: 最大リトライ回数
502
        delay: リトライ間の待機秒数
503
        exceptions: リトライ対象の例外タプル
504
        on_retry: リトライ時のコールバック (attempt, exception)
505
            - None または True を返すとリトライを継続
506
            - False を返すとリトライを中止して例外を再スロー
507

508
    Returns:
509
        成功時は関数の戻り値
510

511
    Raises:
512
        最後の例外を再スロー
513

514
    """
515
    last_exception: Exception | None = None
1✔
516

517
    for attempt in range(max_retries):
1✔
518
        try:
1✔
519
            return func()
1✔
520
        except exceptions as e:
1✔
521
            last_exception = e
1✔
522
            if attempt < max_retries - 1:
1✔
523
                if on_retry:
1✔
524
                    should_continue = on_retry(attempt + 1, e)
1✔
525
                    if should_continue is False:
1✔
526
                        raise
1✔
527
                time.sleep(delay)
1✔
528

529
    if last_exception:
1✔
530
        raise last_exception
1✔
531
    raise RuntimeError("Unexpected state in with_retry")
×
532

533

534
def with_session_retry(
1✔
535
    func: Callable[[], T],
536
    driver_name: str,
537
    data_dir: pathlib.Path,
538
    *,
539
    max_retries: int = 1,
540
    clear_profile_on_error: bool = True,
541
    on_retry: Callable[[int, int], None] | None = None,
542
    before_retry: Callable[[], None] | None = None,
543
) -> T:
544
    """InvalidSessionIdException 発生時にプロファイル削除してリトライ
545

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

549
    Args:
550
        func: 実行する関数
551
        driver_name: Chrome プロファイル名
552
        data_dir: Selenium データディレクトリ
553
        max_retries: 最大リトライ回数 (デフォルト: 1)
554
        clear_profile_on_error: エラー時にプロファイルを削除するか
555
        on_retry: リトライ時のコールバック (attempt, max_retries) - ステータス更新用
556
        before_retry: リトライ前のコールバック - quit_selenium() 呼び出し用
557

558
    Returns:
559
        成功時は関数の戻り値
560

561
    Raises:
562
        InvalidSessionIdException: リトライ上限に達した場合
563

564
    """
565

566
    def retry_handler(attempt: int, exception: Exception) -> bool | None:
1✔
567
        if before_retry:
1✔
568
            before_retry()
1✔
569

570
        if attempt <= max_retries and clear_profile_on_error:
1✔
571
            logging.warning(
1✔
572
                "セッションエラーが発生しました。プロファイルを削除してリトライします(%d/%d)",
573
                attempt,
574
                max_retries,
575
            )
576
            if on_retry:
1✔
577
                on_retry(attempt, max_retries)
1✔
578
            my_lib.chrome_util.delete_profile(driver_name, data_dir)
1✔
579
            return True
1✔
580
        return False
1✔
581

582
    return with_retry(
1✔
583
        func,
584
        max_retries=max_retries + 1,
585
        delay=0,
586
        exceptions=(selenium.common.exceptions.InvalidSessionIdException,),
587
        on_retry=retry_handler,
588
    )
589

590

591
def wait_patiently(
1✔
592
    driver: WebDriver,
593
    wait: WebDriverWait[WebDriver],
594
    target: Any,
595
) -> None:
596
    error: selenium.common.exceptions.TimeoutException | None = None
×
597
    for i in range(WAIT_RETRY_COUNT + 1):
×
598
        try:
×
599
            wait.until(target)
×
600
            return
×
601
        except selenium.common.exceptions.TimeoutException as e:
×
602
            logging.warning(
×
603
                "タイムアウトが発生しました。(%s in %s line %d)",
604
                inspect.stack()[1].function,
605
                inspect.stack()[1].filename,
606
                inspect.stack()[1].lineno,
607
            )
608
            error = e
×
609

610
            logging.info(i)
×
611
            if i != WAIT_RETRY_COUNT:
×
612
                logging.info("refresh")
×
613
                driver.refresh()
×
614

615
    if error is not None:
×
616
        raise error
×
617

618

619
def dump_page(
1✔
620
    driver: WebDriver,
621
    index: int,
622
    dump_path: pathlib.Path,
623
    stack_index: int = 1,
624
) -> None:
625
    name = inspect.stack()[stack_index].function.replace("<", "").replace(">", "")
×
626

627
    dump_path.mkdir(parents=True, exist_ok=True)
×
628

629
    png_path = dump_path / f"{name}_{index:02d}.png"
×
630
    htm_path = dump_path / f"{name}_{index:02d}.htm"
×
631

632
    driver.save_screenshot(str(png_path))
×
633

634
    with htm_path.open("w", encoding="utf-8") as f:
×
635
        f.write(driver.page_source)
×
636

637
    logging.info(
×
638
        "page dump: %02d from %s in %s line %d",
639
        index,
640
        inspect.stack()[stack_index].function,
641
        inspect.stack()[stack_index].filename,
642
        inspect.stack()[stack_index].lineno,
643
    )
644

645

646
def clear_cache(driver: WebDriver) -> None:
1✔
647
    driver.execute_cdp_cmd("Network.clearBrowserCache", {})
×
648

649

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

653
    Chrome の起動オプションだけでは言語設定が変わってしまうことがあるため、
654
    CDP を使って Accept-Language ヘッダーを強制的に日本語に設定する。
655

656
    NOTE: Emulation.setLocaleOverride は使用しない。Chrome 142 + 日本語ロケールの
657
    組み合わせで、内蔵拡張機能(Web Store 等)の manifest.name が Preferences 保存時に
658
    毎回 UTF-8 多重エンコードされ、Preferences ファイルが際限なく肥大化するバグがあるため。
659
    --lang=ja-JP オプションと Accept-Language ヘッダーで十分に日本語環境を維持できる。
660
    """
661
    try:
×
662
        # NOTE: Network.setExtraHTTPHeaders は Network.enable を先に呼ばないと機能しない
663
        driver.execute_cdp_cmd("Network.enable", {})
×
664
        driver.execute_cdp_cmd(
×
665
            "Network.setExtraHTTPHeaders",
666
            {"headers": {"Accept-Language": "ja-JP,ja;q=0.9"}},
667
        )
668
        logging.debug("Japanese locale set via CDP")
×
669
    except Exception:
×
670
        logging.warning("Failed to set Japanese locale via CDP")
×
671

672

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

676
    - OS 部分を Windows に変更
677
    - HeadlessChrome を Chrome に変更
678

679
    Args:
680
        driver: WebDriver インスタンス
681

682
    Returns:
683
        修正された User-Agent 文字列
684

685
    """
686
    original_ua = driver.execute_script("return navigator.userAgent")
×
687
    logging.debug("Original User-Agent: %s", original_ua)
×
688

689
    # OS 部分を Windows に変更
690
    pattern = r"\([^)]*(?:Linux|Macintosh|X11)[^)]*\)"
×
691
    replacement = "(Windows NT 10.0; Win64; x64)"
×
692
    modified_ua = re.sub(pattern, replacement, original_ua)
×
693

694
    # HeadlessChrome を Chrome に変更
695
    modified_ua = modified_ua.replace("HeadlessChrome", "Chrome")
×
696

697
    logging.debug("Modified User-Agent: %s", modified_ua)
×
698
    return modified_ua
×
699

700

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

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

707
    Args:
708
        driver: WebDriver インスタンス
709

710
    """
711
    try:
×
712
        modified_ua = _get_stealth_user_agent(driver)
×
713
        driver.execute_cdp_cmd(
×
714
            "Network.setUserAgentOverride",
715
            {
716
                "userAgent": modified_ua,
717
                "acceptLanguage": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
718
                "platform": "Win32",
719
            },
720
        )
721
        logging.debug("Stealth mode enabled (User-Agent override applied)")
×
722
    except Exception:
×
723
        logging.warning("Failed to enable stealth mode")
×
724

725

726
def clean_dump(dump_path: pathlib.Path, keep_days: int = 1) -> None:
1✔
727
    if not dump_path.exists():
1✔
728
        return
1✔
729

730
    time_threshold = datetime.timedelta(keep_days)
1✔
731

732
    for item in dump_path.iterdir():
1✔
733
        if not item.is_file():
1✔
734
            continue
1✔
735
        try:
1✔
736
            time_diff = datetime.datetime.now(datetime.UTC) - datetime.datetime.fromtimestamp(
1✔
737
                item.stat().st_mtime, datetime.UTC
738
            )
739
        except FileNotFoundError:
×
740
            # ファイルが別プロセスにより削除された場合(SQLiteの一時ファイルなど)
741
            continue
×
742
        if time_diff > time_threshold:
1✔
743
            logging.warning("remove %s [%s day(s) old].", item.absolute(), f"{time_diff.days:,}")
1✔
744

745
            item.unlink(missing_ok=True)
1✔
746

747

748
def _warmup(
1✔
749
    driver: WebDriver,
750
    keyword: str,
751
    url_pattern: str,
752
    sleep_sec: int = 3,
753
) -> None:
754
    # NOTE: ダミーアクセスを行って BOT ではないと思わせる。(効果なさそう...)
755
    driver.get("https://www.yahoo.co.jp/")
×
756
    time.sleep(sleep_sec)
×
757

758
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(keyword)
×
759
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(
×
760
        selenium.webdriver.common.keys.Keys.ENTER
761
    )
762

763
    time.sleep(sleep_sec)
×
764

765
    driver.find_element(
×
766
        selenium.webdriver.common.by.By.XPATH, f'//a[contains(@href, "{url_pattern}")]'
767
    ).click()
768

769
    time.sleep(sleep_sec)
×
770

771

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

775
    def __init__(self, driver: WebDriver, url: str) -> None:
1✔
776
        """初期化
777

778
        Args:
779
            driver: WebDriver インスタンス
780
            url: 開く URL
781

782
        """
783
        self.driver = driver
1✔
784
        self.url = url
1✔
785
        self.original_window: str | None = None
1✔
786

787
    def __enter__(self) -> None:
1✔
788
        """新しいタブを開いて URL にアクセス"""
789
        self.original_window = self.driver.current_window_handle
1✔
790
        self.driver.execute_script("window.open('');")
1✔
791
        self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
792
        try:
1✔
793
            self.driver.get(self.url)
1✔
794
        except Exception:
×
795
            # NOTE: URL読み込みに失敗した場合もクリーンアップしてから例外を再送出
796
            self._cleanup()
×
797
            raise
×
798

799
    def _cleanup(self) -> None:
1✔
800
        """タブを閉じて元のウィンドウに戻る"""
801
        try:
1✔
802
            # 余分なタブを閉じる
803
            while len(self.driver.window_handles) > 1:
1✔
804
                self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
805
                self.driver.close()
1✔
806
            if self.original_window is not None:
1✔
807
                self.driver.switch_to.window(self.original_window)
1✔
808
            time.sleep(0.1)
1✔
809
        except Exception:
1✔
810
            # NOTE: Chromeがクラッシュした場合は無視(既に終了しているため操作不可)
811
            logging.exception("タブのクリーンアップに失敗しました(Chromeがクラッシュした可能性があります)")
1✔
812

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

819
            # about:blank に移動してレンダラーの状態をリセット
820
            self.driver.get("about:blank")
×
821
            time.sleep(0.5)
×
822
        except Exception:
×
823
            logging.warning("ブラウザの回復に失敗しました")
×
824

825
    def __exit__(
1✔
826
        self,
827
        exception_type: type[BaseException] | None,
828
        exception_value: BaseException | None,
829
        traceback: types.TracebackType | None,
830
    ) -> None:
831
        """タブを閉じて元のウィンドウに戻る"""
832
        self._cleanup()
1✔
833

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

838

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

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

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

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

857
    Examples:
858
        基本的な使用方法::
859

860
            with my_lib.selenium_util.error_handler(driver, message="ログイン処理に失敗") as handler:
861
                driver.get(login_url)
862
                driver.find_element(...).click()
863

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

866
            def notify(exc, screenshot, page_source):
867
                slack.notify_error_with_page(config, "エラー発生", exc, screenshot, page_source)
868

869
            with my_lib.selenium_util.error_handler(
870
                driver,
871
                message="クロール処理に失敗",
872
                on_error=notify,
873
            ):
874
                crawl_page(driver)
875

876
        例外を抑制して続行::
877

878
            with my_lib.selenium_util.error_handler(driver, reraise=False) as handler:
879
                risky_operation()
880

881
            if handler.exception:
882
                logging.warning("処理をスキップしました")
883

884
    """
885

886
    def __init__(
1✔
887
        self,
888
        driver: WebDriver,
889
        message: str = "Selenium operation failed",
890
        on_error: Callable[[Exception, PIL.Image.Image | None, str | None], None] | None = None,
891
        capture_screenshot: bool = True,
892
        reraise: bool = True,
893
    ) -> None:
894
        """初期化"""
895
        self.driver = driver
1✔
896
        self.message = message
1✔
897
        self.on_error = on_error
1✔
898
        self.capture_screenshot = capture_screenshot
1✔
899
        self.reraise = reraise
1✔
900
        self.exception: Exception | None = None
1✔
901
        self.screenshot: PIL.Image.Image | None = None
1✔
902
        self.page_source: str | None = None
1✔
903

904
    def __enter__(self) -> Self:
1✔
905
        """コンテキストマネージャの開始"""
906
        return self
1✔
907

908
    def __exit__(
1✔
909
        self,
910
        exception_type: type[BaseException] | None,
911
        exception_value: BaseException | None,
912
        traceback: types.TracebackType | None,
913
    ) -> bool:
914
        """コンテキストマネージャの終了、エラー処理を実行"""
915
        if exception_value is None:
1✔
916
            return False
1✔
917

918
        # 例外を記録
919
        if isinstance(exception_value, Exception):
1✔
920
            self.exception = exception_value
1✔
921
        else:
922
            # BaseException(KeyboardInterrupt など)は処理せず再送出
923
            return False
×
924

925
        # ログ出力
926
        logging.exception(self.message)
1✔
927

928
        # スクリーンショット・ページソース取得
929
        if self.capture_screenshot:
1✔
930
            try:
1✔
931
                self.page_source = self.driver.page_source
1✔
932
            except Exception:
×
933
                logging.debug("Failed to capture page source for error handling")
×
934
            try:
1✔
935
                screenshot_bytes = self.driver.get_screenshot_as_png()
1✔
936
                self.screenshot = PIL.Image.open(io.BytesIO(screenshot_bytes))
1✔
937
            except Exception:
1✔
938
                logging.debug("Failed to capture screenshot for error handling")
1✔
939

940
        # コールバック呼び出し
941
        if self.on_error is not None:
1✔
942
            try:
1✔
943
                self.on_error(self.exception, self.screenshot, self.page_source)
1✔
944
            except Exception:
×
945
                logging.exception("Error in on_error callback")
×
946

947
        # reraise=False なら例外を抑制
948
        return not self.reraise
1✔
949

950

951
def _is_chrome_related_process(process: psutil.Process) -> bool:
1✔
952
    """プロセスがChrome関連かどうかを判定"""
953
    try:
1✔
954
        process_name = process.name().lower()
1✔
955
        # Chrome関連のプロセス名パターン
956
        chrome_patterns = ["chrome", "chromium", "google-chrome", "undetected_chro"]
1✔
957
        # chromedriverは除外
958
        if "chromedriver" in process_name:
1✔
959
            return False
1✔
960
        return any(pattern in process_name for pattern in chrome_patterns)
1✔
961
    except (psutil.NoSuchProcess, psutil.AccessDenied):
1✔
962
        return False
1✔
963

964

965
def _get_chrome_processes_by_pgid(chromedriver_pid: int, existing_pids: set[int]) -> list[int]:
1✔
966
    """プロセスグループIDで追加のChrome関連プロセスを取得"""
967
    additional_pids = []
×
968
    try:
×
969
        pgid = os.getpgid(chromedriver_pid)
×
970
        for proc in psutil.process_iter(["pid", "name", "ppid"]):
×
971
            if proc.info["pid"] in existing_pids:
×
972
                continue
×
973
            try:
×
974
                if os.getpgid(proc.info["pid"]) == pgid:
×
975
                    proc_obj = psutil.Process(proc.info["pid"])
×
976
                    if _is_chrome_related_process(proc_obj):
×
977
                        additional_pids.append(proc.info["pid"])
×
978
                        logging.debug(
×
979
                            "Found Chrome-related process by pgid: PID %d, name: %s",
980
                            proc.info["pid"],
981
                            proc.info["name"],
982
                        )
983
            except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
×
984
                pass
×
985
    except (OSError, psutil.NoSuchProcess):
×
986
        logging.debug("Failed to get process group ID for chromedriver")
×
987
    return additional_pids
×
988

989

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

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

998
    # 1. driver.service.process の子プロセスを検索
999
    # NOTE: Chrome/Firefox WebDriver には service 属性があるが、型スタブでは定義されていない
1000
    try:
×
1001
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "process"):
×
1002
            process = driver.service.process
×
1003
            if process and hasattr(process, "pid"):
×
1004
                chromedriver_pid = process.pid
×
1005

1006
                # psutilでプロセス階層を取得
1007
                parent_process = psutil.Process(chromedriver_pid)
×
1008
                children = parent_process.children(recursive=True)
×
1009

1010
                for child in children:
×
1011
                    chrome_pids.add(child.pid)
×
1012
                    logging.debug(
×
1013
                        "Found Chrome-related process (service child): PID %d, name: %s",
1014
                        child.pid,
1015
                        child.name(),
1016
                    )
1017
    except Exception:
×
1018
        logging.exception("Failed to get Chrome-related processes from service")
×
1019

1020
    # 2. 現在の Python プロセスの全子孫から Chrome 関連プロセスを検索
1021
    try:
×
1022
        current_process = psutil.Process()
×
1023
        all_children = current_process.children(recursive=True)
×
1024

1025
        for child in all_children:
×
1026
            if child.pid in chrome_pids:
×
1027
                continue
×
1028
            try:
×
1029
                if _is_chrome_related_process(child):
×
1030
                    chrome_pids.add(child.pid)
×
1031
                    logging.debug(
×
1032
                        "Found Chrome-related process (python child): PID %d, name: %s",
1033
                        child.pid,
1034
                        child.name(),
1035
                    )
1036
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1037
                pass
×
1038
    except Exception:
×
1039
        logging.exception("Failed to get Chrome-related processes from python children")
×
1040

1041
    return list(chrome_pids)
×
1042

1043

1044
def _send_signal_to_processes(pids: list[int], sig: signal.Signals, signal_name: str) -> None:
1✔
1045
    """プロセスリストに指定されたシグナルを送信"""
1046
    errors = []
×
1047
    for pid in pids:
×
1048
        try:
×
1049
            # プロセス名を取得
1050
            try:
×
1051
                process = psutil.Process(pid)
×
1052
                process_name = process.name()
×
1053
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1054
                process_name = "unknown"
×
1055

1056
            if sig == signal.SIGKILL:
×
1057
                # プロセスがまだ存在するかチェック
1058
                os.kill(pid, 0)  # シグナル0は存在確認
×
1059
            os.kill(pid, sig)
×
1060
            logging.info("Sent %s to process: PID %d (%s)", signal_name, pid, process_name)
×
1061
        except (ProcessLookupError, OSError) as e:
×
1062
            # プロセスが既に終了している場合は無視
1063
            errors.append((pid, e))
×
1064

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

1069

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

1073
    Args:
1074
        chrome_pids: 終了対象のプロセスIDリスト
1075
        timeout: SIGTERM後にプロセス終了を待機する最大時間(秒)
1076

1077
    """
1078
    if not chrome_pids:
×
1079
        return
×
1080

1081
    # 優雅な終了(SIGTERM)
1082
    _send_signal_to_processes(chrome_pids, signal.SIGTERM, "SIGTERM")
×
1083

1084
    # プロセスの終了を待機(ポーリング)
1085
    remaining_pids = list(chrome_pids)
×
1086
    poll_interval = 0.2
×
1087
    elapsed = 0.0
×
1088

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

1093
        # まだ生存しているプロセスをチェック
1094
        still_alive = []
×
1095
        for pid in remaining_pids:
×
1096
            try:
×
1097
                if psutil.pid_exists(pid):
×
1098
                    process = psutil.Process(pid)
×
1099
                    if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
1100
                        still_alive.append(pid)
×
1101
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1102
                pass
×
1103

1104
        remaining_pids = still_alive
×
1105

1106
    # タイムアウト後もまだ残っているプロセスにのみ SIGKILL を送信
1107
    if remaining_pids:
×
1108
        logging.warning(
×
1109
            "Chrome processes still alive after %.1fs, sending SIGKILL to %d processes",
1110
            elapsed,
1111
            len(remaining_pids),
1112
        )
1113
        _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1114

1115

1116
def _reap_single_process(pid: int) -> None:
1✔
1117
    """単一プロセスをwaitpidで回収"""
1118
    try:
×
1119
        # ノンブロッキングでwaitpid
1120
        result_pid, status = os.waitpid(pid, os.WNOHANG)
×
1121
        if result_pid == pid:
×
1122
            # プロセス名を取得
1123
            try:
×
1124
                process = psutil.Process(pid)
×
1125
                process_name = process.name()
×
1126
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1127
                process_name = "unknown"
×
1128
            logging.debug("Reaped Chrome process: PID %d (%s)", pid, process_name)
×
1129
    except (ChildProcessError, OSError):
×
1130
        # 子プロセスでない場合や既に回収済みの場合は無視
1131
        pass
×
1132

1133

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

1139

1140
def _get_remaining_chrome_pids(chrome_pids: list[int]) -> list[int]:
1✔
1141
    """指定されたPIDリストから、まだ生存しているChrome関連プロセスを取得"""
1142
    remaining = []
×
1143
    for pid in chrome_pids:
×
1144
        try:
×
1145
            if psutil.pid_exists(pid):
×
1146
                process = psutil.Process(pid)
×
1147
                if (
×
1148
                    process.is_running()
1149
                    and process.status() != psutil.STATUS_ZOMBIE
1150
                    and _is_chrome_related_process(process)
1151
                ):
1152
                    remaining.append(pid)
×
1153
        except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1154
            pass
×
1155
    return remaining
×
1156

1157

1158
def _wait_for_processes_with_check(
1✔
1159
    chrome_pids: list[int],
1160
    timeout: float,
1161
    poll_interval: float = 0.2,
1162
    log_interval: float = 1.0,
1163
) -> list[int]:
1164
    """プロセスの終了を待機しつつ、残存プロセスをチェック
1165

1166
    Args:
1167
        chrome_pids: 監視対象のプロセスIDリスト
1168
        timeout: 最大待機時間(秒)
1169
        poll_interval: チェック間隔(秒)
1170
        log_interval: ログ出力間隔(秒)
1171

1172
    Returns:
1173
        タイムアウト後も残存しているプロセスIDのリスト
1174

1175
    """
1176
    elapsed = 0.0
×
1177
    last_log_time = 0.0
×
1178
    remaining_pids = list(chrome_pids)
×
1179

1180
    while remaining_pids and elapsed < timeout:
×
1181
        time.sleep(poll_interval)
×
1182
        elapsed += poll_interval
×
1183
        remaining_pids = _get_remaining_chrome_pids(remaining_pids)
×
1184

1185
        if remaining_pids and (elapsed - last_log_time) >= log_interval:
×
1186
            logging.info(
×
1187
                "Found %d remaining Chrome processes after %.0fs",
1188
                len(remaining_pids),
1189
                elapsed,
1190
            )
1191
            last_log_time = elapsed
×
1192

1193
    return remaining_pids
×
1194

1195

1196
def quit_driver_gracefully(
1✔
1197
    driver: WebDriver | None,
1198
    wait_sec: float = 5.0,
1199
    sigterm_wait_sec: float = 5.0,
1200
    sigkill_wait_sec: float = 5.0,
1201
) -> None:
1202
    """Chrome WebDriverを確実に終了する
1203

1204
    終了フロー:
1205
    1. driver.quit() を呼び出し
1206
    2. wait_sec 秒待機しつつプロセス終了をチェック
1207
    3. 残存プロセスがあれば SIGTERM を送信
1208
    4. sigterm_wait_sec 秒待機しつつプロセス終了をチェック
1209
    5. 残存プロセスがあれば SIGKILL を送信
1210
    6. sigkill_wait_sec 秒待機
1211

1212
    Args:
1213
        driver: 終了する WebDriver インスタンス
1214
        wait_sec: quit 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1215
        sigterm_wait_sec: SIGTERM 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1216
        sigkill_wait_sec: SIGKILL 後にプロセス回収を待機する秒数(デフォルト: 5秒)
1217

1218
    """
1219
    if driver is None:
1✔
1220
        return
1✔
1221

1222
    # quit前にChrome関連プロセスを記録
1223
    chrome_pids_before = _get_chrome_related_processes(driver)
1✔
1224

1225
    try:
1✔
1226
        # WebDriverの正常終了を試行(これがタブのクローズも含む)
1227
        driver.quit()
1✔
1228
        logging.info("WebDriver quit successfully")
1✔
1229
    except Exception:
1✔
1230
        logging.warning("Failed to quit driver normally", exc_info=True)
1✔
1231
    finally:
1232
        # undetected_chromedriver の __del__ がシャットダウン時に再度呼ばれるのを防ぐ
1233
        if hasattr(driver, "_has_quit"):
1✔
1234
            driver._has_quit = True  # type: ignore[attr-defined]
1✔
1235

1236
    # ChromeDriverサービスの停止を試行
1237
    try:
1✔
1238
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "stop"):
1✔
1239
            driver.service.stop()  # type: ignore[call-non-callable]
×
1240
    except (ConnectionResetError, OSError):
×
1241
        # Chrome が既に終了している場合は無視
1242
        logging.debug("Chrome service already stopped")
×
1243
    except Exception:
×
1244
        logging.warning("Failed to stop Chrome service", exc_info=True)
×
1245

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

1249
    if not remaining_pids:
1✔
1250
        logging.debug("All Chrome processes exited normally")
1✔
1251
        return
1✔
1252

1253
    # Step 2: 残存プロセスに SIGTERM を送信
1254
    logging.info(
×
1255
        "Found %d remaining Chrome processes after %.0fs, sending SIGTERM",
1256
        len(remaining_pids),
1257
        wait_sec,
1258
    )
1259
    _send_signal_to_processes(remaining_pids, signal.SIGTERM, "SIGTERM")
×
1260

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

1264
    if not remaining_pids:
×
1265
        logging.info("All Chrome processes exited after SIGTERM")
×
1266
        _reap_chrome_processes(chrome_pids_before)
×
1267
        return
×
1268

1269
    # Step 4: 残存プロセスに SIGKILL を送信
1270
    logging.warning(
×
1271
        "Chrome processes still alive after SIGTERM + %.1fs, sending SIGKILL to %d processes",
1272
        sigterm_wait_sec,
1273
        len(remaining_pids),
1274
    )
1275
    _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1276

1277
    # Step 5: SIGKILL 後に sigkill_wait_sec 秒待機してプロセス回収
1278
    time.sleep(sigkill_wait_sec)
×
1279
    _reap_chrome_processes(chrome_pids_before)
×
1280

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

1284
    # 回収できなかったプロセスについて警告
1285
    if still_remaining:
×
1286
        for pid in still_remaining:
×
1287
            try:
×
1288
                process = psutil.Process(pid)
×
1289
                logging.warning("Failed to collect Chrome-related process: PID %d (%s)", pid, process.name())
×
1290
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1291
                pass
×
1292

1293

1294
if __name__ == "__main__":
1295
    import pathlib
1296

1297
    import docopt
1298
    import selenium.webdriver.support.wait
1299

1300
    import my_lib.config
1301
    import my_lib.logger
1302

1303
    assert __doc__ is not None  # noqa: S101
1304
    args = docopt.docopt(__doc__)
1305

1306
    config_file = args["-c"]
1307
    debug_mode = args["-D"]
1308

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

1311
    config = my_lib.config.load(config_file)
1312

1313
    driver = create_driver("test", pathlib.Path(config["data"]["selenium"]))
1314
    wait = selenium.webdriver.support.wait.WebDriverWait(driver, 5)
1315

1316
    driver.get("https://www.google.com/")
1317
    wait.until(
1318
        selenium.webdriver.support.expected_conditions.presence_of_element_located(
1319
            (selenium.webdriver.common.by.By.XPATH, '//input[contains(@value, "Google")]')
1320
        )
1321
    )
1322

1323
    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