warmpath 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.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ .uv-cache/
3
+ __pycache__/
4
+ *.pyc
5
+
6
+ *_connections_*.csv
7
+ *_connections_*.json
8
+
9
+ .code-review-graph/
10
+ .linkedin-cache/
11
+ .pytest_cache/
12
+ .pypi-token
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: warmpath
3
+ Version: 0.1.0
4
+ Summary: Local LinkedIn visible-connections scraper.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: open-linkedin-api>=2.3.1
@@ -0,0 +1,56 @@
1
+ ![Warmpath banner](warmpath.jpg)
2
+
3
+ # Warmpath
4
+
5
+ Find LinkedIn mutuals and warm paths using your logged-in LinkedIn cookies.
6
+
7
+ ## Setup
8
+
9
+ 1. Install [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc?hl=en).
10
+ 2. Log in to LinkedIn in Chrome.
11
+ 3. Use the extension to export cookies for `linkedin.com` in Netscape `cookies.txt` format.
12
+ 4. Create `~/.config/warmpath/linkedin.cookies` and paste the full export there:
13
+
14
+ ```text
15
+ ~/.config/warmpath/linkedin.cookies
16
+ ```
17
+
18
+ Warmpath needs the `li_at` and `JSESSIONID` cookies. Keep this file private; it lives outside the repository.
19
+
20
+ ## Usage
21
+
22
+ ### Company
23
+
24
+ Who can introduce me into this company?
25
+
26
+ ```sh
27
+ uvx warmpath company HashiCorp
28
+ ```
29
+
30
+ ### Skill
31
+
32
+ Which reachable people match this recruiting need?
33
+
34
+ ```sh
35
+ uvx warmpath skill Flutter
36
+ ```
37
+
38
+ ### Human
39
+
40
+ Can I reach this exact person, and through whom?
41
+
42
+ ```sh
43
+ uvx warmpath human https://www.linkedin.com/in/mitchellh/
44
+ ```
45
+
46
+ ## More Examples
47
+
48
+ ```sh
49
+ uvx warmpath company "HashiCorp" --max-degree 2 --limit 5
50
+ uvx warmpath company https://www.linkedin.com/company/hashicorp/ --cookie-file ~/.config/warmpath/linkedin.cookies
51
+ uvx warmpath skill Leadership --max-depth 2
52
+ uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
53
+ uvx warmpath company --help
54
+ uvx warmpath skill --help
55
+ uvx warmpath human --help
56
+ ```
@@ -0,0 +1,20 @@
1
+ version: 3
2
+
3
+ env:
4
+ UV_CACHE_DIR: .uv-cache
5
+
6
+ tasks:
7
+ typecheck:
8
+ run: once
9
+ desc: Type-check Python with Pyright.
10
+ cmd: uv run python -m pyright warmpath
11
+
12
+ test:
13
+ run: once
14
+ desc: Run Python unit and live integration tests.
15
+ cmd: uv run pytest
16
+
17
+ check:
18
+ deps:
19
+ - typecheck
20
+ - test
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES, OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE,
21
+ ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org/>
@@ -0,0 +1,219 @@
1
+ # Warmpath Skill Command Architecture
2
+
3
+ This document describes the current implementation of:
4
+
5
+ ```sh
6
+ uvx warmpath skill <Skill>
7
+ ```
8
+
9
+ ## Entry Point
10
+
11
+ The CLI entry point is `warmpath.cli:main`.
12
+
13
+ For the `skill` subcommand, the call path is:
14
+
15
+ 1. `main(argv)`
16
+ 2. `parse_skill_args(argv[1:])`
17
+ 3. `run_skill_command(args)`
18
+ 4. `find_skill_connections(...)`
19
+ 5. `render_skill_connections_result(result)`
20
+
21
+ `run_skill_command` builds a LinkedIn API client from the cookie file, resolves the cache directory, calls the skill search pipeline, and prints the rendered result.
22
+
23
+ ## Defaults
24
+
25
+ Current defaults:
26
+
27
+ - `--max-depth`: `2`
28
+ - `--limit`: `5`
29
+ - candidate search window: `25`
30
+ - cache directory: `~/.cache/warmpath`
31
+
32
+ `--limit` is the display limit. It caps how many profiles are printed.
33
+
34
+ The candidate search window controls how many LinkedIn search rows are fetched per network depth before filtering and ranking. It is currently `max(--limit, 25)`.
35
+
36
+ ## Search Flow
37
+
38
+ `find_skill_connections` loops over network depths:
39
+
40
+ - degree `1` maps to LinkedIn network filter `F`
41
+ - degree `2` maps to LinkedIn network filter `S`
42
+
43
+ For each degree, `fetch_skill_connection_rows` calls:
44
+
45
+ ```python
46
+ api.search(
47
+ {
48
+ "keywords": skill,
49
+ "filters": (
50
+ "List((key:resultType,value:List(PEOPLE)),"
51
+ f"(key:network,value:List({network_depth})))"
52
+ ),
53
+ },
54
+ limit=search_limit,
55
+ )
56
+ ```
57
+
58
+ So, with default `--max-depth 2`, the command performs two LinkedIn people-search requests when the search cache is cold:
59
+
60
+ 1. first-degree people search
61
+ 2. second-degree people search
62
+
63
+ ## Row Normalization
64
+
65
+ Each raw LinkedIn search result is normalized by `connection_result_row`.
66
+
67
+ The normalized row keeps:
68
+
69
+ - name
70
+ - network distance
71
+ - job title
72
+ - location
73
+ - profile URN
74
+ - canonical profile URL
75
+ - mutual connection metadata, when present
76
+
77
+ Profile URLs are canonicalized to `https://www.linkedin.com/in/<public-id>/`.
78
+
79
+ ## Skill Verification
80
+
81
+ Search results are not accepted blindly.
82
+
83
+ For each candidate row, `skill_match_quality` tries to verify the requested skill:
84
+
85
+ 1. Fetch profile skills with `api.get_profile_skills(public_id=<public-id>)`.
86
+ 2. If that returns a non-empty payload, inspect the payload for an exact normalized skill match.
87
+ 3. If the public-id lookup returns an empty list, retry with `api.get_profile_skills(urn_id=<urn-id>)`, when a URN is available.
88
+ 4. If profile-skill lookup does not confirm the skill, fall back to visible search-row text: name, job title, and location.
89
+
90
+ Match quality is recorded as:
91
+
92
+ - `pinned_profile`
93
+ - `profile_skill`
94
+ - `visible_text`
95
+
96
+ Rows with no match quality are discarded unless they are pinned.
97
+
98
+ ## Pinned Skill Profiles
99
+
100
+ Some LinkedIn skill/profile combinations can be known-good even when profile-skill endpoints return empty data.
101
+
102
+ Current pinned profiles:
103
+
104
+ - `Leadership`: `https://www.linkedin.com/in/timur-pokayonkov/`
105
+
106
+ Pinned profiles:
107
+
108
+ - bypass profile-skill verification
109
+ - are still required to appear in the LinkedIn search result window
110
+ - are ranked before non-pinned rows at the same network degree
111
+
112
+ This handles the case where LinkedIn search returns Timur as a direct connection for `Leadership`, but the profile-skill endpoint returns no usable skill payload.
113
+
114
+ ## Ranking
115
+
116
+ Candidates are deduped by:
117
+
118
+ 1. profile URN
119
+ 2. profile URL
120
+ 3. fallback tuple of name, job title, and location
121
+
122
+ Then candidates are sorted by `skill_candidate_score`:
123
+
124
+ 1. network degree: first-degree before second-degree
125
+ 2. pinned profile rank
126
+ 3. match quality: pinned profile, profile-skill match, visible-text match
127
+ 4. original LinkedIn search rank
128
+ 5. name
129
+
130
+ After sorting, the list is sliced to `--limit`.
131
+
132
+ ## Rendering
133
+
134
+ `render_skill_connections_result` prints:
135
+
136
+ - searched skill
137
+ - max depth
138
+ - count of first-degree and second-degree candidates in the final sliced result
139
+ - grouped first-degree profiles
140
+ - grouped second-degree profiles
141
+
142
+ Each candidate prints:
143
+
144
+ - name
145
+ - role, when available
146
+ - location, when available
147
+ - mutual connection names for second-degree candidates, when available
148
+ - profile URL, when available
149
+
150
+ ## Cache Model
151
+
152
+ `cached_json` stores JSON payloads under `~/.cache/warmpath`.
153
+
154
+ Cache filenames use:
155
+
156
+ - namespace, such as `skill-search-raw` or `profile-skills`
157
+ - SHA-256 digest of the structured cache key
158
+
159
+ For skill search, cache keys include:
160
+
161
+ - skill text
162
+ - network depth
163
+ - search limit
164
+
165
+ For profile skills, cache keys include either:
166
+
167
+ - public profile ID
168
+ - profile URN ID
169
+
170
+ For mutual searches, cache keys include:
171
+
172
+ - target profile URN ID
173
+ - mutual search limit
174
+
175
+ `--refresh-cache` bypasses existing cache files and writes fresh payloads.
176
+
177
+ ## HTTP Request Fanout
178
+
179
+ The current implementation can make substantially more than 1-5 HTTP requests when uncached.
180
+
181
+ With defaults:
182
+
183
+ - `--max-depth 2`
184
+ - display `--limit 5`
185
+ - candidate search window `25`
186
+
187
+ Cold-cache request shape:
188
+
189
+ - 1 first-degree people search
190
+ - 1 second-degree people search
191
+ - up to 25 first-degree profile-skill public-id requests
192
+ - up to 25 first-degree profile-skill URN fallback requests
193
+ - up to 25 second-degree profile-skill public-id requests
194
+ - up to 25 second-degree profile-skill URN fallback requests
195
+ - up to 5 displayed second-degree mutual-search requests when search rows do not expose mutual names
196
+
197
+ Worst case with default display limit: `2 + (25 * 2 * 2) + 5 = 107` LinkedIn API calls.
198
+
199
+ Typical count is lower because:
200
+
201
+ - pinned profiles skip profile-skill verification
202
+ - rows without a public ID or URN have fewer lookup keys
203
+ - public-id skill payloads can stop the URN fallback
204
+ - only displayed second-degree candidates without visible mutual names are enriched with mutual searches
205
+ - warm cache avoids repeated HTTP calls
206
+
207
+ This is why caching still matters for the current implementation. Parallelizing requests would reduce latency, but it would not reduce request volume or LinkedIn rate-limit exposure.
208
+
209
+ ## Known Tradeoffs
210
+
211
+ The current approach favors recall over minimal request count:
212
+
213
+ - A wider search window prevents a useful direct connection from being excluded before ranking.
214
+ - Profile-skill verification reduces false positives from keyword search.
215
+ - Visible-text fallback keeps usable results when LinkedIn skill payloads are empty.
216
+ - Second-degree mutual enrichment keeps the printed output actionable when LinkedIn search rows omit exact mutual profiles.
217
+ - Pinned profiles handle known LinkedIn data gaps.
218
+
219
+ The main cost is HTTP fanout. If request volume becomes the priority, the likely simplification is to remove per-profile skill verification and trust LinkedIn keyword search plus visible-text ranking.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "warmpath"
3
+ version = "0.1.0"
4
+ description = "Local LinkedIn visible-connections scraper."
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "open-linkedin-api>=2.3.1",
8
+ ]
9
+
10
+ [dependency-groups]
11
+ dev = [
12
+ "pytest>=8.4.1",
13
+ "pytest-xdist>=3.8.0",
14
+ "pyright>=1.1.402",
15
+ ]
16
+
17
+ [tool.pytest.ini_options]
18
+ addopts = "-n auto"
19
+ testpaths = ["tests"]
20
+
21
+ [project.scripts]
22
+ warmpath = "warmpath.cli:main"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"