atsfill 0.0.5.dev182__tar.gz → 0.0.5.dev186__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.dev182 → atsfill-0.0.5.dev186}/PKG-INFO +1 -1
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/adpmyjobs.py +5 -5
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/jobstreet.py +35 -17
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/phenom.py +29 -2
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/workatastartup.py +20 -9
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/commands.py +1 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/matching.py +12 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/results.py +2 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/LICENSE +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/README.md +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.5.dev182 → atsfill-0.0.5.dev186}/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.dev186
|
|
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
|
|
@@ -408,15 +408,15 @@ class ADPMyJobs:
|
|
|
408
408
|
every = len(chosen) == len(labels)
|
|
409
409
|
incoming.setdefault(target, []).append(Is(ids[qid], None if every else chosen))
|
|
410
410
|
label = _text(raw.get("questionDescription") or qid)
|
|
411
|
+
knockouts = [labels[a["answerId"]] for a in answers if a.get("knockoutRule")] or None
|
|
411
412
|
match raw["questionType"]:
|
|
412
413
|
case "Instructions":
|
|
413
414
|
continue
|
|
414
|
-
case "Single choice":
|
|
415
|
+
case "Single choice" | "Multiple choice" as kind:
|
|
415
416
|
self._codes[ids[qid]] = labels
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
f = Field(ids[qid], label, FieldType.MULTI_CHOICE, True, list(labels.values()))
|
|
417
|
+
type_ = FieldType.CHOICE if kind == "Single choice" else FieldType.MULTI_CHOICE
|
|
418
|
+
options = list(labels.values())
|
|
419
|
+
f = Field(ids[qid], label, type_, True, options, knockouts=knockouts)
|
|
420
420
|
case "Free form":
|
|
421
421
|
f = Field(ids[qid], label, FieldType.TEXT, True)
|
|
422
422
|
case "Calendar":
|
|
@@ -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}"]')
|
|
@@ -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."""
|
|
@@ -390,6 +390,8 @@ class Control:
|
|
|
390
390
|
return form.locator("input[type=tel]")
|
|
391
391
|
case "country" | "combo":
|
|
392
392
|
return form.locator(f'input[aria-label="{self.where}"]')
|
|
393
|
+
case "companies":
|
|
394
|
+
return form.locator('input[aria-label="Company name"]')
|
|
393
395
|
case "multi" | "skills":
|
|
394
396
|
return form.get_by_label(self.where, exact=False)
|
|
395
397
|
case "month": # the selects' names carry the entry's id: <id>_start_date_month
|
|
@@ -429,15 +431,7 @@ STEPS: dict[str, list[Control]] = {
|
|
|
429
431
|
Control("phone_number", "Phone Number", "phone", FieldType.PHONE, False),
|
|
430
432
|
Control("share", "What is your job search status?", "radio", C, True),
|
|
431
433
|
Control("yc_affiliation", "Have you worked at a YC company?", "radio", C, True),
|
|
432
|
-
Control(
|
|
433
|
-
"hidden_companies",
|
|
434
|
-
"YC companies to be hidden from",
|
|
435
|
-
"combo",
|
|
436
|
-
T,
|
|
437
|
-
False,
|
|
438
|
-
"Company name",
|
|
439
|
-
unfillable="unhandled_rendering: a list of YC companies to be hidden from",
|
|
440
|
-
),
|
|
434
|
+
Control("hidden_companies", "YC companies to be hidden from", "companies", M, False),
|
|
441
435
|
],
|
|
442
436
|
"location": [
|
|
443
437
|
Control("city_current", "What city do you live in?", "place", TA, True),
|
|
@@ -625,6 +619,8 @@ def _value(form: Locator, c: Control) -> Answer | None:
|
|
|
625
619
|
return target.evaluate(_SINGLE_VALUE) or None
|
|
626
620
|
case "multi":
|
|
627
621
|
return target.evaluate(_CHIPS)
|
|
622
|
+
case "companies":
|
|
623
|
+
return target.evaluate(_COMPANIES)
|
|
628
624
|
case "skills":
|
|
629
625
|
return form.evaluate(_SKILLS)
|
|
630
626
|
return target.input_value()
|
|
@@ -640,6 +636,8 @@ _CHIPS = """e => {
|
|
|
640
636
|
const box = e.closest("[id$=__select]") ?? e.parentElement.parentElement.parentElement;
|
|
641
637
|
return [...box.querySelectorAll("[aria-label^='Remove ']")].map(c => c.ariaLabel.slice(7));
|
|
642
638
|
}"""
|
|
639
|
+
_COMPANIES = """e => [...e.parentElement.previousElementSibling.querySelectorAll(
|
|
640
|
+
"span[role=button] + span")].map(s => s.textContent.trim())"""
|
|
643
641
|
_SKILLS = """form => {
|
|
644
642
|
const groups = {};
|
|
645
643
|
for (const r of form.querySelectorAll("input[type=radio][name^=top_skills_]")) {
|
|
@@ -684,6 +682,8 @@ def _profile_fill(page: Page, form: Locator, c: Control, answer: Answer) -> None
|
|
|
684
682
|
form.locator(f'[aria-label="Remove {chip}"]').click()
|
|
685
683
|
for item in [a for a in answer if a not in have]:
|
|
686
684
|
_pick(page, c, target, item, strict=True, exact=True)
|
|
685
|
+
case "companies", list():
|
|
686
|
+
_companies(page, target, c, answer)
|
|
687
687
|
case "skills", list():
|
|
688
688
|
_skills(page, form, target, c, answer)
|
|
689
689
|
case kind, _:
|
|
@@ -715,6 +715,17 @@ def _pick(
|
|
|
715
715
|
box.press("Escape")
|
|
716
716
|
|
|
717
717
|
|
|
718
|
+
def _companies(page: Page, box: Locator, c: Control, answer: list[str]) -> None:
|
|
719
|
+
"""Removes the companies the answer leaves out, then searches each other one and picks the
|
|
720
|
+
result named exactly as it: a nearest name would hide the profile from another company."""
|
|
721
|
+
chips = box.locator("xpath=../preceding-sibling::div[1]//div[span[@role='button']]")
|
|
722
|
+
while extra := [i for i, h in enumerate(box.evaluate(_COMPANIES)) if h not in answer]:
|
|
723
|
+
chips.nth(extra[0]).locator("span[role=button]").click()
|
|
724
|
+
have = box.evaluate(_COMPANIES)
|
|
725
|
+
for company in [a for a in answer if a not in have]:
|
|
726
|
+
_pick(page, c, box, company, strict=True, exact=True)
|
|
727
|
+
|
|
728
|
+
|
|
718
729
|
def _skills(page: Page, form: Locator, box: Locator, c: Control, answer: list[str]) -> None:
|
|
719
730
|
"""Removes the skills the answer leaves out, adds the others, then picks each level."""
|
|
720
731
|
wanted: dict[str, str] = {}
|
|
@@ -424,6 +424,7 @@ def _fill(
|
|
|
424
424
|
if problems := matching.problems(result.fields, answers):
|
|
425
425
|
result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
|
|
426
426
|
return result
|
|
427
|
+
result.warnings = matching.knockouts(result.fields, answers)
|
|
427
428
|
adapter.fill(page, answers)
|
|
428
429
|
_shown_as_listed(page, adapter, result, answers)
|
|
429
430
|
if result.status is not Status.READY:
|
|
@@ -159,3 +159,15 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
|
159
159
|
and (problem := _answer_problem(by_id[i], a, form))
|
|
160
160
|
]
|
|
161
161
|
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def knockouts(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
165
|
+
"""The answers that end the application: never refused, as one can be the truthful one."""
|
|
166
|
+
by_id = {f.id: f for f in fields}
|
|
167
|
+
return [
|
|
168
|
+
f"{i}: {', '.join(map(repr, chosen))} ends the application ({f.label})"
|
|
169
|
+
for i, a in answers.items()
|
|
170
|
+
if (f := by_id.get(i))
|
|
171
|
+
and f.knockouts
|
|
172
|
+
and (chosen := [v for v in ([a] if isinstance(a, str) else a) if v in f.knockouts])
|
|
173
|
+
]
|
|
@@ -75,6 +75,7 @@ class Field:
|
|
|
75
75
|
one_of: str | None = None # an alternative: one of the fields with this key answers (ADR 0005)
|
|
76
76
|
option_sets: list[OptionSet] | None = None # a dependent list's; `options` where none holds
|
|
77
77
|
min_length: int | None = None # a text the system refuses shorter
|
|
78
|
+
knockouts: list[str] | None = None # the options whose choice ends the application (ADR 0005)
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
@dataclass(frozen=True)
|
|
@@ -106,6 +107,7 @@ class Result:
|
|
|
106
107
|
unloaded: list[str] = field(default_factory=list) # steps a profile read could not load yet
|
|
107
108
|
notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
|
|
108
109
|
report: str | None = None # how to send a failed run's report, when it was not sent
|
|
110
|
+
warnings: list[str] = field(default_factory=list) # the knockout answers a fill gave
|
|
109
111
|
|
|
110
112
|
|
|
111
113
|
@dataclass
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|