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,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
|
+
]
|