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/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()