linkedin-search-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. linkedin_search_core-0.1.0/.gitignore +39 -0
  2. linkedin_search_core-0.1.0/LICENSE +3 -0
  3. linkedin_search_core-0.1.0/PKG-INFO +67 -0
  4. linkedin_search_core-0.1.0/README.md +40 -0
  5. linkedin_search_core-0.1.0/pyproject.toml +51 -0
  6. linkedin_search_core-0.1.0/src/linkedin_search_core/__init__.py +35 -0
  7. linkedin_search_core-0.1.0/src/linkedin_search_core/auth/__init__.py +13 -0
  8. linkedin_search_core-0.1.0/src/linkedin_search_core/auth/email_password.py +69 -0
  9. linkedin_search_core-0.1.0/src/linkedin_search_core/auth/methods.py +10 -0
  10. linkedin_search_core-0.1.0/src/linkedin_search_core/auth/social.py +107 -0
  11. linkedin_search_core-0.1.0/src/linkedin_search_core/auth/wait.py +114 -0
  12. linkedin_search_core-0.1.0/src/linkedin_search_core/browser/__init__.py +13 -0
  13. linkedin_search_core-0.1.0/src/linkedin_search_core/browser/config.py +37 -0
  14. linkedin_search_core-0.1.0/src/linkedin_search_core/browser/manager.py +164 -0
  15. linkedin_search_core-0.1.0/src/linkedin_search_core/config.py +75 -0
  16. linkedin_search_core-0.1.0/src/linkedin_search_core/exceptions.py +48 -0
  17. linkedin_search_core-0.1.0/src/linkedin_search_core/loading/__init__.py +5 -0
  18. linkedin_search_core-0.1.0/src/linkedin_search_core/loading/scroller.py +120 -0
  19. linkedin_search_core-0.1.0/src/linkedin_search_core/models/__init__.py +3 -0
  20. linkedin_search_core-0.1.0/src/linkedin_search_core/models/user.py +17 -0
  21. linkedin_search_core-0.1.0/src/linkedin_search_core/scroll/__init__.py +14 -0
  22. linkedin_search_core-0.1.0/src/linkedin_search_core/scroll/human_scroll.py +166 -0
  23. linkedin_search_core-0.1.0/src/linkedin_search_core/session.py +325 -0
@@ -0,0 +1,39 @@
1
+ cookies.json
2
+ cookies*.json
3
+ storage_state.json
4
+ session_snapshot.json
5
+ *.session
6
+ linkedin-session*
7
+ credentials.json
8
+ secrets.json
9
+ .secrets
10
+ posts.json
11
+ selected_posts.json
12
+ worker/results/*.json
13
+ .env
14
+ .venv/
15
+ venv/
16
+ __pycache__/
17
+ *.pyc
18
+ *.pyo
19
+ *.egg-info/
20
+ *.egg
21
+ dist/
22
+ build/
23
+ **/dist/
24
+ **/build/
25
+ **/*.egg-info/
26
+ .pytest_cache/
27
+
28
+ # Cursor debug session logs
29
+ **/.cursor/debug-*.log
30
+
31
+ # Frontend (Next.js)
32
+ frontend/node_modules/
33
+ frontend/.next/
34
+ frontend/.vercel/
35
+ frontend/.env.local
36
+ frontend/.env.*.local
37
+
38
+ # Local tools (portable Node, etc.)
39
+ .tools/
@@ -0,0 +1,3 @@
1
+ Proprietary. All rights reserved.
2
+ This software is licensed for use by the copyright holder only.
3
+ Redistribution or publication without permission is not allowed.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: linkedin-search-core
3
+ Version: 0.1.0
4
+ Summary: Shared Playwright session, authentication, and errors for LinkedIn search.
5
+ Project-URL: Homepage, https://github.com/vkudymov/linkedin-pulse-reader-v.3
6
+ Project-URL: Repository, https://github.com/vkudymov/linkedin-pulse-reader-v.3
7
+ Project-URL: Issues, https://github.com/vkudymov/linkedin-pulse-reader-v.3/issues
8
+ Author: LinkedIn Pulse Reader
9
+ Maintainer: LinkedIn Pulse Reader
10
+ License: Proprietary
11
+ License-File: LICENSE
12
+ Keywords: linkedin,playwright,session
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: Other/Proprietary License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: playwright>=1.49.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # linkedin-search-core
29
+
30
+ ## Description
31
+
32
+ Shared LinkedIn browser session for search libraries. This package opens Chromium through Playwright, restores a session, and runs interactive login. It does not search jobs or posts.
33
+
34
+ Requires Python 3.11+.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install linkedin-search-core
40
+ ```
41
+
42
+ Local editable install from this monorepo:
43
+
44
+ ```bash
45
+ pip install -e packages/linkedin-search-core
46
+ ```
47
+
48
+ ## Dependencies
49
+
50
+ - `playwright>=1.49.0`
51
+ - `session-snapshot` is optional. Import it in the same environment when you restore or export a full Chrome session snapshot. Without it, cookie-only login still works.
52
+
53
+ ## Usage
54
+
55
+ ```python
56
+ from linkedin_search_core import LinkedInSession
57
+
58
+ with LinkedInSession(cookies=cookies, headless=True) as session:
59
+ print(session.is_logged_in())
60
+ ```
61
+
62
+ ## API
63
+
64
+ - `LinkedInSession` — context manager for the browser session (`login_and_get_cookies`, `get_cookies`, `export_session_snapshot`, `is_logged_in`)
65
+ - `LinkedInClientConfig`, `BrowserConfig`, `LoginMethod`
66
+ - `UserIdentity`
67
+ - Errors: `LinkedInClientError`, `BrowserLifecycleError`, `CookieFormatError`, `LoginRequiredError`, `LoginTimeoutError`, `LoginCheckpointError`, `LoginCancelledError`, `FeedLoadError`, `PostParseError`
@@ -0,0 +1,40 @@
1
+ # linkedin-search-core
2
+
3
+ ## Description
4
+
5
+ Shared LinkedIn browser session for search libraries. This package opens Chromium through Playwright, restores a session, and runs interactive login. It does not search jobs or posts.
6
+
7
+ Requires Python 3.11+.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install linkedin-search-core
13
+ ```
14
+
15
+ Local editable install from this monorepo:
16
+
17
+ ```bash
18
+ pip install -e packages/linkedin-search-core
19
+ ```
20
+
21
+ ## Dependencies
22
+
23
+ - `playwright>=1.49.0`
24
+ - `session-snapshot` is optional. Import it in the same environment when you restore or export a full Chrome session snapshot. Without it, cookie-only login still works.
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from linkedin_search_core import LinkedInSession
30
+
31
+ with LinkedInSession(cookies=cookies, headless=True) as session:
32
+ print(session.is_logged_in())
33
+ ```
34
+
35
+ ## API
36
+
37
+ - `LinkedInSession` — context manager for the browser session (`login_and_get_cookies`, `get_cookies`, `export_session_snapshot`, `is_logged_in`)
38
+ - `LinkedInClientConfig`, `BrowserConfig`, `LoginMethod`
39
+ - `UserIdentity`
40
+ - Errors: `LinkedInClientError`, `BrowserLifecycleError`, `CookieFormatError`, `LoginRequiredError`, `LoginTimeoutError`, `LoginCheckpointError`, `LoginCancelledError`, `FeedLoadError`, `PostParseError`
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "linkedin-search-core"
7
+ version = "0.1.0"
8
+ description = "Shared Playwright session, authentication, and errors for LinkedIn search."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "LinkedIn Pulse Reader" }]
13
+ maintainers = [{ name = "LinkedIn Pulse Reader" }]
14
+ keywords = ["linkedin", "playwright", "session"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: Other/Proprietary License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "playwright>=1.49.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/vkudymov/linkedin-pulse-reader-v.3"
32
+ Repository = "https://github.com/vkudymov/linkedin-pulse-reader-v.3"
33
+ Issues = "https://github.com/vkudymov/linkedin-pulse-reader-v.3/issues"
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.0.0",
38
+ ]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = [
42
+ "/src",
43
+ "/README.md",
44
+ "/LICENSE",
45
+ ]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/linkedin_search_core"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -0,0 +1,35 @@
1
+ """Shared LinkedIn browser session, auth, and errors."""
2
+
3
+ from .auth.methods import LoginMethod
4
+ from .browser.config import BrowserConfig
5
+ from .config import LinkedInClientConfig
6
+ from .exceptions import (
7
+ BrowserLifecycleError,
8
+ CookieFormatError,
9
+ FeedLoadError,
10
+ LinkedInClientError,
11
+ LoginCancelledError,
12
+ LoginCheckpointError,
13
+ LoginRequiredError,
14
+ LoginTimeoutError,
15
+ PostParseError,
16
+ )
17
+ from .models.user import UserIdentity
18
+ from .session import LinkedInSession
19
+
20
+ __all__ = [
21
+ "BrowserConfig",
22
+ "BrowserLifecycleError",
23
+ "CookieFormatError",
24
+ "FeedLoadError",
25
+ "LinkedInClientConfig",
26
+ "LinkedInClientError",
27
+ "LinkedInSession",
28
+ "LoginCancelledError",
29
+ "LoginCheckpointError",
30
+ "LoginMethod",
31
+ "LoginRequiredError",
32
+ "LoginTimeoutError",
33
+ "PostParseError",
34
+ "UserIdentity",
35
+ ]
@@ -0,0 +1,13 @@
1
+ """
2
+ RU: Auth boundary.
3
+ В этом пакете собраны механизмы восстановления сессии через session_snapshot и manual login.
4
+ Хранение сессии — ответственность внешнего приложения.
5
+
6
+ EN: Auth boundary.
7
+ This package contains session restoration via session_snapshot and manual login.
8
+ Persistence belongs to the calling application.
9
+ """
10
+
11
+ from .methods import LoginMethod
12
+
13
+ __all__ = ["LoginMethod"]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+
6
+ from playwright.sync_api import BrowserContext, Page
7
+
8
+ from .wait import SessionWaitConfig, wait_for_session
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class EmailPasswordLoginParams:
13
+ identifier: str | None
14
+ password: str | None
15
+
16
+
17
+ class EmailPasswordLoginFlow:
18
+ """
19
+ Best-effort email/phone + password automation for LinkedIn login form.
20
+
21
+ If params are missing, the flow keeps the UI open and only waits for the user
22
+ to complete login (same behavior as manual flow).
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ timeout_ms: int,
29
+ params: EmailPasswordLoginParams,
30
+ cancelled: Callable[[], bool] | None = None,
31
+ on_checkpoint: Callable[[], None] | None = None,
32
+ ) -> None:
33
+ self._timeout_ms = timeout_ms
34
+ self._params = params
35
+ self._cancelled = cancelled
36
+ self._on_checkpoint = on_checkpoint
37
+
38
+ def run(self, page: Page, context: BrowserContext) -> None:
39
+ identifier = (self._params.identifier or "").strip()
40
+ password = self._params.password or ""
41
+
42
+ if identifier and password:
43
+ self._fill_and_submit(page, identifier=identifier, password=password)
44
+
45
+ wait_for_session(
46
+ page,
47
+ cfg=SessionWaitConfig(
48
+ timeout_ms=self._timeout_ms,
49
+ cancelled=self._cancelled,
50
+ on_checkpoint=self._on_checkpoint,
51
+ ),
52
+ )
53
+
54
+ @staticmethod
55
+ def _fill_and_submit(page: Page, *, identifier: str, password: str) -> None:
56
+ # LinkedIn uses slightly different selectors depending on the variant of login page.
57
+ user_sel = "input#username, input[name='session_key']"
58
+ pass_sel = "input#password, input[name='session_password']"
59
+
60
+ page.wait_for_selector(user_sel, timeout=10_000)
61
+ page.locator(user_sel).first.fill(identifier)
62
+ page.locator(pass_sel).first.fill(password)
63
+
64
+ # Submit: prefer explicit button, fallback to form submit.
65
+ if page.locator("button[type='submit']").count() > 0:
66
+ page.locator("button[type='submit']").first.click()
67
+ else:
68
+ page.keyboard.press("Enter")
69
+
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class LoginMethod(StrEnum):
7
+ email = "email"
8
+ google = "google"
9
+ apple = "apple"
10
+
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass
7
+
8
+ from playwright.sync_api import BrowserContext, Page, TimeoutError as PlaywrightTimeoutError
9
+
10
+ from .methods import LoginMethod
11
+ from .wait import SessionWaitConfig, wait_for_session
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SocialLoginParams:
16
+ method: LoginMethod
17
+
18
+
19
+ class SocialLoginFlow:
20
+ """
21
+ Social provider login entry (Google / Apple) on the LinkedIn login page.
22
+
23
+ The OAuth step is intentionally interactive: the user may need to complete
24
+ CAPTCHA/2FA in the opened browser. This flow only clicks the provider button
25
+ and waits until LinkedIn feed becomes available.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ timeout_ms: int,
32
+ params: SocialLoginParams,
33
+ cancelled: Callable[[], bool] | None = None,
34
+ on_checkpoint: Callable[[], None] | None = None,
35
+ ) -> None:
36
+ self._timeout_ms = timeout_ms
37
+ self._params = params
38
+ self._cancelled = cancelled
39
+ self._on_checkpoint = on_checkpoint
40
+
41
+ def run(self, page: Page, context: BrowserContext) -> None:
42
+ # LinkedIn can open OAuth in a popup or do a same-tab redirect.
43
+ # We click once and (best-effort) capture the popup if it appears.
44
+ with suppress(PlaywrightTimeoutError):
45
+ with context.expect_page(timeout=2_000) as pinfo:
46
+ self._click_provider_button(page)
47
+ popup = pinfo.value
48
+ with suppress(Exception):
49
+ popup.bring_to_front()
50
+
51
+ wait_for_session(
52
+ page,
53
+ cfg=SessionWaitConfig(
54
+ timeout_ms=self._timeout_ms,
55
+ cancelled=self._cancelled,
56
+ on_checkpoint=self._on_checkpoint,
57
+ ),
58
+ )
59
+
60
+ def _click_provider_button(self, page: Page) -> None:
61
+ name_rx = self._provider_button_name_regex()
62
+
63
+ # Prefer accessible role-based selectors.
64
+ with suppress(Exception):
65
+ btn = page.get_by_role("button", name=name_rx)
66
+ if btn.count() > 0:
67
+ btn.first.click()
68
+ return
69
+
70
+ # Fallback selectors observed on some LinkedIn variants.
71
+ candidates = self._provider_fallback_selectors()
72
+ for sel in candidates:
73
+ with suppress(Exception):
74
+ loc = page.locator(sel)
75
+ if loc.count() > 0:
76
+ loc.first.click()
77
+ return
78
+
79
+ # Last resort: click by text.
80
+ with suppress(Exception):
81
+ page.get_by_text(name_rx).first.click()
82
+ return
83
+
84
+ raise RuntimeError(f"Could not find LinkedIn {self._params.method} login button.")
85
+
86
+ def _provider_button_name_regex(self) -> re.Pattern[str]:
87
+ if self._params.method == LoginMethod.google:
88
+ return re.compile(r"(continue|sign)\s+in\s+with\s+google", re.IGNORECASE)
89
+ if self._params.method == LoginMethod.apple:
90
+ return re.compile(r"(continue|sign)\s+in\s+with\s+apple", re.IGNORECASE)
91
+ return re.compile(r".*", re.IGNORECASE)
92
+
93
+ def _provider_fallback_selectors(self) -> list[str]:
94
+ if self._params.method == LoginMethod.google:
95
+ return [
96
+ "button[data-tracking-control-name*='google']",
97
+ "button[aria-label*='Google' i]",
98
+ "a[data-tracking-control-name*='google']",
99
+ ]
100
+ if self._params.method == LoginMethod.apple:
101
+ return [
102
+ "button[data-tracking-control-name*='apple']",
103
+ "button[aria-label*='Apple' i]",
104
+ "a[data-tracking-control-name*='apple']",
105
+ ]
106
+ return []
107
+
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from contextlib import suppress
5
+ from dataclasses import dataclass
6
+ from time import monotonic
7
+ from urllib.parse import urlparse
8
+
9
+ from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
10
+
11
+ from ..exceptions import LoginCancelledError, LoginCheckpointError, LoginTimeoutError
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SessionWaitConfig:
16
+ timeout_ms: int
17
+ cancelled: Callable[[], bool] | None = None
18
+ on_checkpoint: Callable[[], None] | None = None
19
+ raise_on_checkpoint: bool = False
20
+
21
+
22
+ def wait_for_session(page: Page, *, cfg: SessionWaitConfig) -> None:
23
+ """
24
+ Wait until LinkedIn session becomes authenticated (feed redirect/layout).
25
+
26
+ This is intentionally best-effort and UI-driven:
27
+ - If LinkedIn redirects to /feed, cookies are usable.
28
+ - If LinkedIn shows checkpoint/challenge, the user must complete it.
29
+ """
30
+ deadline = monotonic() + max(cfg.timeout_ms, 0) / 1000.0
31
+ checkpoint_notified = False
32
+
33
+ while monotonic() < deadline:
34
+ if cfg.cancelled is not None and cfg.cancelled():
35
+ raise LoginCancelledError("Login flow cancelled by caller.")
36
+
37
+ url = _safe_url(page)
38
+ if _is_logged_in_url(url):
39
+ return
40
+
41
+ if _is_linkedin_url(url) and (_is_checkpoint_url(url) or _looks_like_checkpoint_page(page)):
42
+ if cfg.raise_on_checkpoint and cfg.on_checkpoint is None:
43
+ raise LoginCheckpointError(
44
+ "LinkedIn requires verification (checkpoint). "
45
+ "Complete it in the opened browser window."
46
+ )
47
+ if not checkpoint_notified:
48
+ checkpoint_notified = True
49
+ if cfg.on_checkpoint is not None:
50
+ with suppress(Exception):
51
+ cfg.on_checkpoint()
52
+
53
+ remaining_ms = int(max(0.0, (deadline - monotonic()) * 1000.0))
54
+ slice_ms = min(1500, remaining_ms) if remaining_ms else 0
55
+
56
+ # Prefer URL-based signal, but don't block for the entire timeout in one call.
57
+ if slice_ms:
58
+ with suppress(PlaywrightTimeoutError):
59
+ page.wait_for_url("**/feed/**", timeout=slice_ms)
60
+ if _is_logged_in_url(_safe_url(page)):
61
+ return
62
+
63
+ # If nothing happened, yield a bit to allow the page to update.
64
+ with suppress(Exception):
65
+ page.wait_for_timeout(250)
66
+
67
+ raise LoginTimeoutError(
68
+ "Login did not complete in time. Complete authentication in the opened browser window."
69
+ )
70
+
71
+
72
+ def _safe_url(page: Page) -> str:
73
+ try:
74
+ return (page.url or "").strip()
75
+ except Exception:
76
+ return ""
77
+
78
+
79
+ def _is_logged_in_url(url: str) -> bool:
80
+ lowered = url.lower()
81
+ return (
82
+ "linkedin.com/feed" in lowered
83
+ and "/checkpoint" not in lowered
84
+ and "/login" not in lowered
85
+ and "/authwall" not in lowered
86
+ and "linkedin.com/uas/" not in lowered
87
+ )
88
+
89
+
90
+ def _is_checkpoint_url(url: str) -> bool:
91
+ lowered = url.lower()
92
+ return "/checkpoint" in lowered or "/challenge" in lowered
93
+
94
+
95
+ def _is_linkedin_url(url: str) -> bool:
96
+ try:
97
+ host = (urlparse(url).hostname or "").lower()
98
+ return host.endswith("linkedin.com") or host.endswith("www.linkedin.com")
99
+ except Exception:
100
+ return False
101
+
102
+
103
+ def _looks_like_checkpoint_page(page: Page) -> bool:
104
+ # Keep selectors conservative and LinkedIn-specific.
105
+ try:
106
+ return (
107
+ page.locator(
108
+ "form[action*='checkpoint'], input[name='challengeId'], input[name='pin']"
109
+ ).count()
110
+ > 0
111
+ )
112
+ except Exception:
113
+ return False
114
+
@@ -0,0 +1,13 @@
1
+ """
2
+ RU: Platform/browser layer.
3
+ Инкапсулирует lifecycle Playwright/Chromium и runtime-настройки браузера.
4
+
5
+ EN: Platform/browser layer.
6
+ Encapsulates Playwright/Chromium lifecycle and browser runtime configuration.
7
+ """
8
+
9
+ from .config import BrowserConfig
10
+ from .manager import BrowserManager
11
+
12
+ __all__ = ["BrowserConfig", "BrowserManager"]
13
+
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class BrowserConfig:
8
+ """
9
+ RU: Настройки runtime-окружения браузера (platform policy).
10
+ Держим их отдельно от фич-логики, чтобы управление запуском Chromium было централизованным.
11
+
12
+ EN: Browser runtime settings (platform policy).
13
+ Kept separate from feature logic so Chromium startup policy is centralized.
14
+ """
15
+ headless: bool = True
16
+ timeout_ms: int = 30_000
17
+
18
+ locale: str | None = "en-US"
19
+ timezone_id: str | None = None
20
+ user_agent: str | None = None
21
+
22
+ viewport_width: int = 1365
23
+ viewport_height: int = 768
24
+
25
+ slow_mo_ms: int | None = None
26
+
27
+ # Prefer real Chrome for social auth (Google/Apple) where Chromium automation can be blocked.
28
+ # Example: "chrome", "msedge". When not available, the library falls back to default chromium.
29
+ channel: str | None = None
30
+
31
+ # Extra launch args passed to Playwright. Keep optional to avoid breaking defaults.
32
+ launch_args: list[str] | None = None
33
+
34
+ # When set, Playwright will use a persistent browser profile (user-data-dir).
35
+ # This is important for Google OAuth flows that block automated ephemeral contexts.
36
+ user_data_dir: str | None = None
37
+