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.
- linkedin_search_core-0.1.0/.gitignore +39 -0
- linkedin_search_core-0.1.0/LICENSE +3 -0
- linkedin_search_core-0.1.0/PKG-INFO +67 -0
- linkedin_search_core-0.1.0/README.md +40 -0
- linkedin_search_core-0.1.0/pyproject.toml +51 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/__init__.py +35 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/auth/__init__.py +13 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/auth/email_password.py +69 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/auth/methods.py +10 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/auth/social.py +107 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/auth/wait.py +114 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/browser/__init__.py +13 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/browser/config.py +37 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/browser/manager.py +164 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/config.py +75 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/exceptions.py +48 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/loading/__init__.py +5 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/loading/scroller.py +120 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/models/__init__.py +3 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/models/user.py +17 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/scroll/__init__.py +14 -0
- linkedin_search_core-0.1.0/src/linkedin_search_core/scroll/human_scroll.py +166 -0
- 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,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,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
|
+
|