atsfill 0.0.4.dev120__tar.gz → 0.0.4.dev122__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.
Files changed (53) hide show
  1. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/pyproject.toml.orig +1 -1
  4. atsfill-0.0.4.dev122/src/atsfill/adapters/hrmdirect.py +482 -0
  5. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/registry.toml +3 -1
  6. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/LICENSE +0 -0
  7. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/README.md +0 -0
  8. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/hiringthing.py +0 -0
  18. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/jazzhr.py +0 -0
  19. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/lever.py +0 -0
  20. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/manatal.py +0 -0
  21. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/oracle.py +0 -0
  22. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/paycor.py +0 -0
  23. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/paylocity.py +0 -0
  24. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/personio.py +0 -0
  25. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/phenom.py +0 -0
  26. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/pinpoint.py +0 -0
  27. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/recruitee.py +0 -0
  28. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/rippling.py +0 -0
  29. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/smartrecruiters.py +0 -0
  30. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/successfactors.js +0 -0
  31. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/successfactors.py +0 -0
  32. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/taleo.py +0 -0
  33. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/teamtailor.py +0 -0
  34. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/ukg.py +0 -0
  35. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/workable.py +0 -0
  36. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/workday.py +0 -0
  37. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/wttj.py +0 -0
  38. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/adapters/zohorecruit.py +0 -0
  39. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/cli.py +0 -0
  40. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/__init__.py +0 -0
  41. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/accounts.py +0 -0
  42. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/adapter.py +0 -0
  43. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/attempts.py +0 -0
  44. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/browser.py +0 -0
  45. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/commands.py +0 -0
  46. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/conditions.py +0 -0
  47. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/html.py +0 -0
  48. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/matching.py +0 -0
  49. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/passwords.py +0 -0
  50. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/paths.py +0 -0
  51. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/registry.py +0 -0
  52. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/src/atsfill/core/results.py +0 -0
  53. {atsfill-0.0.4.dev120 → atsfill-0.0.4.dev122}/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.dev120
3
+ Version: 0.0.4.dev122
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev120"
3
+ version = "0.0.4.dev122"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev120"
3
+ version = "0.0.4.dev122"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,482 @@
1
+ """HRMDirect, now ClearCompany (`<org>.hrmdirect.com/employment/job-opening.php?req=…`, which
2
+ redirects to `apply.clearcompany.com/<org>?jobId=<job>`, whose Apply opens
3
+ `<org>.clearcompany.com/careers/jobs/<job>/apply`), as docs/adapters/hrmdirect.md observes.
4
+
5
+ Applying needs an account, one per employer (the org): its sign-up is the apply page's first
6
+ step, a name and an email, and sets no password. The form is a wizard of pages, each saved to
7
+ the application as it is left, only the current one in the DOM: the read walks the pages that
8
+ save with what they hold, and the read-back is each page's values, read before it is saved.
9
+ """
10
+
11
+ import re
12
+ import urllib.parse
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, urlsplit
15
+
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
17
+ from atsfill.core.browser import Locator, Page, Request, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ OLD_HOST = re.compile(r"^([a-z0-9-]+)\.hrmdirect\.com$")
21
+ ORG_HOST = re.compile(r"^([a-z0-9-]+)\.clearcompany\.com$")
22
+ SITE_HOST = "apply.clearcompany.com"
23
+ UUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
24
+ APPLY_PATH = re.compile(rf"^/careers/jobs/({UUID})(?:/apply(?:/{UUID})?)?/?$")
25
+ JOB_ID = re.compile(rf"^{UUID}$")
26
+ FORM_TIMEOUT_MS = 30_000
27
+ PAGE_TIMEOUT_MS = 30_000
28
+ UPLOAD_TIMEOUT_MS = 60_000
29
+ SIGN_UP_TIMEOUT_MS = 30_000
30
+ SUBMIT_TIMEOUT_MS = 90_000
31
+ SETTLE_MS = 1_500
32
+ MAX_PAGES = 10
33
+ IDENTITY = "#personal-info-form"
34
+ HAVE_ACCOUNT = "button.have-account-button"
35
+ LOGIN = "a.authenticate-button"
36
+ RECAPTCHA = f"{IDENTITY} iframe[title=reCAPTCHA], {IDENTITY} .g-recaptcha"
37
+ EMAIL_SIGN_IN = "text=Sign in with username and password instead"
38
+ PAGE_FORM = ".form-page-holder form.section-form"
39
+ ACTIVE_PAGE = ".form-progress-holder li.active"
40
+ NEXT = ".form-page-holder ~ * button.submit-button, button.submit-button:visible"
41
+ ERRORS = ".has-error:visible"
42
+ CLOSE_DATEPICKERS = (
43
+ "() => document.querySelectorAll('.datepicker.dropdown-menu')"
44
+ ".forEach(d => { d.style.display = 'none'; })"
45
+ )
46
+ TOKEN = re.compile(r"Token\$\$:\$\$([^$]+)")
47
+ API_HEADERS = {"API-Realm": "CWEB", "API-AppType": "careers"}
48
+
49
+ # Each field of the current page: id, kind, label, required, options, max length, value.
50
+ _FIELDS = """form => [...form.querySelectorAll('.form-field')].flatMap(f => {
51
+ const title = f.querySelector('.field-title');
52
+ if (!title || f.closest('.child-field-holder:empty')) return [];
53
+ const label = [...title.childNodes].filter(n => !(n.classList?.contains('required-indicator')))
54
+ .map(n => n.textContent).join(' ').replace(/\\s+/g, ' ').trim();
55
+ const required = !!title.querySelector('.required-indicator');
56
+ const visible = !!f.offsetParent;
57
+ const qa = [...f.classList].find(c => c.startsWith('qa-')) || '';
58
+ const base = {label, required, visible, qa, options: null, max: null, value: null};
59
+ if (qa === 'qa-resume-upload') {
60
+ const kind = [...f.classList].find(c => c !== 'form-field' && c !== qa);
61
+ const names = [...f.querySelectorAll('.file-list tr td:first-child')]
62
+ .map(t => t.textContent.trim()).filter(n => n && !n.includes('Uploading'));
63
+ const upload = !!f.querySelector('input[type=file]');
64
+ return [{...base, id: 'file.' + kind, kind: upload ? 'file' : 'linkedin',
65
+ value: names.at(-1) || null}];
66
+ }
67
+ const boxes = [...f.querySelectorAll('input.form-radio, input.form-checkbox')];
68
+ if (boxes.length) {
69
+ const own = boxes.filter(b => b.closest('.form-field') === f);
70
+ const text = b => b.closest('label').textContent.replace(/\\s+/g, ' ').trim();
71
+ const checked = own.filter(b => b.checked).map(text);
72
+ return [{...base, id: own[0].dataset.field, kind: own[0].type,
73
+ options: own.map(text), value: checked}];
74
+ }
75
+ const control = f.querySelector('input:not([type=hidden]), select, textarea');
76
+ if (!control || control.closest('.form-field') !== f) return [{...base, id: '', kind: 'none'}];
77
+ let kind = control.tagName.toLowerCase();
78
+ if (kind === 'input') kind = control.classList.contains('date-input') ? 'date' : control.type;
79
+ const options = kind === 'select'
80
+ ? [...control.options].filter(o => o.value).map(o => o.textContent.trim()) : null;
81
+ const value = kind === 'select'
82
+ ? (control.value ? control.selectedOptions[0].textContent.trim() : null) : control.value;
83
+ const max = parseInt(control.dataset.max) || (control.maxLength > 0 ? control.maxLength : null);
84
+ return [{...base, id: control.id, kind, options, value, max}];
85
+ })"""
86
+
87
+
88
+ class HRMDirect:
89
+ name = "hrmdirect"
90
+ per_employer = True
91
+ asks_name = True
92
+ known_gaps = {"screening_first": Gap.FLOW, "sign_up_phone": Gap.FLOW}
93
+ side_effects = [
94
+ SideEffect(
95
+ "sign_up", "employer", "the account is the employer's candidate, emailed a link"
96
+ ),
97
+ SideEffect("fill", "hiring_system", "each page, the resume too, is saved when left"),
98
+ SideEffect("submit", "employer", "the application reaches the employer"),
99
+ ]
100
+
101
+ def __init__(self) -> None:
102
+ self._jobs: dict[str, str] = {} # by org, the last posting's job
103
+ self._fields: dict[str, Field] = {}
104
+ self._kinds: dict[str, str] = {}
105
+ self._values: dict[str, Answer] = {}
106
+ self._apply = ""
107
+
108
+ def detect(self, url: str, page: Page | None) -> bool:
109
+ return _job(url) is not None
110
+
111
+ def posting(self, url: str) -> str:
112
+ """The job's id, the same on the career site and its apply page's host; an HRMDirect
113
+ URL that has not redirected yet keeps its requisition."""
114
+ job = _job(url)
115
+ if job is None:
116
+ return url
117
+ org, key = job
118
+ if JOB_ID.match(key):
119
+ return f"https://{SITE_HOST}/jobs/{key.lower()}"
120
+ return f"https://{org}.hrmdirect.com/employment/job-opening.php?req={key}"
121
+
122
+ def account_scope(self, url: str) -> str | None:
123
+ """The career site's name, or the apply host's org for an apply page."""
124
+ job = _job(url)
125
+ if job is None:
126
+ return None
127
+ if JOB_ID.match(job[1]):
128
+ self._jobs[job[0]] = job[1].lower()
129
+ return job[0]
130
+
131
+ def login_url(self, scope: str | None) -> str:
132
+ """The career site's page of the last posting seen for the scope, else its job list."""
133
+ if scope in self._jobs:
134
+ return f"https://{SITE_HOST}/{scope}?jobId={self._jobs[scope]}"
135
+ return f"https://{SITE_HOST}/{scope}"
136
+
137
+ def signed_in(self, request: Request, scope: str | None) -> bool:
138
+ """An org's `users/auth/authenticate` answers 200 for the token its cookie holds; the
139
+ cookie is the apply host's, which can be another org than the career site's."""
140
+ for cookie in request.storage_state()["cookies"]:
141
+ org = ORG_HOST.match(cookie["domain"].lstrip("."))
142
+ token = TOKEN.search(urllib.parse.unquote(cookie["value"]))
143
+ if cookie["name"] != "HrmBase-careers" or not org or not token:
144
+ continue
145
+ headers = API_HEADERS | {"API-ShortName": org[1], "API-Token": token[1]}
146
+ response = request.get(
147
+ f"https://{org[1]}.clearcompany.com/api/v1/users/auth/authenticate",
148
+ headers=headers,
149
+ )
150
+ if response.ok:
151
+ return True
152
+ return False
153
+
154
+ def sign_in(self, page: Page, email: str, password: str) -> None:
155
+ """Signs in through the header's login, whose modal takes a username and a password;
156
+ an account gets a password through the link its sign-up emailed."""
157
+ _to_apply(page)
158
+ if page.locator(IDENTITY).is_visible():
159
+ page.locator(HAVE_ACCOUNT).click()
160
+ page.locator(EMAIL_SIGN_IN).click()
161
+ page.locator("input.login-username:visible").fill(email)
162
+ page.locator("input.login-password:visible").fill(password)
163
+ page.get_by_role("button", name="Continue").locator("visible=true").click()
164
+ else:
165
+ page.locator(LOGIN).click()
166
+ modal = page.locator(".modal:visible").filter(has=page.locator("input[type=password]"))
167
+ modal.locator("input:not([type=password]):visible").first.fill(email)
168
+ modal.locator("input[type=password]").fill(password)
169
+ modal.locator("button.btn-primary").click()
170
+ try:
171
+ page.locator(f"{PAGE_FORM}, {LOGIN}:text-is('logout')").first.wait_for(
172
+ timeout=SIGN_UP_TIMEOUT_MS
173
+ )
174
+ except Timeout:
175
+ raise Refused("wrong_password") from None
176
+
177
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
178
+ """Fills the apply page's first step, which creates the account signed in and sets no
179
+ password: the email that follows holds a link to set one, `blocked: email_link`."""
180
+ _to_apply(page)
181
+ if not page.locator(IDENTITY).is_visible():
182
+ raise Unsupported(
183
+ known_gap(self, "screening_first", "the form's first page comes before sign-up")
184
+ )
185
+ phone = page.locator(f"{IDENTITY} .form-group:has(input.mobile-number)")
186
+ if phone.count() and not phone.locator(".common-option-label").count():
187
+ raise Unsupported(
188
+ known_gap(self, "sign_up_phone", "the sign-up step requires a mobile phone")
189
+ )
190
+ if page.locator(RECAPTCHA).count():
191
+ raise Blocked("captcha: a reCAPTCHA box keeps the sign-up's Continue disabled")
192
+ first, _, last = (name or "").strip().partition(" ")
193
+ form = page.locator(IDENTITY)
194
+ form.locator("input.first-name").fill(first)
195
+ form.locator("input.last-name").fill(last or first)
196
+ form.locator("input.email").fill(email)
197
+ form.locator("input.email-confirm").fill(email)
198
+ for group in form.locator(".radio-group:has(input[required])").all():
199
+ group.locator("label:has(input[value=false])").click() # declines, the least given
200
+ form.locator("button.submit-button").click()
201
+ try:
202
+ moved = page.locator(f"{PAGE_FORM}, {IDENTITY} .has-error, .alert-danger:visible")
203
+ moved.or_(page.locator(EMAIL_SIGN_IN)).first.wait_for(timeout=SIGN_UP_TIMEOUT_MS)
204
+ except Timeout:
205
+ raise Unsupported("unhandled_rendering: the sign-up step did not move") from None
206
+ if page.locator(EMAIL_SIGN_IN).is_visible(): # the email has an account here
207
+ raise Refused("email_in_use")
208
+ if not page.locator(PAGE_FORM).count():
209
+ message = " ".join(page.locator(".alert-danger:visible, .has-error").all_inner_texts())
210
+ raise Refused(f"email_domain_rejected: {message.strip()[:200]}")
211
+ raise Blocked("email_link: set the account's password through the emailed link")
212
+
213
+ def browser_read(self, page: Page) -> list[Field]:
214
+ """Walks the wizard from its first page as far as its pages save with what they hold,
215
+ reading each page it reaches."""
216
+ self._open(page)
217
+ self._fields = {}
218
+ for _ in range(MAX_PAGES):
219
+ for f in self._page(page):
220
+ self._fields.setdefault(f.id, f)
221
+ if not self._continue(page):
222
+ break
223
+ return list(self._fields.values())
224
+
225
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
226
+ """Fills each page from the first and saves it, up to the last; a page showing a
227
+ required field the read did not list is refused, the pages before it saved."""
228
+ self._open(page)
229
+ self._values = {}
230
+ for _ in range(MAX_PAGES):
231
+ shown = self._page(page)
232
+ for f in shown:
233
+ if f.id in answers and f.id in self._kinds:
234
+ _fill(page, f, self._kinds[f.id], answers[f.id])
235
+ values = self._read(page)
236
+ for f in shown: # a country renders its state list again, emptying the state
237
+ answer = answers.get(f.id)
238
+ if self._kinds.get(f.id) == "select" and answer and values.get(f.id) != answer:
239
+ control = page.locator(PAGE_FORM).locator(f"[id='{f.id}']")
240
+ tag = control.evaluate("el => el.tagName") if control.count() else ""
241
+ _fill(page, f, "select" if tag == "SELECT" else "text", answer)
242
+ unread = [f.id for f in shown if f.required and f.id not in self._fields]
243
+ if unread:
244
+ raise NotTaken(f"the page shows fields the read did not list: {unread}; read again")
245
+ self._values |= self._read(page)
246
+ if _last(page) or not self._continue(page):
247
+ return
248
+
249
+ def read_back(self, page: Page) -> dict[str, Answer]:
250
+ return self._values | self._read(page)
251
+
252
+ def submit(self, page: Page) -> str | None:
253
+ """The last page's Submit; atsfill expects it to post the application. No send was
254
+ made."""
255
+ with page.expect_response(
256
+ lambda r: r.request.method == "POST" and "/api/" in r.url and "submit" in r.url,
257
+ timeout=SUBMIT_TIMEOUT_MS,
258
+ ) as posted:
259
+ page.locator(NEXT).first.click()
260
+ response = posted.value
261
+ return f"{response.url} {response.status}"
262
+
263
+ def _open(self, page: Page) -> None:
264
+ """The wizard's first page, loaded again; `posting_gone` for a page with no
265
+ application."""
266
+ parts = urlsplit(page.url)
267
+ if not self._apply or ORG_HOST.match(parts.hostname or "") is None:
268
+ self._apply = _apply_link(page)
269
+ elif APPLY_PATH.match(parts.path):
270
+ org, job = _job(page.url) or ("", "")
271
+ self._apply = _apply_url(org, job)
272
+ page.goto(self._apply)
273
+ try:
274
+ page.locator(f"{PAGE_FORM}, {IDENTITY}").first.wait_for(timeout=FORM_TIMEOUT_MS)
275
+ except Timeout:
276
+ raise Unsupported("posting_gone: no application form on the page") from None
277
+ if page.locator(IDENTITY).is_visible():
278
+ raise Unsupported("unhandled_rendering: the apply page asks to sign up again")
279
+ first = page.locator(".form-progress-holder li.page-0")
280
+ if first.count() and "active" not in (first.get_attribute("class") or ""):
281
+ first.click()
282
+ page.wait_for_timeout(SETTLE_MS)
283
+
284
+ def _page(self, page: Page) -> list[Field]:
285
+ """The current page's fields; each kept with its kind for the fill."""
286
+ page.wait_for_timeout(SETTLE_MS)
287
+ raw: list[dict[str, Any]] = page.locator(PAGE_FORM).evaluate(_FIELDS)
288
+ out = []
289
+ for r in raw:
290
+ if not r["visible"] and r["kind"] != "file":
291
+ continue
292
+ field = _field(r)
293
+ if not field.unfillable:
294
+ self._kinds[field.id] = r["kind"]
295
+ out.append(field)
296
+ return out
297
+
298
+ def _read(self, page: Page) -> dict[str, Answer]:
299
+ out: dict[str, Answer] = {}
300
+ for r in page.locator(PAGE_FORM).evaluate(_FIELDS):
301
+ value = r["value"]
302
+ if r["id"] not in self._fields or value in (None, ""):
303
+ continue
304
+ if value == [] and not (r["kind"] == "checkbox" and len(r["options"]) == 1):
305
+ continue
306
+ if r["kind"] == "date" and re.match(r"^\d\d/\d\d/\d{4}$", value):
307
+ month, day, year = value.split("/")
308
+ value = f"{year}-{month}-{day}"
309
+ if r["kind"] == "radio":
310
+ value = value[0]
311
+ elif r["kind"] == "checkbox" and len(r["options"]) == 1:
312
+ value = "Yes" if value else "No"
313
+ out[r["id"]] = value
314
+ return out
315
+
316
+ def _continue(self, page: Page) -> bool:
317
+ """Presses Continue; whether the next page showed, not errors."""
318
+ if _last(page):
319
+ return False
320
+ before = _active(page)
321
+ page.locator(NEXT).first.click()
322
+ try:
323
+ page.wait_for_function(
324
+ "([sel, before]) => { const a = document.querySelector(sel);"
325
+ " return (a && a.textContent !== before)"
326
+ " || !!document.querySelector('.has-error'); }",
327
+ arg=[ACTIVE_PAGE, before],
328
+ timeout=PAGE_TIMEOUT_MS,
329
+ )
330
+ except Timeout:
331
+ return False
332
+ return _active(page) != before
333
+
334
+
335
+ def _job(url: str) -> tuple[str, str] | None:
336
+ """The org and the job: the job's id on ClearCompany, the requisition on HRMDirect."""
337
+ parts = urlsplit(url)
338
+ host = (parts.hostname or "").lower()
339
+ query = parse_qs(parts.query)
340
+ if (old := OLD_HOST.match(host)) and parts.path == "/employment/job-opening.php":
341
+ req = query.get("req", [""])[0]
342
+ return (old[1], req) if req.isdigit() else None
343
+ if host == SITE_HOST:
344
+ org = parts.path.strip("/").split("/")[0]
345
+ job = query.get("jobId", [""])[0]
346
+ return (org.lower(), job) if org and JOB_ID.match(job) else None
347
+ org, path = ORG_HOST.match(host), APPLY_PATH.match(parts.path)
348
+ return (org[1], path[1]) if org and path else None
349
+
350
+
351
+ def _apply_url(org: str, job: str) -> str:
352
+ return f"https://{org}.clearcompany.com/careers/jobs/{job.lower()}/apply"
353
+
354
+
355
+ def _apply_link(page: Page) -> str:
356
+ """The apply page of the job page, or of an apply page already open; its host is the
357
+ org the application goes to, which the career site's Apply link names."""
358
+ if ORG_HOST.match(urlsplit(page.url).hostname or "") and "/careers/jobs/" in page.url:
359
+ org, job = _job(page.url) or ("", "")
360
+ return _apply_url(org, job)
361
+ link = page.locator("a[href*='/careers/jobs/'][href*='/apply']").first
362
+ try:
363
+ link.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
364
+ except Timeout:
365
+ raise Unsupported("posting_gone: no Apply link on the page") from None
366
+ return link.get_attribute("href") or ""
367
+
368
+
369
+ def _to_apply(page: Page) -> None:
370
+ """The apply page's first step, from the job's page or the career site's first job."""
371
+ if "jobId=" not in page.url and "/careers/jobs/" not in page.url:
372
+ jobs = page.locator("a[href*='jobId=']").first
373
+ try:
374
+ jobs.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
375
+ except Timeout:
376
+ raise Unsupported("unhandled_rendering: no job on the career site") from None
377
+ page.goto(jobs.get_attribute("href") or "")
378
+ page.goto(_apply_link(page))
379
+ page.locator(f"{IDENTITY}, {PAGE_FORM}").first.wait_for(timeout=FORM_TIMEOUT_MS)
380
+
381
+
382
+ def _field(r: dict[str, Any]) -> Field:
383
+ kinds = {
384
+ "text": FieldType.TEXT,
385
+ "email": FieldType.TEXT,
386
+ "tel": FieldType.TEXT,
387
+ "textarea": FieldType.TEXT,
388
+ "select": FieldType.CHOICE,
389
+ "radio": FieldType.CHOICE,
390
+ "checkbox": FieldType.MULTI_CHOICE,
391
+ "date": FieldType.DATE,
392
+ "file": FieldType.FILE,
393
+ }
394
+ label, required = r["label"] or r["id"], r["required"]
395
+ if r["kind"] == "linkedin":
396
+ reason = "out_of_scope: a LinkedIn profile is imported by signing in to LinkedIn"
397
+ return Field(r["id"], label, FieldType.FILE, False, unfillable=reason)
398
+ if r["kind"] not in kinds or not r["id"]:
399
+ reason = f"unhandled_rendering: {r['id'] or label} is a {r['qa'] or r['kind']}"
400
+ return Field(r["id"] or label, label, FieldType.TEXT, required, unfillable=reason)
401
+ kind = kinds[r["kind"]]
402
+ if kind is FieldType.MULTI_CHOICE and len(r["options"]) == 1:
403
+ kind = FieldType.CHOICE
404
+ return Field(r["id"], label, kind, required, ["Yes", "No"])
405
+ return Field(
406
+ r["id"],
407
+ label,
408
+ kind,
409
+ required,
410
+ r["options"],
411
+ r["max"] if kind is FieldType.TEXT else None,
412
+ )
413
+
414
+
415
+ def _last(page: Page) -> bool:
416
+ """Whether the page's primary button is Submit, not Continue."""
417
+ button = page.locator(NEXT).first
418
+ key = button.get_attribute("data-localized-key") or "" if button.count() else ""
419
+ return bool(button.count()) and not key.endswith("continue")
420
+
421
+
422
+ def _active(page: Page) -> str:
423
+ active = page.locator(ACTIVE_PAGE)
424
+ return active.first.inner_text() if active.count() else ""
425
+
426
+
427
+ def _fill(page: Page, field: Field, kind: str, answer: Answer) -> None:
428
+ values = [answer] if isinstance(answer, str) else list(answer)
429
+ form = page.locator(PAGE_FORM)
430
+ if kind in ("radio", "checkbox"):
431
+ boxes = form.locator(f"input[data-field='{field.id}']")
432
+ if kind == "checkbox" and boxes.count() == 1: # one box, answered Yes or No
433
+ box = boxes.first
434
+ if box.is_checked() != (values[0] == "Yes"):
435
+ box.locator("xpath=ancestor::label[1]").click()
436
+ return
437
+ for value in values:
438
+ box = _option(boxes, value)
439
+ if not box.is_checked():
440
+ box.locator("xpath=ancestor::label[1]").click()
441
+ return
442
+ if kind == "file":
443
+ _upload(form, field.id.removeprefix("file."), values[0])
444
+ return
445
+ control = form.locator(f"[id='{field.id}']")
446
+ if kind == "select":
447
+ options = field.options or []
448
+ if values[0] not in options:
449
+ raise NotTaken(f"{field.id}: no option {values[0]!r}")
450
+ control.select_option(label=values[0])
451
+ elif kind == "date":
452
+ year, month, day = values[0].split("-")
453
+ control.fill(f"{month}/{day}/{year}")
454
+ control.press("Tab")
455
+ page.evaluate(CLOSE_DATEPICKERS) # an open one covers the fields below it
456
+ else:
457
+ control.fill(values[0])
458
+ control.dispatch_event("change")
459
+ control.blur()
460
+
461
+
462
+ def _option(boxes: Locator, value: str) -> Locator:
463
+ for i in range(boxes.count()):
464
+ box = boxes.nth(i)
465
+ text = " ".join(box.locator("xpath=ancestor::label[1]").inner_text().split())
466
+ if text == value:
467
+ return box
468
+ raise NotTaken(f"no option {value!r}")
469
+
470
+
471
+ def _upload(form: Locator, kind: str, path: str) -> None:
472
+ """Attaches the file and waits for its row in the field's file list."""
473
+ holder = form.locator(f".qa-resume-upload.{kind}")
474
+ holder.locator("input[type=file]").set_input_files(path)
475
+ name = path.rsplit("/", 1)[-1]
476
+ holder.page.wait_for_function(
477
+ "([el, name]) => [...el.querySelectorAll('.file-list tr td:first-child')]"
478
+ ".some(t => t.textContent.trim() === name)"
479
+ " && !el.querySelector('.file-list').textContent.includes('Uploading')",
480
+ arg=[holder.element_handle(), name],
481
+ timeout=UPLOAD_TIMEOUT_MS,
482
+ )
@@ -306,6 +306,7 @@ share = 0.0059
306
306
  account = "to_read"
307
307
  hosting = ["hosted"]
308
308
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
309
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "211fe03", by = "agent", reason = "unsupported_flow: screening_first: the form's first page comes before the sign-up step (4 of 14 orgs); sign_up_phone: the sign-up step requires a mobile phone, which login does not take (1 of 17); a reCAPTCHA box on the sign-up step (1 of 17); 3 orgs signed in, under the 5 dry runs the gate needs" }
309
310
 
310
311
  [applicantpro]
311
312
  kind = "ats"
@@ -334,9 +335,10 @@ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "e48c78a", by = "
334
335
  [jobstreet]
335
336
  kind = "board"
336
337
  share = 0.0047
337
- account = "to_read"
338
+ account = "to_read" # Apply needs a SEEK account; its sign-in and sign-up have no password, an emailed one-time code each time
338
339
  hosting = ["hosted"]
339
340
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
341
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "9c63369", by = "agent", reason = "unsupported_flow: sign_in_otp: Apply goes to the SEEK sign-in, whose every sign-in and sign-up is a six-digit code emailed to the address, with no password (14 of 15 employers)" }
340
342
 
341
343
  [whatjobs-uk]
342
344
  kind = "aggregator"
File without changes
File without changes