atsfill 0.0.5.dev181__tar.gz → 0.0.5.dev184__tar.gz
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.
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/PKG-INFO +1 -1
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/ashby.py +20 -8
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/breezy.py +9 -2
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/jobstreet.py +35 -17
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/lever.py +9 -2
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/manatal.py +12 -4
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/phenom.py +29 -2
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/recruitee.py +9 -2
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/teamtailor.py +14 -5
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/workable.py +9 -2
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/adapter.py +10 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/commands.py +11 -2
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/LICENSE +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/README.md +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev184
|
|
4
4
|
Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
|
|
5
5
|
Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
|
|
6
6
|
Author: shbernal
|
|
@@ -78,7 +78,9 @@ class Ashby:
|
|
|
78
78
|
|
|
79
79
|
def __init__(self) -> None:
|
|
80
80
|
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
81
|
-
self._pending: set[Any] = set() # GraphQL requests in flight, since `
|
|
81
|
+
self._pending: set[Any] = set() # GraphQL requests in flight, since `open_form`
|
|
82
|
+
self._listening = False
|
|
83
|
+
self._loaded: Response | None = None # the ApiJobPosting response `open_form` caught
|
|
82
84
|
self._last_change = 0.0
|
|
83
85
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
84
86
|
|
|
@@ -125,14 +127,24 @@ class Ashby:
|
|
|
125
127
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
126
128
|
return None
|
|
127
129
|
|
|
128
|
-
def
|
|
129
|
-
page.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
131
|
+
"""Loads `/application`, listening first for the page's own ApiJobPosting response."""
|
|
132
|
+
if not self._listening:
|
|
133
|
+
page.on("request", self._on_request)
|
|
134
|
+
page.on("requestfinished", self._on_finished)
|
|
135
|
+
page.on("requestfailed", self._on_finished)
|
|
136
|
+
page.on("response", self._on_response)
|
|
137
|
+
self._listening = True
|
|
133
138
|
with page.expect_response(lambda r: _op(r) == "ApiJobPosting") as posting:
|
|
134
|
-
page.goto(self.posting(
|
|
135
|
-
|
|
139
|
+
page.goto(self.posting(url) + "/application")
|
|
140
|
+
self._loaded = posting.value
|
|
141
|
+
|
|
142
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
143
|
+
if self._loaded is None: # a page claimed once loaded: an embed
|
|
144
|
+
self.open_form(page, page.url)
|
|
145
|
+
assert self._loaded
|
|
146
|
+
loaded, self._loaded = self._loaded, None
|
|
147
|
+
job = loaded.json()["data"]["jobPosting"]
|
|
136
148
|
if job is None:
|
|
137
149
|
raise Unsupported("posting_gone")
|
|
138
150
|
self._forms = [job["applicationForm"], *job["surveyForms"]]
|
|
@@ -211,6 +211,10 @@ _FILE_SHOWN = """e => {
|
|
|
211
211
|
}"""
|
|
212
212
|
|
|
213
213
|
|
|
214
|
+
def _on_form(url: str) -> bool:
|
|
215
|
+
return urlsplit(url).path.rstrip("/").endswith("/apply")
|
|
216
|
+
|
|
217
|
+
|
|
214
218
|
class Breezy:
|
|
215
219
|
name = "breezy"
|
|
216
220
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -236,9 +240,12 @@ class Breezy:
|
|
|
236
240
|
match = JOB.match(parts.path)
|
|
237
241
|
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
238
242
|
|
|
243
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
244
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/apply")
|
|
245
|
+
|
|
239
246
|
def browser_read(self, page: Page) -> list[Field]:
|
|
240
|
-
if not
|
|
241
|
-
|
|
247
|
+
if not _on_form(page.url):
|
|
248
|
+
self.open_form(page, page.url)
|
|
242
249
|
form = page.locator(FORM)
|
|
243
250
|
try:
|
|
244
251
|
form.locator("[name=cName]").wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -34,6 +34,8 @@ APPLY = "[data-automation=job-detail-apply]"
|
|
|
34
34
|
CONTINUE = "[data-testid=continue-button]"
|
|
35
35
|
SUBMIT = "[data-testid=review-submit-application]"
|
|
36
36
|
SAVE_DETAILS = "[data-automation=personal-details-save-button]"
|
|
37
|
+
RESUME_FILE = "input[type=file][id^=resume]"
|
|
38
|
+
LETTER_FILE = "input[type=file][id^=coverLetter]"
|
|
37
39
|
CHALLENGE = "Just a moment..." # Cloudflare's interstitial: it passes on its own, or on a reload
|
|
38
40
|
CHALLENGE_TRIES = 2
|
|
39
41
|
SIGN_IN_TIMEOUT_MS = 90_000 # SEEK's sign-in takes up to ~50 s to show its form
|
|
@@ -53,6 +55,7 @@ SUGGEST_TIMEOUT_S = 8
|
|
|
53
55
|
POLL_MS = 250
|
|
54
56
|
KEY_DELAY_MS = 40
|
|
55
57
|
DETAILS = ("firstName", "lastName", "currentLocation", "phone")
|
|
58
|
+
DOCUMENTS = ("resume", "coverLetter", "coverLetterFile")
|
|
56
59
|
VISIBILITY = ["Public", "Standard", "Limited", "Hidden"]
|
|
57
60
|
|
|
58
61
|
_STEP_ONE = """() => {
|
|
@@ -62,7 +65,7 @@ _STEP_ONE = """() => {
|
|
|
62
65
|
.find(e => e.checked);
|
|
63
66
|
const label = e => e && document.querySelector(`label[for="${e.id}"]`)?.innerText.trim();
|
|
64
67
|
const resume = picked('document-select');
|
|
65
|
-
const
|
|
68
|
+
const method = picked('coverLetter-method');
|
|
66
69
|
const phone = value('#phoneNumber');
|
|
67
70
|
return {
|
|
68
71
|
firstName: value('#firstName'), lastName: value('#lastName'),
|
|
@@ -70,7 +73,8 @@ _STEP_ONE = """() => {
|
|
|
70
73
|
phone: phone && code?.value
|
|
71
74
|
? `${code.options[code.selectedIndex].text.match(/\\+\\d+/)?.[0] ?? ''}${phone}` : null,
|
|
72
75
|
resume: resume && resume.value !== 'dont-include' ? label(resume) : null,
|
|
73
|
-
coverLetter:
|
|
76
|
+
coverLetter: method?.value === 'change' ? value('[data-testid=coverLetterTextInput]') : null,
|
|
77
|
+
coverLetterFile: method?.value === 'upload' ? label(method) : null,
|
|
74
78
|
};
|
|
75
79
|
}"""
|
|
76
80
|
|
|
@@ -198,7 +202,7 @@ class Jobstreet:
|
|
|
198
202
|
self._read |= _personal_details(page, self._fields, answers)
|
|
199
203
|
_documents_fill(page, answers)
|
|
200
204
|
documents = page.evaluate(_STEP_ONE)
|
|
201
|
-
self._read |= {k: documents[k] for k in
|
|
205
|
+
self._read |= {k: documents[k] for k in DOCUMENTS if documents[k]}
|
|
202
206
|
_continue(page)
|
|
203
207
|
if page.url.split("?")[0].endswith("/role-requirements"):
|
|
204
208
|
for field_id, answer in answers.items():
|
|
@@ -316,14 +320,12 @@ def _operations(response: Response) -> dict[str, Any]:
|
|
|
316
320
|
|
|
317
321
|
def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
|
|
318
322
|
"""The first step: the profile's personal details, then the resume and the cover letter,
|
|
319
|
-
optional where the page offers to leave them out.
|
|
323
|
+
optional where the page offers to leave them out. The cover letter is written or uploaded,
|
|
324
|
+
two alternatives."""
|
|
320
325
|
details = viewer["personalDetails"]
|
|
321
326
|
code = (details.get("countryCallingCode") or {}).get("countryCallingCode", "")
|
|
322
327
|
location = (viewer.get("currentLocation2") or {}).get("description")
|
|
323
|
-
|
|
324
|
-
accept: list[str] | None = None
|
|
325
|
-
if resume.count():
|
|
326
|
-
accept = [str(a) for a in (resume.get_attribute("accept") or "").split(",")]
|
|
328
|
+
letter = not page.locator("[data-testid=coverLetter-method-none]").count()
|
|
327
329
|
return [
|
|
328
330
|
Field("firstName", "First name", FieldType.TEXT, True,
|
|
329
331
|
prefilled=details.get("firstName") or None),
|
|
@@ -333,12 +335,21 @@ def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
|
|
|
333
335
|
Field("phone", "Phone number", FieldType.PHONE, False,
|
|
334
336
|
prefilled=f"{code}{details['phoneNumber']}" if details.get("phoneNumber") else None),
|
|
335
337
|
Field("resume", "Resumé", FieldType.FILE,
|
|
336
|
-
not page.locator("input[type=radio][value=dont-include]").count(),
|
|
337
|
-
|
|
338
|
-
|
|
338
|
+
not page.locator("input[type=radio][value=dont-include]").count(),
|
|
339
|
+
accept=_accept(page, RESUME_FILE)),
|
|
340
|
+
Field("coverLetter", "Cover letter", FieldType.TEXT, letter, one_of="coverLetter"),
|
|
341
|
+
Field("coverLetterFile", "Cover letter", FieldType.FILE, letter,
|
|
342
|
+
accept=_accept(page, LETTER_FILE), one_of="coverLetter"),
|
|
339
343
|
] # fmt: skip
|
|
340
344
|
|
|
341
345
|
|
|
346
|
+
def _accept(page: Page, selector: str) -> list[str] | None:
|
|
347
|
+
upload = page.locator(selector)
|
|
348
|
+
if not upload.count():
|
|
349
|
+
return None
|
|
350
|
+
return [str(a) for a in (upload.get_attribute("accept") or "").split(",")]
|
|
351
|
+
|
|
352
|
+
|
|
342
353
|
def _question(raw: dict[str, Any]) -> Field | None:
|
|
343
354
|
field_id, label = f"question.{raw['id']}", raw["text"]
|
|
344
355
|
options = [o["text"] for o in raw.get("options") or []]
|
|
@@ -404,20 +415,27 @@ def _phone(page: Page, answer: str) -> None:
|
|
|
404
415
|
|
|
405
416
|
def _documents_fill(page: Page, answers: dict[str, Answer]) -> None:
|
|
406
417
|
"""Uploads the resume, which the page then picks, or leaves it out; the same for the cover
|
|
407
|
-
letter, written in its box."""
|
|
418
|
+
letter, uploaded or written in its box."""
|
|
408
419
|
if (resume := answers.get("resume")) is not None:
|
|
409
|
-
page
|
|
410
|
-
name = str(resume).rsplit("/", 1)[-1]
|
|
411
|
-
page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
420
|
+
_upload(page, RESUME_FILE, str(resume))
|
|
412
421
|
elif (none := page.locator("input[type=radio][value=dont-include]")).count():
|
|
413
422
|
none.check(force=True)
|
|
414
|
-
if (letter := answers.get("
|
|
415
|
-
page
|
|
423
|
+
if (letter := answers.get("coverLetterFile")) is not None:
|
|
424
|
+
_upload(page, LETTER_FILE, str(letter))
|
|
425
|
+
elif (letter := answers.get("coverLetter")) is not None:
|
|
426
|
+
page.locator("input[type=radio][data-testid=coverLetter-method-change]").check(force=True)
|
|
416
427
|
page.locator("[data-testid=coverLetterTextInput]").fill(str(letter))
|
|
417
428
|
elif (none := page.locator("[data-testid=coverLetter-method-none]")).count():
|
|
418
429
|
none.check(force=True)
|
|
419
430
|
|
|
420
431
|
|
|
432
|
+
def _upload(page: Page, selector: str, path: str) -> None:
|
|
433
|
+
"""Sets the file, then waits for the radio the page adds and picks, labelled with its name."""
|
|
434
|
+
page.locator(selector).set_input_files(path)
|
|
435
|
+
name = path.rsplit("/", 1)[-1]
|
|
436
|
+
page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
437
|
+
|
|
438
|
+
|
|
421
439
|
def _answer(page: Page, field: Field, answer: Answer) -> None:
|
|
422
440
|
name = f"questionnaire.{field.id.removeprefix('question.')}"
|
|
423
441
|
select = page.locator(f'select[name="{name}"]')
|
|
@@ -116,6 +116,10 @@ _READ_BACK = """(form, kinds) => {
|
|
|
116
116
|
}"""
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def _on_form(url: str) -> bool:
|
|
120
|
+
return urlsplit(url).path.rstrip("/").endswith("/apply")
|
|
121
|
+
|
|
122
|
+
|
|
119
123
|
class Lever:
|
|
120
124
|
name = "lever"
|
|
121
125
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -134,9 +138,12 @@ class Lever:
|
|
|
134
138
|
parts, job = urlsplit(url), _job(url)
|
|
135
139
|
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
136
140
|
|
|
141
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
142
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/apply")
|
|
143
|
+
|
|
137
144
|
def browser_read(self, page: Page) -> list[Field]:
|
|
138
|
-
if not
|
|
139
|
-
|
|
145
|
+
if not _on_form(page.url):
|
|
146
|
+
self.open_form(page, page.url)
|
|
140
147
|
form = page.locator(FORM)
|
|
141
148
|
try:
|
|
142
149
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
13
|
|
|
14
14
|
from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
|
|
15
|
-
from atsfill.core.browser import HTTP_TIMEOUT_S, Page
|
|
15
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Response
|
|
16
16
|
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
17
17
|
|
|
18
18
|
HOST = "www.careers-page.com"
|
|
@@ -183,6 +183,7 @@ class Manatal:
|
|
|
183
183
|
|
|
184
184
|
def __init__(self) -> None:
|
|
185
185
|
self._entries: list[dict[str, Any]] = []
|
|
186
|
+
self._loaded: Response | None = None # the form's GET `open_form` caught
|
|
186
187
|
|
|
187
188
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
188
189
|
parts = urlsplit(url).path.strip("/").split("/")
|
|
@@ -210,13 +211,20 @@ class Manatal:
|
|
|
210
211
|
except OSError, ValueError, KeyError, TypeError:
|
|
211
212
|
return None
|
|
212
213
|
|
|
213
|
-
def
|
|
214
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
215
|
+
"""Loads `/apply`, listening first for the form's GET."""
|
|
214
216
|
with page.expect_response(
|
|
215
217
|
lambda r: r.request.method == "GET" and "/application-form/" in r.url,
|
|
216
218
|
timeout=LOAD_TIMEOUT_MS,
|
|
217
219
|
) as loaded:
|
|
218
|
-
page.goto(_posting(
|
|
219
|
-
|
|
220
|
+
page.goto(_posting(url) + "/apply")
|
|
221
|
+
self._loaded = loaded.value
|
|
222
|
+
|
|
223
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
224
|
+
if self._loaded is None:
|
|
225
|
+
self.open_form(page, page.url)
|
|
226
|
+
assert self._loaded
|
|
227
|
+
response, self._loaded = self._loaded, None
|
|
220
228
|
if response.headers.get("x-amzn-waf-action") == "challenge":
|
|
221
229
|
raise Blocked("captcha: AWS WAF challenge")
|
|
222
230
|
if not response.ok:
|
|
@@ -44,6 +44,7 @@ SEQ = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/job/([A-Za-z0-9]+)")
|
|
|
44
44
|
CDN = "https://cdn-prod-static.phenompeople.com/CareerConnectResources"
|
|
45
45
|
FORM = "form.rjsf"
|
|
46
46
|
NEXT = "button#next"
|
|
47
|
+
TERMS = "terms" # a notice some sites' wizard opens on, its one button accepting it
|
|
47
48
|
SUBMIT = "button#submit, button.btn-submit, button[type=submit].btn-navigate:not(#next)"
|
|
48
49
|
RESUME = ".resume-upload-wrapper input[type=file]"
|
|
49
50
|
RESUME_ID = "resume"
|
|
@@ -264,6 +265,11 @@ class Phenom:
|
|
|
264
265
|
SideEffect(
|
|
265
266
|
"upload", "hiring_system", "the resume is uploaded to Phenom and parsed at once"
|
|
266
267
|
),
|
|
268
|
+
SideEffect(
|
|
269
|
+
"fill",
|
|
270
|
+
"hiring_system",
|
|
271
|
+
"an employer notice the wizard opens on (`page=terms`) is accepted to reach the form",
|
|
272
|
+
),
|
|
267
273
|
SideEffect(
|
|
268
274
|
"fill",
|
|
269
275
|
"hiring_system",
|
|
@@ -355,6 +361,7 @@ class Phenom:
|
|
|
355
361
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
356
362
|
page.context.clear_cookies()
|
|
357
363
|
self._open(page, fresh=True)
|
|
364
|
+
self._accept_terms(page)
|
|
358
365
|
self._kept, self._shown = {}, {}
|
|
359
366
|
if RESUME_ID in answers and page.locator(RESUME).count():
|
|
360
367
|
_upload(page, _one(answers[RESUME_ID]))
|
|
@@ -414,17 +421,31 @@ class Phenom:
|
|
|
414
421
|
if fresh or not page.url.startswith(self._apply):
|
|
415
422
|
page.goto(self._apply, wait_until="domcontentloaded")
|
|
416
423
|
_check_open(page)
|
|
424
|
+
self._wait_for_step(page)
|
|
425
|
+
|
|
426
|
+
def _wait_for_step(self, page: Page) -> None:
|
|
417
427
|
try:
|
|
418
428
|
page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]").first.wait_for(
|
|
419
429
|
state="attached", timeout=FORM_TIMEOUT_MS
|
|
420
430
|
)
|
|
421
431
|
except Timeout:
|
|
422
|
-
|
|
423
|
-
if step := (query.get("stepname") or query.get("page") or [None])[0]:
|
|
432
|
+
if step := _step_name(page.url):
|
|
424
433
|
gap = known_gap(self, "first_step", f"the wizard opens on {step!r}")
|
|
425
434
|
raise Unsupported(gap) from None
|
|
426
435
|
raise RuntimeError("no application step on the page") from None
|
|
427
436
|
|
|
437
|
+
def _accept_terms(self, page: Page) -> None:
|
|
438
|
+
"""Accepts the terms some sites' wizard opens on, a step with no field before the form."""
|
|
439
|
+
if _step_name(page.url) != TERMS:
|
|
440
|
+
return
|
|
441
|
+
_click(page, page.locator(FORM).locator(SUBMIT).first)
|
|
442
|
+
try:
|
|
443
|
+
page.wait_for_url(lambda url: _step_name(url) != TERMS, timeout=STEP_TIMEOUT_MS)
|
|
444
|
+
except Timeout:
|
|
445
|
+
gap = known_gap(self, "first_step", "the wizard stays on its terms once accepted")
|
|
446
|
+
raise Unsupported(gap) from None
|
|
447
|
+
self._wait_for_step(page)
|
|
448
|
+
|
|
428
449
|
def _step(self, page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
|
|
429
450
|
"""The answered fields the current step shows, in the configuration's order."""
|
|
430
451
|
shown = {r["id"]: r for r in page.locator(FORM).evaluate(_SCAN)}
|
|
@@ -1237,6 +1258,12 @@ def _seq(url: str) -> str | None:
|
|
|
1237
1258
|
return parse_qs(parts.query).get("jobSeqNo", [None])[0]
|
|
1238
1259
|
|
|
1239
1260
|
|
|
1261
|
+
def _step_name(url: str) -> str | None:
|
|
1262
|
+
"""The wizard's step as its URL names it (`stepname=`, `page=`), if it does."""
|
|
1263
|
+
query = parse_qs(urlsplit(url).query)
|
|
1264
|
+
return (query.get("stepname") or query.get("page") or [None])[0]
|
|
1265
|
+
|
|
1266
|
+
|
|
1240
1267
|
def _apply_url(page: Page) -> str:
|
|
1241
1268
|
"""The wizard's URL: the page's, or where the job page's Apply link leads; the wizard's
|
|
1242
1269
|
own when no link shows, which `_open` then finds or not."""
|
|
@@ -62,6 +62,10 @@ _READ_BACK = """(form, kinds) => {
|
|
|
62
62
|
}"""
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def _on_form(url: str) -> bool:
|
|
66
|
+
return urlsplit(url).path.rstrip("/").endswith("/c/new")
|
|
67
|
+
|
|
68
|
+
|
|
65
69
|
class Recruitee:
|
|
66
70
|
name = "recruitee"
|
|
67
71
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -84,9 +88,12 @@ class Recruitee:
|
|
|
84
88
|
match = JOB.match(parts.path)
|
|
85
89
|
return f"https://{parts.hostname}/o/{match[1]}" if match else url
|
|
86
90
|
|
|
91
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
92
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/c/new")
|
|
93
|
+
|
|
87
94
|
def browser_read(self, page: Page) -> list[Field]:
|
|
88
|
-
if not
|
|
89
|
-
|
|
95
|
+
if not _on_form(page.url):
|
|
96
|
+
self.open_form(page, page.url)
|
|
90
97
|
form = page.locator(FORM)
|
|
91
98
|
try:
|
|
92
99
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -125,6 +125,7 @@ class Teamtailor:
|
|
|
125
125
|
self._follow_ups: set[str] = set()
|
|
126
126
|
self._uploads: dict[str, str] = {} # file field id: the answer's file name
|
|
127
127
|
self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
|
|
128
|
+
self._form: str | None = None # the form `open_form` loaded
|
|
128
129
|
|
|
129
130
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
130
131
|
"""A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
|
|
@@ -141,13 +142,21 @@ class Teamtailor:
|
|
|
141
142
|
assert match, url
|
|
142
143
|
return f"https://{parts.hostname}/jobs/{match[1]}"
|
|
143
144
|
|
|
144
|
-
def
|
|
145
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
145
146
|
"""Opens the form's own page, never the overlay nor "Apply with LinkedIn"."""
|
|
146
|
-
parts = urlsplit(
|
|
147
|
+
parts = urlsplit(url)
|
|
147
148
|
job = JOB.search(parts.path)
|
|
148
|
-
assert job,
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
assert job, url # detected on a job URL
|
|
150
|
+
form = f"{parts.scheme}://{parts.netloc}{parts.path[: job.end()]}/applications/new"
|
|
151
|
+
page.goto(form)
|
|
152
|
+
self._form = form
|
|
153
|
+
|
|
154
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
155
|
+
if self._form is None: # a page claimed once loaded: a custom domain
|
|
156
|
+
self.open_form(page, page.url)
|
|
157
|
+
assert self._form
|
|
158
|
+
opened, self._form = self._form, None
|
|
159
|
+
if urlsplit(page.url).hostname != urlsplit(opened).hostname:
|
|
151
160
|
raise Unsupported(f"external_step: the job applies on another site ({page.url})")
|
|
152
161
|
form = page.locator(FORM)
|
|
153
162
|
try:
|
|
@@ -141,6 +141,10 @@ _READ_BACK = """(form, kinds) => {
|
|
|
141
141
|
}"""
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def _on_form(url: str) -> bool:
|
|
145
|
+
return "/apply" in urlsplit(url).path
|
|
146
|
+
|
|
147
|
+
|
|
144
148
|
class Workable:
|
|
145
149
|
name = "workable"
|
|
146
150
|
known_gaps = {"entries": Gap.RENDERING}
|
|
@@ -174,9 +178,12 @@ class Workable:
|
|
|
174
178
|
except OSError, ValueError, KeyError, TypeError:
|
|
175
179
|
return None
|
|
176
180
|
|
|
181
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
182
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/apply/")
|
|
183
|
+
|
|
177
184
|
def browser_read(self, page: Page) -> list[Field]:
|
|
178
|
-
if
|
|
179
|
-
|
|
185
|
+
if not _on_form(page.url):
|
|
186
|
+
self.open_form(page, page.url)
|
|
180
187
|
form = page.locator(FORM)
|
|
181
188
|
try:
|
|
182
189
|
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
@@ -280,6 +280,16 @@ class SendsToOpen(Adapter, Protocol):
|
|
|
280
280
|
...
|
|
281
281
|
|
|
282
282
|
|
|
283
|
+
@runtime_checkable
|
|
284
|
+
class OpensForm(Adapter, Protocol):
|
|
285
|
+
"""An adapter whose form is another page than the posting: the core has it load the form
|
|
286
|
+
in place of the posting, when it claims the posting by its URL (ADR 0003)."""
|
|
287
|
+
|
|
288
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
289
|
+
"""Loads the form of the posting at `url`, so that `browser_read` finds it open."""
|
|
290
|
+
...
|
|
291
|
+
|
|
292
|
+
|
|
283
293
|
@runtime_checkable
|
|
284
294
|
class ReadsByRequest(Adapter, Protocol):
|
|
285
295
|
"""An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
|
|
@@ -29,6 +29,7 @@ from atsfill.core.adapter import (
|
|
|
29
29
|
ListsShownFields,
|
|
30
30
|
NeedsAccount,
|
|
31
31
|
NotTaken,
|
|
32
|
+
OpensForm,
|
|
32
33
|
ProfileIncomplete,
|
|
33
34
|
ReadsByRequest,
|
|
34
35
|
Refused,
|
|
@@ -173,7 +174,7 @@ def _opened(
|
|
|
173
174
|
session = None
|
|
174
175
|
with open_page(data, session, seconds) as page:
|
|
175
176
|
try:
|
|
176
|
-
page
|
|
177
|
+
_load(page, adapter, url)
|
|
177
178
|
except browser.Timeout:
|
|
178
179
|
if adapter is None:
|
|
179
180
|
raise
|
|
@@ -207,6 +208,14 @@ def _opened(
|
|
|
207
208
|
browser.save_session(page, session)
|
|
208
209
|
|
|
209
210
|
|
|
211
|
+
def _load(page: Page, adapter: object, url: str) -> None:
|
|
212
|
+
"""Loads the posting, or its form where the adapter that claims it opens it (ADR 0003)."""
|
|
213
|
+
if isinstance(adapter, OpensForm):
|
|
214
|
+
adapter.open_form(page, url)
|
|
215
|
+
else:
|
|
216
|
+
page.goto(url)
|
|
217
|
+
|
|
218
|
+
|
|
210
219
|
_again: ContextVar[Callable[[], Result | None] | None] = ContextVar("again", default=None)
|
|
211
220
|
|
|
212
221
|
|
|
@@ -281,7 +290,7 @@ def _sign_in_again(
|
|
|
281
290
|
return _login_required(url, adapter, user)
|
|
282
291
|
session = accounts.session_file(mine, adapter.name, scope)
|
|
283
292
|
accounts.sign_in(page, adapter, scope, *credentials, session)
|
|
284
|
-
page
|
|
293
|
+
_load(page, adapter, url)
|
|
285
294
|
return None
|
|
286
295
|
email = credentials[0] if credentials else ""
|
|
287
296
|
scoped = url if adapter.per_employer else None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|