atsfill 0.0.4.dev122__tar.gz → 0.0.4.dev124__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.4.dev122 → atsfill-0.0.4.dev124}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/README.md +1 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev124/src/atsfill/adapters/gupy.py +294 -0
- atsfill-0.0.4.dev124/src/atsfill/adapters/join.py +367 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/registry.toml +6 -2
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/LICENSE +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev124
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                          
|
|
60
|
+
                            
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                          
|
|
32
|
+
                            
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -4,9 +4,11 @@ from atsfill.adapters.breezy import Breezy
|
|
|
4
4
|
from atsfill.adapters.dayforce import Dayforce
|
|
5
5
|
from atsfill.adapters.eightfold import Eightfold
|
|
6
6
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
7
|
+
from atsfill.adapters.gupy import Gupy
|
|
7
8
|
from atsfill.adapters.hireology import Hireology
|
|
8
9
|
from atsfill.adapters.hiringthing import HiringThing
|
|
9
10
|
from atsfill.adapters.jazzhr import JazzHR
|
|
11
|
+
from atsfill.adapters.join import Join
|
|
10
12
|
from atsfill.adapters.lever import Lever
|
|
11
13
|
from atsfill.adapters.manatal import Manatal
|
|
12
14
|
from atsfill.adapters.oracle import Oracle
|
|
@@ -35,6 +37,7 @@ def adapters() -> list[Adapter]:
|
|
|
35
37
|
Teamtailor(),
|
|
36
38
|
Greenhouse(),
|
|
37
39
|
WelcomeToTheJungle(),
|
|
40
|
+
Join(),
|
|
38
41
|
SmartRecruiters(),
|
|
39
42
|
Oracle(),
|
|
40
43
|
Manatal(),
|
|
@@ -55,6 +58,7 @@ def adapters() -> list[Adapter]:
|
|
|
55
58
|
HiringThing(),
|
|
56
59
|
Eightfold(),
|
|
57
60
|
Workday(),
|
|
61
|
+
Gupy(),
|
|
58
62
|
UKG(),
|
|
59
63
|
SuccessFactors(), # it claims a career site's job path on any host
|
|
60
64
|
Phenom(), # last: it claims a URL by its path alone
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Gupy (`<employer>.gupy.io/jobs/<id>`), as docs/adapters/gupy.md observes.
|
|
2
|
+
|
|
3
|
+
Applying needs an account, one for the whole system, that atsfill signs in to; its sign-up asks
|
|
4
|
+
more than `login` takes. Apply opens the application's register step: the account's resume,
|
|
5
|
+
filled once and shared by every application, then the posting's Additional information and the
|
|
6
|
+
employer's questions, which the page lists from `question-forms`. A fill saves Additional
|
|
7
|
+
information; the questions are saved by submit, for good.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import browser
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^([a-z0-9-]+)\.gupy\.io$")
|
|
21
|
+
JOB = re.compile(r"^/(?:candidates/)?jobs/(\d+)")
|
|
22
|
+
API = "https://private-api.gupy.io"
|
|
23
|
+
ACCOUNT = f"{API}/authentication/candidate/account/current"
|
|
24
|
+
LOGIN = "https://login.gupy.io/candidates/signin"
|
|
25
|
+
FORMS = re.compile(r"/question-forms/candidates/applications/\d+/steps/\d+/forms$")
|
|
26
|
+
ADDITIONAL = "input[name=radioGroupIsIndicatedTitle]"
|
|
27
|
+
ANSWER_NOW = "button:has-text('Answer now')" # Additional information saved on an earlier visit
|
|
28
|
+
QUESTIONS = "[name^=question-], [name^=checkbox-], textarea, input[id^=input-]"
|
|
29
|
+
STEP_TIMEOUT_S = 40
|
|
30
|
+
STEP_POLL_MS = 500
|
|
31
|
+
RESUME_WAIT_S = 15
|
|
32
|
+
SAVE_TIMEOUT_MS = 30_000
|
|
33
|
+
SIGN_IN_TIMEOUT_MS = 30_000
|
|
34
|
+
FORMS_TIMEOUT_S = 10
|
|
35
|
+
YES_NO = ["Yes", "No"]
|
|
36
|
+
TYPES = {
|
|
37
|
+
"SELECT": FieldType.CHOICE,
|
|
38
|
+
"MULTI_SELECT": FieldType.MULTI_CHOICE,
|
|
39
|
+
"TEXT": FieldType.TEXT,
|
|
40
|
+
"TEXT_AREA": FieldType.TEXT,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Additional information: a radio group by its name, under its fieldset's legend; a text input.
|
|
44
|
+
_READ_ADDITIONAL = """() => {
|
|
45
|
+
const groups = {};
|
|
46
|
+
for (const e of document.querySelectorAll("main input")) {
|
|
47
|
+
if (e.type === "radio") {
|
|
48
|
+
const g = groups[e.name] ??= {id: e.name, kind: "radio", options: [], value: null,
|
|
49
|
+
label: e.closest("fieldset")?.querySelector("legend")?.textContent ?? e.name};
|
|
50
|
+
const text = e.labels[0]?.innerText.trim() ?? e.value;
|
|
51
|
+
g.options.push(text);
|
|
52
|
+
if (e.checked) g.value = text;
|
|
53
|
+
} else if (e.type === "text" && e.offsetParent !== null) {
|
|
54
|
+
groups[e.name || e.id] = {id: e.name || e.id, kind: e.getAttribute("role") || "text",
|
|
55
|
+
label: e.labels[0]?.innerText.trim() ?? e.name, options: null, value: e.value || null};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Object.values(groups).map(g => ({...g, label: g.label.replace(/\\s+/g, " ").trim()}));
|
|
59
|
+
}"""
|
|
60
|
+
|
|
61
|
+
# The employer's questions, by question id: the checked labels, or the text typed.
|
|
62
|
+
_READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
|
|
63
|
+
const radios = [...document.querySelectorAll(`input[name="question-${id}"]`)];
|
|
64
|
+
const boxes = [...document.querySelectorAll(`input[name^="checkbox-${id}-"]`)];
|
|
65
|
+
const picked = [...radios, ...boxes].filter(e => e.checked)
|
|
66
|
+
.map(e => e.labels[0]?.innerText.trim());
|
|
67
|
+
if (boxes.length) return [`question-${id}`, picked];
|
|
68
|
+
if (radios.length) return [`question-${id}`, picked[0] ?? null];
|
|
69
|
+
const text = [...document.querySelectorAll("textarea, input[type=text]")]
|
|
70
|
+
.find(e => e.name === title || e.id === `input-${title}`);
|
|
71
|
+
return [`question-${id}`, text?.value ?? null];
|
|
72
|
+
}))"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Gupy:
|
|
76
|
+
name = "gupy"
|
|
77
|
+
per_employer = False
|
|
78
|
+
known_gaps = {"resume": Gap.FLOW}
|
|
79
|
+
side_effects = [
|
|
80
|
+
SideEffect("load", "employer", "opening apply creates the application, incomplete"),
|
|
81
|
+
SideEffect("fill", "hiring_system", "a fill saves the Additional information"),
|
|
82
|
+
SideEffect("submit", "employer", "the answers are saved for good, the application sent"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
self._questions: dict[str, dict[str, Any]] = {}
|
|
87
|
+
self._fields: dict[str, Field] = {}
|
|
88
|
+
self._additional: dict[str, Answer] = {}
|
|
89
|
+
|
|
90
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
91
|
+
parts = urlsplit(url)
|
|
92
|
+
return bool(HOST.match(parts.hostname or "")) and bool(JOB.match(parts.path))
|
|
93
|
+
|
|
94
|
+
def posting(self, url: str) -> str:
|
|
95
|
+
parts = urlsplit(url)
|
|
96
|
+
match = JOB.match(parts.path)
|
|
97
|
+
return f"https://{parts.hostname}/jobs/{match[1]}" if match else url
|
|
98
|
+
|
|
99
|
+
def account_scope(self, url: str) -> None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def login_url(self, scope: str | None) -> str:
|
|
103
|
+
return LOGIN
|
|
104
|
+
|
|
105
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
106
|
+
"""The account endpoint answers the account, 401 to a visitor; without an Origin it
|
|
107
|
+
answers 500."""
|
|
108
|
+
return request.get(ACCOUNT, headers={"origin": "https://login.gupy.io"}).status == 200
|
|
109
|
+
|
|
110
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
111
|
+
form = page.locator("main, body").first
|
|
112
|
+
try:
|
|
113
|
+
form.locator("input[type=password]").wait_for(timeout=SIGN_IN_TIMEOUT_MS)
|
|
114
|
+
except Timeout:
|
|
115
|
+
raise Unsupported(f"unhandled_rendering: no sign-in form on {page.url}") from None
|
|
116
|
+
form.locator("input:not([type=password]):not([type=hidden])").first.fill(email)
|
|
117
|
+
form.locator("input[type=password]").fill(password)
|
|
118
|
+
on_form = page.url
|
|
119
|
+
browser.click(page.locator("button:has-text('Access account')"))
|
|
120
|
+
try:
|
|
121
|
+
page.wait_for_url(lambda url: url != on_form, timeout=SIGN_IN_TIMEOUT_MS)
|
|
122
|
+
except Timeout:
|
|
123
|
+
said = page.locator("main").inner_text().strip().replace("\n", " ")
|
|
124
|
+
raise Blocked(f"sign_in: the sign-in stayed on its page: {said[:200]}") from None
|
|
125
|
+
|
|
126
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
127
|
+
"""Opens apply and walks to the posting's Additional information, the employer's
|
|
128
|
+
questions listed from the page's `question-forms` answer."""
|
|
129
|
+
forms: list[Response] = []
|
|
130
|
+
|
|
131
|
+
def on_response(response: Response) -> None:
|
|
132
|
+
if FORMS.search(urlsplit(response.url).path) and response.request.method == "GET":
|
|
133
|
+
forms.append(response)
|
|
134
|
+
|
|
135
|
+
parts = urlsplit(page.url)
|
|
136
|
+
match = JOB.match(parts.path)
|
|
137
|
+
if not match:
|
|
138
|
+
raise RuntimeError(f"{page.url} is not a Gupy job")
|
|
139
|
+
page.on("response", on_response)
|
|
140
|
+
try:
|
|
141
|
+
page.goto(f"https://{parts.hostname}/candidates/jobs/{match[1]}/apply")
|
|
142
|
+
self._walk(page)
|
|
143
|
+
deadline = time.monotonic() + FORMS_TIMEOUT_S
|
|
144
|
+
while not forms and time.monotonic() < deadline:
|
|
145
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
146
|
+
finally:
|
|
147
|
+
page.remove_listener("response", on_response)
|
|
148
|
+
fields = [_additional(raw) for raw in page.evaluate(_READ_ADDITIONAL)]
|
|
149
|
+
questions = (forms[-1].json().get("questionForm") or {}).get("questions") if forms else []
|
|
150
|
+
self._questions = {f"question-{q['questionId']}": q for q in questions or []}
|
|
151
|
+
fields += [_question(q) for q in self._questions.values()]
|
|
152
|
+
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
153
|
+
return fields
|
|
154
|
+
|
|
155
|
+
def _walk(self, page: Page) -> None:
|
|
156
|
+
"""Past the interstitials to Additional information: the dialog asking to confirm the
|
|
157
|
+
account's contact details, Complete resume, Continue Application, Continue."""
|
|
158
|
+
deadline, disabled_since = time.monotonic() + STEP_TIMEOUT_S, None
|
|
159
|
+
while time.monotonic() < deadline:
|
|
160
|
+
if "/candidates/signin" in page.url:
|
|
161
|
+
raise Blocked("login_required: run `atsfill login gupy`")
|
|
162
|
+
if page.locator(f"{ADDITIONAL}, {ANSWER_NOW}").count():
|
|
163
|
+
return
|
|
164
|
+
for name in ("Remind me later", "Complete resume", "Continue Application", "Continue"):
|
|
165
|
+
button = page.locator(f"button:has-text('{name}'):visible").first
|
|
166
|
+
if not button.count():
|
|
167
|
+
continue
|
|
168
|
+
if button.is_enabled():
|
|
169
|
+
disabled_since = None
|
|
170
|
+
browser.click(button)
|
|
171
|
+
# Continue Application is disabled while the resume loads, for good when the
|
|
172
|
+
# resume is incomplete
|
|
173
|
+
elif (disabled_since := disabled_since or time.monotonic()) < (
|
|
174
|
+
time.monotonic() - RESUME_WAIT_S
|
|
175
|
+
):
|
|
176
|
+
detail = "the account's resume is incomplete; complete it once in Gupy"
|
|
177
|
+
raise Unsupported(known_gap(self, "resume", detail))
|
|
178
|
+
break
|
|
179
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
180
|
+
raise Unsupported(f"unhandled_rendering: no Additional information on {page.url}")
|
|
181
|
+
|
|
182
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
183
|
+
"""Fills Additional information and, where the posting has questions, saves it and
|
|
184
|
+
opens them; the questions are filled and left unsaved for submit."""
|
|
185
|
+
for field_id, answer in answers.items():
|
|
186
|
+
if field_id not in self._questions:
|
|
187
|
+
_set(page, self._fields[field_id], field_id, answer)
|
|
188
|
+
self._additional = self._read_additional(page)
|
|
189
|
+
if not self._questions:
|
|
190
|
+
return
|
|
191
|
+
if page.locator(ADDITIONAL).count():
|
|
192
|
+
browser.click(page.locator("button:has-text('Save and continue')").first)
|
|
193
|
+
answer_now = page.locator(ANSWER_NOW).first
|
|
194
|
+
answer_now.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
195
|
+
browser.click(answer_now)
|
|
196
|
+
page.locator(QUESTIONS).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
197
|
+
for field_id, answer in answers.items():
|
|
198
|
+
if (question := self._questions.get(field_id)) is not None:
|
|
199
|
+
_answer(page, self._fields[field_id], question, answer)
|
|
200
|
+
|
|
201
|
+
def _read_additional(self, page: Page) -> dict[str, Answer]:
|
|
202
|
+
values = {raw["id"]: raw["value"] for raw in page.evaluate(_READ_ADDITIONAL)}
|
|
203
|
+
return {k: v for k, v in values.items() if k in self._fields and v}
|
|
204
|
+
|
|
205
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
206
|
+
"""Additional information as it was saved, or as the page holds it; the questions'."""
|
|
207
|
+
additional = self._additional or self._read_additional(page)
|
|
208
|
+
ids = [[q["questionId"], q["title"]] for q in self._questions.values()]
|
|
209
|
+
values = page.evaluate(_READ_QUESTIONS, ids) if page.locator(QUESTIONS).count() else {}
|
|
210
|
+
return {**additional, **{k: v for k, v in values.items() if v not in (None, "", [])}}
|
|
211
|
+
|
|
212
|
+
def submit(self, page: Page) -> str | None:
|
|
213
|
+
"""Save and continue on the last page: the questions, or Additional information."""
|
|
214
|
+
with page.expect_response(
|
|
215
|
+
lambda r: r.request.method in ("PUT", "POST", "PATCH") and API in r.url,
|
|
216
|
+
timeout=SAVE_TIMEOUT_MS,
|
|
217
|
+
) as saved:
|
|
218
|
+
browser.click(page.locator("button:has-text('Save and continue')").first)
|
|
219
|
+
response = saved.value
|
|
220
|
+
if response.ok:
|
|
221
|
+
return f"{response.request.method} {urlsplit(response.url).path} {response.status}"
|
|
222
|
+
raise RuntimeError(f"{response.request.method} {response.url} answered {response.status}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _additional(raw: dict[str, Any]) -> Field:
|
|
226
|
+
"""A radio group is a choice; the source list, a combobox, is not filled."""
|
|
227
|
+
name, label = raw["id"], raw["label"]
|
|
228
|
+
match raw["kind"]:
|
|
229
|
+
case "radio":
|
|
230
|
+
options, value = raw["options"], raw["value"]
|
|
231
|
+
return Field(name, label, FieldType.CHOICE, True, options, prefilled=value)
|
|
232
|
+
case "text":
|
|
233
|
+
return Field(name, label, FieldType.TEXT, "optional" not in label.lower())
|
|
234
|
+
reason = f"unhandled_rendering: {label!r} is a {raw['kind']}"
|
|
235
|
+
return Field(name, label, FieldType.TEXT, "optional" not in label.lower(), unfillable=reason)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _question(q: dict[str, Any]) -> Field:
|
|
239
|
+
field_id, kind = f"question-{q['questionId']}", TYPES.get(q["type"])
|
|
240
|
+
options = [o["label"] for o in q.get("options") or []] or None
|
|
241
|
+
shown = None
|
|
242
|
+
if q.get("parentQuestionId"):
|
|
243
|
+
shown = Is(f"question-{q['parentQuestionId']}", q.get("conditionalTriggerOptions") or None)
|
|
244
|
+
if kind is None:
|
|
245
|
+
reason = f"unhandled_rendering: {q['title']!r} is a {q['type']} question"
|
|
246
|
+
return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason)
|
|
247
|
+
return Field(
|
|
248
|
+
field_id, q["title"], kind, bool(q["required"]),
|
|
249
|
+
options if kind is not FieldType.TEXT else None, prefilled=q.get("answer") or None,
|
|
250
|
+
shown_when=shown,
|
|
251
|
+
) # fmt: skip
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _label(page: Page, selector: str, text: str) -> Locator:
|
|
255
|
+
"""The label, wrapping it, of the input `selector` matches whose text is `text`."""
|
|
256
|
+
inputs = page.locator(selector)
|
|
257
|
+
for n in range(inputs.count()):
|
|
258
|
+
if inputs.nth(n).evaluate("e => e.labels[0]?.innerText.trim()") == text:
|
|
259
|
+
return inputs.nth(n).locator("xpath=ancestor::label[1]")
|
|
260
|
+
raise NotTaken(f"no option {text!r} in {selector}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _set(page: Page, field: Field, field_id: str, answer: Answer) -> None:
|
|
264
|
+
match field.type, answer:
|
|
265
|
+
case FieldType.CHOICE, str():
|
|
266
|
+
browser.click(_label(page, f'main input[name="{field_id}"]', answer))
|
|
267
|
+
case FieldType.TEXT, str():
|
|
268
|
+
page.locator(f'main input[name="{field_id}"], main input[id="{field_id}"]').fill(answer)
|
|
269
|
+
case kind, _:
|
|
270
|
+
raise Unsupported(f"unhandled_rendering: cannot fill {field_id}, a {kind}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer) -> None:
|
|
274
|
+
qid = question["questionId"]
|
|
275
|
+
match field.type, answer:
|
|
276
|
+
case FieldType.CHOICE, str():
|
|
277
|
+
browser.click(_label(page, f'input[name="question-{qid}"]', answer))
|
|
278
|
+
case FieldType.MULTI_CHOICE, list():
|
|
279
|
+
for option in answer:
|
|
280
|
+
box = _label(page, f'input[name^="checkbox-{qid}-"]', option)
|
|
281
|
+
if not box.locator("input").is_checked():
|
|
282
|
+
browser.click(box)
|
|
283
|
+
case FieldType.TEXT, str():
|
|
284
|
+
title = question["title"]
|
|
285
|
+
target = page.locator(f'textarea[name="{_css(title)}"], input[name="{_css(title)}"]')
|
|
286
|
+
if not target.count():
|
|
287
|
+
target = page.locator(f'[id="input-{_css(title)}"]')
|
|
288
|
+
target.first.fill(answer)
|
|
289
|
+
case kind, _:
|
|
290
|
+
raise Unsupported(f"unhandled_rendering: cannot fill question-{qid}, a {kind}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _css(text: str) -> str:
|
|
294
|
+
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""JOIN (`join.com/companies/<company>/<job id>-<slug>`), as docs/adapters/join.md observes.
|
|
2
|
+
|
|
3
|
+
Applying is a wizard of one step per page, ending on a review page whose Confirm & apply is the
|
|
4
|
+
send. Signed in, the wizard takes the name from the account and asks the CV, a cover letter and
|
|
5
|
+
the job's screening questions, which the page's own GraphQL query lists. The fill walks the
|
|
6
|
+
steps to the review page, and the read-back is what the review page lists.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import browser
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = "join.com"
|
|
21
|
+
JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/(\d+)(?:-[^/]*)?(?:/apply(?:/.*)?)?/?$")
|
|
22
|
+
ME = f"https://{HOST}/api/me"
|
|
23
|
+
GRAPHQL = f"https://{HOST}/candidate-api/graphql"
|
|
24
|
+
LOGIN = f"https://{HOST}/auth/login/candidate?redirectUrl=%2Ftalent%2Fhome"
|
|
25
|
+
UNVERIFIED = "/auth/candidates/unverified-email"
|
|
26
|
+
STEP_TIMEOUT_MS = 20_000
|
|
27
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
28
|
+
ACCOUNT_TIMEOUT_MS = 20_000
|
|
29
|
+
SUBMIT_TIMEOUT_MS = 60_000
|
|
30
|
+
BUTTON_TIMEOUT_MS = 2_000
|
|
31
|
+
OPENING_TIMEOUT_S = 20
|
|
32
|
+
CONTINUE_TIMEOUT_S = 15
|
|
33
|
+
POLL_MS = 250
|
|
34
|
+
MAX_STEPS = 60
|
|
35
|
+
MAX_MONTHS = 36 # how far the calendar is paged for a date
|
|
36
|
+
|
|
37
|
+
QUESTIONS = """query JobScreeningQuestions($input: JobScreeningQuestionsInput!) {
|
|
38
|
+
jobScreeningQuestions(input: $input) {
|
|
39
|
+
questions { id question options type isRequired position questionClass }
|
|
40
|
+
}
|
|
41
|
+
}"""
|
|
42
|
+
YES_NO = ["Yes", "No"] # a boolean's cards, in this order, in the job's language
|
|
43
|
+
FILES = {"cv": "cv"} # a file field, by its step
|
|
44
|
+
# A question of a class has a step of its own; the others are `question-<n>`, in order.
|
|
45
|
+
CLASS_STEPS = {
|
|
46
|
+
"workAuthorization": "WORK_AUTHORIZATION",
|
|
47
|
+
"salaryExpectation": "SALARY_EXPECTATION",
|
|
48
|
+
"startAvailability": "AVAILABILITY",
|
|
49
|
+
"availability": "AVAILABILITY",
|
|
50
|
+
}
|
|
51
|
+
PROFILE_STEPS = ("personalInformation", "professionalLinks") # the account profile's
|
|
52
|
+
PDF = [".pdf", "application/pdf"]
|
|
53
|
+
|
|
54
|
+
# The page's own GraphQL request, from the page.
|
|
55
|
+
_FETCH = """async ([url, body]) => (await fetch(url, {method: "POST", body: JSON.stringify(body),
|
|
56
|
+
headers: {"content-type": "application/json"}})).json()"""
|
|
57
|
+
|
|
58
|
+
# What the review page lists: each entry's label, then its value.
|
|
59
|
+
_REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
|
|
60
|
+
const [label, value] = div.children;
|
|
61
|
+
return div.children.length === 2 && label.tagName === "P"
|
|
62
|
+
? [label.textContent.trim(), value.textContent.trim()] : null;
|
|
63
|
+
}).filter(Boolean)"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Join:
|
|
67
|
+
name = "join"
|
|
68
|
+
per_employer = False
|
|
69
|
+
asks_name = True # the account's first and last name
|
|
70
|
+
known_gaps = {"profile_incomplete": Gap.FLOW}
|
|
71
|
+
side_effects = [
|
|
72
|
+
SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
|
|
73
|
+
SideEffect("upload", "hiring_system", "a file is stored as soon as it is chosen"),
|
|
74
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self._fields: dict[str, Field] = {}
|
|
79
|
+
self._questions: list[dict[str, Any]] = []
|
|
80
|
+
self._seen: dict[str, Answer] = {} # each question as its step was left
|
|
81
|
+
self._files: list[str] = [] # the file fields answered, in the steps' order
|
|
82
|
+
|
|
83
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
84
|
+
parts = urlsplit(url)
|
|
85
|
+
return (parts.hostname or "").removeprefix("www.") == HOST and bool(JOB.match(parts.path))
|
|
86
|
+
|
|
87
|
+
def posting(self, url: str) -> str:
|
|
88
|
+
match = JOB.match(urlsplit(url).path)
|
|
89
|
+
return f"https://{HOST}/companies/{match[1]}/{match[2]}" if match else url
|
|
90
|
+
|
|
91
|
+
def account_scope(self, url: str) -> None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def login_url(self, scope: str | None) -> str:
|
|
95
|
+
return LOGIN
|
|
96
|
+
|
|
97
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
98
|
+
"""`api/me` answers the candidate for an account."""
|
|
99
|
+
response = request.get(ME)
|
|
100
|
+
return response.ok and response.json().get("type") == "candidate"
|
|
101
|
+
|
|
102
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
103
|
+
if not _email_step(page, email):
|
|
104
|
+
raise Refused("wrong_password: no account has this email")
|
|
105
|
+
page.locator("#password").fill(password)
|
|
106
|
+
login = _account_post(page, "LoginWithCredentials")
|
|
107
|
+
if login.json().get("errors"):
|
|
108
|
+
raise Refused("wrong_password")
|
|
109
|
+
page.wait_for_url(lambda url: "/auth/login" not in url, timeout=ACCOUNT_TIMEOUT_MS)
|
|
110
|
+
if urlsplit(page.url).path == UNVERIFIED:
|
|
111
|
+
raise Blocked("email_link: the account is not confirmed")
|
|
112
|
+
|
|
113
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
114
|
+
"""Creates the account, signed in, then waits for its confirmation link: until it is
|
|
115
|
+
clicked the wizard does not open."""
|
|
116
|
+
first, _, last = (name or "").strip().rpartition(" ")
|
|
117
|
+
if not first or not last:
|
|
118
|
+
raise Unsupported("unhandled_rendering: the sign-up asks a first and a last name")
|
|
119
|
+
if _email_step(page, email):
|
|
120
|
+
raise Refused("email_in_use")
|
|
121
|
+
page.locator("#firstName").fill(first)
|
|
122
|
+
page.locator("#lastName").fill(last)
|
|
123
|
+
page.locator("#password").fill(password)
|
|
124
|
+
submit = page.locator("[data-testid=SubmitButton]")
|
|
125
|
+
try:
|
|
126
|
+
page.wait_for_function(
|
|
127
|
+
"e => !e.disabled", arg=submit.element_handle(), timeout=BUTTON_TIMEOUT_MS
|
|
128
|
+
)
|
|
129
|
+
except Timeout:
|
|
130
|
+
raise Refused("password_rule: the sign-up keeps its button disabled") from None
|
|
131
|
+
registered = _account_post(page, "RegisterCandidate")
|
|
132
|
+
if errors := registered.json().get("errors"):
|
|
133
|
+
raise Refused(f"password_rule: {errors[0].get('message')}")
|
|
134
|
+
raise Blocked("email_link: the sign-up sent a confirmation link")
|
|
135
|
+
|
|
136
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
137
|
+
"""Lists the fields, then opens the wizard on its first step."""
|
|
138
|
+
if not JOB.match(urlsplit(page.url).path):
|
|
139
|
+
raise RuntimeError(f"{page.url} is not a JOIN job")
|
|
140
|
+
raw = page.locator("script#__NEXT_DATA__").text_content() or "{}"
|
|
141
|
+
job = json.loads(raw)["props"]["pageProps"].get("pageData", {}).get("job")
|
|
142
|
+
if not job or job.get("status") != "ONLINE":
|
|
143
|
+
raise Unsupported(f"posting_gone: the job is {job and job.get('status')}")
|
|
144
|
+
variables = {"input": {"companyId": job["companyId"], "jobId": job["id"],
|
|
145
|
+
"paginationPage": {"page": 1, "pageSize": 100}}} # fmt: skip
|
|
146
|
+
body = {
|
|
147
|
+
"operationName": "JobScreeningQuestions",
|
|
148
|
+
"variables": variables,
|
|
149
|
+
"query": QUESTIONS,
|
|
150
|
+
}
|
|
151
|
+
answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
|
|
152
|
+
questions = answered["data"]["jobScreeningQuestions"]["questions"]
|
|
153
|
+
self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
|
|
154
|
+
fields = [
|
|
155
|
+
Field("cv", "CV", FieldType.FILE, True, accept=PDF),
|
|
156
|
+
*(_question(q) for q in self._questions),
|
|
157
|
+
]
|
|
158
|
+
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
159
|
+
browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
|
|
160
|
+
page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
|
|
161
|
+
# the wizard opens on `authentication`, and leaves it once it knows the account
|
|
162
|
+
signed_out = page.locator("#email:visible")
|
|
163
|
+
deadline = time.monotonic() + OPENING_TIMEOUT_S
|
|
164
|
+
while _step(page) == "authentication" and time.monotonic() < deadline:
|
|
165
|
+
if signed_out.count():
|
|
166
|
+
break
|
|
167
|
+
page.wait_for_timeout(POLL_MS)
|
|
168
|
+
if _step(page) == "authentication":
|
|
169
|
+
raise Blocked("login_required: run `atsfill login join`")
|
|
170
|
+
return fields
|
|
171
|
+
|
|
172
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
173
|
+
"""Walks the wizard from the step it is on to the review page, answering each step; a
|
|
174
|
+
step without a listed field (the account's name, its links) is left as it is."""
|
|
175
|
+
for _ in range(MAX_STEPS):
|
|
176
|
+
step = _step(page)
|
|
177
|
+
if step == "review":
|
|
178
|
+
page.locator("a > div > p + p").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
179
|
+
return
|
|
180
|
+
if step in FILES:
|
|
181
|
+
self._file(page, FILES[step], answers.get(FILES[step]))
|
|
182
|
+
elif question := self._step_question(step):
|
|
183
|
+
field_id = f"question.{question['id']}"
|
|
184
|
+
if field_id in self._fields:
|
|
185
|
+
self._question(page, question, answers.get(field_id))
|
|
186
|
+
elif step == "coverLetter": # asked by some jobs only: left without one
|
|
187
|
+
pass
|
|
188
|
+
elif step in PROFILE_STEPS:
|
|
189
|
+
try: # shown when the job requires what the account profile lacks
|
|
190
|
+
_next(page, step)
|
|
191
|
+
except NotTaken, RuntimeError:
|
|
192
|
+
detail = f"the {step} step asks what the account profile lacks"
|
|
193
|
+
raise Unsupported(known_gap(self, "profile_incomplete", detail)) from None
|
|
194
|
+
continue
|
|
195
|
+
else:
|
|
196
|
+
raise Unsupported(f"unhandled_rendering: a wizard step {step!r}")
|
|
197
|
+
_next(page, step)
|
|
198
|
+
raise RuntimeError(f"the wizard did not reach its review page in {MAX_STEPS} steps")
|
|
199
|
+
|
|
200
|
+
def _step_question(self, step: str) -> dict[str, Any] | None:
|
|
201
|
+
if kind := CLASS_STEPS.get(step):
|
|
202
|
+
return next((q for q in self._questions if q.get("questionClass") == kind), None)
|
|
203
|
+
if match := re.fullmatch(r"question-(\d+)", step):
|
|
204
|
+
plain = [q for q in self._questions if not q.get("questionClass")]
|
|
205
|
+
if int(match[1]) > len(plain):
|
|
206
|
+
raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
|
|
207
|
+
return plain[int(match[1]) - 1]
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
211
|
+
"""The files as the review page lists them; each question as its step was left, since
|
|
212
|
+
the review shows a classed question's answer reworded (`€50,000`)."""
|
|
213
|
+
values: dict[str, Answer] = dict(self._seen)
|
|
214
|
+
files = iter(self._files) # the review lists the files by name, in the steps' order
|
|
215
|
+
for _, value in page.evaluate(_REVIEW):
|
|
216
|
+
if value.lower().endswith(".pdf") and (field_id := next(files, None)):
|
|
217
|
+
values[field_id] = value
|
|
218
|
+
return {k: v for k, v in values.items() if v}
|
|
219
|
+
|
|
220
|
+
def submit(self, page: Page) -> str | None:
|
|
221
|
+
with page.expect_response(
|
|
222
|
+
lambda r: r.request.method == "POST" and r.url.startswith(GRAPHQL)
|
|
223
|
+
and "apply" in r.url.lower(),
|
|
224
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
225
|
+
) as sent: # fmt: skip
|
|
226
|
+
browser.click(page.locator("button[type=submit]:visible").last)
|
|
227
|
+
response = sent.value
|
|
228
|
+
body = response.json()
|
|
229
|
+
if response.ok and not body.get("errors"):
|
|
230
|
+
parts = urlsplit(response.url)
|
|
231
|
+
return f"POST {parts.path}?{parts.query} {response.status}"
|
|
232
|
+
raise RuntimeError(f"POST {response.url} answered {response.status}: {body.get('errors')}")
|
|
233
|
+
|
|
234
|
+
def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
|
|
235
|
+
if not isinstance(answer, str):
|
|
236
|
+
return
|
|
237
|
+
page.locator("input[type=file]").first.set_input_files(answer)
|
|
238
|
+
# once stored, the step shows the file in place of its input
|
|
239
|
+
page.wait_for_function(
|
|
240
|
+
"() => !document.querySelector('input[type=file]')", timeout=UPLOAD_TIMEOUT_MS
|
|
241
|
+
)
|
|
242
|
+
self._files.append(field_id)
|
|
243
|
+
|
|
244
|
+
def _question(self, page: Page, question: dict[str, Any], answer: Answer | None) -> None:
|
|
245
|
+
field_id = f"question.{question['id']}"
|
|
246
|
+
cards = page.locator("[role=radio]")
|
|
247
|
+
match question["type"]:
|
|
248
|
+
case "BOOLEAN" | "SINGLE_CHOICE":
|
|
249
|
+
cards.first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
250
|
+
# the cards are the options in order: a letter, the option, a description
|
|
251
|
+
texts = [_card(t) for t in cards.all_inner_texts()]
|
|
252
|
+
options = YES_NO if question["type"] == "BOOLEAN" else question["options"]
|
|
253
|
+
if isinstance(answer, str):
|
|
254
|
+
if answer not in options or options.index(answer) >= len(texts):
|
|
255
|
+
raise NotTaken(f"no option {answer!r} in {field_id}: {texts}")
|
|
256
|
+
card = cards.nth(options.index(answer))
|
|
257
|
+
if card.get_attribute("aria-checked") != "true":
|
|
258
|
+
card.click()
|
|
259
|
+
checked = [c.get_attribute("aria-checked") == "true" for c in cards.all()]
|
|
260
|
+
if True in checked and checked.index(True) < len(options):
|
|
261
|
+
self._seen[field_id] = options[checked.index(True)]
|
|
262
|
+
case "DATE":
|
|
263
|
+
if isinstance(answer, str):
|
|
264
|
+
_date(page, answer)
|
|
265
|
+
picked = page.locator("td[data-part=table-cell][aria-selected=true]")
|
|
266
|
+
if picked.count():
|
|
267
|
+
self._seen[field_id] = picked.first.get_attribute("data-value") or ""
|
|
268
|
+
case _ if isinstance(answer, str):
|
|
269
|
+
name = f"screeningQuestions.answer-{question['id']}"
|
|
270
|
+
target = page.locator(f'[name="{name}"], [id="{name}"], #salaryExpectationAmount')
|
|
271
|
+
if not target.count():
|
|
272
|
+
target = page.locator(
|
|
273
|
+
"textarea:visible, input:visible:not([type=file]):not([role=combobox])"
|
|
274
|
+
)
|
|
275
|
+
target.first.fill(answer)
|
|
276
|
+
self._seen[field_id] = target.first.input_value()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _step(page: Page) -> str:
|
|
280
|
+
return urlsplit(page.url).path.rstrip("/").rsplit("/", 1)[-1]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _continue(page: Page) -> Locator:
|
|
284
|
+
"""The footer's last button, after Back: its label is in the job's language."""
|
|
285
|
+
return page.locator("button:visible:has(> [data-button-label])").last
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _next(page: Page, step: str) -> None:
|
|
289
|
+
button = _continue(page)
|
|
290
|
+
deadline = time.monotonic() + CONTINUE_TIMEOUT_S # an upload enables it once stored
|
|
291
|
+
while button.is_disabled() and time.monotonic() < deadline:
|
|
292
|
+
page.wait_for_timeout(POLL_MS)
|
|
293
|
+
if button.is_disabled():
|
|
294
|
+
raise NotTaken(f"the {step} step keeps Continue disabled: a required answer is missing")
|
|
295
|
+
on = page.url
|
|
296
|
+
try:
|
|
297
|
+
button.click(timeout=STEP_TIMEOUT_MS)
|
|
298
|
+
page.wait_for_url(lambda url: url != on, timeout=STEP_TIMEOUT_MS)
|
|
299
|
+
except Timeout:
|
|
300
|
+
raise RuntimeError(f"Continue on the {step} step went nowhere") from None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _question(q: dict[str, Any]) -> Field:
|
|
304
|
+
field_id, label, required = f"question.{q['id']}", q["question"], bool(q["isRequired"])
|
|
305
|
+
match q["type"]:
|
|
306
|
+
case "SINGLE_CHOICE":
|
|
307
|
+
return Field(field_id, label, FieldType.CHOICE, required, list(q["options"] or []))
|
|
308
|
+
case "BOOLEAN":
|
|
309
|
+
return Field(field_id, label, FieldType.CHOICE, required, YES_NO)
|
|
310
|
+
case "DATE":
|
|
311
|
+
return Field(field_id, label, FieldType.DATE, required)
|
|
312
|
+
case "INTEGER":
|
|
313
|
+
return Field(field_id, f"{label} (a number)", FieldType.TEXT, required)
|
|
314
|
+
case "TEXT":
|
|
315
|
+
return Field(field_id, label, FieldType.TEXT, required)
|
|
316
|
+
reason = f"unhandled_rendering: {label!r} is a {q['type']} question"
|
|
317
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _card(text: str) -> str:
|
|
321
|
+
"""A choice card's option: the line after its letter."""
|
|
322
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
323
|
+
return lines[1] if len(lines) > 1 else (lines[0] if lines else "")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _date(page: Page, answer: str) -> None:
|
|
327
|
+
"""Pages the calendar to the answer's month, then picks its day."""
|
|
328
|
+
day = page.locator(f'td[data-part=table-cell][data-value="{answer}"]')
|
|
329
|
+
for _ in range(MAX_MONTHS):
|
|
330
|
+
if day.count() and day.first.is_visible():
|
|
331
|
+
break
|
|
332
|
+
shown = page.locator("td[data-part=table-cell][data-value]").evaluate_all(
|
|
333
|
+
"es => es.map(e => e.dataset.value)"
|
|
334
|
+
)
|
|
335
|
+
side = "next" if shown and answer > max(shown) else "prev"
|
|
336
|
+
page.locator(f"button[data-part={side}-trigger]").first.click()
|
|
337
|
+
else:
|
|
338
|
+
raise NotTaken(f"the calendar shows no {answer}")
|
|
339
|
+
trigger = day.first.locator("[data-part=table-cell-trigger]")
|
|
340
|
+
(trigger if trigger.count() else day.first).click()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _email_step(page: Page, email: str) -> bool:
|
|
344
|
+
"""The account page's first step: the email. Whether an account has it, from what the page
|
|
345
|
+
asks next: a password alone for one, a name and a password for none."""
|
|
346
|
+
try:
|
|
347
|
+
page.locator("#email").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
|
|
348
|
+
except Timeout:
|
|
349
|
+
raise Unsupported(f"unhandled_rendering: no email step on {page.url}") from None
|
|
350
|
+
page.locator("#email").fill(email)
|
|
351
|
+
with page.expect_response(
|
|
352
|
+
lambda r: "operationName=CheckCandidate" in r.url, timeout=ACCOUNT_TIMEOUT_MS
|
|
353
|
+
) as checked:
|
|
354
|
+
page.locator("button[type=submit]").first.click()
|
|
355
|
+
page.locator("#password").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
|
|
356
|
+
return bool(checked.value.json()["data"]["checkCandidate"]["exists"])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _account_post(page: Page, operation: str) -> Response:
|
|
360
|
+
with page.expect_response(
|
|
361
|
+
lambda r: f"operationName={operation}" in r.url, timeout=ACCOUNT_TIMEOUT_MS
|
|
362
|
+
) as answered:
|
|
363
|
+
page.locator("[data-testid=SubmitButton]").click()
|
|
364
|
+
response = answered.value
|
|
365
|
+
if response.status == 429:
|
|
366
|
+
raise Blocked(f"rate_limited: {operation} answered 429")
|
|
367
|
+
return response
|
|
@@ -358,9 +358,11 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
|
|
|
358
358
|
[gupy]
|
|
359
359
|
kind = "ats"
|
|
360
360
|
share = 0.0040
|
|
361
|
-
account = "to_read"
|
|
361
|
+
account = "to_read" # one account for the whole system; its sign-up asks an ID document number, a mobile number and a date of birth
|
|
362
362
|
hosting = ["hosted"]
|
|
363
|
+
adapter = "gupy"
|
|
363
364
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
365
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "566668d" }
|
|
364
366
|
|
|
365
367
|
[eightfold]
|
|
366
368
|
kind = "ats"
|
|
@@ -397,9 +399,11 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
|
|
|
397
399
|
[join]
|
|
398
400
|
kind = "ats"
|
|
399
401
|
share = 0.0032
|
|
400
|
-
account = "
|
|
402
|
+
account = "to_send" # an unknown email reads and fills the form; a known one signs in first
|
|
403
|
+
adapter = "join"
|
|
401
404
|
hosting = ["hosted"]
|
|
402
405
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
406
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "bfa5b31" }
|
|
403
407
|
|
|
404
408
|
[4dayweek]
|
|
405
409
|
kind = "aggregator"
|
|
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
|