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.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +72 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  3. package/.github/ISSUE_TEMPLATE/config_help.yml +49 -0
  4. package/.github/ISSUE_TEMPLATE/docs.yml +40 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +45 -0
  6. package/.github/ISSUE_TEMPLATE/safety_compliance.yml +48 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
  8. package/CHANGELOG.md +47 -0
  9. package/CODE_OF_CONDUCT.md +47 -0
  10. package/CONTRIBUTING.md +64 -0
  11. package/GOVERNANCE.md +41 -0
  12. package/LEGAL.md +38 -0
  13. package/LICENSE +22 -0
  14. package/MIGRATION.md +50 -0
  15. package/README.md +167 -0
  16. package/RELEASE_CHECKLIST.md +454 -0
  17. package/SAFETY.md +33 -0
  18. package/SECURITY.md +37 -0
  19. package/SUPPORT.md +44 -0
  20. package/THIRD_PARTY_NOTICES.md +67 -0
  21. package/bin/linkedin-apply-assistant.mjs +95 -0
  22. package/configs/config.example.yml +24 -0
  23. package/configs/qa_bank.example.yml +35 -0
  24. package/docs/apply.md +40 -0
  25. package/docs/assist.md +35 -0
  26. package/docs/browser-session.md +45 -0
  27. package/docs/ci-and-release-policy.md +105 -0
  28. package/docs/commands.md +176 -0
  29. package/docs/install-and-configuration.md +265 -0
  30. package/docs/registry-publication-strategy.md +169 -0
  31. package/docs/reports.md +35 -0
  32. package/docs/search.md +39 -0
  33. package/docs/troubleshooting.md +57 -0
  34. package/examples/dry_run_input.example.json +25 -0
  35. package/examples/reports/apply-audit.example.json +31 -0
  36. package/examples/reports/search-report.example.json +40 -0
  37. package/install.ps1 +178 -0
  38. package/package.json +59 -0
  39. package/pyproject.toml +51 -0
  40. package/src/linkedin_apply_assistant/__init__.py +8 -0
  41. package/src/linkedin_apply_assistant/apply_reports.py +229 -0
  42. package/src/linkedin_apply_assistant/ats_handlers.py +217 -0
  43. package/src/linkedin_apply_assistant/browser_sessions.py +155 -0
  44. package/src/linkedin_apply_assistant/cli.py +570 -0
  45. package/src/linkedin_apply_assistant/config.py +109 -0
  46. package/src/linkedin_apply_assistant/contracts.py +255 -0
  47. package/src/linkedin_apply_assistant/form_engine.py +180 -0
  48. package/src/linkedin_apply_assistant/linkedin_layer.py +436 -0
  49. package/src/linkedin_apply_assistant/page_actions.py +110 -0
  50. package/src/linkedin_apply_assistant/page_selectors.py +88 -0
  51. package/src/linkedin_apply_assistant/paths.py +135 -0
  52. package/src/linkedin_apply_assistant/qa_bank.py +352 -0
  53. package/src/linkedin_apply_assistant/redaction.py +119 -0
  54. package/src/linkedin_apply_assistant/safety.py +230 -0
  55. 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
+ ]