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

kimata / my-py-lib / 20569549039

29 Dec 2025 09:30AM UTC coverage: 65.82% (+0.008%) from 65.812%
20569549039

push

github

kimata
fix: プライベート関数の呼び出しを修正

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

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

4 of 4 new or added lines in 1 file covered. (100.0%)

160 existing lines in 1 file now uncovered.

2902 of 4409 relevant lines covered (65.82%)

0.66 hits per line

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

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

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

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

13
from __future__ import annotations
1✔
14

15
import datetime
1✔
16
import inspect
1✔
17
import json
1✔
18
import logging
1✔
19
import os
1✔
20
import pathlib
1✔
21
import random
1✔
22
import re
1✔
23
import shutil
1✔
24
import signal
1✔
25
import sqlite3
1✔
26
import subprocess
1✔
27
import time
1✔
28
import io
1✔
29
from collections.abc import Callable
1✔
30
from dataclasses import dataclass
1✔
31
from typing import TYPE_CHECKING, Any
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
    from selenium.webdriver.remote.webdriver import WebDriver
47
    from selenium.webdriver.support.wait import WebDriverWait
48

49
WAIT_RETRY_COUNT: int = 1
1✔
50

51

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

55

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

71

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

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

UNCOV
86
    chrome_data_path.mkdir(parents=True, exist_ok=True)
×
87
    log_path.mkdir(parents=True, exist_ok=True)
×
88

UNCOV
89
    options = selenium.webdriver.chrome.options.Options()
×
90

91
    if is_headless:
×
92
        options.add_argument("--headless=new")
×
93

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

UNCOV
98
    options.add_argument("--disable-popup-blocking")
×
99
    options.add_argument("--disable-plugins")
×
100

UNCOV
101
    options.add_argument("--no-first-run")
×
102

103
    options.add_argument("--lang=ja-JP")
×
UNCOV
104
    options.add_argument("--window-size=1920,1080")
×
105

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

UNCOV
109
    options.add_argument("--user-data-dir=" + str(chrome_data_path / profile_name))
×
110

111
    options.add_argument("--enable-logging")
×
UNCOV
112
    options.add_argument("--v=1")
×
113

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

UNCOV
117
    if not is_headless:
×
UNCOV
118
        options.add_argument("--auto-open-devtools-for-tabs")
×
119

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

124
    chrome_version = _get_chrome_version()
×
125

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

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

UNCOV
139
    driver.set_page_load_timeout(30)
×
140

UNCOV
141
    return driver
×
142

143

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

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

154

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

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

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

173

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

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

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

195

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

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

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

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

214
    default_path = profile_path / "Default"
1✔
215

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

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

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

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

247
    is_healthy = len(errors) == 0
1✔
248

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

257

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

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

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

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

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

286

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

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

304

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

309

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

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

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

328

329
def create_driver(
1✔
330
    profile_name: str,
331
    data_path: pathlib.Path,
332
    is_headless: bool = True,
333
    clean_profile: bool = False,
334
    auto_recover: bool = True,
335
    use_subprocess: bool = True,
336
) -> WebDriver:
337
    """Chrome WebDriver を作成する
338

339
    Args:
340
        profile_name: プロファイル名
341
        data_path: データディレクトリのパス
342
        is_headless: ヘッドレスモードで起動するか
343
        clean_profile: 起動前にロックファイルを削除するか
344
        auto_recover: プロファイル破損時に自動リカバリするか
345
        use_subprocess: サブプロセスで Chrome を起動するか
346
    """
347
    # NOTE: ルートロガーの出力レベルを変更した場合でも Selenium 関係は抑制する
UNCOV
348
    logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
×
349
    logging.getLogger("selenium.webdriver.common.selenium_manager").setLevel(logging.WARNING)
×
350
    logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING)
×
351

352
    # NOTE: Pytest を並列実行できるようにする
UNCOV
353
    suffix = os.environ.get("PYTEST_XDIST_WORKER", None)
×
354
    actual_profile_name = profile_name + ("." + suffix if suffix is not None else "")
×
355
    profile_path = data_path / "chrome" / actual_profile_name
×
356

357
    # プロファイル健全性チェック
358
    health = _check_profile_health(profile_path)
×
UNCOV
359
    if not health.is_healthy:
×
360
        logging.warning("Profile health check failed: %s", ", ".join(health.errors))
×
361

362
        if health.has_lock_files and not (health.has_corrupted_json or health.has_corrupted_db):
×
363
            # ロックファイルのみの問題なら削除して続行
364
            logging.info("Cleaning up lock files only")
×
365
            _cleanup_profile_lock(profile_path)
×
366
        elif auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
367
            # JSON または DB が破損している場合はプロファイルをリカバリ
368
            logging.warning("Profile is corrupted, attempting recovery")
×
UNCOV
369
            if _recover_corrupted_profile(profile_path):
×
370
                logging.info("Profile recovery successful, will create new profile")
×
371
            else:
UNCOV
372
                logging.error("Profile recovery failed")
×
373

374
    if clean_profile:
×
375
        _cleanup_profile_lock(profile_path)
×
376

377
    # NOTE: 1回だけ自動リトライ
UNCOV
378
    try:
×
UNCOV
379
        return _create_driver_impl(profile_name, data_path, is_headless, use_subprocess)
×
380
    except Exception as e:
×
UNCOV
381
        logging.warning("First attempt to create driver failed: %s", e)
×
382

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

386
        # プロファイルのロックファイルを削除
387
        _cleanup_profile_lock(profile_path)
×
388

389
        # 再度健全性チェック
UNCOV
390
        health = _check_profile_health(profile_path)
×
391
        if not health.is_healthy and auto_recover and (health.has_corrupted_json or health.has_corrupted_db):
×
UNCOV
392
            logging.warning("Profile still corrupted after first attempt, recovering")
×
UNCOV
393
            _recover_corrupted_profile(profile_path)
×
394

395
        return _create_driver_impl(profile_name, data_path, is_headless, use_subprocess)
×
396

397

398
def xpath_exists(driver: WebDriver, xpath: str) -> bool:
1✔
UNCOV
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:
UNCOV
408
    if wait is not None:
×
UNCOV
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

UNCOV
415
    if len(driver.find_elements(selenium.webdriver.common.by.By.XPATH, xpath)) != 0:
×
UNCOV
416
        return driver.find_element(selenium.webdriver.common.by.By.XPATH, xpath).text.strip()
×
417
    else:
UNCOV
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:
UNCOV
428
    if wait is not None:
×
UNCOV
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:
UNCOV
440
        if is_warn:
×
UNCOV
441
            logging.warning("Element is not found: %s", xpath)
×
UNCOV
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:
UNCOV
452
    if wait is not None:
×
UNCOV
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)
×
UNCOV
462
        if move:
×
463
            action = selenium.webdriver.common.action_chains.ActionChains(driver)
×
464
            action.move_to_element(elem)
×
UNCOV
465
            action.perform()
×
466

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

474

475
def is_display(driver: WebDriver, xpath: str) -> bool:
1✔
UNCOV
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 wait_patiently(
1✔
488
    driver: WebDriver,
489
    wait: WebDriverWait[WebDriver],
490
    target: Any,
491
) -> None:
492
    error: selenium.common.exceptions.TimeoutException | None = None
×
493
    for i in range(WAIT_RETRY_COUNT + 1):
×
494
        try:
×
UNCOV
495
            wait.until(target)
×
UNCOV
496
            return
×
UNCOV
497
        except selenium.common.exceptions.TimeoutException as e:  # noqa: PERF203
×
UNCOV
498
            logging.warning(
×
499
                "タイムアウトが発生しました。(%s in %s line %d)",
500
                inspect.stack()[1].function,
501
                inspect.stack()[1].filename,
502
                inspect.stack()[1].lineno,
503
            )
504
            error = e
×
505

UNCOV
506
            logging.info(i)
×
507
            if i != WAIT_RETRY_COUNT:
×
508
                logging.info("refresh")
×
UNCOV
509
                driver.refresh()
×
510

UNCOV
511
    if error is not None:
×
UNCOV
512
        raise error
×
513

514

515
def dump_page(
1✔
516
    driver: WebDriver,
517
    index: int,
518
    dump_path: pathlib.Path,
519
    stack_index: int = 1,
520
) -> None:
521
    name = inspect.stack()[stack_index].function.replace("<", "").replace(">", "")
×
522

UNCOV
523
    dump_path.mkdir(parents=True, exist_ok=True)
×
524

UNCOV
525
    png_path = dump_path / f"{name}_{index:02d}.png"
×
526
    htm_path = dump_path / f"{name}_{index:02d}.htm"
×
527

UNCOV
528
    driver.save_screenshot(str(png_path))
×
529

UNCOV
530
    with htm_path.open("w", encoding="utf-8") as f:
×
UNCOV
531
        f.write(driver.page_source)
×
532

UNCOV
533
    logging.info(
×
534
        "page dump: %02d from %s in %s line %d",
535
        index,
536
        inspect.stack()[stack_index].function,
537
        inspect.stack()[stack_index].filename,
538
        inspect.stack()[stack_index].lineno,
539
    )
540

541

542
def clear_cache(driver: WebDriver) -> None:
1✔
UNCOV
543
    driver.execute_cdp_cmd("Network.clearBrowserCache", {})
×
544

545

546
def clean_dump(dump_path: pathlib.Path, keep_days: int = 1) -> None:
1✔
547
    if not dump_path.exists():
1✔
548
        return
1✔
549

550
    time_threshold = datetime.timedelta(keep_days)
1✔
551

552
    for item in dump_path.iterdir():
1✔
553
        if not item.is_file():
1✔
554
            continue
1✔
555
        try:
1✔
556
            time_diff = datetime.datetime.now(datetime.timezone.utc) - datetime.datetime.fromtimestamp(
1✔
557
                item.stat().st_mtime, datetime.timezone.utc
558
            )
UNCOV
559
        except FileNotFoundError:
×
560
            # ファイルが別プロセスにより削除された場合(SQLiteの一時ファイルなど)
UNCOV
561
            continue
×
562
        if time_diff > time_threshold:
1✔
563
            logging.info("remove %s [%s day(s) old].", item.absolute(), f"{time_diff.days:,}")
1✔
564

565
            item.unlink(missing_ok=True)
1✔
566

567

568
def get_memory_info(driver: WebDriver) -> dict[str, Any]:
1✔
569
    """ブラウザのメモリ使用量を取得(単位: KB)"""
UNCOV
570
    total_bytes = subprocess.Popen(  # noqa: S602
×
571
        "smem -t -c pss -P chrome | tail -n 1",  # noqa: S607
572
        shell=True,
573
        stdout=subprocess.PIPE,
574
    ).communicate()[0]
575
    total = int(str(total_bytes, "utf-8").strip())  # smem の出力は KB 単位
×
576

577
    try:
×
578
        memory_info = driver.execute_cdp_cmd("Memory.getAllTimeSamplingProfile", {})
×
579
        heap_usage = driver.execute_cdp_cmd("Runtime.getHeapUsage", {})
×
580

UNCOV
581
        heap_used = heap_usage.get("usedSize", 0) // 1024  # bytes → KB
×
582
        heap_total = heap_usage.get("totalSize", 0) // 1024  # bytes → KB
×
583
    except Exception as e:
×
584
        logging.debug("Failed to get memory usage: %s", e)
×
585

586
        memory_info = None
×
UNCOV
587
        heap_used = 0
×
UNCOV
588
        heap_total = 0
×
589

UNCOV
590
    return {
×
591
        "total": total,
592
        "heap_used": heap_used,
593
        "heap_total": heap_total,
594
        "memory_info": memory_info,
595
    }
596

597

598
def log_memory_usage(driver: WebDriver) -> None:
1✔
UNCOV
599
    mem_info = get_memory_info(driver)
×
UNCOV
600
    logging.info(
×
601
        "Chrome memory: %s MB (JS heap: %s MB)",
602
        f"""{mem_info["total"] // 1024:,}""",
603
        f"""{mem_info["heap_used"] // 1024:,}""",
604
    )
605

606

607
def _warmup(
1✔
608
    driver: WebDriver,
609
    keyword: str,
610
    url_pattern: str,
611
    sleep_sec: int = 3,
612
) -> None:
613
    # NOTE: ダミーアクセスを行って BOT ではないと思わせる。(効果なさそう...)
614
    driver.get("https://www.yahoo.co.jp/")
×
UNCOV
615
    time.sleep(sleep_sec)
×
616

UNCOV
617
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(keyword)
×
618
    driver.find_element(selenium.webdriver.common.by.By.XPATH, '//input[@name="p"]').send_keys(
×
619
        selenium.webdriver.common.keys.Keys.ENTER
620
    )
621

UNCOV
622
    time.sleep(sleep_sec)
×
623

624
    driver.find_element(
×
625
        selenium.webdriver.common.by.By.XPATH, f'//a[contains(@href, "{url_pattern}")]'
626
    ).click()
627

UNCOV
628
    time.sleep(sleep_sec)
×
629

630

631
class browser_tab:  # noqa: N801
1✔
632
    def __init__(self, driver: WebDriver, url: str) -> None:  # noqa: D107
1✔
633
        self.driver = driver
1✔
634
        self.url = url
1✔
635
        self.original_window: str | None = None
1✔
636

637
    def __enter__(self) -> None:  # noqa: D105
1✔
638
        self.original_window = self.driver.current_window_handle
1✔
639
        self.driver.execute_script("window.open('');")
1✔
640
        self.driver.switch_to.window(self.driver.window_handles[-1])
1✔
641
        self.driver.get(self.url)
1✔
642

643
    def __exit__(
1✔
644
        self,
645
        exception_type: type[BaseException] | None,
646
        exception_value: BaseException | None,
647
        traceback: Any,
648
    ) -> None:  # noqa: D105
649
        try:
1✔
650
            self.driver.close()
1✔
651
            if self.original_window is not None:
1✔
652
                self.driver.switch_to.window(self.original_window)
1✔
653
            time.sleep(0.1)
1✔
654
        except Exception:
1✔
655
            # NOTE: Chromeがクラッシュした場合は無視(既に終了しているため操作不可)
656
            logging.exception("Failed to close browser tab (Chrome may have crashed)")
1✔
657

658

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

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

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

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

675
    Examples:
676
        基本的な使用方法::
677

678
            with my_lib.selenium_util.error_handler(driver, message="ログイン処理に失敗") as handler:
679
                driver.get(login_url)
680
                driver.find_element(...).click()
681

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

684
            def notify(exc, screenshot):
685
                slack.error("エラー発生", str(exc), screenshot)
686

687
            with my_lib.selenium_util.error_handler(
688
                driver,
689
                message="クロール処理に失敗",
690
                on_error=notify,
691
            ):
692
                crawl_page(driver)
693

694
        例外を抑制して続行::
695

696
            with my_lib.selenium_util.error_handler(driver, reraise=False) as handler:
697
                risky_operation()
698

699
            if handler.exception:
700
                logging.warning("処理をスキップしました")
701
    """
702

703
    def __init__(
1✔
704
        self,
705
        driver: WebDriver,
706
        message: str = "Selenium operation failed",
707
        on_error: Callable[[Exception, PIL.Image.Image | None], None] | None = None,
708
        capture_screenshot: bool = True,
709
        reraise: bool = True,
710
    ) -> None:
711
        self.driver = driver
1✔
712
        self.message = message
1✔
713
        self.on_error = on_error
1✔
714
        self.capture_screenshot = capture_screenshot
1✔
715
        self.reraise = reraise
1✔
716
        self.exception: Exception | None = None
1✔
717
        self.screenshot: PIL.Image.Image | None = None
1✔
718

719
    def __enter__(self) -> "error_handler":
1✔
720
        return self
1✔
721

722
    def __exit__(
1✔
723
        self,
724
        exception_type: type[BaseException] | None,
725
        exception_value: BaseException | None,
726
        traceback: Any,
727
    ) -> bool:
728
        if exception_value is None:
1✔
729
            return False
1✔
730

731
        # 例外を記録
732
        if isinstance(exception_value, Exception):
1✔
733
            self.exception = exception_value
1✔
734
        else:
735
            # BaseException(KeyboardInterrupt など)は処理せず再送出
UNCOV
736
            return False
×
737

738
        # ログ出力
739
        logging.exception(self.message)
1✔
740

741
        # スクリーンショット取得
742
        if self.capture_screenshot:
1✔
743
            try:
1✔
744
                screenshot_bytes = self.driver.get_screenshot_as_png()
1✔
745
                self.screenshot = PIL.Image.open(io.BytesIO(screenshot_bytes))
1✔
746
            except Exception:
1✔
747
                logging.debug("Failed to capture screenshot for error handling")
1✔
748

749
        # コールバック呼び出し
750
        if self.on_error is not None:
1✔
751
            try:
1✔
752
                self.on_error(self.exception, self.screenshot)
1✔
UNCOV
753
            except Exception:
×
UNCOV
754
                logging.exception("Error in on_error callback")
×
755

756
        # reraise=False なら例外を抑制
757
        return not self.reraise
1✔
758

759

760
def _is_chrome_related_process(process: psutil.Process) -> bool:
1✔
761
    """プロセスがChrome関連かどうかを判定"""
762
    try:
1✔
763
        process_name = process.name().lower()
1✔
764
        # Chrome関連のプロセス名パターン
765
        chrome_patterns = ["chrome", "chromium", "google-chrome", "undetected_chro"]
1✔
766
        # chromedriverは除外
767
        if "chromedriver" in process_name:
1✔
768
            return False
1✔
769
        return any(pattern in process_name for pattern in chrome_patterns)
1✔
770
    except (psutil.NoSuchProcess, psutil.AccessDenied):
1✔
771
        return False
1✔
772

773

774
def _get_chrome_processes_by_pgid(chromedriver_pid: int, existing_pids: set[int]) -> list[int]:
1✔
775
    """プロセスグループIDで追加のChrome関連プロセスを取得"""
776
    additional_pids = []
×
777
    try:
×
778
        pgid = os.getpgid(chromedriver_pid)
×
779
        for proc in psutil.process_iter(["pid", "name", "ppid"]):
×
780
            if proc.info["pid"] in existing_pids:
×
781
                continue
×
782
            try:
×
783
                if os.getpgid(proc.info["pid"]) == pgid:
×
UNCOV
784
                    proc_obj = psutil.Process(proc.info["pid"])
×
UNCOV
785
                    if _is_chrome_related_process(proc_obj):
×
UNCOV
786
                        additional_pids.append(proc.info["pid"])
×
UNCOV
787
                        logging.debug(
×
788
                            "Found Chrome-related process by pgid: PID %d, name: %s",
789
                            proc.info["pid"],
790
                            proc.info["name"],
791
                        )
792
            except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
×
UNCOV
793
                pass
×
UNCOV
794
    except (OSError, psutil.NoSuchProcess):
×
UNCOV
795
        logging.debug("Failed to get process group ID for chromedriver")
×
UNCOV
796
    return additional_pids
×
797

798

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

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

807
    # 1. driver.service.process の子プロセスを検索
808
    try:
×
UNCOV
809
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "process"):  # type: ignore[attr-defined]
×
UNCOV
810
            process = driver.service.process  # type: ignore[attr-defined]
×
811
            if process and hasattr(process, "pid"):
×
812
                chromedriver_pid = process.pid
×
813

814
                # psutilでプロセス階層を取得
815
                parent_process = psutil.Process(chromedriver_pid)
×
816
                children = parent_process.children(recursive=True)
×
817

UNCOV
818
                for child in children:
×
UNCOV
819
                    chrome_pids.add(child.pid)
×
UNCOV
820
                    logging.debug(
×
821
                        "Found Chrome-related process (service child): PID %d, name: %s",
822
                        child.pid,
823
                        child.name(),
824
                    )
825
    except Exception:
×
826
        logging.exception("Failed to get Chrome-related processes from service")
×
827

828
    # 2. 現在の Python プロセスの全子孫から Chrome 関連プロセスを検索
829
    try:
×
830
        current_process = psutil.Process()
×
831
        all_children = current_process.children(recursive=True)
×
832

833
        for child in all_children:
×
834
            if child.pid in chrome_pids:
×
835
                continue
×
UNCOV
836
            try:
×
UNCOV
837
                if _is_chrome_related_process(child):
×
UNCOV
838
                    chrome_pids.add(child.pid)
×
UNCOV
839
                    logging.debug(
×
840
                        "Found Chrome-related process (python child): PID %d, name: %s",
841
                        child.pid,
842
                        child.name(),
843
                    )
UNCOV
844
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
845
                pass
×
UNCOV
846
    except Exception:
×
UNCOV
847
        logging.exception("Failed to get Chrome-related processes from python children")
×
848

UNCOV
849
    return list(chrome_pids)
×
850

851

852
def _send_signal_to_processes(pids: list[int], sig: signal.Signals, signal_name: str) -> None:
1✔
853
    """プロセスリストに指定されたシグナルを送信"""
854
    errors = []
×
855
    for pid in pids:
×
856
        try:
×
857
            # プロセス名を取得
858
            try:
×
UNCOV
859
                process = psutil.Process(pid)
×
860
                process_name = process.name()
×
UNCOV
861
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
862
                process_name = "unknown"
×
863

864
            if sig == signal.SIGKILL:
×
865
                # プロセスがまだ存在するかチェック
UNCOV
866
                os.kill(pid, 0)  # シグナル0は存在確認
×
867
            os.kill(pid, sig)
×
UNCOV
868
            logging.info("Sent %s to process: PID %d (%s)", signal_name, pid, process_name)
×
UNCOV
869
        except (ProcessLookupError, OSError) as e:  # noqa: PERF203
×
870
            # プロセスが既に終了している場合は無視
871
            errors.append((pid, e))
×
872

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

877

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

881
    Args:
882
        chrome_pids: 終了対象のプロセスIDリスト
883
        timeout: SIGTERM後にプロセス終了を待機する最大時間(秒)
884
    """
885
    if not chrome_pids:
×
UNCOV
886
        return
×
887

888
    # 優雅な終了(SIGTERM)
889
    _send_signal_to_processes(chrome_pids, signal.SIGTERM, "SIGTERM")
×
890

891
    # プロセスの終了を待機(ポーリング)
892
    remaining_pids = list(chrome_pids)
×
893
    poll_interval = 0.2
×
894
    elapsed = 0.0
×
895

UNCOV
896
    while remaining_pids and elapsed < timeout:
×
897
        time.sleep(poll_interval)
×
898
        elapsed += poll_interval
×
899

900
        # まだ生存しているプロセスをチェック
901
        still_alive = []
×
902
        for pid in remaining_pids:
×
903
            try:
×
904
                if psutil.pid_exists(pid):
×
905
                    process = psutil.Process(pid)
×
UNCOV
906
                    if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
907
                        still_alive.append(pid)
×
UNCOV
908
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
UNCOV
909
                pass
×
910

911
        remaining_pids = still_alive
×
912

913
    # タイムアウト後もまだ残っているプロセスにのみ SIGKILL を送信
UNCOV
914
    if remaining_pids:
×
UNCOV
915
        logging.warning(
×
916
            "Chrome processes still alive after %.1fs, sending SIGKILL to %d processes",
917
            elapsed,
918
            len(remaining_pids),
919
        )
UNCOV
920
        _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
921

922

923
def _reap_single_process(pid: int) -> None:
1✔
924
    """単一プロセスをwaitpidで回収"""
UNCOV
925
    try:
×
926
        # ノンブロッキングでwaitpid
927
        result_pid, status = os.waitpid(pid, os.WNOHANG)
×
928
        if result_pid == pid:
×
929
            # プロセス名を取得
930
            try:
×
931
                process = psutil.Process(pid)
×
932
                process_name = process.name()
×
UNCOV
933
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
934
                process_name = "unknown"
×
UNCOV
935
            logging.debug("Reaped Chrome process: PID %d (%s)", pid, process_name)
×
UNCOV
936
    except (ChildProcessError, OSError):
×
937
        # 子プロセスでない場合や既に回収済みの場合は無視
UNCOV
938
        pass
×
939

940

941
def _reap_chrome_processes(chrome_pids: list[int]) -> None:
1✔
942
    """Chrome関連プロセスを明示的に回収してゾンビ化を防ぐ"""
UNCOV
943
    for pid in chrome_pids:
×
UNCOV
944
        _reap_single_process(pid)
×
945

946

947
def _get_remaining_chrome_pids(chrome_pids: list[int]) -> list[int]:
1✔
948
    """指定されたPIDリストから、まだ生存しているChrome関連プロセスを取得"""
949
    remaining = []
×
950
    for pid in chrome_pids:
×
951
        try:
×
952
            if psutil.pid_exists(pid):
×
953
                process = psutil.Process(pid)
×
954
                if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
×
955
                    if _is_chrome_related_process(process):
×
UNCOV
956
                        remaining.append(pid)
×
UNCOV
957
        except (psutil.NoSuchProcess, psutil.AccessDenied):
×
UNCOV
958
            pass
×
UNCOV
959
    return remaining
×
960

961

962
def _wait_for_processes_with_check(
1✔
963
    chrome_pids: list[int],
964
    timeout: float,
965
    poll_interval: float = 0.2,
966
    log_interval: float = 1.0,
967
) -> list[int]:
968
    """プロセスの終了を待機しつつ、残存プロセスをチェック
969

970
    Args:
971
        chrome_pids: 監視対象のプロセスIDリスト
972
        timeout: 最大待機時間(秒)
973
        poll_interval: チェック間隔(秒)
974
        log_interval: ログ出力間隔(秒)
975

976
    Returns:
977
        タイムアウト後も残存しているプロセスIDのリスト
978
    """
979
    elapsed = 0.0
×
980
    last_log_time = 0.0
×
981
    remaining_pids = list(chrome_pids)
×
982

UNCOV
983
    while remaining_pids and elapsed < timeout:
×
984
        time.sleep(poll_interval)
×
985
        elapsed += poll_interval
×
UNCOV
986
        remaining_pids = _get_remaining_chrome_pids(remaining_pids)
×
987

UNCOV
988
        if remaining_pids and (elapsed - last_log_time) >= log_interval:
×
UNCOV
989
            logging.info(
×
990
                "Found %d remaining Chrome processes after %.0fs",
991
                len(remaining_pids),
992
                elapsed,
993
            )
UNCOV
994
            last_log_time = elapsed
×
995

UNCOV
996
    return remaining_pids
×
997

998

999
def quit_driver_gracefully(
1✔
1000
    driver: WebDriver | None,
1001
    wait_sec: float = 5.0,
1002
    sigterm_wait_sec: float = 5.0,
1003
    sigkill_wait_sec: float = 5.0,
1004
) -> None:  # noqa: C901, PLR0912
1005
    """Chrome WebDriverを確実に終了する
1006

1007
    終了フロー:
1008
    1. driver.quit() を呼び出し
1009
    2. wait_sec 秒待機しつつプロセス終了をチェック
1010
    3. 残存プロセスがあれば SIGTERM を送信
1011
    4. sigterm_wait_sec 秒待機しつつプロセス終了をチェック
1012
    5. 残存プロセスがあれば SIGKILL を送信
1013
    6. sigkill_wait_sec 秒待機
1014

1015
    Args:
1016
        driver: 終了する WebDriver インスタンス
1017
        wait_sec: quit 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1018
        sigterm_wait_sec: SIGTERM 後にプロセス終了を待機する秒数(デフォルト: 5秒)
1019
        sigkill_wait_sec: SIGKILL 後にプロセス回収を待機する秒数(デフォルト: 5秒)
1020
    """
1021
    if driver is None:
1✔
1022
        return
1✔
1023

1024
    # quit前にChrome関連プロセスを記録
1025
    chrome_pids_before = _get_chrome_related_processes(driver)
1✔
1026

1027
    try:
1✔
1028
        # WebDriverの正常終了を試行(これがタブのクローズも含む)
1029
        driver.quit()
1✔
1030
        logging.info("WebDriver quit successfully")
1✔
1031
    except Exception:
1✔
1032
        logging.warning("Failed to quit driver normally", exc_info=True)
1✔
1033
    finally:
1034
        # undetected_chromedriver の __del__ がシャットダウン時に再度呼ばれるのを防ぐ
1035
        if hasattr(driver, "_has_quit"):
1✔
1036
            driver._has_quit = True  # type: ignore[attr-defined]
1✔
1037

1038
    # ChromeDriverサービスの停止を試行
1039
    try:
1✔
1040
        if hasattr(driver, "service") and driver.service and hasattr(driver.service, "stop"):  # type: ignore[attr-defined]
1✔
1041
            driver.service.stop()  # type: ignore[attr-defined]
×
1042
    except (ConnectionResetError, OSError):
×
1043
        # Chrome が既に終了している場合は無視
UNCOV
1044
        logging.debug("Chrome service already stopped")
×
UNCOV
1045
    except Exception:
×
UNCOV
1046
        logging.warning("Failed to stop Chrome service", exc_info=True)
×
1047

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

1051
    if not remaining_pids:
1✔
1052
        logging.debug("All Chrome processes exited normally")
1✔
1053
        return
1✔
1054

1055
    # Step 2: 残存プロセスに SIGTERM を送信
UNCOV
1056
    logging.info(
×
1057
        "Found %d remaining Chrome processes after %.0fs, sending SIGTERM",
1058
        len(remaining_pids),
1059
        wait_sec,
1060
    )
UNCOV
1061
    _send_signal_to_processes(remaining_pids, signal.SIGTERM, "SIGTERM")
×
1062

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

UNCOV
1066
    if not remaining_pids:
×
UNCOV
1067
        logging.info("All Chrome processes exited after SIGTERM")
×
1068
        _reap_chrome_processes(chrome_pids_before)
×
UNCOV
1069
        return
×
1070

1071
    # Step 4: 残存プロセスに SIGKILL を送信
UNCOV
1072
    logging.warning(
×
1073
        "Chrome processes still alive after SIGTERM + %.1fs, sending SIGKILL to %d processes",
1074
        sigterm_wait_sec,
1075
        len(remaining_pids),
1076
    )
1077
    _send_signal_to_processes(remaining_pids, signal.SIGKILL, "SIGKILL")
×
1078

1079
    # Step 5: SIGKILL 後に sigkill_wait_sec 秒待機してプロセス回収
1080
    time.sleep(sigkill_wait_sec)
×
UNCOV
1081
    _reap_chrome_processes(chrome_pids_before)
×
1082

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

1086
    # 回収できなかったプロセスについて警告
1087
    if still_remaining:
×
1088
        for pid in still_remaining:
×
1089
            try:
×
UNCOV
1090
                process = psutil.Process(pid)
×
UNCOV
1091
                logging.warning("Failed to collect Chrome-related process: PID %d (%s)", pid, process.name())
×
UNCOV
1092
            except (psutil.NoSuchProcess, psutil.AccessDenied):
×
UNCOV
1093
                pass
×
1094

1095

1096
if __name__ == "__main__":
1097
    import pathlib
1098

1099
    import docopt
1100
    import selenium.webdriver.support.wait
1101

1102
    import my_lib.config
1103
    import my_lib.logger
1104

1105
    assert __doc__ is not None
1106
    args = docopt.docopt(__doc__)
1107

1108
    config_file = args["-c"]
1109
    debug_mode = args["-D"]
1110

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

1113
    config = my_lib.config.load(config_file)
1114

1115
    driver = create_driver("test", pathlib.Path(config["data"]["selenium"]))
1116
    wait = selenium.webdriver.support.wait.WebDriverWait(driver, 5)
1117

1118
    driver.get("https://www.google.com/")
1119
    wait.until(
1120
        selenium.webdriver.support.expected_conditions.presence_of_element_located(
1121
            (selenium.webdriver.common.by.By.XPATH, '//input[contains(@value, "Google")]')
1122
        )
1123
    )
1124

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

© 2026 Coveralls, Inc