warmpath 0.3.3__tar.gz → 0.3.4__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.
- {warmpath-0.3.3 → warmpath-0.3.4}/PKG-INFO +2 -1
- warmpath-0.3.4/README.md +88 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/pyproject.toml +2 -1
- {warmpath-0.3.3 → warmpath-0.3.4}/tests/test_auth.py +62 -3
- warmpath-0.3.4/tests/test_auth_http.py +405 -0
- warmpath-0.3.4/tests/test_browser_http.py +163 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/tests/test_cli_commands.py +1 -0
- warmpath-0.3.4/tests/test_companies.py +323 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/uv.lock +155 -1
- {warmpath-0.3.3 → warmpath-0.3.4}/warmpath/auth.py +75 -4
- warmpath-0.3.4/warmpath/auth_http.py +182 -0
- warmpath-0.3.4/warmpath/browser_http.py +122 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/warmpath/cli.py +147 -17
- warmpath-0.3.4/warmpath/companies.py +305 -0
- warmpath-0.3.3/README.md +0 -55
- {warmpath-0.3.3 → warmpath-0.3.4}/.gitignore +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/AGENTS.md +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/Taskfile.yaml +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/UNLICENSE +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/docs/skill.md +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/scripts/release.sh +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/tests/test_mutual_connections.py +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/warmpath/__init__.py +0 -0
- {warmpath-0.3.3 → warmpath-0.3.4}/warmpath.jpg +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: warmpath
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Local LinkedIn visible-connections scraper.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: browser-cookie3>=0.20.1
|
|
7
|
+
Requires-Dist: curl-cffi>=0.16.3
|
|
7
8
|
Requires-Dist: open-linkedin-api>=2.3.1
|
|
8
9
|
Requires-Dist: platformdirs>=4.11.8
|
warmpath-0.3.4/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Warmpath
|
|
4
|
+
|
|
5
|
+
Find LinkedIn mutuals and warm paths using your logged-in browser session.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Log in to LinkedIn in a regular browser window, then import that session. Replace `chrome` with your browser's name if needed.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
# Import and check your browser session
|
|
13
|
+
uvx warmpath auth import --browser chrome
|
|
14
|
+
uvx warmpath auth status
|
|
15
|
+
|
|
16
|
+
# Find people who can introduce you to a company
|
|
17
|
+
uvx warmpath company HashiCorp
|
|
18
|
+
uvx warmpath company https://www.linkedin.com/company/hashicorp/ --max-degree 2 --limit 5
|
|
19
|
+
|
|
20
|
+
# List the current employers of all your direct connections
|
|
21
|
+
uvx warmpath companies
|
|
22
|
+
uvx warmpath companies --urls
|
|
23
|
+
uvx warmpath companies --refresh-cache
|
|
24
|
+
|
|
25
|
+
# Find reachable people with a skill
|
|
26
|
+
uvx warmpath skill Flutter
|
|
27
|
+
uvx warmpath skill Leadership --max-depth 2
|
|
28
|
+
|
|
29
|
+
# Find mutual connections with a person
|
|
30
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/
|
|
31
|
+
|
|
32
|
+
# Refresh cached results
|
|
33
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
|
|
34
|
+
|
|
35
|
+
# Show options for a command; also works with companies, auth, skill, and human
|
|
36
|
+
uvx warmpath company --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`companies` walks the complete 1st-degree connections list, looks up current
|
|
40
|
+
employers, and deduplicates by company ID. It includes multiple current employers
|
|
41
|
+
and keeps unlinked employer names; `--urls` prints only known LinkedIn company
|
|
42
|
+
URLs. It does not infer employment from profile headlines.
|
|
43
|
+
|
|
44
|
+
Connections are requested in pages of 1,000. Profiles are looked up once per
|
|
45
|
+
unique connection when employer data is absent from the connection row. Company
|
|
46
|
+
names and URLs come from those responses, with no separate company lookups.
|
|
47
|
+
Completed lookups are cached so interrupted runs can resume, and repeating a
|
|
48
|
+
completed snapshot makes no HTTP calls. Use `--refresh-cache` for updated
|
|
49
|
+
connections and employment. Caches are scoped to the imported session.
|
|
50
|
+
|
|
51
|
+
If LinkedIn restricts a profile or truncates its employment data, the command
|
|
52
|
+
prints the known companies, reports the incomplete result on stderr, and exits
|
|
53
|
+
with status 1. Cached snapshots preserve that status until refreshed.
|
|
54
|
+
|
|
55
|
+
## Browser sessions
|
|
56
|
+
|
|
57
|
+
Supported browsers are Chrome, Chromium, Firefox, Edge, Brave, Safari, Arc, Vivaldi, Opera, Opera GX (`opera-gx`), and LibreWolf; availability depends on your operating system. Your OS may ask for keychain/keyring access. If the cookie store is locked, close the browser and retry. Private-window sessions cannot be imported.
|
|
58
|
+
|
|
59
|
+
Warmpath imports LinkedIn's `li_at` and `JSESSIONID` tokens together with its device, security, and routing cookies, and manages the saved session automatically. `company`, `companies`, `skill`, and `human` use it on subsequent runs. Failed imports preserve the previous session.
|
|
60
|
+
|
|
61
|
+
`auth import` and `auth status` print only `Logged in as <name>` after confirming your session with LinkedIn. If `auth status` cannot confirm a login, it prints `Not logged in` and exits with status 1. Both commands require a connection to LinkedIn.
|
|
62
|
+
|
|
63
|
+
Successful requests preserve updated cookies returned by LinkedIn. When a saved session expires or is rejected, Warmpath reads the current session from the previously imported browser and retries a read once. Healthy requests need no extra auth checks or browser access. If LinkedIn has also signed out the browser, sign in there again and retry; cookie expiry dates alone cannot indicate whether LinkedIn has revoked a session. Rate limits and server errors are reported rather than treated as empty search results.
|
|
64
|
+
|
|
65
|
+
LinkedIn requests use [curl_cffi](https://curl-cffi.readthedocs.io/en/latest/impersonate/_index.html) to match the browser family's TLS and HTTP/2 behavior, rather than just changing the user-agent. Chrome, Chromium, and Edge imports use the installed browser version for the user-agent and client hints, with the closest supported transport preset. Requests are spaced at least half a second apart within a client; this does not add HTTP calls. The CLI reads the browser's cookie store without writing to it. Because the copied cookies share the browser's LinkedIn session, server-side revocation can still affect both.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
Browser cookie import uses [browser-cookie3](https://github.com/borisbabic/browser_cookie3).
|
|
70
|
+
|
|
71
|
+
To test changes in this checkout, use `uv run warmpath` instead of `uvx warmpath`,
|
|
72
|
+
which runs the published package. For example:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv run warmpath auth import --browser chrome
|
|
76
|
+
uv run warmpath auth status
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
[go-task](https://taskfile.dev/) runs the repository checks. Install it with Homebrew on macOS, or follow the [installation guide](https://taskfile.dev/docs/installation) for other platforms. Then verify the tool, install the Python development dependencies, and run the checks:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
# macOS
|
|
83
|
+
brew install go-task
|
|
84
|
+
|
|
85
|
+
task --version
|
|
86
|
+
uv sync
|
|
87
|
+
task check
|
|
88
|
+
```
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "warmpath"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
description = "Local LinkedIn visible-connections scraper."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"browser-cookie3>=0.20.1",
|
|
8
|
+
"curl-cffi>=0.16.3",
|
|
8
9
|
"open-linkedin-api>=2.3.1",
|
|
9
10
|
"platformdirs>=4.11.8",
|
|
10
11
|
]
|
|
@@ -391,7 +391,7 @@ def test_invalid_stored_cookie_is_rejected(field, value, reader, auth_path):
|
|
|
391
391
|
auth.load_cookies()
|
|
392
392
|
|
|
393
393
|
|
|
394
|
-
def
|
|
394
|
+
def test_expired_session_is_reported_when_browser_also_has_no_valid_session(reader, capsys, profile_fetch):
|
|
395
395
|
session = auth.import_browser("chrome")
|
|
396
396
|
next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
|
|
397
397
|
auth.save_auth(session)
|
|
@@ -406,14 +406,73 @@ def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsy
|
|
|
406
406
|
assert output.err == ""
|
|
407
407
|
|
|
408
408
|
with pytest.raises(SystemExit) as api_error:
|
|
409
|
-
cli.
|
|
409
|
+
cli.main(["company", "Acme"])
|
|
410
410
|
|
|
411
411
|
assert api_error.value.code == 2
|
|
412
412
|
assert "warmpath auth import --browser chrome" in capsys.readouterr().err
|
|
413
|
-
reader.
|
|
413
|
+
assert reader.call_count == 2
|
|
414
414
|
profile_fetch.assert_not_called()
|
|
415
415
|
|
|
416
416
|
|
|
417
|
+
def test_expired_saved_session_is_replaced_by_valid_browser_session(reader, capsys):
|
|
418
|
+
from copy import deepcopy
|
|
419
|
+
|
|
420
|
+
session = auth.import_browser("chrome")
|
|
421
|
+
session.cookies = deepcopy(session.cookies)
|
|
422
|
+
next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
|
|
423
|
+
auth.save_auth(session)
|
|
424
|
+
reader.reset_mock()
|
|
425
|
+
|
|
426
|
+
cli.main(["auth", "status"])
|
|
427
|
+
|
|
428
|
+
assert capsys.readouterr().out == "Logged in as Ada Lovelace\n"
|
|
429
|
+
assert not auth.load_auth().missing_cookies()
|
|
430
|
+
reader.assert_called_once_with(domain_name="linkedin.com")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_import_keeps_linkedin_device_security_and_routing_cookies(reader, browser_cookies):
|
|
434
|
+
for name in ("bcookie", "bscookie", "lidc", "liap", "dfpfpt", "fptctx2"):
|
|
435
|
+
browser_cookies.set(name, f"value-for-{name}", domain=".linkedin.com", secure=True)
|
|
436
|
+
|
|
437
|
+
auth.import_browser("chrome")
|
|
438
|
+
|
|
439
|
+
saved = auth.load_cookies()
|
|
440
|
+
for name in ("bcookie", "bscookie", "lidc", "liap", "dfpfpt", "fptctx2"):
|
|
441
|
+
assert saved.get(name) == f"value-for-{name}"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_import_persists_browser_user_agent(reader, monkeypatch):
|
|
445
|
+
monkeypatch.setattr(auth, "browser_user_agent", lambda browser: "Browser/152.0.0.0")
|
|
446
|
+
|
|
447
|
+
auth.import_browser("chrome")
|
|
448
|
+
|
|
449
|
+
assert auth.load_auth().user_agent == "Browser/152.0.0.0"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_chrome_user_agent_uses_installed_major_version(monkeypatch):
|
|
453
|
+
import plistlib
|
|
454
|
+
|
|
455
|
+
monkeypatch.setattr(auth.sys, "platform", "darwin")
|
|
456
|
+
monkeypatch.setattr(Path, "read_bytes", lambda self: plistlib.dumps({"CFBundleShortVersionString": "152.0.7977.83"}))
|
|
457
|
+
|
|
458
|
+
agent = auth.browser_user_agent("chrome")
|
|
459
|
+
|
|
460
|
+
assert agent is not None
|
|
461
|
+
assert "Chrome/152.0.0.0" in agent
|
|
462
|
+
assert "Macintosh; Intel Mac OS X 10_15_7" in agent
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@pytest.mark.parametrize("value", ["Browser\r\nInjected: private-token", "", 123, ["Browser"]])
|
|
466
|
+
def test_invalid_stored_user_agent_is_rejected(value, reader, auth_path):
|
|
467
|
+
auth.import_browser("chrome")
|
|
468
|
+
payload = json.loads(auth_path.read_text())
|
|
469
|
+
payload["user_agent"] = value
|
|
470
|
+
auth_path.write_text(json.dumps(payload))
|
|
471
|
+
|
|
472
|
+
with pytest.raises(auth.AuthError, match="saved LinkedIn session is invalid"):
|
|
473
|
+
auth.load_auth()
|
|
474
|
+
|
|
475
|
+
|
|
417
476
|
@pytest.mark.parametrize("arguments", [[], ["import"], ["import", "--browser", "unknown"], ["login"]])
|
|
418
477
|
def test_auth_rejects_missing_and_unknown_arguments(arguments, reader):
|
|
419
478
|
with pytest.raises(SystemExit) as error:
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from email.message import Message
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from requests import Response
|
|
8
|
+
from requests.adapters import BaseAdapter
|
|
9
|
+
from requests.exceptions import HTTPError, Timeout, TooManyRedirects
|
|
10
|
+
|
|
11
|
+
from warmpath import auth, auth_http, cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def response(status=200, *, cookies=(), location=None):
|
|
15
|
+
result = Response()
|
|
16
|
+
result.status_code = status
|
|
17
|
+
result._content = b'{"miniProfile":{"firstName":"Ada","lastName":"Lovelace"}}'
|
|
18
|
+
result.headers["content-type"] = "application/json"
|
|
19
|
+
if location:
|
|
20
|
+
result.headers["location"] = location
|
|
21
|
+
message = Message()
|
|
22
|
+
for cookie in cookies:
|
|
23
|
+
message.add_header("Set-Cookie", cookie)
|
|
24
|
+
result.raw = Mock(_original_response=SimpleNamespace(msg=message))
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LinkedInAdapter(BaseAdapter):
|
|
29
|
+
def __init__(self, responses):
|
|
30
|
+
self.responses = iter(responses)
|
|
31
|
+
self.requests = []
|
|
32
|
+
|
|
33
|
+
def send(self, request, **kwargs):
|
|
34
|
+
self.requests.append(request)
|
|
35
|
+
result = next(self.responses)
|
|
36
|
+
if isinstance(result, Exception):
|
|
37
|
+
raise result
|
|
38
|
+
result.request = request
|
|
39
|
+
result.url = request.url
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
def close(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def saved_session(tmp_path, monkeypatch):
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from requests.cookies import RequestsCookieJar
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr(auth, "auth_store_path", lambda: tmp_path / "auth.json")
|
|
52
|
+
monkeypatch.setattr(auth_http, "sleep", lambda _: None)
|
|
53
|
+
cookies = RequestsCookieJar()
|
|
54
|
+
for name, value in (
|
|
55
|
+
("li_at", "old-auth-token"), ("JSESSIONID", '"ajax:old-csrf"'),
|
|
56
|
+
("bcookie", "browser-device"), ("bscookie", "verified-device"),
|
|
57
|
+
):
|
|
58
|
+
cookies.set(name, value, domain=".www.linkedin.com", path="/", secure=True)
|
|
59
|
+
session = auth.AuthSession("chrome", datetime.now(timezone.utc), cookies)
|
|
60
|
+
auth.save_auth(session)
|
|
61
|
+
return session
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def reader(saved_session, monkeypatch):
|
|
66
|
+
fresh = deepcopy(saved_session.cookies)
|
|
67
|
+
fresh.set("li_at", "new-auth-token", domain=".www.linkedin.com", path="/", secure=True)
|
|
68
|
+
fresh.set("JSESSIONID", '"ajax:new-csrf"', domain=".www.linkedin.com", path="/", secure=True)
|
|
69
|
+
reader = Mock(return_value=fresh)
|
|
70
|
+
monkeypatch.setattr(auth.browser_cookie3, "chrome", reader)
|
|
71
|
+
return reader
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def api_with_responses(responses):
|
|
75
|
+
api = cli.build_api()
|
|
76
|
+
adapter = LinkedInAdapter(responses)
|
|
77
|
+
api.client.session.mount("https://www.linkedin.com/", adapter)
|
|
78
|
+
return api, adapter
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_healthy_requests_use_device_cookies_without_auth_probe_or_browser_io(saved_session, reader):
|
|
82
|
+
before = auth.auth_store_path().read_bytes()
|
|
83
|
+
api, adapter = api_with_responses([response(), response()])
|
|
84
|
+
|
|
85
|
+
api._fetch("/example")
|
|
86
|
+
api._fetch("/example?page=2")
|
|
87
|
+
|
|
88
|
+
assert len(adapter.requests) == 2
|
|
89
|
+
assert all("bcookie=browser-device" in req.headers["Cookie"] for req in adapter.requests)
|
|
90
|
+
assert all("bscookie=verified-device" in req.headers["Cookie"] for req in adapter.requests)
|
|
91
|
+
reader.assert_not_called()
|
|
92
|
+
assert auth.auth_store_path().read_bytes() == before
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_imported_browser_user_agent_is_sent(saved_session, reader):
|
|
96
|
+
saved_session.user_agent = "Imported Chrome/152.0.0.0"
|
|
97
|
+
auth.save_auth(saved_session)
|
|
98
|
+
api, adapter = api_with_responses([response()])
|
|
99
|
+
|
|
100
|
+
api._fetch("/example")
|
|
101
|
+
|
|
102
|
+
assert adapter.requests[0].headers["user-agent"] == saved_session.user_agent
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_old_import_uses_installed_browser_user_agent(saved_session, reader, monkeypatch):
|
|
106
|
+
monkeypatch.setattr(auth, "browser_user_agent", lambda browser: "Installed Chrome/152.0.0.0")
|
|
107
|
+
api, adapter = api_with_responses([response()])
|
|
108
|
+
|
|
109
|
+
api._fetch("/example")
|
|
110
|
+
|
|
111
|
+
assert adapter.requests[0].headers["user-agent"] == "Installed Chrome/152.0.0.0"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_requests_are_spaced_without_adding_http_calls(saved_session, reader, monkeypatch):
|
|
115
|
+
clock = [10.0]
|
|
116
|
+
monkeypatch.setattr(auth_http, "monotonic", lambda: clock[0])
|
|
117
|
+
monkeypatch.setattr(auth_http, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds))
|
|
118
|
+
api, adapter = api_with_responses([response(), response()])
|
|
119
|
+
|
|
120
|
+
api._fetch("/example")
|
|
121
|
+
api._fetch("/example?page=2")
|
|
122
|
+
|
|
123
|
+
assert clock[0] == 10.0 + auth_http.MIN_REQUEST_INTERVAL
|
|
124
|
+
assert len(adapter.requests) == 2
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_rejected_session_refreshes_once_and_retries_original_request(saved_session, reader):
|
|
128
|
+
api, adapter = api_with_responses([
|
|
129
|
+
response(401, cookies=["li_at=; Max-Age=0; Domain=.www.linkedin.com; Path=/; Secure"]),
|
|
130
|
+
response(), response(),
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
api._fetch("/example", params={"start": 100})
|
|
134
|
+
api._fetch("/example", params={"start": 200})
|
|
135
|
+
|
|
136
|
+
assert len(adapter.requests) == 3
|
|
137
|
+
assert adapter.requests[0].url == adapter.requests[1].url
|
|
138
|
+
assert "start=100" in adapter.requests[1].url
|
|
139
|
+
assert "li_at=new-auth-token" in adapter.requests[1].headers["Cookie"]
|
|
140
|
+
assert adapter.requests[1].headers["csrf-token"] == "ajax:new-csrf"
|
|
141
|
+
reader.assert_called_once_with(domain_name="linkedin.com")
|
|
142
|
+
assert auth.load_cookies().get("li_at") == "new-auth-token"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_successful_responses_persist_rotated_cookies_and_update_csrf(saved_session, reader):
|
|
146
|
+
api, adapter = api_with_responses([
|
|
147
|
+
response(cookies=[
|
|
148
|
+
"li_at=server-token; Domain=.www.linkedin.com; Path=/; Secure",
|
|
149
|
+
'JSESSIONID="ajax:server-csrf"; Domain=.www.linkedin.com; Path=/; Secure',
|
|
150
|
+
]),
|
|
151
|
+
response(),
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
api._fetch("/example")
|
|
155
|
+
assert auth.load_cookies().get("li_at") == "server-token"
|
|
156
|
+
api._fetch("/example")
|
|
157
|
+
|
|
158
|
+
assert adapter.requests[1].headers["csrf-token"] == "ajax:server-csrf"
|
|
159
|
+
assert 'JSESSIONID="ajax:server-csrf"' in adapter.requests[1].headers["Cookie"]
|
|
160
|
+
reader.assert_not_called()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_long_running_client_can_recover_from_later_rotation(saved_session, reader):
|
|
164
|
+
second_rotation = deepcopy(reader.return_value)
|
|
165
|
+
second_rotation.set("li_at", "second-new-token", domain=".www.linkedin.com", path="/", secure=True)
|
|
166
|
+
reader.side_effect = [reader.return_value, second_rotation]
|
|
167
|
+
api, adapter = api_with_responses([response(401), response(), response(401), response()])
|
|
168
|
+
|
|
169
|
+
api._fetch("/example?page=1")
|
|
170
|
+
api._fetch("/example?page=2")
|
|
171
|
+
|
|
172
|
+
assert reader.call_count == 2
|
|
173
|
+
assert len(adapter.requests) == 4
|
|
174
|
+
assert "li_at=second-new-token" in adapter.requests[-1].headers["Cookie"]
|
|
175
|
+
assert auth.load_cookies().get("li_at") == "second-new-token"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_rotated_csrf_cookie_at_more_specific_domain_replaces_old_header(saved_session, reader):
|
|
179
|
+
saved_session.cookies.clear(".www.linkedin.com", "/", "JSESSIONID")
|
|
180
|
+
saved_session.cookies.set("JSESSIONID", '"ajax:broad"', domain=".linkedin.com", path="/", secure=True)
|
|
181
|
+
auth.save_auth(saved_session)
|
|
182
|
+
api, adapter = api_with_responses([
|
|
183
|
+
response(cookies=['JSESSIONID="ajax:specific"; Domain=.www.linkedin.com; Path=/; Secure']),
|
|
184
|
+
response(),
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
api._fetch("/example")
|
|
188
|
+
api._fetch("/example")
|
|
189
|
+
|
|
190
|
+
assert adapter.requests[1].headers["Cookie"].count("JSESSIONID=") == 1
|
|
191
|
+
assert 'JSESSIONID="ajax:specific"' in adapter.requests[1].headers["Cookie"]
|
|
192
|
+
assert adapter.requests[1].headers["csrf-token"] == "ajax:specific"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_import_saves_cookies_returned_by_validation(saved_session, reader, monkeypatch, capsys):
|
|
196
|
+
adapter = LinkedInAdapter([response(cookies=[
|
|
197
|
+
"li_at=validated-token; Domain=.www.linkedin.com; Path=/; Secure",
|
|
198
|
+
])])
|
|
199
|
+
original_build_api = cli.build_api
|
|
200
|
+
|
|
201
|
+
def build(*args, **kwargs):
|
|
202
|
+
api = original_build_api(*args, **kwargs)
|
|
203
|
+
api.client.session.mount("https://www.linkedin.com/", adapter)
|
|
204
|
+
return api
|
|
205
|
+
|
|
206
|
+
monkeypatch.setattr(cli, "build_api", build)
|
|
207
|
+
cli.main(["auth", "import", "--browser", "chrome"])
|
|
208
|
+
|
|
209
|
+
assert capsys.readouterr().out == "Logged in as Ada Lovelace\n"
|
|
210
|
+
assert auth.load_cookies().get("li_at") == "validated-token"
|
|
211
|
+
assert len(adapter.requests) == 1
|
|
212
|
+
reader.assert_called_once()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_import_then_separate_status_clients_reuse_saved_rotation(saved_session, reader, monkeypatch, capsys):
|
|
216
|
+
adapters = []
|
|
217
|
+
replies = [
|
|
218
|
+
response(cookies=[
|
|
219
|
+
"li_at=validated-token; Domain=.www.linkedin.com; Path=/; Secure",
|
|
220
|
+
'JSESSIONID="ajax:validated-csrf"; Domain=.www.linkedin.com; Path=/; Secure',
|
|
221
|
+
"lidc=routing; Domain=.linkedin.com; Path=/; Secure",
|
|
222
|
+
]),
|
|
223
|
+
response(cookies=[
|
|
224
|
+
'JSESSIONID="ajax:status-csrf"; Domain=.www.linkedin.com; Path=/; Secure',
|
|
225
|
+
]),
|
|
226
|
+
response(),
|
|
227
|
+
]
|
|
228
|
+
original_build_api = cli.build_api
|
|
229
|
+
|
|
230
|
+
def build(*args, **kwargs):
|
|
231
|
+
api = original_build_api(*args, **kwargs)
|
|
232
|
+
adapter = LinkedInAdapter([replies[len(adapters)]])
|
|
233
|
+
adapters.append(adapter)
|
|
234
|
+
api.client.session.mount("https://www.linkedin.com/", adapter)
|
|
235
|
+
return api
|
|
236
|
+
|
|
237
|
+
monkeypatch.setattr(cli, "build_api", build)
|
|
238
|
+
cli.main(["auth", "import", "--browser", "chrome"])
|
|
239
|
+
cli.main(["auth", "status"])
|
|
240
|
+
cli.main(["auth", "status"])
|
|
241
|
+
|
|
242
|
+
assert capsys.readouterr().out == "Logged in as Ada Lovelace\n" * 3
|
|
243
|
+
reader.assert_called_once()
|
|
244
|
+
assert len(adapters) == 3
|
|
245
|
+
for adapter, csrf in zip(adapters[1:], ("ajax:validated-csrf", "ajax:status-csrf")):
|
|
246
|
+
assert len(adapter.requests) == 1
|
|
247
|
+
request = adapter.requests[0]
|
|
248
|
+
assert "li_at=validated-token" in request.headers["Cookie"]
|
|
249
|
+
assert "bcookie=browser-device" in request.headers["Cookie"]
|
|
250
|
+
assert "bscookie=verified-device" in request.headers["Cookie"]
|
|
251
|
+
assert "lidc=routing" in request.headers["Cookie"]
|
|
252
|
+
assert request.headers["csrf-token"] == csrf
|
|
253
|
+
assert f'JSESSIONID="{csrf}"' in request.headers["Cookie"]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.parametrize("failure", [response(401), response(403), response(429), Timeout("secret")])
|
|
257
|
+
def test_failed_recovery_preserves_saved_session(saved_session, reader, failure):
|
|
258
|
+
before = auth.auth_store_path().read_bytes()
|
|
259
|
+
api, adapter = api_with_responses([response(401), failure])
|
|
260
|
+
|
|
261
|
+
with pytest.raises((auth.AuthError, HTTPError, Timeout)):
|
|
262
|
+
api._fetch("/example")
|
|
263
|
+
|
|
264
|
+
assert auth.auth_store_path().read_bytes() == before
|
|
265
|
+
assert len(adapter.requests) == 2
|
|
266
|
+
reader.assert_called_once()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_same_revoked_browser_cookie_is_not_retried(saved_session, reader):
|
|
270
|
+
reader.return_value = saved_session.cookies
|
|
271
|
+
api, adapter = api_with_responses([response(401)])
|
|
272
|
+
|
|
273
|
+
with pytest.raises(auth.AuthError, match="same cookies"):
|
|
274
|
+
api._fetch("/example")
|
|
275
|
+
|
|
276
|
+
assert len(adapter.requests) == 1
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.parametrize("status", [429, 500, 503])
|
|
280
|
+
def test_server_errors_are_not_empty_results_or_auth_retries(saved_session, reader, status):
|
|
281
|
+
api, adapter = api_with_responses([response(status)])
|
|
282
|
+
|
|
283
|
+
with pytest.raises(HTTPError):
|
|
284
|
+
api._fetch("/example")
|
|
285
|
+
|
|
286
|
+
assert len(adapter.requests) == 1
|
|
287
|
+
reader.assert_not_called()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_profile_permission_error_does_not_refresh_valid_auth(saved_session, reader):
|
|
291
|
+
api, adapter = api_with_responses([response(403), response()])
|
|
292
|
+
|
|
293
|
+
with pytest.raises(HTTPError):
|
|
294
|
+
api._fetch("/identity/dash/profiles")
|
|
295
|
+
|
|
296
|
+
assert adapter.requests[1].url == "https://www.linkedin.com/voyager/api/me"
|
|
297
|
+
reader.assert_not_called()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_login_redirect_is_recovered_without_following_it(saved_session, reader):
|
|
301
|
+
api, adapter = api_with_responses([response(302, location="/checkpoint/lg/login"), response()])
|
|
302
|
+
|
|
303
|
+
api._fetch("/example")
|
|
304
|
+
|
|
305
|
+
assert len(adapter.requests) == 2
|
|
306
|
+
assert adapter.requests[0].url == adapter.requests[1].url
|
|
307
|
+
reader.assert_called_once()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_api_cookie_redirect_is_followed_with_new_cookie(saved_session, reader):
|
|
311
|
+
api, adapter = api_with_responses([
|
|
312
|
+
response(302, location="https://www.linkedin.com/voyager/api/example?start=100", cookies=[
|
|
313
|
+
"__cf_bm=updated; Domain=.linkedin.com; Path=/; Secure",
|
|
314
|
+
]),
|
|
315
|
+
response(),
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
api._fetch("/example", params={"start": 100})
|
|
319
|
+
|
|
320
|
+
assert len(adapter.requests) == 2
|
|
321
|
+
assert adapter.requests[0].url == adapter.requests[1].url
|
|
322
|
+
assert "__cf_bm=updated" in adapter.requests[1].headers["Cookie"]
|
|
323
|
+
reader.assert_not_called()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_repeated_api_redirects_stop_without_reimport(saved_session, reader):
|
|
327
|
+
api, adapter = api_with_responses([
|
|
328
|
+
response(302, location="/voyager/api/example") for _ in range(4)
|
|
329
|
+
])
|
|
330
|
+
|
|
331
|
+
with pytest.raises(TooManyRedirects):
|
|
332
|
+
api._fetch("/example")
|
|
333
|
+
|
|
334
|
+
assert len(adapter.requests) == 4
|
|
335
|
+
reader.assert_not_called()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.parametrize("url", ["/m/logout", "/uas/logout", "/%6Cogout"])
|
|
339
|
+
def test_logout_is_never_requested(saved_session, reader, url):
|
|
340
|
+
api, adapter = api_with_responses([])
|
|
341
|
+
before = auth.auth_store_path().read_bytes()
|
|
342
|
+
|
|
343
|
+
with pytest.raises(auth.AuthError, match="logout"):
|
|
344
|
+
api.client.session.get(f"https://www.linkedin.com{url}")
|
|
345
|
+
|
|
346
|
+
assert adapter.requests == []
|
|
347
|
+
assert auth.auth_store_path().read_bytes() == before
|
|
348
|
+
reader.assert_not_called()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_profile_redirect_cannot_log_out_the_shared_session(saved_session, reader):
|
|
352
|
+
api, adapter = api_with_responses([response(302, location="/m/logout")])
|
|
353
|
+
before = auth.auth_store_path().read_bytes()
|
|
354
|
+
|
|
355
|
+
with pytest.raises(auth.AuthError, match="logout"):
|
|
356
|
+
api.client.session.get("https://www.linkedin.com/in/example/")
|
|
357
|
+
|
|
358
|
+
assert len(adapter.requests) == 1
|
|
359
|
+
assert auth.auth_store_path().read_bytes() == before
|
|
360
|
+
reader.assert_not_called()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_profile_login_redirect_is_recovered_without_following_login(saved_session, reader):
|
|
364
|
+
api, adapter = api_with_responses([response(302, location="/login"), response()])
|
|
365
|
+
|
|
366
|
+
api.client.session.get("https://www.linkedin.com/in/example/")
|
|
367
|
+
|
|
368
|
+
assert len(adapter.requests) == 2
|
|
369
|
+
assert adapter.requests[0].url == adapter.requests[1].url
|
|
370
|
+
reader.assert_called_once()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_post_is_never_replayed(saved_session, reader):
|
|
374
|
+
api, adapter = api_with_responses([response(401)])
|
|
375
|
+
|
|
376
|
+
with pytest.raises(auth.AuthError):
|
|
377
|
+
api._post("/example", json={"value": 1})
|
|
378
|
+
|
|
379
|
+
assert len(adapter.requests) == 1
|
|
380
|
+
reader.assert_not_called()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_newer_import_is_reused_instead_of_browser_read(saved_session, reader):
|
|
384
|
+
api, adapter = api_with_responses([response(401), response()])
|
|
385
|
+
newer = deepcopy(saved_session)
|
|
386
|
+
newer.cookies = reader.return_value
|
|
387
|
+
auth.save_auth(newer)
|
|
388
|
+
|
|
389
|
+
api._fetch("/example")
|
|
390
|
+
|
|
391
|
+
assert "li_at=new-auth-token" in adapter.requests[1].headers["Cookie"]
|
|
392
|
+
reader.assert_not_called()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_cookie_rotation_does_not_overwrite_newer_import(saved_session, reader):
|
|
396
|
+
api, _ = api_with_responses([response(cookies=[
|
|
397
|
+
"li_at=old-session-rotated; Domain=.www.linkedin.com; Path=/; Secure",
|
|
398
|
+
])])
|
|
399
|
+
newer = deepcopy(saved_session)
|
|
400
|
+
newer.cookies = reader.return_value
|
|
401
|
+
auth.save_auth(newer)
|
|
402
|
+
|
|
403
|
+
api._fetch("/example")
|
|
404
|
+
|
|
405
|
+
assert auth.load_cookies().get("li_at") == "new-auth-token"
|