warmpath 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- warmpath/cli.py +1852 -0
- warmpath-0.1.0.dist-info/METADATA +6 -0
- warmpath-0.1.0.dist-info/RECORD +6 -0
- warmpath-0.1.0.dist-info/WHEEL +4 -0
- warmpath-0.1.0.dist-info/entry_points.txt +2 -0
warmpath/cli.py
ADDED
|
@@ -0,0 +1,1852 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import hashlib
|
|
3
|
+
import http.cookiejar
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, NoReturn
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from open_linkedin_api import Linkedin
|
|
12
|
+
from requests.cookies import RequestsCookieJar
|
|
13
|
+
|
|
14
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "warmpath"
|
|
15
|
+
DEFAULT_COOKIE_FILE = DEFAULT_CONFIG_DIR / "linkedin.cookies"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PROFILE_URL_RE = re.compile(r"/in/([^/?#]+)/?")
|
|
19
|
+
COMPANY_URL_RE = re.compile(r"/company/([^/?#]+)/?")
|
|
20
|
+
COMPANY_URN_RE = re.compile(
|
|
21
|
+
r"urn:li:(?:fsd_company|fs_normalized_company|company):([^,)]+)"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
NETWORK_DEPTH_BY_DEGREE = {1: "F", 2: "S"}
|
|
25
|
+
DEFAULT_COMPANY_PATH_LIMIT = 5
|
|
26
|
+
DEFAULT_SKILL_SEARCH_LIMIT = 5
|
|
27
|
+
DEFAULT_SKILL_CANDIDATE_WINDOW = 25
|
|
28
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "warmpath"
|
|
29
|
+
DEFAULT_MAX_MUTUAL_CONNECTIONS = 50
|
|
30
|
+
PINNED_SKILL_PROFILE_URLS = {
|
|
31
|
+
"leadership": ("https://www.linkedin.com/in/timur-pokayonkov/",),
|
|
32
|
+
}
|
|
33
|
+
MUTUAL_CONNECTION_TEXT_RE = re.compile(r"\bmutual connections?\b", re.IGNORECASE)
|
|
34
|
+
OTHER_MUTUALS_RE = re.compile(
|
|
35
|
+
r"(?:^|[\s,])(?:&|and)\s+(\d+)\s+other mutual connections?\s*$",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
MUTUAL_COUNT_RE = re.compile(r"\b(\d+)\s+mutual connections?\b", re.IGNORECASE)
|
|
39
|
+
PROFILE_NETWORK_DISTANCE_KEYS = {
|
|
40
|
+
"connectiondistance",
|
|
41
|
+
"degree",
|
|
42
|
+
"distance",
|
|
43
|
+
"memberdistance",
|
|
44
|
+
"networkdistance",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fail(message: str, exit_code: int = 1) -> NoReturn:
|
|
49
|
+
print(message, file=sys.stderr)
|
|
50
|
+
raise SystemExit(exit_code)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def profile_slug(value: str) -> str:
|
|
54
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
55
|
+
match = PROFILE_URL_RE.search(urlparse(value).path)
|
|
56
|
+
if not match:
|
|
57
|
+
fail(f"Expected LinkedIn /in/ profile URL, got: {value}", 2)
|
|
58
|
+
return match.group(1)
|
|
59
|
+
return value.strip().strip("/")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def company_slug(value: str) -> str | None:
|
|
63
|
+
if not value.startswith(("http://", "https://")):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
match = COMPANY_URL_RE.search(urlparse(value).path)
|
|
67
|
+
if not match:
|
|
68
|
+
fail(f"Expected LinkedIn /company/ URL, got: {value}", 2)
|
|
69
|
+
return match.group(1).strip().strip("/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def company_search_keywords(value: str) -> str:
|
|
73
|
+
slug = company_slug(value)
|
|
74
|
+
if slug:
|
|
75
|
+
return slug.replace("-", " ")
|
|
76
|
+
return value.strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_netscape_cookies(path: Path) -> RequestsCookieJar:
|
|
80
|
+
source = http.cookiejar.MozillaCookieJar()
|
|
81
|
+
source.load(str(path), ignore_discard=True, ignore_expires=True)
|
|
82
|
+
|
|
83
|
+
jar = RequestsCookieJar()
|
|
84
|
+
for cookie in source:
|
|
85
|
+
jar.set_cookie(cookie)
|
|
86
|
+
return jar
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_cookies(path: Path) -> RequestsCookieJar:
|
|
90
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
91
|
+
fail(
|
|
92
|
+
"\n".join(
|
|
93
|
+
[
|
|
94
|
+
f"Cookie file empty: {path}",
|
|
95
|
+
"Paste LinkedIn cookies.txt there, then rerun.",
|
|
96
|
+
"Accepted format: Netscape cookies.txt from Get cookies.txt LOCALLY.",
|
|
97
|
+
"Required cookies: li_at and JSESSIONID.",
|
|
98
|
+
]
|
|
99
|
+
),
|
|
100
|
+
2,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
jar = load_netscape_cookies(path)
|
|
105
|
+
except http.cookiejar.LoadError as exc:
|
|
106
|
+
fail(f"Could not load Netscape cookies.txt: {exc}", 2)
|
|
107
|
+
|
|
108
|
+
names = {cookie.name for cookie in jar}
|
|
109
|
+
missing = {"li_at", "JSESSIONID"} - names
|
|
110
|
+
if missing:
|
|
111
|
+
fail(f"Missing required cookie(s): {', '.join(sorted(missing))}", 2)
|
|
112
|
+
return jar
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_path(path: Path) -> Path:
|
|
116
|
+
path = path.expanduser()
|
|
117
|
+
if path.is_absolute():
|
|
118
|
+
return path
|
|
119
|
+
return Path.cwd() / path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def no_delay() -> None:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def use_fast_fetches(api: Any) -> None:
|
|
127
|
+
original_fetch: Callable[..., Any] = api._fetch
|
|
128
|
+
original_post: Callable[..., Any] = api._post
|
|
129
|
+
|
|
130
|
+
def fast_fetch(uri: str, *args: Any, **kwargs: Any) -> Any:
|
|
131
|
+
kwargs["evade"] = no_delay
|
|
132
|
+
return original_fetch(uri, *args, **kwargs)
|
|
133
|
+
|
|
134
|
+
def fast_post(uri: str, *args: Any, **kwargs: Any) -> Any:
|
|
135
|
+
kwargs["evade"] = no_delay
|
|
136
|
+
return original_post(uri, *args, **kwargs)
|
|
137
|
+
|
|
138
|
+
api._fetch = fast_fetch
|
|
139
|
+
api._post = fast_post
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_api(cookie_file: Path) -> Any:
|
|
143
|
+
cookies = load_cookies(resolve_path(cookie_file))
|
|
144
|
+
api = Linkedin("", "", cookies=cookies)
|
|
145
|
+
use_fast_fetches(api)
|
|
146
|
+
return api
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def profile_urn_id(api: Any, public_id: str) -> str:
|
|
150
|
+
urn_id = profile_urn_id_from_html(api, public_id)
|
|
151
|
+
if urn_id:
|
|
152
|
+
return urn_id
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
profile = api.get_profile(public_id=public_id)
|
|
156
|
+
except KeyError:
|
|
157
|
+
urn_id = profile_urn_id_from_html(api, public_id)
|
|
158
|
+
if urn_id:
|
|
159
|
+
return urn_id
|
|
160
|
+
fail(f"Could not find fsd_profile URN in profile page HTML for {public_id}")
|
|
161
|
+
if not profile:
|
|
162
|
+
fail(f"Could not read profile: https://www.linkedin.com/in/{public_id}/")
|
|
163
|
+
|
|
164
|
+
urn_id = profile.get("urn_id")
|
|
165
|
+
if not urn_id and profile.get("profile_urn"):
|
|
166
|
+
urn_id = profile["profile_urn"].split(":")[-1]
|
|
167
|
+
if not urn_id:
|
|
168
|
+
fail(f"Could not find URN for profile: {public_id}")
|
|
169
|
+
return urn_id
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def profile_urn_id_from_html(api: Any, public_id: str) -> str | None:
|
|
173
|
+
patterns = [
|
|
174
|
+
re.compile(r"urn:li:fsd_profile:([A-Za-z0-9_-]+)"),
|
|
175
|
+
re.compile(r"ref([A-Za-z0-9_-]+)Topcard"),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
with api.client.session.get(
|
|
179
|
+
f"https://www.linkedin.com/in/{public_id}/", stream=True
|
|
180
|
+
) as res:
|
|
181
|
+
if res.status_code != 200:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
text = ""
|
|
185
|
+
for chunk in res.iter_content(chunk_size=8192, decode_unicode=True):
|
|
186
|
+
if not chunk:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if isinstance(chunk, bytes):
|
|
190
|
+
chunk = chunk.decode(res.encoding or "utf-8", errors="ignore")
|
|
191
|
+
|
|
192
|
+
text = (text + chunk)[-40000:]
|
|
193
|
+
decoded = unquote(text)
|
|
194
|
+
for pattern in patterns:
|
|
195
|
+
match = pattern.search(decoded)
|
|
196
|
+
if match:
|
|
197
|
+
return match.group(1)
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def canonical_profile_url(value: str) -> str | None:
|
|
203
|
+
decoded = unquote(value)
|
|
204
|
+
parsed = urlparse(decoded)
|
|
205
|
+
match = PROFILE_URL_RE.search(parsed.path)
|
|
206
|
+
if not match:
|
|
207
|
+
match = PROFILE_URL_RE.search(decoded)
|
|
208
|
+
if not match:
|
|
209
|
+
return None
|
|
210
|
+
return f"https://www.linkedin.com/in/{match.group(1)}/"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def canonical_profile_public_id(value: str) -> str | None:
|
|
214
|
+
url = canonical_profile_url(value)
|
|
215
|
+
if not url:
|
|
216
|
+
return None
|
|
217
|
+
match = PROFILE_URL_RE.search(urlparse(url).path)
|
|
218
|
+
if not match:
|
|
219
|
+
return None
|
|
220
|
+
return match.group(1)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def profile_url_from_result(result: dict[str, Any]) -> str | None:
|
|
224
|
+
preferred_keys = ("navigationUrl", "profileUrl", "targetUrl", "url")
|
|
225
|
+
for key in preferred_keys:
|
|
226
|
+
value = result.get(key)
|
|
227
|
+
if isinstance(value, str):
|
|
228
|
+
url = canonical_profile_url(value)
|
|
229
|
+
if url:
|
|
230
|
+
return url
|
|
231
|
+
|
|
232
|
+
stack: list[Any] = [result]
|
|
233
|
+
|
|
234
|
+
while stack:
|
|
235
|
+
value = stack.pop()
|
|
236
|
+
if isinstance(value, str):
|
|
237
|
+
url = canonical_profile_url(value)
|
|
238
|
+
if url:
|
|
239
|
+
return url
|
|
240
|
+
elif isinstance(value, dict):
|
|
241
|
+
stack.extend(value.values())
|
|
242
|
+
elif isinstance(value, list):
|
|
243
|
+
stack.extend(value)
|
|
244
|
+
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def text_field(result: dict[str, Any], key: str) -> str | None:
|
|
249
|
+
value = result.get(key)
|
|
250
|
+
if isinstance(value, dict):
|
|
251
|
+
text = value.get("text")
|
|
252
|
+
if isinstance(text, str):
|
|
253
|
+
return text
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def urn_id_from_result(result: dict[str, Any]) -> str | None:
|
|
258
|
+
urn = result.get("entityUrn")
|
|
259
|
+
if not isinstance(urn, str):
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
match = re.search(r"(?:fsd_profile|fs_miniProfile):([^,)]+)", urn)
|
|
263
|
+
if match:
|
|
264
|
+
return match.group(1)
|
|
265
|
+
return urn.rsplit(":", maxsplit=1)[-1]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def text_values_containing(value: Any, pattern: re.Pattern[str]) -> list[str]:
|
|
269
|
+
matches: list[str] = []
|
|
270
|
+
stack: list[Any] = [value]
|
|
271
|
+
while stack:
|
|
272
|
+
current = stack.pop()
|
|
273
|
+
if isinstance(current, str):
|
|
274
|
+
if pattern.search(current):
|
|
275
|
+
matches.append(current)
|
|
276
|
+
elif isinstance(current, dict):
|
|
277
|
+
stack.extend(current.values())
|
|
278
|
+
elif isinstance(current, list):
|
|
279
|
+
stack.extend(current)
|
|
280
|
+
return matches
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def parse_mutual_connection_text(
|
|
284
|
+
text: str,
|
|
285
|
+
) -> tuple[list[dict[str, str | None]], int | None, bool]:
|
|
286
|
+
normalized = re.sub(r"\s+", " ", text).strip()
|
|
287
|
+
other_match = OTHER_MUTUALS_RE.search(normalized)
|
|
288
|
+
other_count = int(other_match.group(1)) if other_match else 0
|
|
289
|
+
names_text = normalized[: other_match.start()].strip(" ,") if other_match else ""
|
|
290
|
+
|
|
291
|
+
count_match = MUTUAL_COUNT_RE.search(normalized)
|
|
292
|
+
if not names_text and count_match:
|
|
293
|
+
return [], int(count_match.group(1)), True
|
|
294
|
+
|
|
295
|
+
names = [
|
|
296
|
+
name.strip(" ,")
|
|
297
|
+
for name in re.split(r"\s*,\s*|\s+and\s+", names_text)
|
|
298
|
+
if name.strip(" ,")
|
|
299
|
+
]
|
|
300
|
+
mutual_connections = [
|
|
301
|
+
{"name": name, "url": None, "urn_id": None}
|
|
302
|
+
for name in dict.fromkeys(names)
|
|
303
|
+
]
|
|
304
|
+
if not mutual_connections and not count_match:
|
|
305
|
+
return [], None, False
|
|
306
|
+
|
|
307
|
+
mutual_count = len(mutual_connections) + other_count
|
|
308
|
+
if count_match and int(count_match.group(1)) > mutual_count:
|
|
309
|
+
mutual_count = int(count_match.group(1))
|
|
310
|
+
return mutual_connections, mutual_count, other_count > 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def mutual_connections_from_result(
|
|
314
|
+
result: dict[str, Any],
|
|
315
|
+
) -> tuple[list[dict[str, str | None]], int | None, bool]:
|
|
316
|
+
best_connections: list[dict[str, str | None]] = []
|
|
317
|
+
best_count: int | None = None
|
|
318
|
+
best_truncated = False
|
|
319
|
+
|
|
320
|
+
for text in text_values_containing(result, MUTUAL_CONNECTION_TEXT_RE):
|
|
321
|
+
connections, count, truncated = parse_mutual_connection_text(text)
|
|
322
|
+
best_score = (best_count or 0, len(best_connections))
|
|
323
|
+
score = (count or 0, len(connections))
|
|
324
|
+
if score > best_score:
|
|
325
|
+
best_connections = connections
|
|
326
|
+
best_count = count
|
|
327
|
+
best_truncated = truncated
|
|
328
|
+
|
|
329
|
+
return best_connections, best_count, best_truncated
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def connection_result_row(result: dict[str, Any]) -> dict[str, Any]:
|
|
333
|
+
tracking = result.get("entityCustomTrackingInfo")
|
|
334
|
+
if not isinstance(tracking, dict):
|
|
335
|
+
tracking = {}
|
|
336
|
+
|
|
337
|
+
row: dict[str, Any] = {
|
|
338
|
+
"name": text_field(result, "title"),
|
|
339
|
+
"distance": tracking.get("memberDistance"),
|
|
340
|
+
"jobtitle": text_field(result, "primarySubtitle"),
|
|
341
|
+
"location": text_field(result, "secondarySubtitle"),
|
|
342
|
+
"urn_id": urn_id_from_result(result),
|
|
343
|
+
"url": profile_url_from_result(result),
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
mutual_connections, mutual_count, mutuals_truncated = mutual_connections_from_result(
|
|
347
|
+
result
|
|
348
|
+
)
|
|
349
|
+
if mutual_connections or mutual_count is not None:
|
|
350
|
+
row["mutual_connections"] = mutual_connections
|
|
351
|
+
row["mutual_count"] = mutual_count
|
|
352
|
+
row["mutuals_truncated"] = mutuals_truncated
|
|
353
|
+
|
|
354
|
+
return row
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cache_file_path(cache_dir: Path, namespace: str, key: dict[str, Any]) -> Path:
|
|
358
|
+
digest = hashlib.sha256(
|
|
359
|
+
json.dumps(key, sort_keys=True, ensure_ascii=True).encode("utf-8")
|
|
360
|
+
).hexdigest()[:20]
|
|
361
|
+
return cache_dir / f"{namespace}-{digest}.json"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cached_json(
|
|
365
|
+
cache_dir: Path,
|
|
366
|
+
namespace: str,
|
|
367
|
+
key: dict[str, Any],
|
|
368
|
+
refresh_cache: bool,
|
|
369
|
+
fetch: Callable[[], Any],
|
|
370
|
+
) -> Any:
|
|
371
|
+
path = cache_file_path(cache_dir, namespace, key)
|
|
372
|
+
if not refresh_cache and path.exists():
|
|
373
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
374
|
+
|
|
375
|
+
data = fetch()
|
|
376
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
378
|
+
return data
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def company_urn_id_from_value(value: Any) -> str | None:
|
|
382
|
+
stack: list[Any] = [value]
|
|
383
|
+
while stack:
|
|
384
|
+
current = stack.pop()
|
|
385
|
+
if isinstance(current, str):
|
|
386
|
+
match = COMPANY_URN_RE.search(current)
|
|
387
|
+
if match:
|
|
388
|
+
return match.group(1)
|
|
389
|
+
elif isinstance(current, dict):
|
|
390
|
+
stack.extend(current.values())
|
|
391
|
+
elif isinstance(current, list):
|
|
392
|
+
stack.extend(current)
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def compact_company_payload(
|
|
397
|
+
payload: dict[str, Any], source: str, public_id: str | None = None
|
|
398
|
+
) -> dict[str, str | None]:
|
|
399
|
+
name = payload.get("name")
|
|
400
|
+
if not isinstance(name, str):
|
|
401
|
+
name = payload.get("localizedName")
|
|
402
|
+
if not isinstance(name, str):
|
|
403
|
+
name = None
|
|
404
|
+
|
|
405
|
+
company_public_id = payload.get("universalName")
|
|
406
|
+
if not isinstance(company_public_id, str):
|
|
407
|
+
company_public_id = public_id
|
|
408
|
+
|
|
409
|
+
url = None
|
|
410
|
+
if company_public_id:
|
|
411
|
+
url = f"https://www.linkedin.com/company/{company_public_id}/"
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
"urn_id": company_urn_id_from_value(payload),
|
|
415
|
+
"name": name,
|
|
416
|
+
"headline": None,
|
|
417
|
+
"subline": None,
|
|
418
|
+
"url": url,
|
|
419
|
+
"public_id": company_public_id,
|
|
420
|
+
"source": source,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def compact_company_search_result(
|
|
425
|
+
result: dict[str, Any], source: str
|
|
426
|
+
) -> dict[str, str | None]:
|
|
427
|
+
return {
|
|
428
|
+
"urn_id": result.get("urn_id"),
|
|
429
|
+
"name": result.get("name"),
|
|
430
|
+
"headline": result.get("headline"),
|
|
431
|
+
"subline": result.get("subline"),
|
|
432
|
+
"url": None,
|
|
433
|
+
"public_id": None,
|
|
434
|
+
"source": source,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def resolve_company(
|
|
439
|
+
api: Any, company: str, cache_dir: Path, refresh_cache: bool
|
|
440
|
+
) -> tuple[dict[str, str | None], list[dict[str, str | None]]]:
|
|
441
|
+
slug = company_slug(company)
|
|
442
|
+
candidates: list[dict[str, str | None]] = []
|
|
443
|
+
|
|
444
|
+
if slug:
|
|
445
|
+
try:
|
|
446
|
+
payload = cached_json(
|
|
447
|
+
cache_dir,
|
|
448
|
+
"company-get",
|
|
449
|
+
{"public_id": slug},
|
|
450
|
+
refresh_cache,
|
|
451
|
+
lambda: api.get_company(slug),
|
|
452
|
+
)
|
|
453
|
+
except Exception:
|
|
454
|
+
payload = {}
|
|
455
|
+
|
|
456
|
+
if isinstance(payload, dict):
|
|
457
|
+
resolved = compact_company_payload(payload, "get_company", slug)
|
|
458
|
+
if resolved["urn_id"]:
|
|
459
|
+
return resolved, [resolved]
|
|
460
|
+
|
|
461
|
+
keywords = company_search_keywords(company)
|
|
462
|
+
raw_results = cached_json(
|
|
463
|
+
cache_dir,
|
|
464
|
+
"company-search",
|
|
465
|
+
{"keywords": keywords},
|
|
466
|
+
refresh_cache,
|
|
467
|
+
lambda: api.search_companies(keywords=[keywords], limit=10),
|
|
468
|
+
)
|
|
469
|
+
if not isinstance(raw_results, list):
|
|
470
|
+
raw_results = []
|
|
471
|
+
|
|
472
|
+
for result in raw_results:
|
|
473
|
+
if isinstance(result, dict):
|
|
474
|
+
candidate = compact_company_search_result(result, "search_companies")
|
|
475
|
+
if candidate["urn_id"]:
|
|
476
|
+
candidates.append(candidate)
|
|
477
|
+
|
|
478
|
+
if not candidates:
|
|
479
|
+
fail(f"Could not resolve LinkedIn company: {company}")
|
|
480
|
+
|
|
481
|
+
normalized_keywords = keywords.casefold()
|
|
482
|
+
selected = candidates[0]
|
|
483
|
+
for candidate in candidates:
|
|
484
|
+
name = candidate.get("name")
|
|
485
|
+
if isinstance(name, str) and name.casefold() == normalized_keywords:
|
|
486
|
+
selected = candidate
|
|
487
|
+
break
|
|
488
|
+
|
|
489
|
+
if slug and not selected.get("url"):
|
|
490
|
+
selected["url"] = f"https://www.linkedin.com/company/{slug}/"
|
|
491
|
+
selected["public_id"] = slug
|
|
492
|
+
|
|
493
|
+
return selected, candidates
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def candidate_score(candidate: dict[str, Any]) -> tuple[int, int]:
|
|
497
|
+
target = candidate.get("target")
|
|
498
|
+
if not isinstance(target, dict):
|
|
499
|
+
target = {}
|
|
500
|
+
|
|
501
|
+
title = str(target.get("jobtitle") or "").casefold()
|
|
502
|
+
boost = 0
|
|
503
|
+
if any(word in title for word in ("recruit", "talent", "hiring")):
|
|
504
|
+
boost += 2
|
|
505
|
+
if any(word in title for word in ("engineer", "developer", "manager", "lead")):
|
|
506
|
+
boost += 1
|
|
507
|
+
|
|
508
|
+
degree = candidate.get("degree")
|
|
509
|
+
if not isinstance(degree, int):
|
|
510
|
+
degree = 99
|
|
511
|
+
return degree, -boost
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def person_target(row: dict[str, Any]) -> dict[str, str | None]:
|
|
515
|
+
return {
|
|
516
|
+
"urn_id": row.get("urn_id"),
|
|
517
|
+
"name": row.get("name"),
|
|
518
|
+
"jobtitle": row.get("jobtitle"),
|
|
519
|
+
"location": row.get("location"),
|
|
520
|
+
"distance": row.get("distance"),
|
|
521
|
+
"url": row.get("url"),
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def row_mutual_details(
|
|
526
|
+
row: dict[str, Any],
|
|
527
|
+
) -> tuple[list[dict[str, str | None]], int | None, bool]:
|
|
528
|
+
raw_mutual_connections = row.get("mutual_connections")
|
|
529
|
+
if isinstance(raw_mutual_connections, list):
|
|
530
|
+
mutual_connections = [
|
|
531
|
+
item
|
|
532
|
+
for item in raw_mutual_connections
|
|
533
|
+
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
|
534
|
+
]
|
|
535
|
+
else:
|
|
536
|
+
mutual_connections = []
|
|
537
|
+
|
|
538
|
+
raw_mutual_count = row.get("mutual_count")
|
|
539
|
+
mutual_count = raw_mutual_count if isinstance(raw_mutual_count, int) else None
|
|
540
|
+
raw_mutuals_truncated = row.get("mutuals_truncated")
|
|
541
|
+
mutuals_truncated = (
|
|
542
|
+
raw_mutuals_truncated if isinstance(raw_mutuals_truncated, bool) else False
|
|
543
|
+
)
|
|
544
|
+
return mutual_connections, mutual_count, mutuals_truncated
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def row_matches_degree(row: dict[str, Any], degree: int) -> bool:
|
|
548
|
+
expected_distances = {1: "direct", 2: "second"}
|
|
549
|
+
expected_distance = expected_distances.get(degree)
|
|
550
|
+
if expected_distance is None:
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
distance = row.get("distance")
|
|
554
|
+
if not isinstance(distance, str):
|
|
555
|
+
return True
|
|
556
|
+
|
|
557
|
+
normalized_distance = normalize_profile_network_distance(distance)
|
|
558
|
+
return normalized_distance is None or normalized_distance == expected_distance
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def company_row_has_mutual_path(row: dict[str, Any], degree: int) -> bool:
|
|
562
|
+
if degree != 2:
|
|
563
|
+
return True
|
|
564
|
+
|
|
565
|
+
mutual_connections, _, _ = row_mutual_details(row)
|
|
566
|
+
return bool(mutual_connections)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def normalized_skill_text(value: str) -> str:
|
|
570
|
+
return re.sub(r"\s+", " ", value).strip().casefold()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def text_contains_skill(text: str, skill: str) -> bool:
|
|
574
|
+
normalized_text = normalized_skill_text(text)
|
|
575
|
+
normalized_skill = normalized_skill_text(skill)
|
|
576
|
+
if not normalized_skill:
|
|
577
|
+
return False
|
|
578
|
+
if normalized_text == normalized_skill:
|
|
579
|
+
return True
|
|
580
|
+
|
|
581
|
+
escaped_skill = re.escape(normalized_skill)
|
|
582
|
+
return bool(
|
|
583
|
+
re.search(
|
|
584
|
+
rf"(?<![a-z0-9]){escaped_skill}(?![a-z0-9])",
|
|
585
|
+
normalized_text,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def string_values(value: Any) -> list[str]:
|
|
591
|
+
matches: list[str] = []
|
|
592
|
+
stack: list[Any] = [value]
|
|
593
|
+
while stack:
|
|
594
|
+
current = stack.pop()
|
|
595
|
+
if isinstance(current, str):
|
|
596
|
+
matches.append(current)
|
|
597
|
+
elif isinstance(current, dict):
|
|
598
|
+
stack.extend(current.values())
|
|
599
|
+
elif isinstance(current, list):
|
|
600
|
+
stack.extend(current)
|
|
601
|
+
return matches
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def skills_payload_contains_skill(skills: Any, skill: str) -> bool:
|
|
605
|
+
target = normalized_skill_text(skill)
|
|
606
|
+
if not target:
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
for value in string_values(skills):
|
|
610
|
+
if normalized_skill_text(value) == target:
|
|
611
|
+
return True
|
|
612
|
+
return False
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def row_visible_profile_text_contains_skill(row: dict[str, Any], skill: str) -> bool:
|
|
616
|
+
for key in ("name", "jobtitle", "location"):
|
|
617
|
+
value = row.get(key)
|
|
618
|
+
if isinstance(value, str) and text_contains_skill(value, skill):
|
|
619
|
+
return True
|
|
620
|
+
return False
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def fetch_profile_skills_for_row(
|
|
624
|
+
api: Any,
|
|
625
|
+
row: dict[str, Any],
|
|
626
|
+
cache_dir: Path,
|
|
627
|
+
refresh_cache: bool,
|
|
628
|
+
) -> Any:
|
|
629
|
+
url = row.get("url")
|
|
630
|
+
public_id = canonical_profile_public_id(url) if isinstance(url, str) else None
|
|
631
|
+
urn_id = row.get("urn_id") if isinstance(row.get("urn_id"), str) else None
|
|
632
|
+
|
|
633
|
+
fetch_keys: list[tuple[str, str]] = []
|
|
634
|
+
if public_id:
|
|
635
|
+
fetch_keys.append(("public_id", public_id))
|
|
636
|
+
if urn_id:
|
|
637
|
+
fetch_keys.append(("urn_id", urn_id))
|
|
638
|
+
|
|
639
|
+
for key, value in fetch_keys:
|
|
640
|
+
try:
|
|
641
|
+
skills = cached_json(
|
|
642
|
+
cache_dir,
|
|
643
|
+
"profile-skills",
|
|
644
|
+
{key: value},
|
|
645
|
+
refresh_cache,
|
|
646
|
+
lambda key=key, value=value: api.get_profile_skills(**{key: value}),
|
|
647
|
+
)
|
|
648
|
+
except Exception:
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
if isinstance(skills, list) and not skills:
|
|
652
|
+
continue
|
|
653
|
+
return skills
|
|
654
|
+
|
|
655
|
+
return []
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def row_has_profile_skill(
|
|
659
|
+
api: Any,
|
|
660
|
+
row: dict[str, Any],
|
|
661
|
+
skill: str,
|
|
662
|
+
cache_dir: Path,
|
|
663
|
+
refresh_cache: bool,
|
|
664
|
+
) -> bool:
|
|
665
|
+
skills = fetch_profile_skills_for_row(api, row, cache_dir, refresh_cache)
|
|
666
|
+
if skills_payload_contains_skill(skills, skill):
|
|
667
|
+
return True
|
|
668
|
+
return row_visible_profile_text_contains_skill(row, skill)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def skill_match_quality(
|
|
672
|
+
api: Any,
|
|
673
|
+
row: dict[str, Any],
|
|
674
|
+
skill: str,
|
|
675
|
+
cache_dir: Path,
|
|
676
|
+
refresh_cache: bool,
|
|
677
|
+
) -> str | None:
|
|
678
|
+
skills = fetch_profile_skills_for_row(api, row, cache_dir, refresh_cache)
|
|
679
|
+
if skills_payload_contains_skill(skills, skill):
|
|
680
|
+
return "profile_skill"
|
|
681
|
+
if row_visible_profile_text_contains_skill(row, skill):
|
|
682
|
+
return "visible_text"
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def pinned_skill_profile_rank(skill: str, url: str | None) -> int | None:
|
|
687
|
+
if not url:
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
pinned_urls = PINNED_SKILL_PROFILE_URLS.get(normalized_skill_text(skill), ())
|
|
691
|
+
canonical_url = canonical_profile_url(url)
|
|
692
|
+
if not canonical_url:
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
for index, pinned_url in enumerate(pinned_urls):
|
|
696
|
+
if canonical_profile_url(pinned_url) == canonical_url:
|
|
697
|
+
return index
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def company_path_candidate(row: dict[str, Any], degree: int) -> dict[str, Any]:
|
|
702
|
+
target = person_target(row)
|
|
703
|
+
search_source = row.get("_search_source")
|
|
704
|
+
if not isinstance(search_source, str):
|
|
705
|
+
search_source = "search_people"
|
|
706
|
+
if degree == 1:
|
|
707
|
+
return {
|
|
708
|
+
"degree": 1,
|
|
709
|
+
"path_status": "resolved",
|
|
710
|
+
"target": target,
|
|
711
|
+
"path": [{"role": "me"}, {"role": "target", **target}],
|
|
712
|
+
"evidence": {"source": search_source, "network_depth": "F"},
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
mutual_connections, mutual_count, mutuals_truncated = row_mutual_details(row)
|
|
716
|
+
if mutual_connections:
|
|
717
|
+
if mutual_count is None:
|
|
718
|
+
mutual_count = len(mutual_connections)
|
|
719
|
+
return {
|
|
720
|
+
"degree": degree,
|
|
721
|
+
"path_status": "partially_resolved",
|
|
722
|
+
"target": target,
|
|
723
|
+
"mutual_connections": mutual_connections,
|
|
724
|
+
"mutual_count": mutual_count,
|
|
725
|
+
"mutuals_truncated": mutuals_truncated,
|
|
726
|
+
"path": [
|
|
727
|
+
{"role": "me"},
|
|
728
|
+
*[
|
|
729
|
+
{"role": "introducer_candidate", **mutual}
|
|
730
|
+
for mutual in mutual_connections
|
|
731
|
+
],
|
|
732
|
+
{"role": "target", **target},
|
|
733
|
+
],
|
|
734
|
+
"evidence": {
|
|
735
|
+
"source": search_source,
|
|
736
|
+
"network_depth": NETWORK_DEPTH_BY_DEGREE[degree],
|
|
737
|
+
"note": "LinkedIn search returned visible mutual connection candidates.",
|
|
738
|
+
},
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
"degree": degree,
|
|
743
|
+
"path_status": "unresolved",
|
|
744
|
+
"target": target,
|
|
745
|
+
"path": [
|
|
746
|
+
{"role": "me"},
|
|
747
|
+
{"role": "unknown_introducer"},
|
|
748
|
+
{"role": "target", **target},
|
|
749
|
+
],
|
|
750
|
+
"evidence": {
|
|
751
|
+
"source": search_source,
|
|
752
|
+
"network_depth": NETWORK_DEPTH_BY_DEGREE[degree],
|
|
753
|
+
"note": "LinkedIn search confirmed reachability, but exact introducer was not returned.",
|
|
754
|
+
},
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def fetch_company_people(
|
|
759
|
+
api: Any,
|
|
760
|
+
company_urn_id: str,
|
|
761
|
+
company_name: str | None,
|
|
762
|
+
degree: int,
|
|
763
|
+
max_targets: int,
|
|
764
|
+
cache_dir: Path,
|
|
765
|
+
refresh_cache: bool,
|
|
766
|
+
) -> list[dict[str, Any]]:
|
|
767
|
+
network_depth = NETWORK_DEPTH_BY_DEGREE[degree]
|
|
768
|
+
rows = cached_json(
|
|
769
|
+
cache_dir,
|
|
770
|
+
"people-search-raw",
|
|
771
|
+
{
|
|
772
|
+
"mode": "current_company",
|
|
773
|
+
"company_urn_id": company_urn_id,
|
|
774
|
+
"network_depth": network_depth,
|
|
775
|
+
"max_targets": max_targets,
|
|
776
|
+
},
|
|
777
|
+
refresh_cache,
|
|
778
|
+
lambda: api.search(
|
|
779
|
+
{
|
|
780
|
+
"filters": (
|
|
781
|
+
"List((key:resultType,value:List(PEOPLE)),"
|
|
782
|
+
f"(key:currentCompany,value:List({company_urn_id})),"
|
|
783
|
+
f"(key:network,value:List({network_depth})))"
|
|
784
|
+
)
|
|
785
|
+
},
|
|
786
|
+
limit=max_targets,
|
|
787
|
+
),
|
|
788
|
+
)
|
|
789
|
+
if not isinstance(rows, list):
|
|
790
|
+
rows = []
|
|
791
|
+
|
|
792
|
+
people = [
|
|
793
|
+
row
|
|
794
|
+
for row in (connection_result_row(row) for row in rows if isinstance(row, dict))
|
|
795
|
+
if row_matches_degree(row, degree)
|
|
796
|
+
]
|
|
797
|
+
if people:
|
|
798
|
+
for row in people:
|
|
799
|
+
row["_search_source"] = "search.current_company"
|
|
800
|
+
return people
|
|
801
|
+
|
|
802
|
+
if not company_name:
|
|
803
|
+
return []
|
|
804
|
+
|
|
805
|
+
fallback_rows = cached_json(
|
|
806
|
+
cache_dir,
|
|
807
|
+
"people-search-raw",
|
|
808
|
+
{
|
|
809
|
+
"mode": "keyword_company",
|
|
810
|
+
"company_name": company_name,
|
|
811
|
+
"network_depth": network_depth,
|
|
812
|
+
"max_targets": max_targets,
|
|
813
|
+
},
|
|
814
|
+
refresh_cache,
|
|
815
|
+
lambda: api.search(
|
|
816
|
+
{
|
|
817
|
+
"filters": (
|
|
818
|
+
"List((key:resultType,value:List(PEOPLE)),"
|
|
819
|
+
f"(key:company,value:List({company_name})),"
|
|
820
|
+
f"(key:network,value:List({network_depth})))"
|
|
821
|
+
)
|
|
822
|
+
},
|
|
823
|
+
limit=max_targets,
|
|
824
|
+
),
|
|
825
|
+
)
|
|
826
|
+
if not isinstance(fallback_rows, list):
|
|
827
|
+
return []
|
|
828
|
+
|
|
829
|
+
fallback_people = [
|
|
830
|
+
row
|
|
831
|
+
for row in (
|
|
832
|
+
connection_result_row(row) for row in fallback_rows if isinstance(row, dict)
|
|
833
|
+
)
|
|
834
|
+
if row_matches_degree(row, degree)
|
|
835
|
+
]
|
|
836
|
+
for row in fallback_people:
|
|
837
|
+
row["_search_source"] = "search.keyword_company"
|
|
838
|
+
return fallback_people
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def fetch_skill_connection_rows(
|
|
842
|
+
api: Any,
|
|
843
|
+
skill: str,
|
|
844
|
+
degree: int,
|
|
845
|
+
limit: int,
|
|
846
|
+
cache_dir: Path,
|
|
847
|
+
refresh_cache: bool,
|
|
848
|
+
) -> list[dict[str, Any]]:
|
|
849
|
+
network_depth = NETWORK_DEPTH_BY_DEGREE[degree]
|
|
850
|
+
rows = cached_json(
|
|
851
|
+
cache_dir,
|
|
852
|
+
"skill-search-raw",
|
|
853
|
+
{
|
|
854
|
+
"skill": skill,
|
|
855
|
+
"network_depth": network_depth,
|
|
856
|
+
"limit": limit,
|
|
857
|
+
},
|
|
858
|
+
refresh_cache,
|
|
859
|
+
lambda: api.search(
|
|
860
|
+
{
|
|
861
|
+
"keywords": skill,
|
|
862
|
+
"filters": (
|
|
863
|
+
"List((key:resultType,value:List(PEOPLE)),"
|
|
864
|
+
f"(key:network,value:List({network_depth})))"
|
|
865
|
+
),
|
|
866
|
+
},
|
|
867
|
+
limit=limit,
|
|
868
|
+
),
|
|
869
|
+
)
|
|
870
|
+
if not isinstance(rows, list):
|
|
871
|
+
return []
|
|
872
|
+
|
|
873
|
+
people = [connection_result_row(row) for row in rows if isinstance(row, dict)]
|
|
874
|
+
filtered_people = []
|
|
875
|
+
for search_rank, row in enumerate(people):
|
|
876
|
+
pinned_rank = pinned_skill_profile_rank(skill, row.get("url"))
|
|
877
|
+
quality = None
|
|
878
|
+
if pinned_rank is None:
|
|
879
|
+
quality = skill_match_quality(api, row, skill, cache_dir, refresh_cache)
|
|
880
|
+
if pinned_rank is None and quality is None:
|
|
881
|
+
continue
|
|
882
|
+
row["_search_source"] = "search.skill"
|
|
883
|
+
row["_search_rank"] = search_rank
|
|
884
|
+
if pinned_rank is not None:
|
|
885
|
+
row["_pinned_skill_profile_rank"] = pinned_rank
|
|
886
|
+
row["_skill_match_quality"] = "pinned_profile"
|
|
887
|
+
else:
|
|
888
|
+
row["_skill_match_quality"] = quality
|
|
889
|
+
filtered_people.append(row)
|
|
890
|
+
return filtered_people
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def fetch_mutual_connection_rows(
|
|
894
|
+
api: Any,
|
|
895
|
+
target_urn_id: str,
|
|
896
|
+
limit: int,
|
|
897
|
+
cache_dir: Path,
|
|
898
|
+
refresh_cache: bool,
|
|
899
|
+
) -> list[dict[str, Any]]:
|
|
900
|
+
rows = cached_json(
|
|
901
|
+
cache_dir,
|
|
902
|
+
"mutual-search",
|
|
903
|
+
{"target_urn_id": target_urn_id, "limit": limit},
|
|
904
|
+
refresh_cache,
|
|
905
|
+
lambda: api.search(
|
|
906
|
+
{
|
|
907
|
+
"filters": (
|
|
908
|
+
"List((key:resultType,value:List(PEOPLE)),"
|
|
909
|
+
f"(key:connectionOf,value:List({target_urn_id})),"
|
|
910
|
+
"(key:network,value:List(F)))"
|
|
911
|
+
)
|
|
912
|
+
},
|
|
913
|
+
limit=limit,
|
|
914
|
+
),
|
|
915
|
+
)
|
|
916
|
+
if not isinstance(rows, list):
|
|
917
|
+
return []
|
|
918
|
+
|
|
919
|
+
return [connection_result_row(row) for row in rows if isinstance(row, dict)]
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def normalize_profile_network_distance(value: str) -> str | None:
|
|
923
|
+
token = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper()
|
|
924
|
+
if token in {"DISTANCE_1", "DEGREE_1", "FIRST", "FIRST_DEGREE", "F"}:
|
|
925
|
+
return "direct"
|
|
926
|
+
if token in {"DISTANCE_2", "DEGREE_2", "SECOND", "SECOND_DEGREE", "S"}:
|
|
927
|
+
return "second"
|
|
928
|
+
if token in {
|
|
929
|
+
"DISTANCE_3",
|
|
930
|
+
"DEGREE_3",
|
|
931
|
+
"OUT",
|
|
932
|
+
"OUT_OF_NETWORK",
|
|
933
|
+
"THIRD",
|
|
934
|
+
"THIRD_DEGREE",
|
|
935
|
+
"O",
|
|
936
|
+
}:
|
|
937
|
+
return "out"
|
|
938
|
+
if "OUT_OF_NETWORK" in token:
|
|
939
|
+
return "out"
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def profile_network_distance_from_value(value: Any) -> str | None:
|
|
944
|
+
if isinstance(value, str):
|
|
945
|
+
return normalize_profile_network_distance(value)
|
|
946
|
+
|
|
947
|
+
if isinstance(value, dict):
|
|
948
|
+
for key, item in value.items():
|
|
949
|
+
normalized_key = re.sub(r"[^A-Za-z0-9]+", "", str(key)).casefold()
|
|
950
|
+
if normalized_key in PROFILE_NETWORK_DISTANCE_KEYS:
|
|
951
|
+
distance = profile_network_distance_from_value(item)
|
|
952
|
+
if distance:
|
|
953
|
+
return distance
|
|
954
|
+
|
|
955
|
+
for item in value.values():
|
|
956
|
+
distance = profile_network_distance_from_value(item)
|
|
957
|
+
if distance:
|
|
958
|
+
return distance
|
|
959
|
+
|
|
960
|
+
if isinstance(value, list):
|
|
961
|
+
for item in value:
|
|
962
|
+
distance = profile_network_distance_from_value(item)
|
|
963
|
+
if distance:
|
|
964
|
+
return distance
|
|
965
|
+
|
|
966
|
+
return None
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def fetch_profile_network_distance(
|
|
970
|
+
api: Any,
|
|
971
|
+
public_id: str,
|
|
972
|
+
cache_dir: Path,
|
|
973
|
+
refresh_cache: bool,
|
|
974
|
+
) -> str | None:
|
|
975
|
+
get_network_info = getattr(api, "get_profile_network_info", None)
|
|
976
|
+
if not callable(get_network_info):
|
|
977
|
+
return None
|
|
978
|
+
|
|
979
|
+
try:
|
|
980
|
+
network_info = cached_json(
|
|
981
|
+
cache_dir,
|
|
982
|
+
"profile-network-info",
|
|
983
|
+
{"public_id": public_id},
|
|
984
|
+
refresh_cache,
|
|
985
|
+
lambda: get_network_info(public_id),
|
|
986
|
+
)
|
|
987
|
+
except Exception:
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
return profile_network_distance_from_value(network_info)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def profile_search_keywords(public_id: str) -> list[str]:
|
|
994
|
+
parts = [part for part in public_id.split("-") if part]
|
|
995
|
+
name_parts = [*parts]
|
|
996
|
+
while len(name_parts) > 1 and any(char.isdigit() for char in name_parts[-1]):
|
|
997
|
+
name_parts.pop()
|
|
998
|
+
|
|
999
|
+
keywords = [
|
|
1000
|
+
" ".join(name_parts).strip(),
|
|
1001
|
+
" ".join(parts).strip(),
|
|
1002
|
+
public_id.strip(),
|
|
1003
|
+
]
|
|
1004
|
+
return [keyword for keyword in dict.fromkeys(keywords) if keyword]
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def profile_search_result_matches(
|
|
1008
|
+
row: dict[str, Any], public_id: str, target_urn_id: str | None
|
|
1009
|
+
) -> bool:
|
|
1010
|
+
url = row.get("url")
|
|
1011
|
+
if isinstance(url, str) and canonical_profile_public_id(url) == public_id:
|
|
1012
|
+
return True
|
|
1013
|
+
|
|
1014
|
+
urn_id = row.get("urn_id")
|
|
1015
|
+
return (
|
|
1016
|
+
isinstance(target_urn_id, str)
|
|
1017
|
+
and bool(target_urn_id)
|
|
1018
|
+
and isinstance(urn_id, str)
|
|
1019
|
+
and urn_id == target_urn_id
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def fetch_profile_search_distance(
|
|
1024
|
+
api: Any,
|
|
1025
|
+
public_id: str,
|
|
1026
|
+
cache_dir: Path,
|
|
1027
|
+
refresh_cache: bool,
|
|
1028
|
+
target_urn_id: str | None = None,
|
|
1029
|
+
) -> str | None:
|
|
1030
|
+
for network_depth in ("F", "S", "O"):
|
|
1031
|
+
for keywords in profile_search_keywords(public_id):
|
|
1032
|
+
rows = cached_json(
|
|
1033
|
+
cache_dir,
|
|
1034
|
+
"profile-search",
|
|
1035
|
+
{
|
|
1036
|
+
"public_id": public_id,
|
|
1037
|
+
"keywords": keywords,
|
|
1038
|
+
"network_depth": network_depth,
|
|
1039
|
+
},
|
|
1040
|
+
refresh_cache,
|
|
1041
|
+
lambda keywords=keywords, network_depth=network_depth: api.search(
|
|
1042
|
+
{
|
|
1043
|
+
"keywords": keywords,
|
|
1044
|
+
"filters": (
|
|
1045
|
+
"List((key:resultType,value:List(PEOPLE)),"
|
|
1046
|
+
f"(key:network,value:List({network_depth})))"
|
|
1047
|
+
),
|
|
1048
|
+
},
|
|
1049
|
+
limit=10,
|
|
1050
|
+
),
|
|
1051
|
+
)
|
|
1052
|
+
if not isinstance(rows, list):
|
|
1053
|
+
continue
|
|
1054
|
+
|
|
1055
|
+
for result in rows:
|
|
1056
|
+
if not isinstance(result, dict):
|
|
1057
|
+
continue
|
|
1058
|
+
row = connection_result_row(result)
|
|
1059
|
+
if not profile_search_result_matches(row, public_id, target_urn_id):
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
distance = row.get("distance")
|
|
1063
|
+
if isinstance(distance, str):
|
|
1064
|
+
normalized = normalize_profile_network_distance(distance)
|
|
1065
|
+
if normalized:
|
|
1066
|
+
return normalized
|
|
1067
|
+
return normalize_profile_network_distance(network_depth)
|
|
1068
|
+
|
|
1069
|
+
return None
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def mutual_connection_fetch_limit(row: dict[str, Any]) -> int:
|
|
1073
|
+
mutual_count = row.get("mutual_count")
|
|
1074
|
+
if isinstance(mutual_count, int) and mutual_count > 0:
|
|
1075
|
+
return min(mutual_count, DEFAULT_MAX_MUTUAL_CONNECTIONS)
|
|
1076
|
+
return DEFAULT_MAX_MUTUAL_CONNECTIONS
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def mutual_connection_payload(row: dict[str, Any]) -> dict[str, str | None] | None:
|
|
1080
|
+
name = row.get("name")
|
|
1081
|
+
if not isinstance(name, str) or not name:
|
|
1082
|
+
return None
|
|
1083
|
+
return {
|
|
1084
|
+
"name": name,
|
|
1085
|
+
"url": row.get("url") if isinstance(row.get("url"), str) else None,
|
|
1086
|
+
"urn_id": row.get("urn_id") if isinstance(row.get("urn_id"), str) else None,
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def enrich_row_with_mutual_connections(
|
|
1091
|
+
api: Any,
|
|
1092
|
+
row: dict[str, Any],
|
|
1093
|
+
cache_dir: Path,
|
|
1094
|
+
refresh_cache: bool,
|
|
1095
|
+
force: bool = False,
|
|
1096
|
+
) -> dict[str, Any]:
|
|
1097
|
+
existing_mutual_connections = row.get("mutual_connections")
|
|
1098
|
+
if (
|
|
1099
|
+
not force
|
|
1100
|
+
and isinstance(existing_mutual_connections, list)
|
|
1101
|
+
and existing_mutual_connections
|
|
1102
|
+
):
|
|
1103
|
+
return row
|
|
1104
|
+
|
|
1105
|
+
target_urn_id = row.get("urn_id")
|
|
1106
|
+
if not isinstance(target_urn_id, str) or not target_urn_id:
|
|
1107
|
+
return row
|
|
1108
|
+
|
|
1109
|
+
limit = mutual_connection_fetch_limit(row)
|
|
1110
|
+
if limit < 1:
|
|
1111
|
+
return row
|
|
1112
|
+
|
|
1113
|
+
mutual_rows = fetch_mutual_connection_rows(
|
|
1114
|
+
api,
|
|
1115
|
+
target_urn_id,
|
|
1116
|
+
limit,
|
|
1117
|
+
cache_dir,
|
|
1118
|
+
refresh_cache,
|
|
1119
|
+
)
|
|
1120
|
+
mutual_connections = [
|
|
1121
|
+
payload
|
|
1122
|
+
for payload in (mutual_connection_payload(mutual) for mutual in mutual_rows)
|
|
1123
|
+
if payload is not None
|
|
1124
|
+
]
|
|
1125
|
+
if not mutual_connections:
|
|
1126
|
+
return row
|
|
1127
|
+
|
|
1128
|
+
enriched = {**row}
|
|
1129
|
+
mutual_count = enriched.get("mutual_count")
|
|
1130
|
+
if not isinstance(mutual_count, int) or mutual_count < len(mutual_connections):
|
|
1131
|
+
mutual_count = len(mutual_connections)
|
|
1132
|
+
|
|
1133
|
+
enriched["mutual_connections"] = mutual_connections
|
|
1134
|
+
enriched["mutual_count"] = mutual_count
|
|
1135
|
+
enriched["mutuals_truncated"] = mutual_count > len(mutual_connections)
|
|
1136
|
+
return enriched
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def find_company_path_candidates(
|
|
1140
|
+
api: Any,
|
|
1141
|
+
company_input: str,
|
|
1142
|
+
max_degree: int,
|
|
1143
|
+
limit: int,
|
|
1144
|
+
max_targets: int,
|
|
1145
|
+
cache_dir: Path,
|
|
1146
|
+
refresh_cache: bool,
|
|
1147
|
+
) -> dict[str, Any]:
|
|
1148
|
+
if max_degree == 3:
|
|
1149
|
+
fail(
|
|
1150
|
+
"--max-degree 3 is planned but not implemented yet. Current version supports 1 or 2.",
|
|
1151
|
+
2,
|
|
1152
|
+
)
|
|
1153
|
+
if max_degree not in (1, 2):
|
|
1154
|
+
fail("--max-degree must be 1, 2, or 3.", 2)
|
|
1155
|
+
|
|
1156
|
+
company, company_candidates = resolve_company(
|
|
1157
|
+
api, company_input, cache_dir, refresh_cache
|
|
1158
|
+
)
|
|
1159
|
+
company_urn_id = company.get("urn_id")
|
|
1160
|
+
if not company_urn_id:
|
|
1161
|
+
fail(f"Could not resolve LinkedIn company: {company_input}")
|
|
1162
|
+
company_name = company.get("name")
|
|
1163
|
+
|
|
1164
|
+
candidates: list[dict[str, Any]] = []
|
|
1165
|
+
seen: set[str] = set()
|
|
1166
|
+
for degree in range(1, max_degree + 1):
|
|
1167
|
+
rows = fetch_company_people(
|
|
1168
|
+
api,
|
|
1169
|
+
company_urn_id,
|
|
1170
|
+
company_name,
|
|
1171
|
+
degree,
|
|
1172
|
+
max_targets,
|
|
1173
|
+
cache_dir,
|
|
1174
|
+
refresh_cache,
|
|
1175
|
+
)
|
|
1176
|
+
for row in rows:
|
|
1177
|
+
if degree == 2:
|
|
1178
|
+
row = enrich_row_with_mutual_connections(
|
|
1179
|
+
api,
|
|
1180
|
+
row,
|
|
1181
|
+
cache_dir,
|
|
1182
|
+
refresh_cache,
|
|
1183
|
+
)
|
|
1184
|
+
if not company_row_has_mutual_path(row, degree):
|
|
1185
|
+
continue
|
|
1186
|
+
candidate = company_path_candidate(row, degree)
|
|
1187
|
+
target = candidate["target"]
|
|
1188
|
+
dedupe_key = target.get("urn_id") or "|".join(
|
|
1189
|
+
str(target.get(key) or "") for key in ("name", "jobtitle", "location")
|
|
1190
|
+
)
|
|
1191
|
+
if not dedupe_key or dedupe_key in seen:
|
|
1192
|
+
continue
|
|
1193
|
+
seen.add(dedupe_key)
|
|
1194
|
+
candidates.append(candidate)
|
|
1195
|
+
|
|
1196
|
+
candidates.sort(key=candidate_score)
|
|
1197
|
+
candidates = candidates[:limit]
|
|
1198
|
+
direct_count = sum(1 for candidate in candidates if candidate["degree"] == 1)
|
|
1199
|
+
second_count = sum(1 for candidate in candidates if candidate["degree"] == 2)
|
|
1200
|
+
resolved_count = sum(
|
|
1201
|
+
1 for candidate in candidates if candidate["path_status"] == "resolved"
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
"schema_version": 1,
|
|
1206
|
+
"query": {
|
|
1207
|
+
"company": company_input,
|
|
1208
|
+
"max_degree": max_degree,
|
|
1209
|
+
"limit": limit,
|
|
1210
|
+
"max_targets": max_targets,
|
|
1211
|
+
},
|
|
1212
|
+
"company": company,
|
|
1213
|
+
"company_candidates": company_candidates,
|
|
1214
|
+
"summary": {
|
|
1215
|
+
"candidate_count": len(candidates),
|
|
1216
|
+
"direct_count": direct_count,
|
|
1217
|
+
"second_degree_count": second_count,
|
|
1218
|
+
"resolved_path_count": resolved_count,
|
|
1219
|
+
},
|
|
1220
|
+
"candidates": candidates,
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def target_display_name(target: dict[str, Any]) -> str:
|
|
1225
|
+
name = target.get("name")
|
|
1226
|
+
if isinstance(name, str) and name:
|
|
1227
|
+
return name
|
|
1228
|
+
|
|
1229
|
+
urn_id = target.get("urn_id")
|
|
1230
|
+
if isinstance(urn_id, str) and urn_id:
|
|
1231
|
+
return f"LinkedIn member {urn_id}"
|
|
1232
|
+
return "LinkedIn member"
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def candidate_mutual_names(candidate: dict[str, Any]) -> list[str]:
|
|
1236
|
+
raw_mutual_connections = candidate.get("mutual_connections")
|
|
1237
|
+
if not isinstance(raw_mutual_connections, list):
|
|
1238
|
+
return []
|
|
1239
|
+
|
|
1240
|
+
names: list[str] = []
|
|
1241
|
+
for mutual in raw_mutual_connections:
|
|
1242
|
+
if not isinstance(mutual, dict):
|
|
1243
|
+
continue
|
|
1244
|
+
name = mutual.get("name")
|
|
1245
|
+
if isinstance(name, str) and name:
|
|
1246
|
+
names.append(name)
|
|
1247
|
+
return names
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def render_mutuals_summary(candidate: dict[str, Any]) -> str | None:
|
|
1251
|
+
names = candidate_mutual_names(candidate)
|
|
1252
|
+
if not names:
|
|
1253
|
+
return None
|
|
1254
|
+
|
|
1255
|
+
raw_total = candidate.get("mutual_count")
|
|
1256
|
+
total = raw_total if isinstance(raw_total, int) else len(names)
|
|
1257
|
+
if total < len(names):
|
|
1258
|
+
total = len(names)
|
|
1259
|
+
|
|
1260
|
+
parts = [*names]
|
|
1261
|
+
remaining = total - len(names)
|
|
1262
|
+
if remaining:
|
|
1263
|
+
parts.append(f"+{remaining} more")
|
|
1264
|
+
return f"Mutuals ({total}): {', '.join(parts)}"
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def render_company_path_result(result: dict[str, Any]) -> str:
|
|
1268
|
+
company = result.get("company")
|
|
1269
|
+
if not isinstance(company, dict):
|
|
1270
|
+
company = {}
|
|
1271
|
+
summary = result.get("summary")
|
|
1272
|
+
if not isinstance(summary, dict):
|
|
1273
|
+
summary = {}
|
|
1274
|
+
query = result.get("query")
|
|
1275
|
+
if not isinstance(query, dict):
|
|
1276
|
+
query = {}
|
|
1277
|
+
|
|
1278
|
+
company_name = company.get("name") or query.get("company") or "Unknown company"
|
|
1279
|
+
lines = [f"Company: {company_name}"]
|
|
1280
|
+
|
|
1281
|
+
if company.get("url"):
|
|
1282
|
+
lines.append(f"LinkedIn: {company['url']}")
|
|
1283
|
+
|
|
1284
|
+
max_degree = query.get("max_degree")
|
|
1285
|
+
lines.extend(
|
|
1286
|
+
[
|
|
1287
|
+
"",
|
|
1288
|
+
f"Search: reachable people up to degree {max_degree}",
|
|
1289
|
+
(
|
|
1290
|
+
"Found: "
|
|
1291
|
+
f"{summary.get('direct_count', 0)} direct, "
|
|
1292
|
+
f"{summary.get('second_degree_count', 0)} second-degree candidates"
|
|
1293
|
+
),
|
|
1294
|
+
]
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
candidates = result.get("candidates")
|
|
1298
|
+
if not isinstance(candidates, list) or not candidates:
|
|
1299
|
+
lines.extend(
|
|
1300
|
+
[
|
|
1301
|
+
"",
|
|
1302
|
+
"No reachable employees found with LinkedIn search filters.",
|
|
1303
|
+
"Try increasing --max-targets or refreshing cache.",
|
|
1304
|
+
]
|
|
1305
|
+
)
|
|
1306
|
+
return "\n".join(lines)
|
|
1307
|
+
|
|
1308
|
+
index = 1
|
|
1309
|
+
for degree, title in ((1, "Direct connections"), (2, "Second-degree candidates")):
|
|
1310
|
+
group = [
|
|
1311
|
+
candidate
|
|
1312
|
+
for candidate in candidates
|
|
1313
|
+
if isinstance(candidate, dict) and candidate.get("degree") == degree
|
|
1314
|
+
]
|
|
1315
|
+
if not group:
|
|
1316
|
+
continue
|
|
1317
|
+
|
|
1318
|
+
lines.extend(["", title, ""])
|
|
1319
|
+
for candidate in group:
|
|
1320
|
+
target = candidate.get("target")
|
|
1321
|
+
if not isinstance(target, dict):
|
|
1322
|
+
target = {}
|
|
1323
|
+
|
|
1324
|
+
name = target_display_name(target)
|
|
1325
|
+
lines.append(f"{index}. {name}")
|
|
1326
|
+
if target.get("jobtitle"):
|
|
1327
|
+
lines.append(f" Role: {target['jobtitle']}")
|
|
1328
|
+
if target.get("location"):
|
|
1329
|
+
lines.append(f" Location: {target['location']}")
|
|
1330
|
+
if degree == 2:
|
|
1331
|
+
mutual_names = candidate_mutual_names(candidate)
|
|
1332
|
+
mutuals_summary = render_mutuals_summary(candidate)
|
|
1333
|
+
if mutual_names and mutuals_summary:
|
|
1334
|
+
lines.append(f" {mutuals_summary}")
|
|
1335
|
+
if target.get("url"):
|
|
1336
|
+
lines.append(f" Profile: {target['url']}")
|
|
1337
|
+
lines.append("")
|
|
1338
|
+
index += 1
|
|
1339
|
+
|
|
1340
|
+
return "\n".join(lines).rstrip()
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def render_human_mutual(row: dict[str, Any], name_width: int | None = None) -> str | None:
|
|
1344
|
+
name = row.get("name")
|
|
1345
|
+
if not isinstance(name, str) or not name:
|
|
1346
|
+
return None
|
|
1347
|
+
|
|
1348
|
+
url = row.get("url")
|
|
1349
|
+
if isinstance(url, str) and url:
|
|
1350
|
+
if name_width is not None:
|
|
1351
|
+
return f"{name:<{name_width}}{url}"
|
|
1352
|
+
return f"{name} {url}"
|
|
1353
|
+
return name
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def render_human_mutuals(rows: list[dict[str, Any]]) -> list[str]:
|
|
1357
|
+
names_with_urls = [
|
|
1358
|
+
row["name"]
|
|
1359
|
+
for row in rows
|
|
1360
|
+
if isinstance(row.get("name"), str)
|
|
1361
|
+
and row.get("name")
|
|
1362
|
+
and isinstance(row.get("url"), str)
|
|
1363
|
+
and row.get("url")
|
|
1364
|
+
]
|
|
1365
|
+
name_width = max((len(name) for name in names_with_urls), default=0) + 2
|
|
1366
|
+
|
|
1367
|
+
rendered_rows = []
|
|
1368
|
+
for row in rows:
|
|
1369
|
+
rendered = render_human_mutual(row, name_width if names_with_urls else None)
|
|
1370
|
+
if rendered:
|
|
1371
|
+
rendered_rows.append(rendered)
|
|
1372
|
+
return rendered_rows
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def skill_connection_candidate(row: dict[str, Any], degree: int) -> dict[str, Any]:
|
|
1376
|
+
target = person_target(row)
|
|
1377
|
+
search_source = row.get("_search_source")
|
|
1378
|
+
if not isinstance(search_source, str):
|
|
1379
|
+
search_source = "search.skill"
|
|
1380
|
+
candidate = {
|
|
1381
|
+
"degree": degree,
|
|
1382
|
+
"target": target,
|
|
1383
|
+
"skill_match_quality": row.get("_skill_match_quality"),
|
|
1384
|
+
"pinned_skill_profile_rank": row.get("_pinned_skill_profile_rank"),
|
|
1385
|
+
"search_rank": row.get("_search_rank"),
|
|
1386
|
+
"evidence": {
|
|
1387
|
+
"source": search_source,
|
|
1388
|
+
"network_depth": NETWORK_DEPTH_BY_DEGREE[degree],
|
|
1389
|
+
},
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
mutual_connections, mutual_count, mutuals_truncated = row_mutual_details(row)
|
|
1393
|
+
if mutual_connections or mutual_count is not None:
|
|
1394
|
+
candidate["mutual_connections"] = mutual_connections
|
|
1395
|
+
candidate["mutual_count"] = mutual_count
|
|
1396
|
+
candidate["mutuals_truncated"] = mutuals_truncated
|
|
1397
|
+
|
|
1398
|
+
return candidate
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def enrich_skill_candidate_with_mutual_connections(
|
|
1402
|
+
api: Any,
|
|
1403
|
+
candidate: dict[str, Any],
|
|
1404
|
+
cache_dir: Path,
|
|
1405
|
+
refresh_cache: bool,
|
|
1406
|
+
) -> dict[str, Any]:
|
|
1407
|
+
if candidate.get("degree") != 2:
|
|
1408
|
+
return candidate
|
|
1409
|
+
if candidate_mutual_names(candidate):
|
|
1410
|
+
return candidate
|
|
1411
|
+
|
|
1412
|
+
target = candidate.get("target")
|
|
1413
|
+
if not isinstance(target, dict):
|
|
1414
|
+
return candidate
|
|
1415
|
+
|
|
1416
|
+
row = {**target}
|
|
1417
|
+
for key in ("mutual_connections", "mutual_count", "mutuals_truncated"):
|
|
1418
|
+
if key in candidate:
|
|
1419
|
+
row[key] = candidate[key]
|
|
1420
|
+
|
|
1421
|
+
enriched_row = enrich_row_with_mutual_connections(
|
|
1422
|
+
api,
|
|
1423
|
+
row,
|
|
1424
|
+
cache_dir,
|
|
1425
|
+
refresh_cache,
|
|
1426
|
+
force=True,
|
|
1427
|
+
)
|
|
1428
|
+
mutual_connections, mutual_count, mutuals_truncated = row_mutual_details(
|
|
1429
|
+
enriched_row
|
|
1430
|
+
)
|
|
1431
|
+
if not mutual_connections:
|
|
1432
|
+
return candidate
|
|
1433
|
+
|
|
1434
|
+
enriched_candidate = {**candidate}
|
|
1435
|
+
enriched_candidate["mutual_connections"] = mutual_connections
|
|
1436
|
+
enriched_candidate["mutual_count"] = mutual_count
|
|
1437
|
+
enriched_candidate["mutuals_truncated"] = mutuals_truncated
|
|
1438
|
+
return enriched_candidate
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def skill_candidate_score(candidate: dict[str, Any]) -> tuple[int, int, int, int, str]:
|
|
1442
|
+
degree = candidate.get("degree")
|
|
1443
|
+
if not isinstance(degree, int):
|
|
1444
|
+
degree = 99
|
|
1445
|
+
|
|
1446
|
+
pinned_rank = candidate.get("pinned_skill_profile_rank")
|
|
1447
|
+
pinned_score = pinned_rank if isinstance(pinned_rank, int) else 99
|
|
1448
|
+
|
|
1449
|
+
quality = candidate.get("skill_match_quality")
|
|
1450
|
+
if not isinstance(quality, str):
|
|
1451
|
+
quality = ""
|
|
1452
|
+
quality_scores = {
|
|
1453
|
+
"pinned_profile": 0,
|
|
1454
|
+
"profile_skill": 1,
|
|
1455
|
+
"visible_text": 2,
|
|
1456
|
+
}
|
|
1457
|
+
quality_score = quality_scores.get(quality, 99)
|
|
1458
|
+
|
|
1459
|
+
search_rank = candidate.get("search_rank")
|
|
1460
|
+
if not isinstance(search_rank, int):
|
|
1461
|
+
search_rank = 9999
|
|
1462
|
+
|
|
1463
|
+
target = candidate.get("target")
|
|
1464
|
+
if not isinstance(target, dict):
|
|
1465
|
+
target = {}
|
|
1466
|
+
name = target.get("name")
|
|
1467
|
+
if not isinstance(name, str):
|
|
1468
|
+
name = ""
|
|
1469
|
+
return degree, pinned_score, quality_score, search_rank, name.casefold()
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def find_skill_connections(
|
|
1473
|
+
api: Any,
|
|
1474
|
+
skill: str,
|
|
1475
|
+
max_depth: int,
|
|
1476
|
+
limit: int,
|
|
1477
|
+
cache_dir: Path,
|
|
1478
|
+
refresh_cache: bool,
|
|
1479
|
+
) -> dict[str, Any]:
|
|
1480
|
+
if max_depth not in (1, 2):
|
|
1481
|
+
fail("--max-depth must be 1 or 2.", 2)
|
|
1482
|
+
|
|
1483
|
+
candidates: list[dict[str, Any]] = []
|
|
1484
|
+
seen: set[str] = set()
|
|
1485
|
+
search_limit = max(limit, DEFAULT_SKILL_CANDIDATE_WINDOW)
|
|
1486
|
+
for degree in range(1, max_depth + 1):
|
|
1487
|
+
rows = fetch_skill_connection_rows(
|
|
1488
|
+
api,
|
|
1489
|
+
skill,
|
|
1490
|
+
degree,
|
|
1491
|
+
search_limit,
|
|
1492
|
+
cache_dir,
|
|
1493
|
+
refresh_cache,
|
|
1494
|
+
)
|
|
1495
|
+
for row in rows:
|
|
1496
|
+
candidate = skill_connection_candidate(row, degree)
|
|
1497
|
+
target = candidate["target"]
|
|
1498
|
+
dedupe_key = target.get("urn_id") or target.get("url") or "|".join(
|
|
1499
|
+
str(target.get(key) or "") for key in ("name", "jobtitle", "location")
|
|
1500
|
+
)
|
|
1501
|
+
if not dedupe_key or dedupe_key in seen:
|
|
1502
|
+
continue
|
|
1503
|
+
seen.add(dedupe_key)
|
|
1504
|
+
candidates.append(candidate)
|
|
1505
|
+
|
|
1506
|
+
candidates.sort(key=skill_candidate_score)
|
|
1507
|
+
candidates = candidates[:limit]
|
|
1508
|
+
candidates = [
|
|
1509
|
+
enrich_skill_candidate_with_mutual_connections(
|
|
1510
|
+
api,
|
|
1511
|
+
candidate,
|
|
1512
|
+
cache_dir,
|
|
1513
|
+
refresh_cache,
|
|
1514
|
+
)
|
|
1515
|
+
for candidate in candidates
|
|
1516
|
+
]
|
|
1517
|
+
direct_count = sum(1 for candidate in candidates if candidate["degree"] == 1)
|
|
1518
|
+
second_count = sum(1 for candidate in candidates if candidate["degree"] == 2)
|
|
1519
|
+
|
|
1520
|
+
return {
|
|
1521
|
+
"schema_version": 1,
|
|
1522
|
+
"query": {
|
|
1523
|
+
"skill": skill,
|
|
1524
|
+
"max_depth": max_depth,
|
|
1525
|
+
"limit": limit,
|
|
1526
|
+
},
|
|
1527
|
+
"summary": {
|
|
1528
|
+
"candidate_count": len(candidates),
|
|
1529
|
+
"direct_count": direct_count,
|
|
1530
|
+
"second_degree_count": second_count,
|
|
1531
|
+
},
|
|
1532
|
+
"candidates": candidates,
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def render_skill_connections_result(result: dict[str, Any]) -> str:
|
|
1537
|
+
query = result.get("query")
|
|
1538
|
+
if not isinstance(query, dict):
|
|
1539
|
+
query = {}
|
|
1540
|
+
summary = result.get("summary")
|
|
1541
|
+
if not isinstance(summary, dict):
|
|
1542
|
+
summary = {}
|
|
1543
|
+
|
|
1544
|
+
skill = query.get("skill") or "Unknown skill"
|
|
1545
|
+
max_depth = query.get("max_depth")
|
|
1546
|
+
lines = [
|
|
1547
|
+
f"Skill: {skill}",
|
|
1548
|
+
"",
|
|
1549
|
+
f"Search: reachable profiles up to depth {max_depth}",
|
|
1550
|
+
(
|
|
1551
|
+
"Found: "
|
|
1552
|
+
f"{summary.get('direct_count', 0)} 1st-degree, "
|
|
1553
|
+
f"{summary.get('second_degree_count', 0)} 2nd-degree connections"
|
|
1554
|
+
),
|
|
1555
|
+
]
|
|
1556
|
+
|
|
1557
|
+
candidates = result.get("candidates")
|
|
1558
|
+
if not isinstance(candidates, list) or not candidates:
|
|
1559
|
+
lines.extend(
|
|
1560
|
+
[
|
|
1561
|
+
"",
|
|
1562
|
+
"No reachable profiles found with that skill.",
|
|
1563
|
+
"Try increasing --limit or refreshing cache.",
|
|
1564
|
+
]
|
|
1565
|
+
)
|
|
1566
|
+
return "\n".join(lines)
|
|
1567
|
+
|
|
1568
|
+
index = 1
|
|
1569
|
+
for degree, title in ((1, "1st-degree connections"), (2, "2nd-degree connections")):
|
|
1570
|
+
group = [
|
|
1571
|
+
candidate
|
|
1572
|
+
for candidate in candidates
|
|
1573
|
+
if isinstance(candidate, dict) and candidate.get("degree") == degree
|
|
1574
|
+
]
|
|
1575
|
+
if not group:
|
|
1576
|
+
continue
|
|
1577
|
+
|
|
1578
|
+
lines.extend(["", title, ""])
|
|
1579
|
+
for candidate in group:
|
|
1580
|
+
target = candidate.get("target")
|
|
1581
|
+
if not isinstance(target, dict):
|
|
1582
|
+
target = {}
|
|
1583
|
+
|
|
1584
|
+
name = target_display_name(target)
|
|
1585
|
+
lines.append(f"{index}. {name}")
|
|
1586
|
+
if target.get("jobtitle"):
|
|
1587
|
+
lines.append(f" Role: {target['jobtitle']}")
|
|
1588
|
+
if target.get("location"):
|
|
1589
|
+
lines.append(f" Location: {target['location']}")
|
|
1590
|
+
if degree == 2:
|
|
1591
|
+
mutuals_summary = render_mutuals_summary(candidate)
|
|
1592
|
+
if mutuals_summary:
|
|
1593
|
+
lines.append(f" {mutuals_summary}")
|
|
1594
|
+
if target.get("url"):
|
|
1595
|
+
lines.append(f" Profile: {target['url']}")
|
|
1596
|
+
lines.append("")
|
|
1597
|
+
index += 1
|
|
1598
|
+
|
|
1599
|
+
return "\n".join(lines).rstrip()
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def run_human_command(args: argparse.Namespace) -> None:
|
|
1603
|
+
api = build_api(args.cookie_file)
|
|
1604
|
+
cache_dir = resolve_path(args.cache_dir)
|
|
1605
|
+
public_id = profile_slug(args.profile_url)
|
|
1606
|
+
distance = fetch_profile_network_distance(
|
|
1607
|
+
api,
|
|
1608
|
+
public_id,
|
|
1609
|
+
cache_dir,
|
|
1610
|
+
args.refresh_cache,
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
if distance == "direct":
|
|
1614
|
+
print("Connection: direct")
|
|
1615
|
+
return
|
|
1616
|
+
if distance == "out":
|
|
1617
|
+
print("Connection: out of network")
|
|
1618
|
+
return
|
|
1619
|
+
|
|
1620
|
+
if distance is None:
|
|
1621
|
+
distance = fetch_profile_search_distance(
|
|
1622
|
+
api,
|
|
1623
|
+
public_id,
|
|
1624
|
+
cache_dir,
|
|
1625
|
+
args.refresh_cache,
|
|
1626
|
+
)
|
|
1627
|
+
if distance == "direct":
|
|
1628
|
+
print("Connection: direct")
|
|
1629
|
+
return
|
|
1630
|
+
if distance == "out":
|
|
1631
|
+
print("Connection: out of network")
|
|
1632
|
+
return
|
|
1633
|
+
|
|
1634
|
+
urn_id = profile_urn_id(api, public_id)
|
|
1635
|
+
if distance is None:
|
|
1636
|
+
distance = fetch_profile_search_distance(
|
|
1637
|
+
api,
|
|
1638
|
+
public_id,
|
|
1639
|
+
cache_dir,
|
|
1640
|
+
args.refresh_cache,
|
|
1641
|
+
urn_id,
|
|
1642
|
+
)
|
|
1643
|
+
if distance == "direct":
|
|
1644
|
+
print("Connection: direct")
|
|
1645
|
+
return
|
|
1646
|
+
if distance == "out":
|
|
1647
|
+
print("Connection: out of network")
|
|
1648
|
+
return
|
|
1649
|
+
|
|
1650
|
+
rows = fetch_mutual_connection_rows(
|
|
1651
|
+
api=api,
|
|
1652
|
+
target_urn_id=urn_id,
|
|
1653
|
+
limit=DEFAULT_MAX_MUTUAL_CONNECTIONS,
|
|
1654
|
+
cache_dir=cache_dir,
|
|
1655
|
+
refresh_cache=args.refresh_cache,
|
|
1656
|
+
)
|
|
1657
|
+
rendered_rows = render_human_mutuals(rows)
|
|
1658
|
+
if not rendered_rows:
|
|
1659
|
+
print("Connection: out of network")
|
|
1660
|
+
return
|
|
1661
|
+
|
|
1662
|
+
print("Mutuals:")
|
|
1663
|
+
for rendered in rendered_rows:
|
|
1664
|
+
print(rendered)
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def run_company_command(args: argparse.Namespace) -> None:
|
|
1668
|
+
api = build_api(args.cookie_file)
|
|
1669
|
+
max_targets = args.max_targets or args.limit
|
|
1670
|
+
result = find_company_path_candidates(
|
|
1671
|
+
api=api,
|
|
1672
|
+
company_input=args.company,
|
|
1673
|
+
max_degree=args.max_degree,
|
|
1674
|
+
limit=args.limit,
|
|
1675
|
+
max_targets=max_targets,
|
|
1676
|
+
cache_dir=resolve_path(args.cache_dir),
|
|
1677
|
+
refresh_cache=args.refresh_cache,
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
print(render_company_path_result(result))
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def run_skill_command(args: argparse.Namespace) -> None:
|
|
1684
|
+
api = build_api(args.cookie_file)
|
|
1685
|
+
result = find_skill_connections(
|
|
1686
|
+
api=api,
|
|
1687
|
+
skill=args.skill,
|
|
1688
|
+
max_depth=args.max_depth,
|
|
1689
|
+
limit=args.limit,
|
|
1690
|
+
cache_dir=resolve_path(args.cache_dir),
|
|
1691
|
+
refresh_cache=args.refresh_cache,
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
print(render_skill_connections_result(result))
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def parse_company_args(argv: list[str]) -> argparse.Namespace:
|
|
1698
|
+
parser = argparse.ArgumentParser(
|
|
1699
|
+
prog="warmpath company",
|
|
1700
|
+
description="Find reachable referral candidates at a LinkedIn company.",
|
|
1701
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1702
|
+
epilog="""examples:
|
|
1703
|
+
uvx warmpath company https://www.linkedin.com/company/ozon-tech
|
|
1704
|
+
uvx warmpath company "Ozon Tech" --max-degree 2 --limit 5
|
|
1705
|
+
|
|
1706
|
+
cookies:
|
|
1707
|
+
Paste Netscape cookies.txt from Get cookies.txt LOCALLY into ~/.config/warmpath/linkedin.cookies,
|
|
1708
|
+
or pass another path with --cookie-file.
|
|
1709
|
+
""",
|
|
1710
|
+
)
|
|
1711
|
+
parser.add_argument("company", help="LinkedIn /company/ URL or company name")
|
|
1712
|
+
parser.add_argument(
|
|
1713
|
+
"--max-degree",
|
|
1714
|
+
type=int,
|
|
1715
|
+
choices=(1, 2, 3),
|
|
1716
|
+
default=2,
|
|
1717
|
+
help="Maximum network degree to search. Default: 2.",
|
|
1718
|
+
)
|
|
1719
|
+
parser.add_argument(
|
|
1720
|
+
"--limit",
|
|
1721
|
+
type=int,
|
|
1722
|
+
default=DEFAULT_COMPANY_PATH_LIMIT,
|
|
1723
|
+
help="Maximum candidates to print. Default: 5.",
|
|
1724
|
+
)
|
|
1725
|
+
parser.add_argument(
|
|
1726
|
+
"--max-targets",
|
|
1727
|
+
type=int,
|
|
1728
|
+
help="Maximum LinkedIn search results to fetch per degree. Default: --limit.",
|
|
1729
|
+
)
|
|
1730
|
+
parser.add_argument("--max-bridges", type=int, help=argparse.SUPPRESS)
|
|
1731
|
+
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR)
|
|
1732
|
+
parser.add_argument("--refresh-cache", action="store_true")
|
|
1733
|
+
parser.add_argument("--cookie-file", type=Path, default=DEFAULT_COOKIE_FILE)
|
|
1734
|
+
return parser.parse_args(argv)
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def parse_skill_args(argv: list[str]) -> argparse.Namespace:
|
|
1738
|
+
parser = argparse.ArgumentParser(
|
|
1739
|
+
prog="warmpath skill",
|
|
1740
|
+
description="Find reachable LinkedIn profiles with a skill.",
|
|
1741
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1742
|
+
epilog="""examples:
|
|
1743
|
+
uvx warmpath skill Flutter
|
|
1744
|
+
uvx warmpath skill Leadership --max-depth 2
|
|
1745
|
+
|
|
1746
|
+
cookies:
|
|
1747
|
+
Paste Netscape cookies.txt from Get cookies.txt LOCALLY into ~/.config/warmpath/linkedin.cookies,
|
|
1748
|
+
or pass another path with --cookie-file.
|
|
1749
|
+
""",
|
|
1750
|
+
)
|
|
1751
|
+
parser.add_argument("skill", help="skill name to search for")
|
|
1752
|
+
parser.add_argument(
|
|
1753
|
+
"--max-depth",
|
|
1754
|
+
type=int,
|
|
1755
|
+
choices=(1, 2),
|
|
1756
|
+
default=2,
|
|
1757
|
+
help="Maximum network depth to search. Default: 2.",
|
|
1758
|
+
)
|
|
1759
|
+
parser.add_argument(
|
|
1760
|
+
"--limit",
|
|
1761
|
+
type=int,
|
|
1762
|
+
default=DEFAULT_SKILL_SEARCH_LIMIT,
|
|
1763
|
+
help="Maximum LinkedIn search results to inspect per depth. Default: 5.",
|
|
1764
|
+
)
|
|
1765
|
+
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR)
|
|
1766
|
+
parser.add_argument("--refresh-cache", action="store_true")
|
|
1767
|
+
parser.add_argument("--cookie-file", type=Path, default=DEFAULT_COOKIE_FILE)
|
|
1768
|
+
return parser.parse_args(argv)
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def parse_human_args(argv: list[str]) -> argparse.Namespace:
|
|
1772
|
+
parser = argparse.ArgumentParser(
|
|
1773
|
+
prog="warmpath human",
|
|
1774
|
+
description="Print mutual LinkedIn connections for a profile.",
|
|
1775
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1776
|
+
epilog="""examples:
|
|
1777
|
+
uvx warmpath human https://www.linkedin.com/in/ruslan-gilemzianov/
|
|
1778
|
+
|
|
1779
|
+
cookies:
|
|
1780
|
+
Paste Netscape cookies.txt from Get cookies.txt LOCALLY into ~/.config/warmpath/linkedin.cookies,
|
|
1781
|
+
or pass another path with --cookie-file.
|
|
1782
|
+
""",
|
|
1783
|
+
)
|
|
1784
|
+
parser.add_argument("profile_url", help="LinkedIn /in/ profile URL")
|
|
1785
|
+
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR)
|
|
1786
|
+
parser.add_argument("--refresh-cache", action="store_true")
|
|
1787
|
+
parser.add_argument("--cookie-file", type=Path, default=DEFAULT_COOKIE_FILE)
|
|
1788
|
+
return parser.parse_args(argv)
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def parse_main_args(argv: list[str]) -> argparse.Namespace:
|
|
1792
|
+
parser = argparse.ArgumentParser(
|
|
1793
|
+
prog="warmpath",
|
|
1794
|
+
description="Local LinkedIn connection and referral-path tools.",
|
|
1795
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1796
|
+
epilog="""commands:
|
|
1797
|
+
human PROFILE_URL
|
|
1798
|
+
Print mutual LinkedIn connections for a profile URL.
|
|
1799
|
+
|
|
1800
|
+
company COMPANY
|
|
1801
|
+
Find people at a company reachable through your LinkedIn network.
|
|
1802
|
+
COMPANY can be a LinkedIn /company/ URL or a company name.
|
|
1803
|
+
|
|
1804
|
+
skill SKILL
|
|
1805
|
+
Find 1st- and 2nd-degree LinkedIn profiles with a skill.
|
|
1806
|
+
|
|
1807
|
+
examples:
|
|
1808
|
+
uvx warmpath human https://www.linkedin.com/in/ruslan-gilemzianov/
|
|
1809
|
+
uvx warmpath company https://www.linkedin.com/company/ozon-tech
|
|
1810
|
+
uvx warmpath company "Ozon Tech" --max-degree 2 --limit 5
|
|
1811
|
+
uvx warmpath skill Flutter
|
|
1812
|
+
|
|
1813
|
+
more help:
|
|
1814
|
+
uvx warmpath human --help
|
|
1815
|
+
uvx warmpath company --help
|
|
1816
|
+
uvx warmpath skill --help
|
|
1817
|
+
""",
|
|
1818
|
+
)
|
|
1819
|
+
return parser.parse_args(argv)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def main(argv: list[str] | None = None) -> None:
|
|
1823
|
+
if argv is None:
|
|
1824
|
+
argv = sys.argv[1:]
|
|
1825
|
+
|
|
1826
|
+
if not argv:
|
|
1827
|
+
parse_main_args(["--help"])
|
|
1828
|
+
return
|
|
1829
|
+
|
|
1830
|
+
if argv and argv[0] == "human":
|
|
1831
|
+
run_human_command(parse_human_args(argv[1:]))
|
|
1832
|
+
return
|
|
1833
|
+
|
|
1834
|
+
if argv and argv[0] == "company":
|
|
1835
|
+
run_company_command(parse_company_args(argv[1:]))
|
|
1836
|
+
return
|
|
1837
|
+
|
|
1838
|
+
if argv and argv[0] == "skill":
|
|
1839
|
+
run_skill_command(parse_skill_args(argv[1:]))
|
|
1840
|
+
return
|
|
1841
|
+
|
|
1842
|
+
if argv[0].startswith("-"):
|
|
1843
|
+
parse_main_args(argv)
|
|
1844
|
+
return
|
|
1845
|
+
|
|
1846
|
+
fail(f"Unknown command: {argv[0]}", 2)
|
|
1847
|
+
|
|
1848
|
+
parse_main_args(argv)
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
if __name__ == "__main__":
|
|
1852
|
+
main()
|