warmpath 0.3.2__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.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: warmpath
3
- Version: 0.3.2
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
@@ -0,0 +1,88 @@
1
+ ![Warmpath banner](warmpath.jpg)
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
+ ```
@@ -18,7 +18,7 @@ For the `skill` subcommand, the call path is:
18
18
  4. `find_skill_connections(...)`
19
19
  5. `render_skill_connections_result(result)`
20
20
 
21
- `run_skill_command` builds a LinkedIn API client from the session saved by `warmpath auth import --browser <browser>`, resolves the cache directory, calls the skill search pipeline, and prints the rendered result. Use `warmpath auth status` to check the saved session's expiry locally.
21
+ `run_skill_command` builds a LinkedIn API client from the session saved by `warmpath auth import --browser <browser>`, resolves the cache directory, calls the skill search pipeline, and prints the rendered result. Use `warmpath auth status` to check whether you are logged in and as whom.
22
22
 
23
23
  ## Defaults
24
24
 
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "warmpath"
3
- version = "0.3.2"
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
  ]
@@ -148,42 +148,41 @@ def test_imported_session_reaches_linkedin_with_csrf_and_cookie_attributes(reade
148
148
  assert "Cookie" not in session.prepare_request(Request("GET", "http://www.linkedin.com/")).headers
149
149
 
150
150
 
151
- def test_import_and_status_report_metadata_without_cookie_values(reader, capsys, auth_path, profile_fetch):
151
+ def test_import_and_status_print_only_the_logged_in_user(reader, capsys, auth_path, profile_fetch):
152
152
  cli.main(["auth", "import", "--browser", "Chrome"])
153
- profile_fetch.assert_not_called()
153
+ output = capsys.readouterr()
154
+ assert output.out == "Logged in as Ada Lovelace\n"
155
+ assert output.err == ""
156
+ profile_fetch.assert_called_once_with("/me", timeout=15, evade=cli.no_delay)
157
+ profile_fetch.reset_mock()
154
158
  before = auth_path.read_bytes()
155
159
  reader.reset_mock()
156
160
  cli.main(["auth", "status"])
157
161
 
158
162
  output = capsys.readouterr()
159
163
  assert output.err == ""
160
- assert output.out.count("Status: ready (local expiry check)") == 2
161
- assert output.out.count("User: Ada Lovelace") == 1
162
- assert "Browser: chrome" in output.out
163
- assert "Imported:" in output.out
164
- assert str(auth_path) in output.out
165
- assert "Cookie: JSESSIONID; expires: session" in output.out
166
- assert "Cookie: li_at; expires: 2100-01-01T00:00:00+00:00" in output.out
167
- assert "private-linkedin-token" not in output.out
168
- assert "private-csrf-token" not in output.out
164
+ assert output.out == "Logged in as Ada Lovelace\n"
169
165
  assert auth_path.read_bytes() == before
170
166
  reader.assert_not_called()
171
167
  profile_fetch.assert_called_once_with("/me", timeout=15, evade=cli.no_delay)
172
168
 
173
169
 
170
+ @pytest.mark.parametrize("command", [["status"], ["import", "--browser", "chrome"]])
174
171
  @pytest.mark.parametrize("profile,expected", [
175
172
  ({"firstName": " Ada ", "lastName": " Lovelace "}, "Ada Lovelace"),
176
173
  ({"firstName": "Адель", "lastName": "Низамутдинов"}, "Адель Низамутдинов"),
177
174
  ({"firstName": "Ada"}, "Ada"),
178
175
  ({"lastName": "Lovelace"}, "Lovelace"),
179
176
  ])
180
- def test_status_prints_the_logged_in_users_name(profile, expected, reader, profile_fetch, capsys):
177
+ def test_auth_prints_the_logged_in_users_name(command, profile, expected, reader, profile_fetch, capsys):
181
178
  auth.import_browser("chrome")
182
179
  profile_fetch.return_value.json.return_value = {"miniProfile": profile}
183
180
 
184
- cli.main(["auth", "status"])
181
+ cli.main(["auth", *command])
185
182
 
186
- assert f"User: {expected}\n" in capsys.readouterr().out
183
+ output = capsys.readouterr()
184
+ assert output.out == f"Logged in as {expected}\n"
185
+ assert output.err == ""
187
186
 
188
187
 
189
188
  @pytest.mark.parametrize("payload", [
@@ -198,11 +197,10 @@ def test_status_rejects_a_profile_without_a_name(payload, reader, profile_fetch,
198
197
  with pytest.raises(SystemExit) as error:
199
198
  cli.main(["auth", "status"])
200
199
 
201
- assert error.value.code == 2
200
+ assert error.value.code == 1
202
201
  output = capsys.readouterr()
203
- assert "LinkedIn did not return a name" in output.err
204
- assert "User:" not in output.out
205
- assert "private-linkedin-token" not in output.out + output.err
202
+ assert output.out == "Not logged in\n"
203
+ assert output.err == ""
206
204
 
207
205
 
208
206
  @pytest.mark.parametrize("failure", ["connection", "timeout", "http", "json"])
@@ -222,12 +220,10 @@ def test_status_handles_failed_user_lookup_without_exposing_secrets(failure, rea
222
220
  with pytest.raises(SystemExit) as error:
223
221
  cli.main(["auth", "status"])
224
222
 
225
- assert error.value.code == 2
223
+ assert error.value.code == 1
226
224
  output = capsys.readouterr()
227
- assert "Could not fetch the logged-in LinkedIn user" in output.err
228
- assert "warmpath auth import --browser" in output.err
229
- assert "User:" not in output.out
230
- assert "private-linkedin-token" not in output.out + output.err
225
+ assert output.out == "Not logged in\n"
226
+ assert output.err == ""
231
227
  assert auth_path.read_bytes() == before
232
228
  reader.assert_not_called()
233
229
 
@@ -240,14 +236,36 @@ def test_status_reports_rejected_sessions(status_code, reader, profile_fetch, ca
240
236
  with pytest.raises(SystemExit) as error:
241
237
  cli.main(["auth", "status"])
242
238
 
243
- assert error.value.code == 2
239
+ assert error.value.code == 1
244
240
  output = capsys.readouterr()
245
- assert "LinkedIn rejected the saved session" in output.err
246
- assert "warmpath auth import --browser" in output.err
247
- assert "User:" not in output.out
241
+ assert output.out == "Not logged in\n"
242
+ assert output.err == ""
248
243
  profile_fetch.return_value.json.assert_not_called()
249
244
 
250
245
 
246
+ @pytest.mark.parametrize("failure", ["rejected", "connection", "missing_name"])
247
+ def test_failed_import_lookup_preserves_previous_session(failure, reader, browser_cookies, auth_path, profile_fetch, capsys):
248
+ auth.import_browser("chrome")
249
+ before = auth_path.read_bytes()
250
+ next(cookie for cookie in browser_cookies if cookie.name == "li_at").value = "new-private-token"
251
+ if failure == "rejected":
252
+ profile_fetch.return_value.status_code = 401
253
+ elif failure == "connection":
254
+ profile_fetch.side_effect = ConnectionError("new-private-token")
255
+ else:
256
+ profile_fetch.return_value.json.return_value = {}
257
+
258
+ with pytest.raises(SystemExit) as error:
259
+ cli.main(["auth", "import", "--browser", "chrome"])
260
+
261
+ assert error.value.code == 2
262
+ output = capsys.readouterr()
263
+ assert output.out == ""
264
+ assert output.err
265
+ assert "new-private-token" not in output.err
266
+ assert auth_path.read_bytes() == before
267
+
268
+
251
269
  @pytest.mark.skipif(os.name != "posix", reason="Unix file permissions")
252
270
  def test_auth_store_is_private_even_when_replacing_an_existing_file(reader, auth_path):
253
271
  auth_path.parent.mkdir()
@@ -309,7 +327,7 @@ def test_failed_save_preserves_previous_session_and_cleans_up(reader, auth_path,
309
327
 
310
328
 
311
329
  @pytest.mark.parametrize("command", [
312
- ["auth", "status"], ["company", "Acme"], ["skill", "Python"],
330
+ ["company", "Acme"], ["skill", "Python"],
313
331
  ["human", "https://www.linkedin.com/in/example/"],
314
332
  ])
315
333
  def test_missing_auth_has_import_guidance_without_browser_or_api_access(command, reader, monkeypatch, capsys, auth_path):
@@ -326,19 +344,34 @@ def test_missing_auth_has_import_guidance_without_browser_or_api_access(command,
326
344
  linkedin.assert_not_called()
327
345
 
328
346
 
347
+ def test_missing_auth_status_prints_not_logged_in(reader, monkeypatch, capsys, auth_path):
348
+ linkedin = Mock()
349
+ monkeypatch.setattr(cli, "Linkedin", linkedin)
350
+
351
+ with pytest.raises(SystemExit) as error:
352
+ cli.main(["auth", "status"])
353
+
354
+ assert error.value.code == 1
355
+ output = capsys.readouterr()
356
+ assert output.out == "Not logged in\n"
357
+ assert output.err == ""
358
+ assert not auth_path.exists()
359
+ reader.assert_not_called()
360
+ linkedin.assert_not_called()
361
+
362
+
329
363
  @pytest.mark.parametrize("content", [b"", b"[]", b"{}", b"private-token", b"\xff"])
330
- def test_corrupt_store_has_reimport_guidance(content, auth_path, capsys):
364
+ def test_corrupt_store_status_prints_not_logged_in(content, auth_path, capsys):
331
365
  auth_path.parent.mkdir()
332
366
  auth_path.write_bytes(content)
333
367
 
334
368
  with pytest.raises(SystemExit) as error:
335
369
  cli.main(["auth", "status"])
336
370
 
337
- assert error.value.code == 2
371
+ assert error.value.code == 1
338
372
  output = capsys.readouterr()
339
- assert "saved LinkedIn session is invalid" in output.err
340
- assert "warmpath auth import --browser" in output.err
341
- assert "private-token" not in output.out + output.err
373
+ assert output.out == "Not logged in\n"
374
+ assert output.err == ""
342
375
  assert auth_path.read_bytes() == content
343
376
 
344
377
 
@@ -358,7 +391,7 @@ def test_invalid_stored_cookie_is_rejected(field, value, reader, auth_path):
358
391
  auth.load_cookies()
359
392
 
360
393
 
361
- def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsys, profile_fetch):
394
+ def test_expired_session_is_reported_when_browser_also_has_no_valid_session(reader, capsys, profile_fetch):
362
395
  session = auth.import_browser("chrome")
363
396
  next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
364
397
  auth.save_auth(session)
@@ -369,19 +402,77 @@ def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsy
369
402
 
370
403
  assert status_error.value.code == 1
371
404
  output = capsys.readouterr()
372
- assert "Status: expired or incomplete" in output.out
373
- assert "Missing or expired: li_at" in output.out
374
- assert "warmpath auth import --browser chrome" in output.out
405
+ assert output.out == "Not logged in\n"
406
+ assert output.err == ""
375
407
 
376
408
  with pytest.raises(SystemExit) as api_error:
377
- cli.build_api()
409
+ cli.main(["company", "Acme"])
378
410
 
379
411
  assert api_error.value.code == 2
380
412
  assert "warmpath auth import --browser chrome" in capsys.readouterr().err
381
- reader.assert_not_called()
413
+ assert reader.call_count == 2
382
414
  profile_fetch.assert_not_called()
383
415
 
384
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
+
385
476
  @pytest.mark.parametrize("arguments", [[], ["import"], ["import", "--browser", "unknown"], ["login"]])
386
477
  def test_auth_rejects_missing_and_unknown_arguments(arguments, reader):
387
478
  with pytest.raises(SystemExit) as error: