linkedin-apply-assistant 0.1.1
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.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +72 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/config_help.yml +49 -0
- package/.github/ISSUE_TEMPLATE/docs.yml +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +45 -0
- package/.github/ISSUE_TEMPLATE/safety_compliance.yml +48 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
- package/CHANGELOG.md +47 -0
- package/CODE_OF_CONDUCT.md +47 -0
- package/CONTRIBUTING.md +64 -0
- package/GOVERNANCE.md +41 -0
- package/LEGAL.md +38 -0
- package/LICENSE +22 -0
- package/MIGRATION.md +50 -0
- package/README.md +167 -0
- package/RELEASE_CHECKLIST.md +454 -0
- package/SAFETY.md +33 -0
- package/SECURITY.md +37 -0
- package/SUPPORT.md +44 -0
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/bin/linkedin-apply-assistant.mjs +95 -0
- package/configs/config.example.yml +24 -0
- package/configs/qa_bank.example.yml +35 -0
- package/docs/apply.md +40 -0
- package/docs/assist.md +35 -0
- package/docs/browser-session.md +45 -0
- package/docs/ci-and-release-policy.md +105 -0
- package/docs/commands.md +176 -0
- package/docs/install-and-configuration.md +265 -0
- package/docs/registry-publication-strategy.md +169 -0
- package/docs/reports.md +35 -0
- package/docs/search.md +39 -0
- package/docs/troubleshooting.md +57 -0
- package/examples/dry_run_input.example.json +25 -0
- package/examples/reports/apply-audit.example.json +31 -0
- package/examples/reports/search-report.example.json +40 -0
- package/install.ps1 +178 -0
- package/package.json +59 -0
- package/pyproject.toml +51 -0
- package/src/linkedin_apply_assistant/__init__.py +8 -0
- package/src/linkedin_apply_assistant/apply_reports.py +229 -0
- package/src/linkedin_apply_assistant/ats_handlers.py +217 -0
- package/src/linkedin_apply_assistant/browser_sessions.py +155 -0
- package/src/linkedin_apply_assistant/cli.py +570 -0
- package/src/linkedin_apply_assistant/config.py +109 -0
- package/src/linkedin_apply_assistant/contracts.py +255 -0
- package/src/linkedin_apply_assistant/form_engine.py +180 -0
- package/src/linkedin_apply_assistant/linkedin_layer.py +436 -0
- package/src/linkedin_apply_assistant/page_actions.py +110 -0
- package/src/linkedin_apply_assistant/page_selectors.py +88 -0
- package/src/linkedin_apply_assistant/paths.py +135 -0
- package/src/linkedin_apply_assistant/qa_bank.py +352 -0
- package/src/linkedin_apply_assistant/redaction.py +119 -0
- package/src/linkedin_apply_assistant/safety.py +230 -0
- package/src/linkedin_apply_assistant/workflows.py +435 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Pure LinkedIn URL and context helpers for the standalone assistant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import parse_qs, urlencode, unquote, urlparse, urlunparse
|
|
9
|
+
|
|
10
|
+
from .ats_handlers import fill_external_apply_page
|
|
11
|
+
from .browser_sessions import page_auth_status
|
|
12
|
+
from .form_engine import DetectionResult, FillResult, detect_ats, normalize_space
|
|
13
|
+
from .page_actions import (
|
|
14
|
+
fill_first,
|
|
15
|
+
fill_question,
|
|
16
|
+
required_documents,
|
|
17
|
+
set_file_first,
|
|
18
|
+
visible_questions,
|
|
19
|
+
)
|
|
20
|
+
from .page_selectors import DOCUMENT_SELECTORS, EASY_APPLY_ACTIONS, PROFILE_FIELD_SELECTORS
|
|
21
|
+
from .qa_bank import QABank
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_SEARCH_URL = "https://www.linkedin.com/jobs/search/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_linkedin_host(hostname: str | None) -> bool:
|
|
28
|
+
if not hostname:
|
|
29
|
+
return False
|
|
30
|
+
host = hostname.lower().rstrip(".")
|
|
31
|
+
return host == "linkedin.com" or host.endswith(".linkedin.com")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sanitize_linkedin_search_url(search_url: str | None) -> str:
|
|
35
|
+
"""Return a stable LinkedIn jobs-search URL without selected-job state."""
|
|
36
|
+
|
|
37
|
+
parsed = urlparse(search_url or DEFAULT_SEARCH_URL)
|
|
38
|
+
if not _is_linkedin_host(parsed.hostname):
|
|
39
|
+
return search_url or DEFAULT_SEARCH_URL
|
|
40
|
+
query = parse_qs(parsed.query)
|
|
41
|
+
query.pop("currentJobId", None)
|
|
42
|
+
query.pop("start", None)
|
|
43
|
+
clean_query = urlencode(query, doseq=True)
|
|
44
|
+
path = parsed.path or "/jobs/search/"
|
|
45
|
+
return urlunparse((parsed.scheme or "https", parsed.netloc, path, "", clean_query, ""))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def linkedin_search_url_for_job(search_url: str, job_id: str) -> str:
|
|
49
|
+
"""Add a selected job id to a LinkedIn search URL."""
|
|
50
|
+
|
|
51
|
+
parsed = urlparse(sanitize_linkedin_search_url(search_url))
|
|
52
|
+
query = parse_qs(parsed.query)
|
|
53
|
+
query["currentJobId"] = [str(job_id)]
|
|
54
|
+
return urlunparse(
|
|
55
|
+
(parsed.scheme, parsed.netloc, parsed.path, "", urlencode(query, doseq=True), "")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def linkedin_job_id_from_job_record(job: dict[str, Any]) -> str:
|
|
60
|
+
"""Extract a LinkedIn job id from a normalized job record."""
|
|
61
|
+
|
|
62
|
+
for key in ("job_id", "linkedin_job_id", "id"):
|
|
63
|
+
value = str(job.get(key) or "").strip()
|
|
64
|
+
if value:
|
|
65
|
+
return value
|
|
66
|
+
url = str(job.get("url") or job.get("linkedin_url") or "")
|
|
67
|
+
match = re.search(r"/jobs/view/(\d+)", url)
|
|
68
|
+
if match:
|
|
69
|
+
return match.group(1)
|
|
70
|
+
match = re.search(r"[?&]currentJobId=(\d+)", url)
|
|
71
|
+
return match.group(1) if match else ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _decode_linkedin_apply_url(url: str) -> str:
|
|
75
|
+
"""Decode common LinkedIn redirect wrappers around external application URLs."""
|
|
76
|
+
|
|
77
|
+
parsed = urlparse(url or "")
|
|
78
|
+
if not _is_linkedin_host(parsed.hostname):
|
|
79
|
+
return url
|
|
80
|
+
query = parse_qs(parsed.query)
|
|
81
|
+
for key in ("url", "dest", "destination", "redirect"):
|
|
82
|
+
values = query.get(key)
|
|
83
|
+
if values:
|
|
84
|
+
return unquote(values[0])
|
|
85
|
+
return url
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _looks_like_apply_url(url: str) -> bool:
|
|
89
|
+
"""Return whether a URL looks like an external application destination."""
|
|
90
|
+
|
|
91
|
+
if not url:
|
|
92
|
+
return False
|
|
93
|
+
decoded = _decode_linkedin_apply_url(html.unescape(url))
|
|
94
|
+
parsed = urlparse(decoded)
|
|
95
|
+
if not parsed.scheme.startswith("http"):
|
|
96
|
+
return False
|
|
97
|
+
if _is_linkedin_host(parsed.hostname):
|
|
98
|
+
return False
|
|
99
|
+
haystack = f"{parsed.netloc} {parsed.path} {parsed.query}".lower()
|
|
100
|
+
return detect_ats(decoded) != "unknown" or any(
|
|
101
|
+
token in haystack for token in ("apply", "job", "career", "position", "opening")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _external_apply_url_candidate(
|
|
106
|
+
url: str,
|
|
107
|
+
*,
|
|
108
|
+
require_apply_signal: bool = False,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Return a decoded external apply URL candidate, or an empty string."""
|
|
111
|
+
|
|
112
|
+
decoded = _decode_linkedin_apply_url(html.unescape(url or ""))
|
|
113
|
+
if require_apply_signal and not _looks_like_apply_url(decoded):
|
|
114
|
+
return ""
|
|
115
|
+
parsed = urlparse(decoded)
|
|
116
|
+
if parsed.scheme.startswith("http") and not _is_linkedin_host(parsed.hostname):
|
|
117
|
+
return decoded
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _external_apply_url_from_html(markup: str) -> str:
|
|
122
|
+
"""Extract the first plausible external application URL from an HTML fragment."""
|
|
123
|
+
|
|
124
|
+
for match in re.finditer(r"""href=["']([^"']+)["']""", markup or "", re.IGNORECASE):
|
|
125
|
+
candidate = _external_apply_url_candidate(match.group(1), require_apply_signal=True)
|
|
126
|
+
if candidate:
|
|
127
|
+
return candidate
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def current_linkedin_job_info(page: Any, card: Any | None = None) -> dict[str, str]:
|
|
132
|
+
"""Best-effort job context placeholder for later browser integration."""
|
|
133
|
+
|
|
134
|
+
if isinstance(card, dict):
|
|
135
|
+
return {
|
|
136
|
+
"title": normalize_space(card.get("title")),
|
|
137
|
+
"company": normalize_space(card.get("company")),
|
|
138
|
+
"url": normalize_space(card.get("url")),
|
|
139
|
+
"job_id": linkedin_job_id_from_job_record(card),
|
|
140
|
+
}
|
|
141
|
+
return {"title": "", "company": "", "url": "", "job_id": ""}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def detect_current_apply_surface(
|
|
145
|
+
context: Any,
|
|
146
|
+
profile: dict[str, Any] | None = None,
|
|
147
|
+
bank: QABank | None = None,
|
|
148
|
+
qa_context: dict[str, Any] | None = None,
|
|
149
|
+
) -> DetectionResult:
|
|
150
|
+
"""Detect a fillable surface without starting browser work."""
|
|
151
|
+
|
|
152
|
+
pages = getattr(context, "pages", None) or []
|
|
153
|
+
for page in reversed(list(pages)):
|
|
154
|
+
url = str(getattr(page, "url", "") or "")
|
|
155
|
+
page_context = dict(qa_context or {})
|
|
156
|
+
page_context.setdefault("apply_url", url)
|
|
157
|
+
for key in ("job_id", "company", "role", "title"):
|
|
158
|
+
value = getattr(page, key, "")
|
|
159
|
+
if value and key not in page_context:
|
|
160
|
+
page_context[key] = value
|
|
161
|
+
auth_status = page_auth_status(page)
|
|
162
|
+
if auth_status != "ready":
|
|
163
|
+
page_context["blocked_reason"] = (
|
|
164
|
+
f"{auth_status} page requires user action in the visible browser"
|
|
165
|
+
)
|
|
166
|
+
page_context["ats"] = "linkedin"
|
|
167
|
+
return DetectionResult(
|
|
168
|
+
surface="browser_blocked",
|
|
169
|
+
page=page,
|
|
170
|
+
ats="linkedin",
|
|
171
|
+
job_context=page_context,
|
|
172
|
+
)
|
|
173
|
+
if (
|
|
174
|
+
bool(getattr(page, "easy_apply_open", False))
|
|
175
|
+
or getattr(page, "surface", "") == "linkedin_easy_apply"
|
|
176
|
+
):
|
|
177
|
+
return DetectionResult(
|
|
178
|
+
surface="linkedin_easy_apply",
|
|
179
|
+
page=page,
|
|
180
|
+
ats="linkedin",
|
|
181
|
+
job_context=page_context,
|
|
182
|
+
)
|
|
183
|
+
ats = detect_ats(url)
|
|
184
|
+
if ats != "unknown":
|
|
185
|
+
page_context["ats"] = ats
|
|
186
|
+
return DetectionResult(
|
|
187
|
+
surface="external_ats", page=page, ats=ats, job_context=page_context
|
|
188
|
+
)
|
|
189
|
+
if bool(getattr(page, "has_form", False)):
|
|
190
|
+
page_context["ats"] = "generic"
|
|
191
|
+
return DetectionResult(
|
|
192
|
+
surface="external_ats", page=page, ats="generic", job_context=page_context
|
|
193
|
+
)
|
|
194
|
+
return DetectionResult(surface="none", job_context=qa_context or {})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class CurrentSurfaceDetector:
|
|
198
|
+
"""ApplySurfaceDetector using package-local visible-page detection."""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
profile: dict[str, Any] | None = None,
|
|
204
|
+
bank: QABank | None = None,
|
|
205
|
+
qa_context: dict[str, Any] | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
self.profile = profile or {}
|
|
208
|
+
self.bank = bank
|
|
209
|
+
self.qa_context = qa_context or {}
|
|
210
|
+
|
|
211
|
+
def detect(self, session: Any) -> DetectionResult:
|
|
212
|
+
return detect_current_apply_surface(session, self.profile, self.bank, self.qa_context)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class CurrentSurfaceFillAdapter:
|
|
216
|
+
"""FillAdapter dispatching to package-local no-submit adapters."""
|
|
217
|
+
|
|
218
|
+
def fill(
|
|
219
|
+
self,
|
|
220
|
+
detection: DetectionResult,
|
|
221
|
+
profile: dict[str, Any],
|
|
222
|
+
bank: QABank | None = None,
|
|
223
|
+
qa_context: dict[str, Any] | None = None,
|
|
224
|
+
documents: dict[str, Any] | None = None,
|
|
225
|
+
) -> FillResult:
|
|
226
|
+
return fill_current_surface(detection, profile, bank, qa_context, documents)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fill_current_surface(
|
|
230
|
+
detection: DetectionResult,
|
|
231
|
+
profile: dict[str, Any],
|
|
232
|
+
bank: QABank | None = None,
|
|
233
|
+
qa_context: dict[str, Any] | None = None,
|
|
234
|
+
documents: dict[str, Any] | None = None,
|
|
235
|
+
) -> FillResult:
|
|
236
|
+
"""Fill the detected surface when Phase 15 provides browser adapters."""
|
|
237
|
+
|
|
238
|
+
if detection.surface == "external_ats":
|
|
239
|
+
merged_context = dict(detection.job_context)
|
|
240
|
+
merged_context.update(qa_context or {})
|
|
241
|
+
return fill_external_apply_page(
|
|
242
|
+
detection.page, detection.ats, profile, bank, merged_context, documents
|
|
243
|
+
)
|
|
244
|
+
if detection.surface == "linkedin_easy_apply":
|
|
245
|
+
merged_context = dict(detection.job_context)
|
|
246
|
+
merged_context.update(qa_context or {})
|
|
247
|
+
return fill_linkedin_easy_apply(
|
|
248
|
+
detection.page, profile, bank, merged_context, documents=documents
|
|
249
|
+
)
|
|
250
|
+
return FillResult(surface=detection.surface)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _fill_easy_apply_profile(page: Any, profile: dict[str, Any]) -> list[str]:
|
|
254
|
+
filled: list[str] = []
|
|
255
|
+
for key, selectors in PROFILE_FIELD_SELECTORS["linkedin_easy_apply"].items():
|
|
256
|
+
value = profile.get(key)
|
|
257
|
+
if key == "full_name" and not value:
|
|
258
|
+
value = " ".join(
|
|
259
|
+
part
|
|
260
|
+
for part in (
|
|
261
|
+
normalize_space(profile.get("first_name")),
|
|
262
|
+
normalize_space(profile.get("last_name")),
|
|
263
|
+
)
|
|
264
|
+
if part
|
|
265
|
+
)
|
|
266
|
+
if fill_first(page, selectors, value):
|
|
267
|
+
filled.append(key)
|
|
268
|
+
return filled
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _fill_easy_apply_documents(page: Any, documents: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
272
|
+
filled: list[str] = []
|
|
273
|
+
required_empty: list[str] = []
|
|
274
|
+
required = required_documents(page)
|
|
275
|
+
for kind, selectors in DOCUMENT_SELECTORS.items():
|
|
276
|
+
value = documents.get(kind) or documents.get(f"{kind}_path")
|
|
277
|
+
if value:
|
|
278
|
+
if set_file_first(page, selectors, value):
|
|
279
|
+
filled.append(kind)
|
|
280
|
+
else:
|
|
281
|
+
required_empty.append(f"{kind} document path is missing or unreadable.")
|
|
282
|
+
elif kind in required:
|
|
283
|
+
required_empty.append(f"{kind} document path is required but not configured.")
|
|
284
|
+
return filled, required_empty
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _fill_easy_apply_questions(
|
|
288
|
+
page: Any,
|
|
289
|
+
bank: QABank | None,
|
|
290
|
+
qa_context: dict[str, Any],
|
|
291
|
+
) -> tuple[list[str], list[Any], list[Any]]:
|
|
292
|
+
filled: list[str] = []
|
|
293
|
+
required_empty: list[Any] = []
|
|
294
|
+
unknown_questions: list[Any] = []
|
|
295
|
+
if bank is None:
|
|
296
|
+
return filled, required_empty, unknown_questions
|
|
297
|
+
for question in visible_questions(page):
|
|
298
|
+
text = normalize_space(question.get("question") or question.get("label") or "")
|
|
299
|
+
if not text:
|
|
300
|
+
continue
|
|
301
|
+
field_type = normalize_space(question.get("field_type") or "text")
|
|
302
|
+
is_required = bool(question.get("required"))
|
|
303
|
+
answer = bank.find_answer(text, field_type=field_type, context=qa_context)
|
|
304
|
+
if answer and fill_question(page, question, answer.get("answer", "")):
|
|
305
|
+
filled.append(text)
|
|
306
|
+
continue
|
|
307
|
+
pending = bank.log_pending(
|
|
308
|
+
text,
|
|
309
|
+
context=qa_context,
|
|
310
|
+
field_type=field_type,
|
|
311
|
+
is_required=is_required,
|
|
312
|
+
)
|
|
313
|
+
unknown_questions.append(pending)
|
|
314
|
+
if is_required:
|
|
315
|
+
required_empty.append(text)
|
|
316
|
+
return filled, required_empty, unknown_questions
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _next_easy_apply_action(page: Any) -> str:
|
|
320
|
+
if hasattr(page, "next_easy_apply_action"):
|
|
321
|
+
action = normalize_space(page.next_easy_apply_action()).lower()
|
|
322
|
+
return "final" if "submit" in action else action
|
|
323
|
+
return "final" if bool(getattr(page, "at_submit_step", False)) else ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _advance_easy_apply(page: Any, action: str) -> bool:
|
|
327
|
+
if hasattr(page, "advance_easy_apply"):
|
|
328
|
+
return bool(page.advance_easy_apply(action))
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def fill_linkedin_easy_apply(
|
|
333
|
+
page: Any,
|
|
334
|
+
profile: dict[str, Any],
|
|
335
|
+
bank: QABank | None = None,
|
|
336
|
+
qa_context: dict[str, Any] | None = None,
|
|
337
|
+
easy_btn: Any | None = None,
|
|
338
|
+
documents: dict[str, Any] | None = None,
|
|
339
|
+
) -> FillResult:
|
|
340
|
+
"""Fill LinkedIn Easy Apply without performing final submission."""
|
|
341
|
+
|
|
342
|
+
context = qa_context or {}
|
|
343
|
+
docs = documents or {}
|
|
344
|
+
filled = _fill_easy_apply_profile(page, profile)
|
|
345
|
+
document_filled, document_missing = _fill_easy_apply_documents(page, docs)
|
|
346
|
+
question_filled, question_missing, unknown_questions = _fill_easy_apply_questions(
|
|
347
|
+
page, bank, context
|
|
348
|
+
)
|
|
349
|
+
filled.extend(f"document:{name}" for name in document_filled)
|
|
350
|
+
filled.extend(f"question:{name}" for name in question_filled)
|
|
351
|
+
|
|
352
|
+
reached_submit_step = False
|
|
353
|
+
for _ in range(5):
|
|
354
|
+
action = _next_easy_apply_action(page)
|
|
355
|
+
if not action:
|
|
356
|
+
break
|
|
357
|
+
if action in {item.lower() for item in EASY_APPLY_ACTIONS["final"]}:
|
|
358
|
+
reached_submit_step = True
|
|
359
|
+
break
|
|
360
|
+
if action in {item.lower() for item in EASY_APPLY_ACTIONS["advance"]}:
|
|
361
|
+
if not _advance_easy_apply(page, action):
|
|
362
|
+
break
|
|
363
|
+
filled.append(f"advanced:{action}")
|
|
364
|
+
continue
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
if bool(getattr(page, "at_submit_step", False)):
|
|
368
|
+
reached_submit_step = True
|
|
369
|
+
return FillResult(
|
|
370
|
+
filled=filled,
|
|
371
|
+
required_empty=[*document_missing, *question_missing],
|
|
372
|
+
unknown_questions=unknown_questions,
|
|
373
|
+
reached_submit_step=reached_submit_step,
|
|
374
|
+
surface="linkedin_easy_apply",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class StaticLinkedInDiscovery:
|
|
379
|
+
"""LinkedInDiscovery implementation backed by preloaded records."""
|
|
380
|
+
|
|
381
|
+
def __init__(self, records: list[dict[str, Any]] | None = None) -> None:
|
|
382
|
+
self.records = records or []
|
|
383
|
+
|
|
384
|
+
def discover(self, request: Any) -> list[dict[str, Any]]:
|
|
385
|
+
return list(self.records)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class BrowserLinkedInDiscovery:
|
|
389
|
+
"""Visible-browser LinkedIn discovery adapter."""
|
|
390
|
+
|
|
391
|
+
def __init__(self, session_factory: Any | None = None) -> None:
|
|
392
|
+
self.session_factory = session_factory
|
|
393
|
+
|
|
394
|
+
def discover(self, request: Any) -> list[dict[str, Any]]:
|
|
395
|
+
if self.session_factory is None:
|
|
396
|
+
raise RuntimeError(
|
|
397
|
+
"Live LinkedIn discovery requires a configured browser session factory."
|
|
398
|
+
)
|
|
399
|
+
session = self.session_factory.open(request)
|
|
400
|
+
try:
|
|
401
|
+
if request.search_url:
|
|
402
|
+
session.open_url(request.search_url)
|
|
403
|
+
page = (
|
|
404
|
+
list(getattr(session, "pages", []) or [])[-1]
|
|
405
|
+
if getattr(session, "pages", None)
|
|
406
|
+
else None
|
|
407
|
+
)
|
|
408
|
+
if page is None:
|
|
409
|
+
return []
|
|
410
|
+
if hasattr(page, "linkedin_jobs"):
|
|
411
|
+
return list(page.linkedin_jobs(limit=request.limit))
|
|
412
|
+
return []
|
|
413
|
+
finally:
|
|
414
|
+
if getattr(session, "close_on_exit", False):
|
|
415
|
+
session.close()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def run_linkedin_search_flow(*args: Any, **kwargs: Any) -> dict[str, str]:
|
|
419
|
+
"""Return a status for callers that have not supplied a discovery adapter."""
|
|
420
|
+
|
|
421
|
+
return {"status": "not_configured", "reason": "Use the package search workflow with discovery."}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def run_linkedin_json_flow(*args: Any, **kwargs: Any) -> dict[str, str]:
|
|
425
|
+
"""Return a disabled status for JSON-driven browser orchestration."""
|
|
426
|
+
|
|
427
|
+
return {"status": "disabled", "reason": "JSON browser orchestration is not enabled."}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def run_assistive_flow(*args: Any, **kwargs: Any) -> dict[str, str]:
|
|
431
|
+
"""Return a status for callers that have not supplied assist dependencies."""
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
"status": "not_configured",
|
|
435
|
+
"reason": "Use the package assist workflow with a session factory.",
|
|
436
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Small page-operation helpers used by fill adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def safe_count(target: Any) -> int:
|
|
10
|
+
"""Return a best-effort count for a page object or locator."""
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
count = target.count
|
|
14
|
+
return int(count() if callable(count) else count)
|
|
15
|
+
except Exception:
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe_visible(target: Any) -> bool:
|
|
20
|
+
"""Return whether a locator-like object appears visible."""
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
visible = target.is_visible
|
|
24
|
+
return bool(visible() if callable(visible) else visible)
|
|
25
|
+
except Exception:
|
|
26
|
+
return safe_count(target) > 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _first_locator(page: Any, selectors: Iterable[str]) -> Any | None:
|
|
30
|
+
for selector in selectors:
|
|
31
|
+
try:
|
|
32
|
+
locator = page.locator(selector).first
|
|
33
|
+
except Exception:
|
|
34
|
+
continue
|
|
35
|
+
if safe_count(locator) > 0 and safe_visible(locator):
|
|
36
|
+
return locator
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fill_first(page: Any, selectors: Iterable[str], value: Any) -> bool:
|
|
41
|
+
"""Fill the first matching control, supporting fake pages and Playwright-like pages."""
|
|
42
|
+
|
|
43
|
+
if value is None or str(value).strip() == "":
|
|
44
|
+
return False
|
|
45
|
+
if hasattr(page, "fill_field"):
|
|
46
|
+
return bool(page.fill_field(tuple(selectors), str(value)))
|
|
47
|
+
locator = _first_locator(page, selectors)
|
|
48
|
+
if locator is None:
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
locator.fill(str(value), timeout=3000)
|
|
52
|
+
return True
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_file_first(page: Any, selectors: Iterable[str], path: str | Path) -> bool:
|
|
58
|
+
"""Set a file input when available."""
|
|
59
|
+
|
|
60
|
+
target = Path(path).expanduser()
|
|
61
|
+
if not target.exists():
|
|
62
|
+
return False
|
|
63
|
+
if hasattr(page, "set_file"):
|
|
64
|
+
return bool(page.set_file(tuple(selectors), target))
|
|
65
|
+
locator = _first_locator(page, selectors)
|
|
66
|
+
if locator is None:
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
locator.set_input_files(str(target), timeout=3000)
|
|
70
|
+
return True
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def visible_questions(page: Any) -> list[dict[str, Any]]:
|
|
76
|
+
"""Return fake-page questions when available, otherwise no browser-free questions."""
|
|
77
|
+
|
|
78
|
+
questions = getattr(page, "visible_questions", None)
|
|
79
|
+
if questions is None:
|
|
80
|
+
questions = getattr(page, "questions", None)
|
|
81
|
+
if callable(questions):
|
|
82
|
+
questions = questions()
|
|
83
|
+
if not questions:
|
|
84
|
+
return []
|
|
85
|
+
normalized: list[dict[str, Any]] = []
|
|
86
|
+
for question in questions:
|
|
87
|
+
if isinstance(question, dict):
|
|
88
|
+
normalized.append(dict(question))
|
|
89
|
+
else:
|
|
90
|
+
normalized.append({"question": str(question), "field_type": "text", "required": False})
|
|
91
|
+
return normalized
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def fill_question(page: Any, question: dict[str, Any], answer: Any) -> bool:
|
|
95
|
+
"""Fill a fake-page custom question when supported."""
|
|
96
|
+
|
|
97
|
+
if answer is None or str(answer).strip() == "":
|
|
98
|
+
return False
|
|
99
|
+
if hasattr(page, "fill_question"):
|
|
100
|
+
return bool(page.fill_question(question, str(answer)))
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def required_documents(page: Any) -> set[str]:
|
|
105
|
+
"""Return required document kinds advertised by a fake page."""
|
|
106
|
+
|
|
107
|
+
value = getattr(page, "required_documents", set())
|
|
108
|
+
if callable(value):
|
|
109
|
+
value = value()
|
|
110
|
+
return {str(item) for item in value or set()}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Named selector groups for page adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PROFILE_FIELD_SELECTORS: dict[str, dict[str, tuple[str, ...]]] = {
|
|
7
|
+
"greenhouse": {
|
|
8
|
+
"first_name": ("#first_name", 'input[name*="first_name" i]'),
|
|
9
|
+
"last_name": ("#last_name", 'input[name*="last_name" i]'),
|
|
10
|
+
"email": ('input[type="email"]', 'input[name*="email" i]'),
|
|
11
|
+
"phone": ('input[type="tel"]', 'input[name*="phone" i]'),
|
|
12
|
+
"linkedin": ('input[name*="linkedin" i]',),
|
|
13
|
+
"portfolio": ('input[name*="website" i]', 'input[name*="portfolio" i]'),
|
|
14
|
+
},
|
|
15
|
+
"lever": {
|
|
16
|
+
"full_name": ('input[name="name"]',),
|
|
17
|
+
"email": ('input[name="email"]', 'input[type="email"]'),
|
|
18
|
+
"phone": ('input[name="phone"]', 'input[type="tel"]'),
|
|
19
|
+
"current_company": ('input[name="org"]',),
|
|
20
|
+
"linkedin": ('input[name="urls[LinkedIn]"]',),
|
|
21
|
+
"portfolio": ('input[name="urls[Portfolio]"]',),
|
|
22
|
+
"github": ('input[name="urls[GitHub]"]',),
|
|
23
|
+
},
|
|
24
|
+
"ashby": {
|
|
25
|
+
"full_name": ('input[name="_systemfield_name"]', 'input[id*="name" i]'),
|
|
26
|
+
"email": ('input[name="_systemfield_email"]', 'input[type="email"]'),
|
|
27
|
+
"phone": ('input[name="_systemfield_phone"]', 'input[type="tel"]'),
|
|
28
|
+
"linkedin": ('input[name*="linkedin" i]', 'input[id*="linkedin" i]'),
|
|
29
|
+
"portfolio": ('input[name*="website" i]', 'input[id*="portfolio" i]'),
|
|
30
|
+
},
|
|
31
|
+
"generic": {
|
|
32
|
+
"email": ('input[type="email"]', 'input[name*="email" i]'),
|
|
33
|
+
"phone": ('input[type="tel"]', 'input[name*="phone" i]'),
|
|
34
|
+
"first_name": ('input[name*="first" i]',),
|
|
35
|
+
"last_name": ('input[name*="last" i]',),
|
|
36
|
+
"full_name": ('input[autocomplete="name"]', 'input[name*="name" i]'),
|
|
37
|
+
"linkedin": ('input[name*="linkedin" i]',),
|
|
38
|
+
"portfolio": ('input[name*="portfolio" i]', 'input[name*="website" i]'),
|
|
39
|
+
"github": ('input[name*="github" i]',),
|
|
40
|
+
},
|
|
41
|
+
"linkedin_easy_apply": {
|
|
42
|
+
"first_name": ('input[name*="first" i]',),
|
|
43
|
+
"last_name": ('input[name*="last" i]',),
|
|
44
|
+
"full_name": ('input[autocomplete="name"]',),
|
|
45
|
+
"email": ('input[type="email"]',),
|
|
46
|
+
"phone": ('input[type="tel"]',),
|
|
47
|
+
"linkedin": ('input[name*="linkedin" i]',),
|
|
48
|
+
"portfolio": ('input[name*="portfolio" i]', 'input[name*="website" i]'),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DOCUMENT_SELECTORS: dict[str, tuple[str, ...]] = {
|
|
54
|
+
"resume": (
|
|
55
|
+
'input[type="file"][name*="resume" i]',
|
|
56
|
+
'input[type="file"][id*="resume" i]',
|
|
57
|
+
'input[type="file"][aria-label*="resume" i]',
|
|
58
|
+
'input[type="file"][name*="cv" i]',
|
|
59
|
+
'input[type="file"][id*="cv" i]',
|
|
60
|
+
),
|
|
61
|
+
"cover_letter": (
|
|
62
|
+
'input[type="file"][name*="cover" i]',
|
|
63
|
+
'input[type="file"][id*="cover" i]',
|
|
64
|
+
'input[type="file"][aria-label*="cover" i]',
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
QUESTION_SELECTORS: tuple[str, ...] = (
|
|
70
|
+
"label",
|
|
71
|
+
"[data-test-form-element]",
|
|
72
|
+
"[data-qa-form-element]",
|
|
73
|
+
".form-question",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
EASY_APPLY_ACTIONS: dict[str, tuple[str, ...]] = {
|
|
78
|
+
"advance": ("Next", "Continue", "Review"),
|
|
79
|
+
"final": ("final",),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"DOCUMENT_SELECTORS",
|
|
85
|
+
"EASY_APPLY_ACTIONS",
|
|
86
|
+
"PROFILE_FIELD_SELECTORS",
|
|
87
|
+
"QUESTION_SELECTORS",
|
|
88
|
+
]
|