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

kimata / my-py-lib / 20676538754

03 Jan 2026 11:17AM UTC coverage: 64.958% (-0.09%) from 65.051%
20676538754

push

github

kimata
fix: _is_running_in_container テストのモック対象を修正

- pathlib.Path.exists() をモックするように変更
  (os.path.exists では pathlib.Path のメソッドに効かない)
- ruff エラーを修正:
  - shebang 削除、未使用の json import 削除
  - noqa に SLF001, PT012 を追加
  - ネストした with 文を括弧付き構文に統一
  - pytest.raises に match パラメータを追加
  - docstring の先頭を大文字に修正

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

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

3168 of 4877 relevant lines covered (64.96%)

0.65 hits per line

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

40.47
/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 json
1✔
20
import logging
1✔
21
import os
1✔
22
import pathlib
1✔
23
import random
1✔
24
import re
1✔
25
import shutil
1✔
26
import signal
1✔
27
import sqlite3
1✔
28
import subprocess
1✔
29
import time
1✔
30
from dataclasses import dataclass
1✔
31
from typing import TYPE_CHECKING, Any, Self, TypeVar
1✔
32

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

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

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

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

54
WAIT_RETRY_COUNT: int = 1
1✔
55

56

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

60

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

77

78
def _create_driver_impl(
1✔
79
    profile_name: str,
80
    data_path: pathlib.Path,
81
    is_headless: bool,  # noqa: FBT001
82
    use_subprocess: bool = True,  # noqa: FBT001
83
) -> WebDriver:
84
    chrome_data_path = data_path / "chrome"
×
85
    log_path = data_path / "log"
×
86

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

92
    chrome_data_path.mkdir(parents=True, exist_ok=True)
×
93
    log_path.mkdir(parents=True, exist_ok=True)
×
94

95
    options = selenium.webdriver.chrome.options.Options()
×
96

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

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

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

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

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

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

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

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

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

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

126
    service = selenium.webdriver.chrome.service.Service(
×
127
        service_args=["--verbose", f"--log-path={str(log_path / 'webdriver.log')!s}"],
128
    )
129

130
    chrome_version = _get_chrome_version()
×
131

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

137
    driver = undetected_chromedriver.Chrome(
×
138
        service=service,
139
        options=options,
140
        use_subprocess=use_subprocess,
141
        version_main=chrome_version,
142
        user_multi_procs=use_multi_procs,
143
    )
144

145
    driver.set_page_load_timeout(30)
×
146

147
    # CDP を使って日本語ロケールを強制設定
148
    set_japanese_locale(driver)
×
149

150
    return driver
×
151

152

153
@dataclass
1✔
154
class _ProfileHealthResult:
1✔
155
    """プロファイル健全性チェックの結果"""
156

157
    is_healthy: bool
1✔
158
    errors: list[str]
1✔
159
    has_lock_files: bool = False
1✔
160
    has_corrupted_json: bool = False
1✔
161
    has_corrupted_db: bool = False
1✔
162

163

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

167
    Returns:
168
        エラーメッセージ(正常な場合は None)
169

170
    """
171
    if not file_path.exists():
1✔
172
        return None
1✔
173

174
    try:
1✔
175
        content = file_path.read_text(encoding="utf-8")
1✔
176
        json.loads(content)
1✔
177
        return None
1✔
178
    except json.JSONDecodeError as e:
1✔
179
        return f"{file_path.name} is corrupted: {e}"
1✔
180
    except Exception as e:
×
181
        return f"{file_path.name} read error: {e}"
×
182

183

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

187
    Returns:
188
        エラーメッセージ(正常な場合は None)
189

190
    """
191
    if not db_path.exists():
1✔
192
        return None
1✔
193

194
    try:
1✔
195
        conn = sqlite3.connect(str(db_path), timeout=5)
1✔
196
        result = conn.execute("PRAGMA integrity_check").fetchone()
1✔
197
        conn.close()
1✔
198
        if result[0] != "ok":
1✔
199
            return f"{db_path.name} database is corrupted: {result[0]}"
×
200
        return None
1✔
201
    except sqlite3.DatabaseError as e:
1✔
202
        return f"{db_path.name} database error: {e}"
1✔
203
    except Exception as e:
×
204
        return f"{db_path.name} check error: {e}"
×
205

206

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

210
    Args:
211
        profile_path: Chrome プロファイルのディレクトリパス
212

213
    Returns:
214
        ProfileHealthResult: チェック結果
215

216
    """
217
    errors: list[str] = []
1✔
218
    has_lock_files = False
1✔
219
    has_corrupted_json = False
1✔
220
    has_corrupted_db = False
1✔
221

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

226
    default_path = profile_path / "Default"
1✔
227

228
    # 1. ロックファイルのチェック
229
    lock_files = ["SingletonLock", "SingletonSocket", "SingletonCookie"]
1✔
230
    existing_locks = []
1✔
231
    for lock_file in lock_files:
1✔
232
        lock_path = profile_path / lock_file
1✔
233
        if lock_path.exists() or lock_path.is_symlink():
1✔
234
            existing_locks.append(lock_file)
1✔
235
            has_lock_files = True
1✔
236
    if existing_locks:
1✔
237
        errors.append(f"Lock files exist: {', '.join(existing_locks)}")
1✔
238

239
    # 2. Local State の JSON チェック
240
    local_state_error = _check_json_file(profile_path / "Local State")
1✔
241
    if local_state_error:
1✔
242
        errors.append(local_state_error)
1✔
243
        has_corrupted_json = True
1✔
244

245
    # 3. Preferences の JSON チェック
246
    if default_path.exists():
1✔
247
        prefs_error = _check_json_file(default_path / "Preferences")
1✔
248
        if prefs_error:
1✔
249
            errors.append(prefs_error)
×
250
            has_corrupted_json = True
×
251

252
        # 4. SQLite データベースの整合性チェック
253
        for db_name in ["Cookies", "History", "Web Data"]:
1✔
254
            db_error = _check_sqlite_db(default_path / db_name)
1✔
255
            if db_error:
1✔
256
                errors.append(db_error)
1✔
257
                has_corrupted_db = True
1✔
258

259
    is_healthy = len(errors) == 0
1✔
260

261
    return _ProfileHealthResult(
1✔
262
        is_healthy=is_healthy,
263
        errors=errors,
264
        has_lock_files=has_lock_files,
265
        has_corrupted_json=has_corrupted_json,
266
        has_corrupted_db=has_corrupted_db,
267
    )
268

269

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

273
    Args:
274
        profile_path: Chrome プロファイルのディレクトリパス
275

276
    Returns:
277
        bool: リカバリが成功したかどうか
278

279
    """
280
    if not profile_path.exists():
1✔
281
        return True
1✔
282

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

287
    try:
1✔
288
        shutil.move(str(profile_path), str(backup_path))
1✔
289
        logging.warning(
1✔
290
            "Corrupted profile moved to backup: %s -> %s",
291
            profile_path,
292
            backup_path,
293
        )
294
        return True
1✔
295
    except Exception:
×
296
        logging.exception("Failed to backup corrupted profile")
×
297
        return False
×
298

299

300
def _cleanup_profile_lock(profile_path: pathlib.Path) -> None:
1✔
301
    """プロファイルのロックファイルを削除する"""
302
    lock_files = ["SingletonLock", "SingletonSocket", "SingletonCookie"]
1✔
303
    found_locks = []
1✔
304
    for lock_file in lock_files:
1✔
305
        lock_path = profile_path / lock_file
1✔
306
        if lock_path.exists() or lock_path.is_symlink():
1✔
307
            found_locks.append(lock_path)
1✔
308

309
    if found_locks:
1✔
310
        logging.warning("Profile lock files found: %s", ", ".join(str(p.name) for p in found_locks))
1✔
311
        for lock_path in found_locks:
1✔
312
            try:
1✔
313
                lock_path.unlink()
1✔
314
            except OSError as e:
×
315
                logging.warning("Failed to remove lock file %s: %s", lock_path, e)
×
316

317

318
def _is_running_in_container() -> bool:
1✔
319
    """コンテナ内で実行中かどうかを判定"""
320
    return pathlib.Path("/.dockerenv").exists()
1✔
321

322

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

326
    NOTE: プロセスツリーに関係なくプロセス名で一律終了するのはコンテナ内限定
327
    """
328
    if not _is_running_in_container():
×
329
        return
×
330

331
    for proc in psutil.process_iter(["pid", "name"]):
×
332
        try:
×
333
            proc_name = proc.info["name"].lower() if proc.info["name"] else ""
×
334
            if "chrome" in proc_name:
×
335
                logging.info("Terminating orphaned Chrome process: PID %d", proc.info["pid"])
×
336
                os.kill(proc.info["pid"], signal.SIGTERM)
×
337
        except (psutil.NoSuchProcess, psutil.AccessDenied, ProcessLookupError, OSError):
×
338
            pass
×
339
    time.sleep(1)
×
340

341

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

347

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

351
    Args:
352
        profile_name: プロファイル名
353
        data_path: データディレクトリのパス
354

355
    Returns:
356
        bool: 削除が成功したかどうか
357

358
    """
359
    actual_profile_name = _get_actual_profile_name(profile_name)
×
360
    profile_path = data_path / "chrome" / actual_profile_name
×
361

362
    if not profile_path.exists():
×
363
        logging.info("Profile does not exist: %s", profile_path)
×
364
        return True
×
365

366
    try:
×
367
        shutil.rmtree(profile_path)
×
368
        logging.warning("Deleted Chrome profile: %s", profile_path)
×
369
        return True
×
370
    except Exception:
×
371
        logging.exception("Failed to delete Chrome profile: %s", profile_path)
×
372
        return False
×
373

374

375
def create_driver(  # noqa: PLR0913
1✔
376
    profile_name: str,
377
    data_path: pathlib.Path,
378
    is_headless: bool = True,  # noqa: FBT001
379
    clean_profile: bool = False,  # noqa: FBT001
380
    auto_recover: bool = True,  # noqa: FBT001
381
    use_subprocess: bool = True,  # noqa: FBT001
382
) -> WebDriver:
383
    """Chrome WebDriver を作成する
384

385
    Args:
386
        profile_name: プロファイル名
387
        data_path: データディレクトリのパス
388
        is_headless: ヘッドレスモードで起動するか
389
        clean_profile: 起動前にロックファイルを削除するか
390
        auto_recover: プロファイル破損時に自動リカバリするか
391
        use_subprocess: サブプロセスで Chrome を起動するか
392

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

399
    actual_profile_name = _get_actual_profile_name(profile_name)
×
400
    profile_path = data_path / "chrome" / actual_profile_name
×
401

402
    # プロファイル健全性チェック
403
    health = _check_profile_health(profile_path)
×
404
    if not health.is_healthy:
×
405
        logging.warning("Profile health check failed: %s", ", ".join(health.errors))
×
406

407
        if health.has_lock_files and not (health.has_corrupted_json or health.has_corrupted_db):
×
408
            # ロックファイルのみの問題なら削除して続行
409
            logging.info("Cleaning up lock files only")
×
410
            _cleanup_profile_lock(profile_path)
×
411
        elif auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
412
            # JSON または DB が破損している場合はプロファイルをリカバリ
413
            logging.warning("Profile is corrupted, attempting recovery")
×
414
            if _recover_corrupted_profile(profile_path):
×
415
                logging.info("Profile recovery successful, will create new profile")
×
416
            else:
417
                logging.error("Profile recovery failed")
×
418

419
    if clean_profile:
×
420
        _cleanup_profile_lock(profile_path)
×
421

422
    # NOTE: 1回だけ自動リトライ
423
    try:
×
424
        return _create_driver_impl(profile_name, data_path, is_headless, use_subprocess)
×
425
    except Exception as e:
×
426
        logging.warning("First attempt to create driver failed: %s", e)
×
427

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

431
        # プロファイルのロックファイルを削除
432
        _cleanup_profile_lock(profile_path)
×
433

434
        # 再度健全性チェック
435
        health = _check_profile_health(profile_path)
×
436
        if not health.is_healthy and auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
437
            logging.warning("Profile still corrupted after first attempt, recovering")
×
438
            _recover_corrupted_profile(profile_path)
×
439

440
        return _create_driver_impl(profile_name, data_path, is_headless, use_subprocess)
×
441

442

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

446

447
def get_text(
1✔
448
    driver: WebDriver,
449
    xpath: str,
450
    safe_text: str,
451
    wait: WebDriverWait[WebDriver] | None = None,
452
) -> str:
453
    if wait is not None:
×
454
        wait.until(
×
455
            selenium.webdriver.support.expected_conditions.presence_of_all_elements_located(
456
                (selenium.webdriver.common.by.By.XPATH, xpath)
457
            )
458
        )
459

460
    if len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0:
×
461
        return driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).text.strip()
×
462
    else:
463
        return safe_text
×
464

465

466
def input_xpath(
1✔
467
    driver: WebDriver,
468
    xpath: str,
469
    text: str,
470
    wait: WebDriverWait[WebDriver] | None = None,
471
    is_warn: bool = True,  # noqa: FBT001
472
) -> bool:
473
    if wait is not None:
×
474
        wait.until(
×
475
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
476
                (selenium.webdriver.common.by.By.XPATH, xpath)
477
            )
478
        )
479
        time.sleep(0.05)
×
480

481
    if xpath_exists(driver, xpath):
×
482
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).send_keys(text)
×
483
        return True
×
484
    else:
485
        if is_warn:
×
486
            logging.warning("Element is not found: %s", xpath)
×
487
        return False
×
488

489

490
def click_xpath(
1✔
491
    driver: WebDriver,
492
    xpath: str,
493
    wait: WebDriverWait[WebDriver] | None = None,
494
    is_warn: bool = True,  # noqa: FBT001
495
    move: bool = False,  # noqa: FBT001
496
) -> bool:
497
    if wait is not None:
×
498
        wait.until(
×
499
            selenium.webdriver.support.expected_conditions.element_to_be_clickable(
500
                (selenium.webdriver.common.by.By.XPATH, xpath)
501
            )
502
        )
503
        time.sleep(0.05)
×
504

505
    if xpath_exists(driver, xpath):
×
506
        elem = driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath)
×
507
        if move:
×
508
            action = selenium.webdriver.common.action_chains.ActionChains(driver)
×
509
            action.move_to_element(elem)
×
510
            action.perform()
×
511

512
        elem.click()
×
513
        return True
×
514
    else:
515
        if is_warn:
×
516
            logging.warning("Element is not found: %s", xpath)
×
517
        return False
×
518

519

520
def is_display(driver: WebDriver, xpath: str) -> bool:
1✔
521
    return (len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0) and (
×
522
        driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).is_displayed()
523
    )
524

525

526
def random_sleep(sec: float) -> None:
1✔
527
    RATIO = 0.8
1✔
528

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

531

532
def with_retry(
1✔
533
    func: Callable[[], T],
534
    max_retries: int = 3,
535
    delay: float = 1.0,
536
    exceptions: tuple[type[Exception], ...] = (Exception,),
537
    on_retry: Callable[[int, Exception], bool | None] | None = None,
538
) -> T:
539
    """リトライ付きで関数を実行
540

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

544
    Args:
545
        func: 実行する関数
546
        max_retries: 最大リトライ回数
547
        delay: リトライ間の待機秒数
548
        exceptions: リトライ対象の例外タプル
549
        on_retry: リトライ時のコールバック (attempt, exception)
550
            - None または True を返すとリトライを継続
551
            - False を返すとリトライを中止して例外を再スロー
552

553
    Returns:
554
        成功時は関数の戻り値
555

556
    Raises:
557
        最後の例外を再スロー
558

559
    """
560
    last_exception: Exception | None = None
×
561

562
    for attempt in range(max_retries):
×
563
        try:
×
564
            return func()
×
565
        except exceptions as e:
×
566
            last_exception = e
×
567
            if attempt < max_retries - 1:
×
568
                if on_retry:
×
569
                    should_continue = on_retry(attempt + 1, e)
×
570
                    if should_continue is False:
×
571
                        raise
×
572
                time.sleep(delay)
×
573

574
    if last_exception:
×
575
        raise last_exception
×
576
    raise RuntimeError("Unexpected state in with_retry")
×
577

578

579
def wait_patiently(
1✔
580
    driver: WebDriver,
581
    wait: WebDriverWait[WebDriver],
582
    target: Any,
583
) -> None:
584
    error: selenium.common.exceptions.TimeoutException | None = None
×
585
    for i in range(WAIT_RETRY_COUNT + 1):
×
586
        try:
×
587
            wait.until(target)
×
588
            return
×
589
        except selenium.common.exceptions.TimeoutException as e:
×
590
            logging.warning(
×
591
                "タイムアウトが発生しました。(%s in %s line %d)",
592
                inspect.stack()[1].function,
593
                inspect.stack()[1].filename,
594
                inspect.stack()[1].lineno,
595
            )
596
            error = e
×
597

598
            logging.info(i)
×
599
            if i != WAIT_RETRY_COUNT:
×
600
                logging.info("refresh")
×
601
                driver.refresh()
×
602

603
    if error is not None:
×
604
        raise error
×
605

606

607
def dump_page(
1✔
608
    driver: WebDriver,
609
    index: int,
610
    dump_path: pathlib.Path,
611
    stack_index: int = 1,
612
) -> None:
613
    name = inspect.stack()[stack_index].function.replace("<", "").replace(">", "")
×
614

615
    dump_path.mkdir(parents=True, exist_ok=True)
×
616

617
    png_path = dump_path / f"{name}_{index:02d}.png"
×
618
    htm_path = dump_path / f"{name}_{index:02d}.htm"
×
619

620
    driver.save_screenshot(str(png_path))
×
621

622
    with htm_path.open("w", encoding="utf-8") as f:
×
623
        f.write(driver.page_source)
×
624

625
    logging.info(
×
626
        "page dump: %02d from %s in %s line %d",
627
        index,
628
        inspect.stack()[stack_index].function,
629
        inspect.stack()[stack_index].filename,
630
        inspect.stack()[stack_index].lineno,
631
    )
632

633

634
def clear_cache(driver: WebDriver) -> None:
1✔
635
    driver.execute_cdp_cmd("Network.clearBrowserCache", {})
×
636

637

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

641
    Chrome の起動オプションだけでは言語設定が変わってしまうことがあるため、
642
    CDP を使って Accept-Language ヘッダーとロケールを強制的に日本語に設定する。
643
    """
644
    try:
×
645
        # NOTE: Network.setExtraHTTPHeaders は Network.enable を先に呼ばないと機能しない
646
        driver.execute_cdp_cmd("Network.enable", {})
×
647
        driver.execute_cdp_cmd(
×
648
            "Network.setExtraHTTPHeaders",
649
            {"headers": {"Accept-Language": "ja-JP,ja;q=0.9"}},
650
        )
651
        driver.execute_cdp_cmd(
×
652
            "Emulation.setLocaleOverride",
653
            {"locale": "ja-JP"},
654
        )
655
        logging.debug("Japanese locale set via CDP")
×
656
    except Exception:
×
657
        logging.warning("Failed to set Japanese locale via CDP")
×
658

659

660
def clean_dump(dump_path: pathlib.Path, keep_days: int = 1) -> None:
1✔
661
    if not dump_path.exists():
1✔
662
        return
1✔
663

664
    time_threshold = datetime.timedelta(keep_days)
1✔
665

666
    for item in dump_path.iterdir():
1✔
667
        if not item.is_file():
1✔
668
            continue
1✔
669
        try:
1✔
670
            time_diff = datetime.datetime.now(datetime.UTC) - datetime.datetime.fromtimestamp(
1✔
671
                item.stat().st_mtime, datetime.UTC
672
            )
673
        except FileNotFoundError:
×
674
            # ファイルが別プロセスにより削除された場合(SQLiteの一時ファイルなど)
675
            continue
×
676
        if time_diff > time_threshold:
1✔
677
            logging.warning("remove %s [%s day(s) old].", item.absolute(), f"{time_diff.days:,}")
1✔
678

679
            item.unlink(missing_ok=True)
1✔
680

681

682
def get_memory_info(driver: WebDriver) -> dict[str, Any]:
1✔
683
    """ブラウザのメモリ使用量を取得(単位: KB)"""
684
    total_bytes = subprocess.Popen(  # noqa: S602
×
685
        "smem -t -c pss -P chrome | tail -n 1",  # noqa: S607
686
        shell=True,
687
        stdout=subprocess.PIPE,
688
    ).communicate()[0]
689
    total = int(str(total_bytes, "utf-8").strip())  # smem の出力は KB 単位
×
690

691
    try:
×
692
        memory_info = driver.execute_cdp_cmd("Memory.getAllTimeSamplingProfile", {})
×
693
        heap_usage = driver.execute_cdp_cmd("Runtime.getHeapUsage", {})
×
694

695
        heap_used = heap_usage.get("usedSize", 0) // 1024  # bytes → KB
×
696
        heap_total = heap_usage.get("totalSize", 0) // 1024  # bytes → KB
×
697
    except Exception as e:
×
698
        logging.debug("Failed to get memory usage: %s", e)
×
699

700
        memory_info = None
×
701
        heap_used = 0
×
702
        heap_total = 0
×
703

704
    return {
×
705
        "total": total,
706
        "heap_used": heap_used,
707
        "heap_total": heap_total,
708
        "memory_info": memory_info,
709
    }
710

711

712
def log_memory_usage(driver: WebDriver) -> None:
1✔
713
    mem_info = get_memory_info(driver)
×
714
    logging.info(
×
715
        "Chrome memory: %s MB (JS heap: %s MB)",
716
        f"""{mem_info["total"] // 1024:,}""",
717
        f"""{mem_info["heap_used"] // 1024:,}""",
718
    )
719

720

721
def _warmup(
1✔
722
    driver: WebDriver,
723
    keyword: str,
724
    url_pattern: str,
725
    sleep_sec: int = 3,
726
) -> None:
727
    # NOTE: ダミーアクセスを行って BOT ではないと思わせる。(効果なさそう...)
728
    driver.get("https://www.yahoo.co.jp/")
×
729
    time.sleep(sleep_sec)
×
730

731
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(keyword)
×
732
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(
×
733
        selenium.webdriver.common.keys.Keys.ENTER
734
    )
735

736
    time.sleep(sleep_sec)
×
737

738
    driver.find_element(
×
739
        selenium.webdriver.common.by.By.XPATH, f'//a[contains(@href, "{url_pattern}")]'
740
    ).click()
741

742
    time.sleep(sleep_sec)
×
743

744

745
class browser_tab:  # noqa: N801
1✔
746
    """新しいブラウザタブで URL を開くコンテキストマネージャ"""
747

748
    def __init__(self, driver: WebDriver, url: str) -> None:
1✔
749
        """初期化
750

751
        Args:
752
            driver: WebDriver インスタンス
753
            url: 開く URL
754

755
        """
756
        self.driver = driver
1✔
757
        self.url = url
1✔
758
        self.original_window: str | None = None
1✔
759

760
    def __enter__(self) -> None:
1✔
761
        """新しいタブを開いて URL にアクセス"""
762
        self.original_window = self.driver.current_window_handle
1✔
763
        self.driver.execute_script("window.open('');")
1✔
764
        self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
765
        try:
1✔
766
            self.driver.get(self.url)
1✔
767
        except Exception:
×
768
            # NOTE: URL読み込みに失敗した場合もクリーンアップしてから例外を再送出
769
            self._cleanup()
×
770
            raise
×
771

772
    def _cleanup(self) -> None:
1✔
773
        """タブを閉じて元のウィンドウに戻る"""
774
        try:
1✔
775
            # 余分なタブを閉じる
776
            while len(self.driver.window_handles) > 1:
1✔
777
                self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
778
                self.driver.close()
1✔
779
            if self.original_window is not None:
1✔
780
                self.driver.switch_to.window(self.original_window)
1✔
781
            time.sleep(0.1)
1✔
782
        except Exception:
1✔
783
            # NOTE: Chromeがクラッシュした場合は無視(既に終了しているため操作不可)
784
            logging.exception("タブのクリーンアップに失敗しました(Chromeがクラッシュした可能性があります)")
1✔
785

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

792
            # about:blank に移動してレンダラーの状態をリセット
793
            self.driver.get("about:blank")
×
794
            time.sleep(0.5)
×
795
        except Exception:
×
796
            logging.warning("ブラウザの回復に失敗しました")
×
797

798
    def __exit__(
1✔
799
        self,
800
        exception_type: type[BaseException] | None,
801
        exception_value: BaseException | None,
802
        traceback: types.TracebackType | None,
803
    ) -> None:
804
        """タブを閉じて元のウィンドウに戻る"""
805
        self._cleanup()
1✔
806

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

811

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

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

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

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

828
    Examples:
829
        基本的な使用方法::
830

831
            with my_lib.selenium_util.error_handler(driver, message="ログイン処理に失敗") as handler:
832
                driver.get(login_url)
833
                driver.find_element(...).click()
834

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

837
            def notify(exc, screenshot):
838
                slack.error("エラー発生", str(exc), screenshot)
839

840
            with my_lib.selenium_util.error_handler(
841
                driver,
842
                message="クロール処理に失敗",
843
                on_error=notify,
844
            ):
845
                crawl_page(driver)
846

847
        例外を抑制して続行::
848

849
            with my_lib.selenium_util.error_handler(driver, reraise=False) as handler:
850
                risky_operation()
851

852
            if handler.exception:
853
                logging.warning("処理をスキップしました")
854

855
    """
856

857
    def __init__(
1✔
858
        self,
859
        driver: WebDriver,
860
        message: str = "Selenium operation failed",
861
        on_error: Callable[[Exception, PIL.Image.Image | None], None] | None = None,
862
        capture_screenshot: bool = True,  # noqa: FBT001
863
        reraise: bool = True,  # noqa: FBT001
864
    ) -> None:
865
        """初期化"""
866
        self.driver = driver
1✔
867
        self.message = message
1✔
868
        self.on_error = on_error
1✔
869
        self.capture_screenshot = capture_screenshot
1✔
870
        self.reraise = reraise
1✔
871
        self.exception: Exception | None = None
1✔
872
        self.screenshot: PIL.Image.Image | None = None
1✔
873

874
    def __enter__(self) -> Self:
1✔
875
        """コンテキストマネージャの開始"""
876
        return self
1✔
877

878
    def __exit__(
1✔
879
        self,
880
        exception_type: type[BaseException] | None,
881
        exception_value: BaseException | None,
882
        traceback: types.TracebackType | None,
883
    ) -> bool:
884
        """コンテキストマネージャの終了、エラー処理を実行"""
885
        if exception_value is None:
1✔
886
            return False
1✔
887

888
        # 例外を記録
889
        if isinstance(exception_value, Exception):
1✔
890
            self.exception = exception_value
1✔
891
        else:
892
            # BaseException(KeyboardInterrupt など)は処理せず再送出
893
            return False
×
894

895
        # ログ出力
896
        logging.exception(self.message)
1✔
897

898
        # スクリーンショット取得
899
        if self.capture_screenshot:
1✔
900
            try:
1✔
901
                screenshot_bytes = self.driver.get_screenshot_as_png()
1✔
902
                self.screenshot = PIL.Image.open(io.BytesIO(screenshot_bytes))
1✔
903
            except Exception:
1✔
904
                logging.debug("Failed to capture screenshot for error handling")
1✔
905

906
        # コールバック呼び出し
907
        if self.on_error is not None:
1✔
908
            try:
1✔
909
                self.on_error(self.exception, self.screenshot)
1✔
910
            except Exception:
×
911
                logging.exception("Error in on_error callback")
×
912

913
        # reraise=False なら例外を抑制
914
        return not self.reraise
1✔
915

916

917
def _is_chrome_related_process(process: psutil.Process) -> bool:
1✔
918
    """プロセスがChrome関連かどうかを判定"""
919
    try:
1✔
920
        process_name = process.name().lower()
1✔
921
        # Chrome関連のプロセス名パターン
922
        chrome_patterns = ["chrome", "chromium", "google-chrome", "undetected_chro"]
1✔
923
        # chromedriverは除外
924
        if "chromedriver" in process_name:
1✔
925
            return False
1✔
926
        return any(pattern in process_name for pattern in chrome_patterns)
1✔
927
    except (psutil.NoSuchProcess, psutil.AccessDenied):
1✔
928
        return False
1✔
929

930

931
def _get_chrome_processes_by_pgid(chromedriver_pid: int, existing_pids: set[int]) -> list[int]:
1✔
932
    """プロセスグループIDで追加のChrome関連プロセスを取得"""
933
    additional_pids = []
×
934
    try:
×
935
        pgid = os.getpgid(chromedriver_pid)
×
936
        for proc in psutil.process_iter(["pid", "name", "ppid"]):
×
937
            if proc.info["pid"] in existing_pids:
×
938
                continue
×
939
            try:
×
940
                if os.getpgid(proc.info["pid"]) == pgid:
×
941
                    proc_obj = psutil.Process(proc.info["pid"])
×
942
                    if _is_chrome_related_process(proc_obj):
×
943
                        additional_pids.append(proc.info["pid"])
×
944
                        logging.debug(
×
945
                            "Found Chrome-related process by pgid: PID %d, name: %s",
946
                            proc.info["pid"],
947
                            proc.info["name"],
948
                        )
949
            except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
×
950
                pass
×
951
    except (OSError, psutil.NoSuchProcess):
×
952
        logging.debug("Failed to get process group ID for chromedriver")
×
953
    return additional_pids
×
954

955

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

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

964
    # 1. driver.service.process の子プロセスを検索
965
    try:
×
966
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "process"):  # type: ignore[attr-defined]
×
967
            process = driver.service.process  # type: ignore[attr-defined]
×
968
            if process and hasattr(process, "pid"):
×
969
                chromedriver_pid = process.pid
×
970

971
                # psutilでプロセス階層を取得
972
                parent_process = psutil.Process(chromedriver_pid)
×
973
                children = parent_process.children(recursive=True)
×
974

975
                for child in children:
×
976
                    chrome_pids.add(child.pid)
×
977
                    logging.debug(
×
978
                        "Found Chrome-related process (service child): PID %d, name: %s",
979
                        child.pid,
980
                        child.name(),
981
                    )
982
    except Exception:
×
983
        logging.exception("Failed to get Chrome-related processes from service")
×
984

985
    # 2. 現在の Python プロセスの全子孫から Chrome 関連プロセスを検索
986
    try:
×
987
        current_process = psutil.Process()
×
988
        all_children = current_process.children(recursive=True)
×
989

990
        for child in all_children:
×
991
            if child.pid in chrome_pids:
×
992
                continue
×
993
            try:
×
994
                if _is_chrome_related_process(child):
×
995
                    chrome_pids.add(child.pid)
×
996
                    logging.debug(
×
997
                        "Found Chrome-related process (python child): PID %d, name: %s",
998
                        child.pid,
999
                        child.name(),
1000
                    )
1001
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1002
                pass
×
1003
    except Exception:
×
1004
        logging.exception("Failed to get Chrome-related processes from python children")
×
1005

1006
    return list(chrome_pids)
×
1007

1008

1009
def _send_signal_to_processes(pids: list[int], sig: signal.Signals, signal_name: str) -> None:
1✔
1010
    """プロセスリストに指定されたシグナルを送信"""
1011
    errors = []
×
1012
    for pid in pids:
×
1013
        try:
×
1014
            # プロセス名を取得
1015
            try:
×
1016
                process = psutil.Process(pid)
×
1017
                process_name = process.name()
×
1018
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1019
                process_name = "unknown"
×
1020

1021
            if sig == signal.SIGKILL:
×
1022
                # プロセスがまだ存在するかチェック
1023
                os.kill(pid, 0)  # シグナル0は存在確認
×
1024
            os.kill(pid, sig)
×
1025
            logging.info("Sent %s to process: PID %d (%s)", signal_name, pid, process_name)
×
1026
        except (ProcessLookupError, OSError) as e:
×
1027
            # プロセスが既に終了している場合は無視
1028
            errors.append((pid, e))
×
1029

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

1034

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

1038
    Args:
1039
        chrome_pids: 終了対象のプロセスIDリスト
1040
        timeout: SIGTERM後にプロセス終了を待機する最大時間(秒)
1041

1042
    """
1043
    if not chrome_pids:
×
1044
        return
×
1045

1046
    # 優雅な終了(SIGTERM)
1047
    _send_signal_to_processes(chrome_pids, signal.SIGTERM, "SIGTERM")
×
1048

1049
    # プロセスの終了を待機(ポーリング)
1050
    remaining_pids = list(chrome_pids)
×
1051
    poll_interval = 0.2
×
1052
    elapsed = 0.0
×
1053

1054
    while remaining_pids and elapsed < timeout:
×
1055
        time.sleep(poll_interval)
×
1056
        elapsed += poll_interval
×
1057

1058
        # まだ生存しているプロセスをチェック
1059
        still_alive = []
×
1060
        for pid in remaining_pids:
×
1061
            try:
×
1062
                if psutil.pid_exists(pid):
×
1063
                    process = psutil.Process(pid)
×
1064
                    if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
1065
                        still_alive.append(pid)
×
1066
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1067
                pass
×
1068

1069
        remaining_pids = still_alive
×
1070

1071
    # タイムアウト後もまだ残っているプロセスにのみ SIGKILL を送信
1072
    if remaining_pids:
×
1073
        logging.warning(
×
1074
            "Chrome processes still alive after %.1fs, sending SIGKILL to %d processes",
1075
            elapsed,
1076
            len(remaining_pids),
1077
        )
1078
        _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1079

1080

1081
def _reap_single_process(pid: int) -> None:
1✔
1082
    """単一プロセスをwaitpidで回収"""
1083
    try:
×
1084
        # ノンブロッキングでwaitpid
1085
        result_pid, status = os.waitpid(pid, os.WNOHANG)
×
1086
        if result_pid == pid:
×
1087
            # プロセス名を取得
1088
            try:
×
1089
                process = psutil.Process(pid)
×
1090
                process_name = process.name()
×
1091
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1092
                process_name = "unknown"
×
1093
            logging.debug("Reaped Chrome process: PID %d (%s)", pid, process_name)
×
1094
    except (ChildProcessError, OSError):
×
1095
        # 子プロセスでない場合や既に回収済みの場合は無視
1096
        pass
×
1097

1098

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

1104

1105
def _get_remaining_chrome_pids(chrome_pids: list[int]) -> list[int]:
1✔
1106
    """指定されたPIDリストから、まだ生存しているChrome関連プロセスを取得"""
1107
    remaining = []
×
1108
    for pid in chrome_pids:
×
1109
        try:
×
1110
            if psutil.pid_exists(pid):
×
1111
                process = psutil.Process(pid)
×
1112
                if (
×
1113
                    process.is_running()
1114
                    and process.status() != psutil.STATUS_ZOMBIE
1115
                    and _is_chrome_related_process(process)
1116
                ):
1117
                    remaining.append(pid)
×
1118
        except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1119
            pass
×
1120
    return remaining
×
1121

1122

1123
def _wait_for_processes_with_check(
1✔
1124
    chrome_pids: list[int],
1125
    timeout: float,
1126
    poll_interval: float = 0.2,
1127
    log_interval: float = 1.0,
1128
) -> list[int]:
1129
    """プロセスの終了を待機しつつ、残存プロセスをチェック
1130

1131
    Args:
1132
        chrome_pids: 監視対象のプロセスIDリスト
1133
        timeout: 最大待機時間(秒)
1134
        poll_interval: チェック間隔(秒)
1135
        log_interval: ログ出力間隔(秒)
1136

1137
    Returns:
1138
        タイムアウト後も残存しているプロセスIDのリスト
1139

1140
    """
1141
    elapsed = 0.0
×
1142
    last_log_time = 0.0
×
1143
    remaining_pids = list(chrome_pids)
×
1144

1145
    while remaining_pids and elapsed < timeout:
×
1146
        time.sleep(poll_interval)
×
1147
        elapsed += poll_interval
×
1148
        remaining_pids = _get_remaining_chrome_pids(remaining_pids)
×
1149

1150
        if remaining_pids and (elapsed - last_log_time) >= log_interval:
×
1151
            logging.info(
×
1152
                "Found %d remaining Chrome processes after %.0fs",
1153
                len(remaining_pids),
1154
                elapsed,
1155
            )
1156
            last_log_time = elapsed
×
1157

1158
    return remaining_pids
×
1159

1160

1161
def quit_driver_gracefully(  # noqa: C901
1✔
1162
    driver: WebDriver | None,
1163
    wait_sec: float = 5.0,
1164
    sigterm_wait_sec: float = 5.0,
1165
    sigkill_wait_sec: float = 5.0,
1166
) -> None:
1167
    """Chrome WebDriverを確実に終了する
1168

1169
    終了フロー:
1170
    1. driver.quit() を呼び出し
1171
    2. wait_sec 秒待機しつつプロセス終了をチェック
1172
    3. 残存プロセスがあれば SIGTERM を送信
1173
    4. sigterm_wait_sec 秒待機しつつプロセス終了をチェック
1174
    5. 残存プロセスがあれば SIGKILL を送信
1175
    6. sigkill_wait_sec 秒待機
1176

1177
    Args:
1178
        driver: 終了する WebDriver インスタンス
1179
        wait_sec: quit 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1180
        sigterm_wait_sec: SIGTERM 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1181
        sigkill_wait_sec: SIGKILL 後にプロセス回収を待機する秒数(デフォルト: 5秒)
1182

1183
    """
1184
    if driver is None:
1✔
1185
        return
1✔
1186

1187
    # quit前にChrome関連プロセスを記録
1188
    chrome_pids_before = _get_chrome_related_processes(driver)
1✔
1189

1190
    try:
1✔
1191
        # WebDriverの正常終了を試行(これがタブのクローズも含む)
1192
        driver.quit()
1✔
1193
        logging.info("WebDriver quit successfully")
1✔
1194
    except Exception:
1✔
1195
        logging.warning("Failed to quit driver normally", exc_info=True)
1✔
1196
    finally:
1197
        # undetected_chromedriver の __del__ がシャットダウン時に再度呼ばれるのを防ぐ
1198
        if hasattr(driver, "_has_quit"):
1✔
1199
            driver._has_quit = True  # type: ignore[attr-defined]  # noqa: SLF001
1✔
1200

1201
    # ChromeDriverサービスの停止を試行
1202
    try:
1✔
1203
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "stop"):  # type: ignore[attr-defined]
1✔
1204
            driver.service.stop()  # type: ignore[attr-defined]
×
1205
    except (ConnectionResetError, OSError):
×
1206
        # Chrome が既に終了している場合は無視
1207
        logging.debug("Chrome service already stopped")
×
1208
    except Exception:
×
1209
        logging.warning("Failed to stop Chrome service", exc_info=True)
×
1210

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

1214
    if not remaining_pids:
1✔
1215
        logging.debug("All Chrome processes exited normally")
1✔
1216
        return
1✔
1217

1218
    # Step 2: 残存プロセスに SIGTERM を送信
1219
    logging.info(
×
1220
        "Found %d remaining Chrome processes after %.0fs, sending SIGTERM",
1221
        len(remaining_pids),
1222
        wait_sec,
1223
    )
1224
    _send_signal_to_processes(remaining_pids, signal.SIGTERM, "SIGTERM")
×
1225

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

1229
    if not remaining_pids:
×
1230
        logging.info("All Chrome processes exited after SIGTERM")
×
1231
        _reap_chrome_processes(chrome_pids_before)
×
1232
        return
×
1233

1234
    # Step 4: 残存プロセスに SIGKILL を送信
1235
    logging.warning(
×
1236
        "Chrome processes still alive after SIGTERM + %.1fs, sending SIGKILL to %d processes",
1237
        sigterm_wait_sec,
1238
        len(remaining_pids),
1239
    )
1240
    _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1241

1242
    # Step 5: SIGKILL 後に sigkill_wait_sec 秒待機してプロセス回収
1243
    time.sleep(sigkill_wait_sec)
×
1244
    _reap_chrome_processes(chrome_pids_before)
×
1245

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

1249
    # 回収できなかったプロセスについて警告
1250
    if still_remaining:
×
1251
        for pid in still_remaining:
×
1252
            try:
×
1253
                process = psutil.Process(pid)
×
1254
                logging.warning("Failed to collect Chrome-related process: PID %d (%s)", pid, process.name())
×
1255
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
1256
                pass
×
1257

1258

1259
if __name__ == "__main__":
1260
    import pathlib
1261

1262
    import docopt
1263
    import selenium.webdriver.support.wait
1264

1265
    import my_lib.config
1266
    import my_lib.logger
1267

1268
    assert __doc__ is not None  # noqa: S101
1269
    args = docopt.docopt(__doc__)
1270

1271
    config_file = args["-c"]
1272
    debug_mode = args["-D"]
1273

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

1276
    config = my_lib.config.load(config_file)
1277

1278
    driver = create_driver("test", pathlib.Path(config["data"]["selenium"]))
1279
    wait = selenium.webdriver.support.wait.WebDriverWait(driver, 5)
1280

1281
    driver.get("https://www.google.com/")
1282
    wait.until(
1283
        selenium.webdriver.support.expected_conditions.presence_of_element_located(
1284
            (selenium.webdriver.common.by.By.XPATH, '//input[contains(@value, "Google")]')
1285
        )
1286
    )
1287

1288
    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