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,217 @@
1
+ """Fill-only ATS surfaces for the standalone assistant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .form_engine import FillResult, detect_ats, normalize_space
8
+ from .page_actions import (
9
+ fill_first,
10
+ fill_question,
11
+ required_documents,
12
+ set_file_first,
13
+ visible_questions,
14
+ )
15
+ from .page_selectors import DOCUMENT_SELECTORS, PROFILE_FIELD_SELECTORS
16
+ from .qa_bank import QABank
17
+ from .safety import DISABLED_SUBMISSION_REASON, DisabledSubmissionPolicy
18
+
19
+
20
+ __all__ = ["DisabledSubmissionPolicy"]
21
+
22
+
23
+ SUPPORTED_ATS: tuple[str, ...] = (
24
+ "greenhouse",
25
+ "lever",
26
+ "ashby",
27
+ "workday",
28
+ "smartrecruiters",
29
+ "recruitee",
30
+ "workable",
31
+ "bamboohr",
32
+ "icims",
33
+ "taleo",
34
+ "successfactors",
35
+ "personio",
36
+ "teamtailor",
37
+ "jobvite",
38
+ "resumator",
39
+ "generic",
40
+ )
41
+
42
+
43
+ def normalize_ats(value: str | None, *, url: str | None = None) -> str:
44
+ """Normalize an ATS name or infer it from a URL."""
45
+
46
+ candidate = (value or "").strip().lower()
47
+ if not candidate and url:
48
+ candidate = detect_ats(url)
49
+ if candidate in SUPPORTED_ATS:
50
+ return candidate
51
+ if candidate in {"unknown", ""}:
52
+ return "generic"
53
+ return candidate
54
+
55
+
56
+ def is_supported_ats(value: str | None) -> bool:
57
+ """Return whether a normalized ATS surface is supported by package naming."""
58
+
59
+ return normalize_ats(value) in SUPPORTED_ATS
60
+
61
+
62
+ def fill_external_apply_page(
63
+ page: Any,
64
+ ats: str,
65
+ profile: dict[str, Any],
66
+ bank: QABank | None = None,
67
+ qa_context: dict[str, Any] | None = None,
68
+ documents: dict[str, Any] | None = None,
69
+ ) -> FillResult:
70
+ """Fill a selected external application page without submission."""
71
+
72
+ normalized_ats = normalize_ats(ats)
73
+ if page is None:
74
+ return FillResult(
75
+ required_empty=["A browser page object is required before filling can run."],
76
+ reached_submit_step=False,
77
+ surface=f"external:{normalized_ats}",
78
+ )
79
+
80
+ adapter_key = (
81
+ normalized_ats if normalized_ats in {"greenhouse", "lever", "ashby"} else "generic"
82
+ )
83
+ return _fill_selected_external_adapter(
84
+ page,
85
+ adapter_key,
86
+ profile,
87
+ bank,
88
+ qa_context or {},
89
+ documents or {},
90
+ )
91
+
92
+
93
+ def _profile_value(profile: dict[str, Any], key: str) -> Any:
94
+ if key == "full_name":
95
+ return profile.get("full_name") or " ".join(
96
+ part
97
+ for part in (
98
+ normalize_space(profile.get("first_name")),
99
+ normalize_space(profile.get("last_name")),
100
+ )
101
+ if part
102
+ )
103
+ return profile.get(key)
104
+
105
+
106
+ def _fill_profile_fields(page: Any, adapter_key: str, profile: dict[str, Any]) -> list[str]:
107
+ filled: list[str] = []
108
+ fields = PROFILE_FIELD_SELECTORS.get(adapter_key) or PROFILE_FIELD_SELECTORS["generic"]
109
+ for key, selectors in fields.items():
110
+ if fill_first(page, selectors, _profile_value(profile, key)):
111
+ filled.append(key)
112
+ return filled
113
+
114
+
115
+ def _document_value(documents: dict[str, Any], kind: str) -> Any:
116
+ aliases = {
117
+ "resume": ("resume", "cv", "resume_path"),
118
+ "cover_letter": ("cover_letter", "cover", "cover_letter_path"),
119
+ }
120
+ for key in aliases[kind]:
121
+ value = documents.get(key)
122
+ if value:
123
+ return value
124
+ return None
125
+
126
+
127
+ def _fill_documents(page: Any, documents: dict[str, Any]) -> tuple[list[str], list[str]]:
128
+ filled: list[str] = []
129
+ required_empty: list[str] = []
130
+ required = required_documents(page)
131
+ for kind, selectors in DOCUMENT_SELECTORS.items():
132
+ configured = _document_value(documents, kind)
133
+ if configured:
134
+ if set_file_first(page, selectors, configured):
135
+ filled.append(kind)
136
+ elif kind in required:
137
+ required_empty.append(f"{kind} document path is missing or unreadable.")
138
+ elif kind in required:
139
+ required_empty.append(f"{kind} document path is required but not configured.")
140
+ return filled, required_empty
141
+
142
+
143
+ def _fill_questions(
144
+ page: Any,
145
+ bank: QABank | None,
146
+ qa_context: dict[str, Any],
147
+ ) -> tuple[list[str], list[Any], list[Any]]:
148
+ filled: list[str] = []
149
+ required_empty: list[Any] = []
150
+ unknown_questions: list[Any] = []
151
+
152
+ for question in visible_questions(page):
153
+ text = normalize_space(question.get("question") or question.get("label") or "")
154
+ if not text:
155
+ continue
156
+ field_type = normalize_space(question.get("field_type") or "text")
157
+ is_required = bool(question.get("required"))
158
+ if bank is None:
159
+ unknown_questions.append(
160
+ {
161
+ "question": text,
162
+ "field_type": field_type,
163
+ "required": is_required,
164
+ "company": normalize_space(qa_context.get("company") or ""),
165
+ "role": normalize_space(
166
+ qa_context.get("role") or qa_context.get("title") or ""
167
+ ),
168
+ "ats": normalize_space(qa_context.get("ats") or ""),
169
+ "domain": normalize_space(qa_context.get("domain") or ""),
170
+ }
171
+ )
172
+ if is_required:
173
+ required_empty.append(text)
174
+ continue
175
+ answer = bank.find_answer(text, field_type=field_type, context=qa_context)
176
+ if answer and fill_question(page, question, answer.get("answer", "")):
177
+ filled.append(text)
178
+ continue
179
+ pending = bank.log_pending(
180
+ text,
181
+ context=qa_context,
182
+ field_type=field_type,
183
+ is_required=is_required,
184
+ )
185
+ unknown_questions.append(pending)
186
+ if is_required:
187
+ required_empty.append(text)
188
+ return filled, required_empty, unknown_questions
189
+
190
+
191
+ def _fill_selected_external_adapter(
192
+ page: Any,
193
+ adapter_key: str,
194
+ profile: dict[str, Any],
195
+ bank: QABank | None,
196
+ qa_context: dict[str, Any],
197
+ documents: dict[str, Any],
198
+ ) -> FillResult:
199
+ filled = _fill_profile_fields(page, adapter_key, profile)
200
+ document_filled, document_missing = _fill_documents(page, documents)
201
+ question_filled, question_missing, unknown_questions = _fill_questions(page, bank, qa_context)
202
+ filled.extend(f"document:{name}" for name in document_filled)
203
+ filled.extend(f"question:{name}" for name in question_filled)
204
+ reached_submit_step = bool(getattr(page, "at_submit_step", False))
205
+ return FillResult(
206
+ filled=filled,
207
+ required_empty=[*document_missing, *question_missing],
208
+ unknown_questions=unknown_questions,
209
+ reached_submit_step=reached_submit_step,
210
+ surface=f"external:{adapter_key}",
211
+ )
212
+
213
+
214
+ def submit_disabled_status() -> tuple[bool, str]:
215
+ """Return the disabled status for browser submission."""
216
+
217
+ return False, DISABLED_SUBMISSION_REASON
@@ -0,0 +1,155 @@
1
+ """Visible-browser session boundary for assist workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Sequence
7
+ from urllib.parse import urlparse
8
+
9
+ from .contracts import AssistRequest, SearchRequest
10
+ from .paths import RuntimePaths, ensure_runtime_dirs
11
+ from .safety import BROWSER_PROFILE_WARNING, RISK_STATUSES
12
+
13
+
14
+ AUTH_PATH_MARKERS = (
15
+ "login",
16
+ "checkpoint",
17
+ "challenge",
18
+ "captcha",
19
+ "rate-limit",
20
+ "ratelimit",
21
+ "throttle",
22
+ )
23
+ PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND = "python -m playwright install chromium"
24
+
25
+
26
+ def _is_linkedin_host(hostname: str | None) -> bool:
27
+ if not hostname:
28
+ return False
29
+ host = hostname.lower().rstrip(".")
30
+ return host == "linkedin.com" or host.endswith(".linkedin.com")
31
+
32
+
33
+ def page_auth_status(page: Any) -> str:
34
+ """Return a warning status for pages that need user action."""
35
+
36
+ explicit = str(getattr(page, "auth_status", "") or "").strip().lower()
37
+ if explicit in RISK_STATUSES:
38
+ return explicit
39
+ parsed = urlparse(str(getattr(page, "url", "") or ""))
40
+ path = parsed.path.lower()
41
+ haystack = f"{path} {_page_text(page)}".lower()
42
+ if "captcha" in haystack:
43
+ return "captcha"
44
+ if "rate limit" in haystack or "rate-limit" in haystack or "too many requests" in haystack:
45
+ return "rate_limited"
46
+ if "throttle" in haystack or "temporarily restricted" in haystack:
47
+ return "throttled"
48
+ if not _is_linkedin_host(parsed.hostname):
49
+ return "ready"
50
+ if any(marker in path for marker in AUTH_PATH_MARKERS):
51
+ return "checkpoint" if "checkpoint" in path or "challenge" in path else "login"
52
+ return "ready"
53
+
54
+
55
+ def _page_text(page: Any) -> str:
56
+ parts: list[str] = []
57
+ for attr_name in ("title", "text", "body_text", "content"):
58
+ attr = getattr(page, attr_name, "")
59
+ try:
60
+ value = attr() if callable(attr) else attr
61
+ except TypeError:
62
+ value = ""
63
+ if value:
64
+ parts.append(str(value))
65
+ return " ".join(parts)
66
+
67
+
68
+ def browser_profile_warning() -> str:
69
+ """Return the reusable visible-browser profile warning."""
70
+
71
+ return BROWSER_PROFILE_WARNING
72
+
73
+
74
+ @dataclass
75
+ class VisibleBrowserSession:
76
+ """Thin wrapper around a visible browser context."""
77
+
78
+ context: Any
79
+ close_on_exit: bool = False
80
+
81
+ @property
82
+ def pages(self) -> Sequence[Any]:
83
+ return list(getattr(self.context, "pages", []) or [])
84
+
85
+ def open_url(self, url: str) -> None:
86
+ pages = list(self.pages)
87
+ page = pages[-1] if pages else self.context.new_page()
88
+ page.goto(url)
89
+
90
+ def close(self) -> None:
91
+ manager = getattr(self, "_playwright_manager", None)
92
+ try:
93
+ self.context.close()
94
+ finally:
95
+ if manager is not None:
96
+ manager.stop()
97
+
98
+ def warnings(self) -> list[str]:
99
+ warnings: list[str] = [browser_profile_warning()]
100
+ for page in self.pages:
101
+ status = page_auth_status(page)
102
+ if status != "ready":
103
+ warnings.append(f"{status} page requires user action in the visible browser")
104
+ return warnings
105
+
106
+
107
+ class VisibleBrowserSessionFactory:
108
+ """Factory for user-visible browser sessions backed by RuntimePaths."""
109
+
110
+ def __init__(self, paths: RuntimePaths, *, close_on_exit: bool = False) -> None:
111
+ self.paths = paths
112
+ self.close_on_exit = close_on_exit
113
+
114
+ def open(self, request: AssistRequest | SearchRequest) -> VisibleBrowserSession:
115
+ ensure_runtime_dirs(self.paths, include_browser_profile=True)
116
+ try:
117
+ from playwright.sync_api import sync_playwright
118
+ except ModuleNotFoundError as exc:
119
+ raise RuntimeError(
120
+ "Browser setup failed: Playwright is required for live visible-browser workflows.\n"
121
+ f"Try: {PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND}\n"
122
+ f"Browser profile: {self.paths.browser_profile_dir}\n"
123
+ "You can choose another profile with --browser-profile <path>."
124
+ ) from exc
125
+
126
+ manager = sync_playwright().start()
127
+ try:
128
+ context = manager.chromium.launch_persistent_context(
129
+ str(self.paths.browser_profile_dir),
130
+ headless=False,
131
+ )
132
+ except Exception as exc:
133
+ manager.stop()
134
+ raise RuntimeError(
135
+ "Browser setup failed: Chromium could not be launched.\n"
136
+ f"Try: {PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND}\n"
137
+ f"Browser profile: {self.paths.browser_profile_dir}\n"
138
+ "You can choose another profile with --browser-profile <path>."
139
+ ) from exc
140
+ session = VisibleBrowserSession(context=context, close_on_exit=self.close_on_exit)
141
+ setattr(session, "_playwright_manager", manager)
142
+ return session
143
+
144
+ @staticmethod
145
+ def auth_status(page: Any) -> str:
146
+ return page_auth_status(page)
147
+
148
+
149
+ __all__ = [
150
+ "VisibleBrowserSession",
151
+ "VisibleBrowserSessionFactory",
152
+ "browser_profile_warning",
153
+ "page_auth_status",
154
+ "PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND",
155
+ ]