atsfill 0.0.7.dev211__tar.gz → 0.0.8.dev219__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 (69) hide show
  1. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/PKG-INFO +2 -2
  2. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/pyproject.toml +2 -2
  3. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/pyproject.toml.orig +2 -2
  4. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/breezy.py +5 -0
  5. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/greenhouse.py +42 -3
  6. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/hiringthing.py +20 -2
  7. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/jazzhr.py +3 -0
  8. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/jobaffinity.py +19 -0
  9. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/jobstreet.py +14 -0
  10. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/join.py +8 -0
  11. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/lever.py +19 -6
  12. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/manatal.py +20 -0
  13. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/oracle.py +73 -9
  14. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/paycor.py +4 -0
  15. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/personio.py +4 -0
  16. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/recruitee.py +4 -0
  17. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/rippling.py +33 -4
  18. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/successfactors.py +3 -0
  19. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/ukg.py +28 -5
  20. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/welcomekit.py +5 -0
  21. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/wellfound.py +16 -0
  22. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/werecruit.py +3 -0
  23. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/workable.py +3 -0
  24. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/workatastartup.py +18 -0
  25. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/workday.py +9 -2
  26. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/wttj.py +7 -2
  27. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/browser.py +12 -20
  28. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/LICENSE +0 -0
  29. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/README.md +0 -0
  30. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/__init__.py +0 -0
  31. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/__init__.py +0 -0
  32. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/adp.py +0 -0
  33. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/adpmyjobs.py +0 -0
  34. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/ashby.py +0 -0
  35. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/bamboohr.py +0 -0
  36. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/dayforce.py +0 -0
  37. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/eightfold.py +0 -0
  38. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/gupy.py +0 -0
  39. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/hireology.py +0 -0
  40. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/hrmdirect.py +0 -0
  41. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/isolvedhire.py +0 -0
  42. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/paylocity.py +0 -0
  43. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/phenom.py +0 -0
  44. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/pinpoint.py +0 -0
  45. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/recruiterflow.py +0 -0
  46. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/smartrecruiters.py +0 -0
  47. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/successfactors.js +0 -0
  48. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/taleo.py +0 -0
  49. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/teamtailor.py +0 -0
  50. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/adapters/zohorecruit.py +0 -0
  51. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/cli.py +0 -0
  52. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/__init__.py +0 -0
  53. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/accounts.py +0 -0
  54. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/adapter.py +0 -0
  55. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/attempts.py +0 -0
  56. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/commands.py +0 -0
  57. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/conditions.py +0 -0
  58. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/countries.py +0 -0
  59. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/matching.py +0 -0
  61. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/py.typed +0 -0
  69. {atsfill-0.0.7.dev211 → atsfill-0.0.8.dev219}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.7.dev211
3
+ Version: 0.0.8.dev219
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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Office/Business
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: camoufox==0.5.8
19
+ Requires-Dist: camoufox==0.5.9b6
20
20
  Requires-Dist: filelock==4.1.1
21
21
  Requires-Dist: keyring==25.7.0
22
22
  Requires-Dist: packaging==26.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.7.dev211"
3
+ version = "0.0.8.dev219"
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"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Typing :: Typed",
29
29
  ]
30
30
  dependencies = [
31
- "camoufox==0.5.8",
31
+ "camoufox==0.5.9b6",
32
32
  "filelock==4.1.1",
33
33
  "keyring==25.7.0",
34
34
  "packaging==26.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.7.dev211"
3
+ version = "0.0.8.dev219"
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"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "camoufox==0.5.8",
24
+ "camoufox==0.5.9b6",
25
25
  "filelock==4.1.1",
26
26
  "keyring==25.7.0",
27
27
  "packaging==26.3",
@@ -328,6 +328,11 @@ class Breezy:
328
328
  case _:
329
329
  controls.first.fill(values[0])
330
330
 
331
+ def shown_fields(self, page: Page) -> list[str]:
332
+ """The controls `browser_read` names; a work history or education entry's have no name.
333
+ Every step stays in the page, the others hidden."""
334
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
335
+
331
336
  def read_back(self, page: Page) -> dict[str, Answer]:
332
337
  form = page.locator(FORM)
333
338
  out: dict[str, Answer] = {}
@@ -11,13 +11,14 @@ import re
11
11
  import time
12
12
  import urllib.request
13
13
  from contextlib import suppress
14
+ from dataclasses import replace
14
15
  from typing import Any
15
16
  from urllib.parse import parse_qs, urlencode, urlsplit
16
17
 
17
18
  from atsfill.core import browser, otp
18
19
  from atsfill.core.adapter import Blocked, Gap, Unsupported
19
20
  from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Page, Timeout
20
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
21
22
 
22
23
  HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
23
24
  FORM = "#application-form"
@@ -29,6 +30,7 @@ EDUCATION_API = "https://boards-api.greenhouse.io/v1/boards/{board}/education/{k
29
30
  EMBED = "https://job-boards.greenhouse.io/embed/job_app"
30
31
  EMBED_TIMEOUT_MS = 15_000
31
32
  OPTIONS_TIMEOUT_MS = 3_000
33
+ RACE_TIMEOUT_MS = 3_000
32
34
  HYDRATE_TIMEOUT_MS = 15_000
33
35
  FORM_TIMEOUT_MS = 20_000
34
36
  ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
@@ -52,6 +54,7 @@ OTP_ERROR = "#email-verification-error"
52
54
  # Sections the page adds or names its own way, or shows a question of only on another answer
53
55
  # (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
54
56
  UNMAPPED = ("education", "compliance", "demographic_questions")
57
+ ETHNICITY, NOT_HISPANIC, RACE = "hispanic_ethnicity", "No", "race"
55
58
 
56
59
  # The fields in page order. A select's options are read by opening it, from Python.
57
60
  _READ = """form => {
@@ -196,10 +199,11 @@ class Greenhouse:
196
199
  fields = [self._field(page, raw) for raw in raws]
197
200
  self._fields = {f.id: f for f in fields}
198
201
  self._kinds = {raw["id"]: raw["kind"] for raw in raws}
199
- return fields
202
+ return self._with_race(page, fields)
200
203
 
201
204
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
202
- for field_id, answer in answers.items():
205
+ # the page draws race only once the answer to Hispanic/Latino is in
206
+ for field_id, answer in sorted(answers.items(), key=lambda a: a[0] == RACE):
203
207
  values = [answer] if isinstance(answer, str) else answer
204
208
  match self._kinds[field_id]:
205
209
  case "file":
@@ -235,6 +239,9 @@ class Greenhouse:
235
239
  case _:
236
240
  page.locator(_by_id(field_id)).fill(values[0])
237
241
 
242
+ def shown_fields(self, page: Page) -> list[str]:
243
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
244
+
238
245
  def read_back(self, page: Page) -> dict[str, Answer]:
239
246
  values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
240
247
  return {k: v for k, v in values.items() if v not in (None, "", [])}
@@ -295,6 +302,30 @@ class Greenhouse:
295
302
  one_of=raw.get("oneOf"),
296
303
  )
297
304
 
305
+ def _with_race(self, page: Page, fields: list[Field]) -> list[Field]:
306
+ """`fields` with the EEOC race question, a follow-up of Hispanic/Latino: its page's own
307
+ label and options show only with the question, so the read answers No, reads it and
308
+ clears the answer. The API's options are not the page's."""
309
+ ethnicity = self._fields.get(ETHNICITY)
310
+ if (
311
+ RACE in self._fields
312
+ or ethnicity is None
313
+ or NOT_HISPANIC not in (ethnicity.options or [])
314
+ ):
315
+ return fields
316
+ _select(page, ETHNICITY, NOT_HISPANIC, False)
317
+ with suppress(Timeout):
318
+ page.locator(_by_id(RACE)).wait_for(timeout=RACE_TIMEOUT_MS)
319
+ raws = page.locator(FORM).evaluate(_READ)
320
+ raw = next((r for r in raws if r["id"] == RACE), None)
321
+ if raw is not None:
322
+ race = replace(self._field(page, raw), shown_when=Is(ETHNICITY, [NOT_HISPANIC]))
323
+ self._fields[RACE], self._kinds[RACE] = race, raw["kind"]
324
+ fields = [*fields]
325
+ fields.insert(fields.index(ethnicity) + 1, race)
326
+ _clear(page, ETHNICITY)
327
+ return fields
328
+
298
329
  def _education(self, page: Page, field_id: str) -> list[str] | None:
299
330
  """An education select's options, every page of them, read once per kind; None for
300
331
  another field or when the endpoint does not answer."""
@@ -429,6 +460,14 @@ def _exact(value: str) -> re.Pattern[str]:
429
460
  return re.compile(rf"^\s*{re.escape(value)}\s*$")
430
461
 
431
462
 
463
+ def _clear(page: Page, field_id: str) -> None:
464
+ """Empties a single react-select."""
465
+ box = page.locator(_by_id(field_id))
466
+ box.click()
467
+ box.press("Backspace")
468
+ box.press("Escape")
469
+
470
+
432
471
  def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
433
472
  """Types into a react-select and picks the option: focused with the arrow keys then Enter,
434
473
  which every select takes (a hover does not move the focus once typed). A suggestion list
@@ -16,7 +16,7 @@ from urllib.parse import urlsplit
16
16
 
17
17
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
18
18
  from atsfill.core.browser import Locator, Page, Timeout
19
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
20
20
 
21
21
  # The white-label domains HiringThing serves its career sites on, as freehire indexes them.
22
22
  DOMAINS = (
@@ -56,6 +56,9 @@ SUBMIT_TIMEOUT_MS = 90_000
56
56
  REQUIRED = re.compile(r"\s*\(required\)\s*$")
57
57
  DATE = "%m/%d/%Y"
58
58
  YES_NO = ["Yes", "No"]
59
+ SOURCE = "app.where_from"
60
+ SOURCE_DETAIL = "app.where_from_other" # rendered only once a source below is picked
61
+ DETAILED_SOURCES = ("Friend/Colleague", "Other")
59
62
  TYPES = {
60
63
  "text": FieldType.TEXT,
61
64
  "date": FieldType.DATE,
@@ -176,10 +179,19 @@ class HiringThing:
176
179
  questions = _questions(props.get_attribute("data-react-props") or "{}")
177
180
  controls = form.evaluate(_CONTROLS)
178
181
  self._kinds = {c["id"]: c["kind"] for c in controls if c["kind"] != "unhandled"}
179
- return [
182
+ fields = [
180
183
  _field(c, questions, _menu(page, c["id"]) if c["kind"] == "select" else None)
181
184
  for c in controls
182
185
  ]
186
+ source = next((f for f in fields if f.id == SOURCE), None)
187
+ if source and (detailed := [o for o in source.options or [] if o in DETAILED_SOURCES]):
188
+ self._kinds[SOURCE_DETAIL] = "text"
189
+ at = fields.index(source) + 1
190
+ fields.insert(at, _source_detail(detailed))
191
+ return fields
192
+
193
+ def shown_fields(self, page: Page) -> list[str]:
194
+ return [c["id"] for c in page.locator(FORM).evaluate(_CONTROLS)]
183
195
 
184
196
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
185
197
  form = page.locator(FORM)
@@ -275,6 +287,12 @@ def _field(control: dict, questions: dict[str, str], options: list[str] | None)
275
287
  return Field(field_id, label, TYPES[kind], required, options, max_length)
276
288
 
277
289
 
290
+ def _source_detail(sources: list[str]) -> Field:
291
+ """What the page asks once one of `sources` is picked as the referral source."""
292
+ label = "Please Specify Source"
293
+ return Field(SOURCE_DETAIL, label, FieldType.TEXT, True, shown_when=Is(SOURCE, sources))
294
+
295
+
278
296
  def _combobox(page: Page, field_id: str) -> Locator:
279
297
  return page.locator(f"{FORM} input[role=combobox][id='{field_id}']")
280
298
 
@@ -190,6 +190,9 @@ class JazzHR:
190
190
  case _:
191
191
  field.fill(values[0])
192
192
 
193
+ def shown_fields(self, page: Page) -> list[str]:
194
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
195
+
193
196
  def read_back(self, page: Page) -> dict[str, Answer]:
194
197
  values = page.locator(FORM).evaluate(_READ_BACK, self._fields)
195
198
  return {k: v for k, v in values.items() if v not in (None, "", [])}
@@ -68,6 +68,19 @@ _READ_BACK = """(form, fields) => {
68
68
  }
69
69
  return out;
70
70
  }"""
71
+ # The fields a form shows, by the names `_parse` gives them: its controls, a radio group once,
72
+ # and its documents, the older form's in forms of their own beside it.
73
+ _SHOWN = """form => {
74
+ const shown = el => el.getClientRects().length > 0;
75
+ const skipped = ["hidden", "submit", "button", "file"];
76
+ const names = [...form.querySelectorAll("input, select, textarea")]
77
+ .filter(el => el.name && !skipped.includes(el.type) && shown(el)).map(el => el.name);
78
+ const newer = [...form.querySelectorAll("[data-form-field-name]")]
79
+ .filter(shown).map(el => el.dataset.formFieldName);
80
+ const older = [...document.querySelectorAll("form[id^='js-'][id$='-form']")]
81
+ .filter(shown).map(el => el.id.slice("js-".length, -"-form".length));
82
+ return [...new Set([...names, ...newer, ...older])];
83
+ }"""
71
84
 
72
85
 
73
86
  @dataclass
@@ -152,6 +165,12 @@ class JobAffinity:
152
165
  if c.questionnaire:
153
166
  _set(questions, c, answers[c.field.id])
154
167
 
168
+ def shown_fields(self, page: Page) -> list[str]:
169
+ shown = page.locator(NEW if self._form.new else OLD).evaluate(_SHOWN)
170
+ if self._questions:
171
+ shown += self._questions.locator(QUESTIONNAIRE).evaluate(_SHOWN)
172
+ return shown
173
+
155
174
  def read_back(self, page: Page) -> dict[str, Answer]:
156
175
  main = [c for c in self._form.controls if not c.questionnaire]
157
176
  values = _values(page, NEW if self._form.new else OLD, main)
@@ -92,6 +92,10 @@ _QUESTIONS = """() => {
92
92
  }"""
93
93
 
94
94
 
95
+ # The questions the employer's step shows, by control name.
96
+ _ASKED = """() => [...document.querySelectorAll('[name^="questionnaire."]')].map(e => e.name)"""
97
+
98
+
95
99
  class Jobstreet:
96
100
  name = "jobstreet"
97
101
  per_employer = True # one SEEK account, signed in on each site apart: the scope is the site
@@ -105,6 +109,7 @@ class Jobstreet:
105
109
  ]
106
110
 
107
111
  def __init__(self) -> None:
112
+ self._asked: list[str] = [] # the employer's step's control names, once answered
108
113
  self._fields: dict[str, Field] = {}
109
114
  self._read: dict[str, Answer] = {}
110
115
 
@@ -198,6 +203,7 @@ class Jobstreet:
198
203
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
199
204
  """Walks the steps in order, each answered and left with Continue, which stays put with
200
205
  its errors while one is missing; the review step takes the profile's visibility."""
206
+ self._asked = []
201
207
  self._read = {k: f.prefilled for k in DETAILS if (f := self._fields[k]).prefilled}
202
208
  self._read |= _personal_details(page, self._fields, answers)
203
209
  _documents_fill(page, answers)
@@ -208,6 +214,7 @@ class Jobstreet:
208
214
  for field_id, answer in answers.items():
209
215
  if field_id.startswith("question."):
210
216
  _answer(page, self._fields[field_id], answer)
217
+ self._asked = page.evaluate(_ASKED)
211
218
  asked = page.evaluate(_QUESTIONS)
212
219
  self._read |= {
213
220
  f"question.{name.removeprefix('questionnaire.')}": value
@@ -232,6 +239,13 @@ class Jobstreet:
232
239
  if radio.is_checked():
233
240
  self._read["profileVisibility"] = radio.get_attribute("value") or ""
234
241
 
242
+ def shown_fields(self, page: Page) -> list[str]:
243
+ """The questions the employer's step showed once answered: the wizard has left it. The
244
+ other steps' controls are the account's and the page's, read as `browser_read` lists
245
+ them."""
246
+ names = dict.fromkeys(self._asked)
247
+ return [f"question.{name.removeprefix('questionnaire.')}" for name in names]
248
+
235
249
  def read_back(self, page: Page) -> dict[str, Answer]:
236
250
  return dict(self._read)
237
251
 
@@ -151,6 +151,7 @@ class Join:
151
151
  self._fields: dict[str, Field] = {}
152
152
  self._questions: list[dict[str, Any]] = []
153
153
  self._seen: dict[str, Answer] = {} # each question as its step was left
154
+ self._walked: dict[str, None] = {} # the file and question fields of the steps walked
154
155
  self._files: list[str] = [] # the file fields answered, in the steps' order
155
156
 
156
157
  def detect(self, url: str, page: Page | None) -> bool:
@@ -253,15 +254,18 @@ class Join:
253
254
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
254
255
  """Walks the wizard from the step it is on to the review page, answering each step; a
255
256
  step without a listed field (the account's name, its links) is left as it is."""
257
+ self._walked = {}
256
258
  for _ in range(MAX_STEPS):
257
259
  step = _step(page)
258
260
  if step == "review":
259
261
  page.locator("a > div > p + p").first.wait_for(timeout=STEP_TIMEOUT_MS)
260
262
  return
261
263
  if step in FILES:
264
+ self._walked[FILES[step]] = None
262
265
  self._file(page, FILES[step], answers.get(FILES[step]))
263
266
  elif question := self._step_question(step):
264
267
  field_id = f"question.{question['id']}"
268
+ self._walked[field_id] = None
265
269
  if field_id in self._fields:
266
270
  self._question(page, question, answers.get(field_id))
267
271
  elif step in PROFILE_STEPS:
@@ -289,6 +293,10 @@ class Join:
289
293
  raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
290
294
  return None
291
295
 
296
+ def shown_fields(self, page: Page) -> list[str]:
297
+ """The fields of the steps the fill walked: the wizard keeps only the step it is on."""
298
+ return list(self._walked)
299
+
292
300
  def read_back(self, page: Page) -> dict[str, Answer]:
293
301
  """The files as the review page lists them; each question as its step was left, since
294
302
  the review shows a classed question's answer reworded (`€50,000`)."""
@@ -7,6 +7,7 @@ id. Nothing but the resume reaches Lever before submit, so the read-back is the
7
7
 
8
8
  import re
9
9
  from contextlib import suppress
10
+ from dataclasses import replace
10
11
  from datetime import date
11
12
  from typing import Any
12
13
  from urllib.parse import urlsplit
@@ -14,7 +15,7 @@ from urllib.parse import urlsplit
14
15
  from atsfill.core import browser
15
16
  from atsfill.core.adapter import Blocked, Gap, Unsupported
16
17
  from atsfill.core.browser import Locator, Page, Timeout
17
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
18
19
 
19
20
  HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
20
21
  JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
@@ -26,6 +27,8 @@ SUGGEST_TIMEOUT_MS = 10_000
26
27
  SUBMIT_TIMEOUT_MS = 90_000
27
28
  KEY_DELAY_MS = 40
28
29
  YES_NO = ["Yes", "No"]
30
+ DISABILITY = "eeo[disability]"
31
+ SIGNATURE = ("eeo[disabilitySignature]", "eeo[disabilitySignatureDate]") # shown by its answer
29
32
  KINDS = {
30
33
  "text": FieldType.TEXT,
31
34
  "phone": FieldType.PHONE,
@@ -38,7 +41,8 @@ KINDS = {
38
41
  "checkboxes": FieldType.MULTI_CHOICE,
39
42
  }
40
43
 
41
- # The fields in page order, one per control name: its question's label, and how it renders.
44
+ # The fields in page order, one per control name: its question's label, how it renders, and
45
+ # whether its question is on show.
42
46
  _READ = """form => {
43
47
  const text = el => {
44
48
  if (!el) return "";
@@ -62,7 +66,7 @@ _READ = """form => {
62
66
  const label = box.querySelector(".application-label .text") ||
63
67
  box.querySelector(".application-label");
64
68
  field = {id: name, label: text(label), required: !!box.querySelector(".required"),
65
- options: [], count: 0};
69
+ options: [], count: 0, shown: box.getClientRects().length > 0};
66
70
  if (el.type === "file") {
67
71
  field.kind = "file";
68
72
  } else if (el.tagName === "SELECT") {
@@ -153,14 +157,20 @@ class Lever:
153
157
  except Timeout:
154
158
  raise Unsupported("posting_gone: no application form on the page") from None
155
159
  fields, self._kinds = [], {}
156
- for raw in form.evaluate(_READ):
157
- fields.append(_field(raw))
160
+ raws = form.evaluate(_READ)
161
+ asked = {raw["id"] for raw in raws}
162
+ for raw in raws:
163
+ field = _field(raw)
164
+ if raw["id"] in SIGNATURE and DISABILITY in asked:
165
+ field = replace(field, shown_when=Is(DISABILITY))
166
+ fields.append(field)
158
167
  self._kinds[raw["id"]] = raw["kind"]
159
168
  return fields
160
169
 
161
170
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
162
171
  # the resume is parsed into the form, so it goes first and is waited for
163
- for name, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
172
+ first = sorted(answers.items(), key=lambda a: a[0] != "resume")
173
+ for name, answer in sorted(first, key=lambda a: a[0] in SIGNATURE):
164
174
  values = [answer] if isinstance(answer, str) else answer
165
175
  controls = page.locator(f"{FORM} [name={_quoted(name)}]:not([type=hidden])")
166
176
  match self._kinds[name]:
@@ -183,6 +193,9 @@ class Lever:
183
193
  case _:
184
194
  controls.first.fill(values[0])
185
195
 
196
+ def shown_fields(self, page: Page) -> list[str]:
197
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ) if raw["shown"]]
198
+
186
199
  def read_back(self, page: Page) -> dict[str, Answer]:
187
200
  values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
188
201
  for name, kind in self._kinds.items():
@@ -61,6 +61,23 @@ _READ = """fields => Object.fromEntries(fields.map(([id, kind, dom]) => {
61
61
  default: return [id, el ? el.value || null : null];
62
62
  }
63
63
  }))"""
64
+ # The fields the form shows: a control by the field id of its element, else by its name, an
65
+ # Other text box by its list. A radio or a box sits unrendered under its styled mark, and
66
+ # select2 adds a search input of its own.
67
+ _SHOWN = """fields => {
68
+ const form = document.querySelector("#submit-id-submit").closest("form");
69
+ const rendered = el => el.getClientRects().length > 0;
70
+ const out = new Set();
71
+ for (const el of form.querySelectorAll("input, select, textarea")) {
72
+ if (["hidden", "submit", "button"].includes(el.type)) continue;
73
+ if (el.matches(".select2-search__field")) continue;
74
+ if (!rendered(el) && !rendered(el.parentElement)) continue;
75
+ if (el.matches(".custom-other-val-input"))
76
+ out.add(`${el.closest("li").querySelector("input[name]").name}.other`);
77
+ else out.add(fields[el.id] ?? (el.name || el.id));
78
+ }
79
+ return [...out];
80
+ }"""
64
81
  # Selects the options with these labels on a select2 multiple select, which select2 follows.
65
82
  _SELECT = """(el, labels) => {
66
83
  for (const o of el.options) o.selected = labels.includes(o.text.trim());
@@ -290,6 +307,9 @@ class Manatal:
290
307
  f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
291
308
  )
292
309
 
310
+ def shown_fields(self, page: Page) -> list[str]:
311
+ return page.evaluate(_SHOWN, {dom: fid for fid, _, dom in _inputs(self._entries)})
312
+
293
313
  def read_back(self, page: Page) -> dict[str, Answer]:
294
314
  inputs = [list(i) for i in _inputs(self._entries)]
295
315
  if page.locator(f'input[name="{TERMS}"]').count():
@@ -4,7 +4,8 @@ as docs/adapters/oracle.md observes.
4
4
  The form opens only past an email step, and an email the employer's Oracle already knows gets an
5
5
  emailed OTP first, typed in by the core. Reading passes the step with a throwaway address;
6
6
  filling starts over with the answer's. Oracle keeps a draft of the application from then on,
7
- autosaved as fields change.
7
+ autosaved as fields change. The address follows the country's format, and which rows a country
8
+ shows is known only once it is picked: the fill reveals those of a country other than the form's.
8
9
  """
9
10
 
10
11
  import re
@@ -42,6 +43,8 @@ ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-nam
42
43
  REMOVE = ".attachment-upload-button__filled .attachment-upload-button__bottom-button"
43
44
  STEP_TIMEOUT_MS = 30_000
44
45
  LIST_TIMEOUT_MS = 3_000
46
+ LIST_POLL_MS = 300 # between two looks at a list, which a search narrows as it answers
47
+ LIST_POLLS = 10
45
48
  SEARCH_TIMEOUT_MS = 6_000 # for a typed search's results
46
49
  PAGE_TIMEOUT_MS = 15_000 # per click on a page number
47
50
  SUBMIT_TIMEOUT_MS = 90_000
@@ -115,7 +118,7 @@ _READ = """() => {
115
118
  const named = row.querySelector("[id^=labelText-]");
116
119
  const input = row.querySelector("input:not([type=hidden]), textarea");
117
120
  let id = stable(named?.id || label?.getAttribute("for") || label?.id || input?.name);
118
- const field = {id, label: text(row.querySelector(
121
+ const field = {id, address: !!row.closest(".address-form"), label: text(row.querySelector(
119
122
  ".input-row__linebreak, .input-row__label-text") || label),
120
123
  required: !!label?.classList.contains("input-row__label--required")
121
124
  || !!row.querySelector("[aria-required=true], [required]")};
@@ -260,6 +263,8 @@ class Oracle:
260
263
  self._kinds: dict[str, str] = {}
261
264
  self._pages: dict[str, int] = {} # field id: the page of the form it is on
262
265
  self._picked: dict[str, str] = {} # a geography field's id: the item picked in it
266
+ self._left_out: set[str] = set() # the geography levels the most specific one fills
267
+ self._filling = False # past the email step, with the answers given so far
263
268
  self._months: list[str] = []
264
269
  self._job = ""
265
270
 
@@ -284,7 +289,7 @@ class Oracle:
284
289
  self._pass_email_step(
285
290
  page, f"atsfill.read.{uuid.uuid4().hex[:12]}@example.com", bool(legal)
286
291
  )
287
- self._kinds, self._pages = {}, {}
292
+ self._kinds, self._pages, self._left_out, self._filling = {}, {}, set(), False
288
293
  # The page numbers move between pages without checking the page left.
289
294
  for number in range(1, max(len(_page_numbers(page)), 1) + 1):
290
295
  _go_to(page, number)
@@ -293,13 +298,40 @@ class Oracle:
293
298
  for raw in page.evaluate(_READ)
294
299
  if raw["id"] not in self._kinds and (field := self._described(page, raw))
295
300
  ]
296
- for raw, field in _geography(described):
301
+ kept = _geography(described)
302
+ self._left_out |= {r["id"] for r, _ in described} - {f.id for _, f in kept}
303
+ for raw, field in _of_the_country(kept):
297
304
  self._kinds[field.id], self._pages[field.id] = raw["kind"], number
298
305
  fields.append(field)
299
306
  self._fields = {f.id: f for f in fields}
300
307
  return fields
301
308
 
309
+ def revealed(self, page: Page) -> list[Field]:
310
+ """The fields of every page, once filled: an address row follows the country, which no
311
+ read lists but at a request per country, and a question can follow an answer."""
312
+ _settle(page)
313
+ for number in range(1, max(len(_page_numbers(page)), 1) + 1):
314
+ _go_to(page, number)
315
+ known = {*self._fields, *self._left_out}
316
+ described = [
317
+ (raw, field)
318
+ for raw in page.evaluate(_READ)
319
+ if raw["id"] not in known and (field := self._described(page, raw))
320
+ ]
321
+ for raw, field in _geography(described):
322
+ self._kinds[field.id], self._pages[field.id] = raw["kind"], number
323
+ self._fields[field.id] = field
324
+ return list(self._fields.values())
325
+
302
326
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
327
+ """Passes the email step under the answer's address and fills the form; once it did, what
328
+ `revealed` found, on the form as it stands."""
329
+ if not self._filling:
330
+ self._start(page, answers)
331
+ self._filling = True
332
+ self._fill_pages(page, answers)
333
+
334
+ def _start(self, page: Page, answers: dict[str, Answer]) -> None:
303
335
  if LEGAL in self._fields and answers.get(LEGAL) != "Yes":
304
336
  raise Unsupported("not_qualified: the form opens only once the terms are accepted")
305
337
  page.context.clear_cookies()
@@ -309,6 +341,8 @@ class Oracle:
309
341
  email = answers.get(EMAIL)
310
342
  assert isinstance(email, str) # required, so answered
311
343
  self._pass_email_step(page, email, LEGAL in self._fields)
344
+
345
+ def _fill_pages(self, page: Page, answers: dict[str, Answer]) -> None:
312
346
  for number in sorted(set(self._pages.values())):
313
347
  on_page = [i for i in answers if self._pages.get(i) == number]
314
348
  if not on_page:
@@ -414,7 +448,7 @@ class Oracle:
414
448
  """The innermost row holding the field."""
415
449
  selector = page.evaluate(_ROW, field_id)
416
450
  if selector is None:
417
- raise RuntimeError(f"{field_id} left the form while it was filled")
451
+ raise NotTaken(f"{field_id} is not on the form under the answers given before it")
418
452
  return page.locator(".input-row").filter(has=page.locator(selector)).last
419
453
 
420
454
  def _options(self, page: Page, field_id: str) -> list[str]:
@@ -560,6 +594,24 @@ def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
560
594
  ]
561
595
 
562
596
 
597
+ def _of_the_country(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
598
+ """Keys the address rows to the page's country: required under it alone, as another has rows
599
+ of its own, which the fill reveals, and a list's items its alone, another's a request each."""
600
+ country = next((f.default for _, f in described if f.id == COUNTRY), None)
601
+ if not isinstance(country, str):
602
+ return described
603
+ held = Is(COUNTRY, [country])
604
+
605
+ def keyed(field: Field) -> Field:
606
+ if field.required:
607
+ field = replace(field, required=False, required_when=held)
608
+ if field.options:
609
+ field = replace(field, options=None, option_sets=[OptionSet(held, field.options)])
610
+ return field
611
+
612
+ return [(r, keyed(f) if r.get("address") else f) for r, f in described]
613
+
614
+
563
615
  def _page_numbers(page: Page) -> list[str]:
564
616
  """The page numbers of a form over several pages, none for a single page."""
565
617
  return [_text(t) for t in page.locator(PAGE_NUMBERS).all_inner_texts() if _text(t).isdigit()]
@@ -617,6 +669,18 @@ def _open_list(page: Page, box: Locator) -> list[str]:
617
669
  return options
618
670
 
619
671
 
672
+ def _list_settled(page: Page, cells: Locator) -> list[str]:
673
+ """A list's items, once the search typed has stopped narrowing it."""
674
+ items = None
675
+ for _ in range(LIST_POLLS):
676
+ now = [_text(t) for t in cells.all_inner_texts()]
677
+ if now == items:
678
+ break
679
+ items = now
680
+ page.wait_for_timeout(LIST_POLL_MS)
681
+ return items or []
682
+
683
+
620
684
  def _choose(
621
685
  page: Page,
622
686
  box: Locator,
@@ -637,15 +701,15 @@ def _choose(
637
701
  cells = page.locator(f"[id='{box.get_attribute('aria-controls')}'] [role=gridcell]")
638
702
  with suppress(browser.Timeout):
639
703
  cells.first.wait_for(timeout=SEARCH_TIMEOUT_MS)
640
- for i in range(cells.count()):
641
- item = _text(cells.nth(i).inner_text())
704
+ for item in _list_settled(page, cells):
642
705
  if (
643
706
  (exact and item == answer)
644
707
  or (code and re.search(rf"{re.escape(answer)}(\D|$)", item))
645
708
  or (field is not None and matching.matches(field, answer, item))
646
709
  ):
647
- cells.nth(i).scroll_into_view_if_needed()
648
- _click(page, cells.nth(i))
710
+ cell = cells.filter(has_text=_exact(item)).first # by its text: a late search
711
+ cell.scroll_into_view_if_needed() # moves it, or leaves it alone in the list
712
+ _click(page, cell)
649
713
  return
650
714
  box.press("Escape")
651
715
  if field is not None:
@@ -268,6 +268,10 @@ class Paycor:
268
268
  f"unhandled_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
269
269
  )
270
270
 
271
+ def shown_fields(self, page: Page) -> list[str]:
272
+ """The form's controls; the answers to the pages before it are hidden inputs by now."""
273
+ return [c["id"] for c in page.evaluate(_CONTROLS)]
274
+
271
275
  def read_back(self, page: Page) -> dict[str, Answer]:
272
276
  held: dict[str, Answer] = {}
273
277
  for field, step in self._before:
@@ -111,6 +111,10 @@ class Personio:
111
111
  case _:
112
112
  control.fill(value)
113
113
 
114
+ def shown_fields(self, page: Page) -> list[str]:
115
+ form = html.parse(page.locator(FORM).evaluate("f => f.outerHTML"))
116
+ return [f.id for f, _ in _form(form)]
117
+
114
118
  def read_back(self, page: Page) -> dict[str, Answer]:
115
119
  values = page.locator(FORM).evaluate(_READ_BACK, [f.id for f in self._fields])
116
120
  out: dict[str, Answer] = {}
@@ -149,6 +149,10 @@ class Recruitee:
149
149
  case _:
150
150
  controls.first.fill(values[0])
151
151
 
152
+ def shown_fields(self, page: Page) -> list[str]:
153
+ form = html.parse(page.locator(FORM).evaluate("f => f.outerHTML"))
154
+ return [raw["id"] for raw in _read(form)]
155
+
152
156
  def read_back(self, page: Page) -> dict[str, Answer]:
153
157
  values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
154
158
  return {k: v for k, v in values.items() if v not in (None, "", [])}
@@ -159,6 +159,26 @@ _SHOWN = """(root, [id, nth, testids]) => {
159
159
  return {kind: "unknown"};
160
160
  }"""
161
161
 
162
+ # The fields the page shows, one `[data-testid=field]` block each, named by the first
163
+ # `data-testid` in it that names a field. A second block of one id is its "Other" text box.
164
+ _FIELDS = """(root, names) => {
165
+ const out = new Set();
166
+ const control = "input:not([type=hidden]), textarea, [role=combobox], [role=radio], "
167
+ + "[role=checkbox]";
168
+ const generic = ["field", "screen-reader-only"];
169
+ for (const block of root.querySelectorAll("[data-testid=field]")) {
170
+ if (block.parentElement.closest("[data-testid=field]")) continue;
171
+ if (!block.getClientRects().length || !block.querySelector(control)) continue;
172
+ const el = [...block.querySelectorAll("[data-testid]")]
173
+ .find(e => !generic.includes(e.dataset.testid));
174
+ if (!el) continue;
175
+ const id = el.dataset.testid.replace(/_others$/, "_other").replace(/-code$/, "");
176
+ const name = names[id] ?? id;
177
+ out.add(out.has(name) ? `${name}_other` : name);
178
+ }
179
+ return [...out];
180
+ }"""
181
+
162
182
 
163
183
  class Rippling:
164
184
  name = "rippling"
@@ -236,9 +256,7 @@ class Rippling:
236
256
  if field_id.endswith("_comment"):
237
257
  _comment(page, field_id.removesuffix("_comment"), value)
238
258
  elif field_id.endswith("_other"):
239
- page.locator(f"[data-testid='{field_id.removesuffix('_other')}']").nth(1).locator(
240
- "input"
241
- ).fill(value)
259
+ _other(page, field_id.removesuffix("_other")).fill(value)
242
260
  elif field_id == "phone_number":
243
261
  _phone(page, value)
244
262
  elif field_id == "location":
@@ -248,11 +266,15 @@ class Rippling:
248
266
  else:
249
267
  _fill(page, field_id, answer)
250
268
 
269
+ def shown_fields(self, page: Page) -> list[str]:
270
+ return page.locator("body").evaluate(_FIELDS, {v: k for k, v in TESTIDS.items()})
271
+
251
272
  def read_back(self, page: Page) -> dict[str, Answer]:
252
273
  body, out = page.locator("body"), {}
253
274
  for field_id in self._fields:
254
275
  if field_id.endswith("_other"):
255
- shown = body.evaluate(_SHOWN, [field_id.removesuffix("_other"), 1, TESTIDS])
276
+ box = _other(page, field_id.removesuffix("_other"))
277
+ shown = {"value": box.input_value() if box.count() else None}
256
278
  elif field_id == "phone_number":
257
279
  code = page.locator("[data-testid='phone_number-code'] input").input_value()
258
280
  number = page.locator("[data-testid='input-phone_number']").input_value()
@@ -358,6 +380,13 @@ def _box(page: Page, field_id: str) -> Locator:
358
380
  return page.locator(f"[data-testid='{TESTIDS.get(field_id, field_id)}']").first
359
381
 
360
382
 
383
+ def _other(page: Page, field_id: str) -> Locator:
384
+ """The text box an "Other" answer shows: `<id>_others` under boxes, a second `<id>` under
385
+ radios."""
386
+ second = page.locator(f"[data-testid='{field_id}']").nth(1)
387
+ return page.locator(f"[data-testid='{field_id}_others']").or_(second).locator("input")
388
+
389
+
361
390
  def _option(page: Page, field_id: str, value: str) -> None:
362
391
  """Picks the open list's option whose text is `value`."""
363
392
  option = page.locator("[role=listbox] [role=option]").filter(has_text=_exact(value))
@@ -439,6 +439,9 @@ class SuccessFactors:
439
439
  raise NotTaken(f"{key} is no longer on the page")
440
440
  return found | {"options": self._fields[key]["options"]}
441
441
 
442
+ def shown_fields(self, page: Page) -> list[str]:
443
+ return [d["key"] for d in page.evaluate(FIELDS)]
444
+
442
445
  def read_back(self, page: Page) -> dict[str, Answer]:
443
446
  """Each field's value; an attachment's, the file uploaded to it when it lists several."""
444
447
  values = {}
@@ -35,7 +35,6 @@ SETTLE_MS = 500
35
35
  YES_NO = ["Yes", "No"]
36
36
  DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
37
37
  COUNTRY, STATE = "Country", "State"
38
- HISPANIC = "HispanicOrigin" # race (`EthnicOrigin`) is asked when not Hispanic or Latino
39
38
  DECLINE_BOX = "input[type=checkbox][data-automation$='-decline-checkbox']"
40
39
  DAYS, DOCUMENTS = "availability_days", "documents"
41
40
 
@@ -50,7 +49,9 @@ ERRORS = [
50
49
  ] # fmt: skip
51
50
 
52
51
  # Each visible field of the form, in page order: a control's id (a radio group's name), its
53
- # label, how it renders, whether it is required, its options and value.
52
+ # label, how it renders, whether it is required, its options and value. Two follow-ups are
53
+ # listed hidden, `unshown`, after the answer that shows them: race, when not Hispanic or Latino,
54
+ # and the referrer's details, when referred by an employee.
54
55
  _READ = """root => {
55
56
  const clean = s => (s || "").replace(/\\s+/g, " ").trim();
56
57
  const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) &&
@@ -87,6 +88,7 @@ _READ = """root => {
87
88
  const type = (el.type || "").toLowerCase();
88
89
  if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
89
90
  if (el.matches("[data-automation$='-decline-checkbox']")) continue; // its select's option
91
+ if (el.matches("[data-automation=select-document-checkbox]")) continue; // the upload's own
90
92
  if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
91
93
  const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
92
94
  if (!id || seen.has(id)) continue;
@@ -131,8 +133,25 @@ _READ = """root => {
131
133
  const label = labelOf(race);
132
134
  out.splice(out.findIndex(f => f.id === "HispanicOrigin") + 1, 0, select(race, {
133
135
  id: race.id, label: clean(label?.textContent), required: required(race, label),
134
- readonly: false, maxLength: null,
135
- shownWhen: clean([...hispanic.options].find(o => o.value === "No")?.textContent)}));
136
+ readonly: false, maxLength: null, unshown: !shown(race),
137
+ shownWhen: ["HispanicOrigin",
138
+ clean([...hispanic.options].find(o => o.value === "No")?.textContent)]}));
139
+ }
140
+ const referred = root.querySelector("input[type=radio][name=employeereferral][value=true]");
141
+ if (referred && out.some(f => f.id === "employeereferral")) {
142
+ const details = ["ReferralName", "ReferralEmail", "ReferralPhone"]
143
+ .map(id => root.querySelector(`input[id="${id}"]`)).filter(Boolean);
144
+ for (const el of details) {
145
+ const kept = out.findIndex(f => f.id === el.id);
146
+ if (kept >= 0) out.splice(kept, 1);
147
+ }
148
+ out.splice(out.findIndex(f => f.id === "employeereferral") + 1, 0, ...details.map(el => {
149
+ const label = labelOf(el);
150
+ return {id: el.id, label: clean(label?.textContent), required: required(el, label),
151
+ kind: "text", options: [], value: "", readonly: false, maxLength: el.maxLength,
152
+ unshown: !shown(el),
153
+ shownWhen: ["employeereferral", clean(referred.closest("label").textContent)]};
154
+ }));
136
155
  }
137
156
  const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
138
157
  if (days.length) {
@@ -396,6 +415,9 @@ class UKG:
396
415
  case _:
397
416
  _control(root, field_id).fill(values[0])
398
417
 
418
+ def shown_fields(self, page: Page) -> list[str]:
419
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ) if not raw.get("unshown")]
420
+
399
421
  def read_back(self, page: Page) -> dict[str, Answer]:
400
422
  values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
401
423
  return {k: v for k, v in values.items() if v not in (None, "", [])}
@@ -496,7 +518,8 @@ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
496
518
  return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
497
519
  unfillable=reason) # fmt: skip
498
520
  options = raw["options"] if kind in ("select", "radio") else None
499
- shown_when = Is(HISPANIC, [raw["shownWhen"]]) if raw.get("shownWhen") else None
521
+ when = raw.get("shownWhen")
522
+ shown_when = Is(when[0], [when[1]]) if when else None
500
523
  if field_id == STATE and states is not None:
501
524
  sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
502
525
  return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
@@ -179,6 +179,11 @@ class WelcomeKit:
179
179
  case _:
180
180
  control.fill(value)
181
181
 
182
+ def shown_fields(self, page: Page) -> list[str]:
183
+ """The older form's controls; the newer form's API lists every field whatever the
184
+ answers, so it has nothing to tell."""
185
+ return [] if self._new else [c.field.id for c in _old_controls(page.content())]
186
+
182
187
  def read_back(self, page: Page) -> dict[str, Answer]:
183
188
  form = page.locator("form").first if self._new else page.locator(OLD)
184
189
  controls = [
@@ -49,6 +49,8 @@ DECLINE = "Skip for now" # its other button, Start my application, enrols the a
49
49
  FORM = 'form:has([data-test="JobApplicationModal--SubmitButton"])'
50
50
  SEND = '[data-test="JobApplicationModal--SubmitButton"]'
51
51
  NOTE = "note"
52
+ NOTE_INPUT = "userNote"
53
+ QUESTION = re.compile(r"customQuestionAnswers\[(\d+)\]") # the start of a question's control name
52
54
  GONE = "no longer available"
53
55
  WRONG_PASSWORD = "Email or password is incorrect"
54
56
  PASSWORD_LENGTH = (8, 128)
@@ -258,6 +260,12 @@ class Wellfound:
258
260
  said = f"cannot fill {field_id}, a {kind}, with {answer!r}"
259
261
  raise Unsupported(f"unhandled_rendering: {said}")
260
262
 
263
+ def shown_fields(self, page: Page) -> list[str]:
264
+ """The dialog's controls, named as the read names its questions and its note."""
265
+ names = page.locator(FORM).evaluate("""form => [...form.querySelectorAll("[name]")]
266
+ .filter(e => e.getClientRects().length).map(e => e.name)""")
267
+ return list(dict.fromkeys(i for name in names if (i := _field_named(name))))
268
+
261
269
  def read_back(self, page: Page) -> dict[str, Answer]:
262
270
  form = page.locator(FORM)
263
271
  values: dict[str, Answer] = {}
@@ -373,6 +381,14 @@ def _opened(page: Page, read: list[dict[str, Any]]) -> dict[str, Any]:
373
381
  raise RuntimeError("Apply opened no dialog")
374
382
 
375
383
 
384
+ def _field_named(name: str) -> str | None:
385
+ """The field a control of the dialog belongs to."""
386
+ if name == NOTE_INPUT:
387
+ return NOTE
388
+ question = QUESTION.match(name)
389
+ return f"question_{question[1]}" if question else None
390
+
391
+
376
392
  def _field(question: dict[str, Any]) -> Field:
377
393
  field_id, label = f"question_{question['id']}", question["question"].strip()
378
394
  required, options = question["required"], [o["option"] for o in question["options"]]
@@ -122,6 +122,9 @@ class WeRecruit:
122
122
  case _:
123
123
  control.fill(value)
124
124
 
125
+ def shown_fields(self, page: Page) -> list[str]:
126
+ return [c.field.id for c in _parse(page.content()) or []]
127
+
125
128
  def read_back(self, page: Page) -> dict[str, Answer]:
126
129
  shown = [c for c in self._controls if not c.field.unfillable]
127
130
  values = page.locator(FORM).evaluate(_READ_BACK, [c.name for c in shown])
@@ -243,6 +243,9 @@ class Workable:
243
243
  case _:
244
244
  field.fill(values[0])
245
245
 
246
+ def shown_fields(self, page: Page) -> list[str]:
247
+ return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
248
+
246
249
  def read_back(self, page: Page) -> dict[str, Answer]:
247
250
  values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
248
251
  for field_id, fmt in self._formats.items():
@@ -97,6 +97,20 @@ _READ_BACK = """dialog => {
97
97
  return out;
98
98
  }"""
99
99
 
100
+ # The fields the dialog shows, once tagged; a control `_TAG` did not place, by its place in the
101
+ # dialog. The relocation box is the account profile's, not a field.
102
+ _SHOWN = """dialog => {
103
+ const out = new Set();
104
+ [...dialog.querySelectorAll("input, select, textarea")].forEach((el, n) => {
105
+ if (el.type === "hidden" || !el.getClientRects().length) return;
106
+ const block = el.closest("[data-atsfill-question]");
107
+ if (block) out.add("question_" + block.dataset.atsfillQuestion);
108
+ else if (el.dataset.atsfillField === "message") out.add("message");
109
+ else if (!el.dataset.atsfillField) out.add(`control #${n + 1}`);
110
+ });
111
+ return [...out];
112
+ }"""
113
+
100
114
 
101
115
  class WorkAtAStartup:
102
116
  name = "workatastartup"
@@ -261,6 +275,10 @@ class WorkAtAStartup:
261
275
  if relocation.count():
262
276
  relocation.uncheck()
263
277
 
278
+ def shown_fields(self, page: Page) -> list[str]:
279
+ self._tag(page)
280
+ return page.locator(DIALOG).first.evaluate(_SHOWN)
281
+
264
282
  def read_back(self, page: Page) -> dict[str, Answer]:
265
283
  self._tag(page)
266
284
  values = page.locator(DIALOG).first.evaluate(_READ_BACK)
@@ -322,8 +322,10 @@ class Workday:
322
322
  _add_entries(page, answers)
323
323
  filled: set[str] = set()
324
324
  for _ in range(FOLLOW_UP_ROUNDS): # an answer shows its follow-up questions
325
- todo = [r for r in _rendered(page, answers) if r["id"] not in filled]
326
- todo = [r for r in todo if r["id"] in answers and r["id"] in self._fields]
325
+ todo = [r for r in _rendered(page, answers) if r["id"] in answers]
326
+ todo = [r for r in todo if r["id"] in self._fields]
327
+ # a reply that came after its wait rendered over the answers given meanwhile
328
+ todo = [r for r in todo if r["id"] not in filled or _lost(r, answers[r["id"]])]
327
329
  if not todo:
328
330
  break
329
331
  for raw in todo:
@@ -560,6 +562,11 @@ def _filled(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
560
562
  return raws
561
563
 
562
564
 
565
+ def _lost(raw: dict[str, Any], answer: Answer) -> bool:
566
+ """Whether the page dropped a field's answer; an upload still attaching shows no value."""
567
+ return bool(answer) and raw["kind"] != "file" and raw["value"] in (None, "", [])
568
+
569
+
563
570
  def _field(raw: dict[str, Any]) -> Field | None:
564
571
  kind, options = KINDS.get(raw["kind"]), raw["options"]
565
572
  if raw["kind"] == "box":
@@ -36,9 +36,10 @@ SUGGEST_POLL_MS = 250
36
36
  KEY_DELAY_MS = 30
37
37
  YES_NO = ["Yes", "No"]
38
38
 
39
- # The fillable fields: an input's name is its id. Email comes from the account, disabled.
39
+ # The fillable fields: an input's name is its id. Email comes from the account, disabled; an
40
+ # upload shows `save_resume`, whether the CV stays on the account, left as it is.
40
41
  _READ = """form => [...form.querySelectorAll("input[name], textarea[name], select[name]")]
41
- .filter(e => !e.disabled && e.name !== "avatar")
42
+ .filter(e => !e.disabled && !["avatar", "save_resume"].includes(e.name))
42
43
  .map(e => {
43
44
  const label = form.querySelector(`label[for="${CSS.escape(e.id)}"]`);
44
45
  return {id: e.name, tag: e.tagName.toLowerCase(), type: e.type,
@@ -172,6 +173,10 @@ class WelcomeToTheJungle:
172
173
  )
173
174
  raise Unsupported(reason)
174
175
 
176
+ def shown_fields(self, page: Page) -> list[str]:
177
+ raws = page.locator(FORM).first.evaluate(_READ)
178
+ return list(dict.fromkeys(r["id"] for r in raws if r["type"] != "hidden"))
179
+
175
180
  def read_back(self, page: Page) -> dict[str, Answer]:
176
181
  values = page.locator(FORM).first.evaluate(_READ_BACK, list(self._fields))
177
182
  return {k: v for k, v in values.items() if v not in (None, "")}
@@ -18,10 +18,10 @@ from urllib.parse import urljoin
18
18
  from urllib.request import Request as HttpRequest
19
19
  from urllib.request import urlopen
20
20
 
21
- from camoufox.addons import ADDONS_DIR, DefaultAddons, get_addon_path, maybe_download_addons
21
+ from camoufox.addons import DefaultAddons, get_addon_path
22
22
  from camoufox.exceptions import CamoufoxNotInstalled
23
23
  from camoufox.fingerprints import Screen, generate_fingerprint
24
- from camoufox.pkgman import installed_verstr
24
+ from camoufox.pkgman import INSTALL_DIR, installed_verstr, launch_path
25
25
  from camoufox.sync_api import Camoufox
26
26
  from filelock import FileLock
27
27
  from filelock import Timeout as LockTimeout
@@ -58,11 +58,9 @@ BUDGET = 180.0 # seconds per command, lock wait included
58
58
  PROFILE_BUDGET = 300.0 # seconds for a `profile` run, which may save every profile step
59
59
  LOCK_POLL_S = 0.1
60
60
  HTTP_TIMEOUT_S = 20 # per request to a hiring system's API
61
- BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
62
- BLOCKER = ADDONS_DIR / "UBO-frozen" # Camoufox's uBlock Origin: annoyance lists on, updates off
61
+ BUILD = "158.0.1-beta.41" # the Camoufox browser build, pinned so results compare across machines
62
+ BLOCKER = INSTALL_DIR / "atsfill" / f"UBO-{BUILD}" # the build's uBlock Origin, annoyance lists on
63
63
  BLOCKER_LISTS = "annoyances" # the group of uBlock Origin lists: cookie banners, pop-ups, widgets
64
- # On, its first update, 37s into a launch, leaves the blocker hiding nothing for the rest of it.
65
- BLOCKER_AUTO_UPDATE = "autoUpdate: true,"
66
64
  # The adapters' filters for the banners the lists miss: a list of the blocker's, on like them.
67
65
  FILTERS = "assets/atsfill/filters.txt"
68
66
  INCLUDE = re.compile(r"^!#include (\S+)$", re.M)
@@ -90,14 +88,13 @@ def version() -> str:
90
88
 
91
89
 
92
90
  def install() -> int:
93
- """Fetches the pinned browser build unless it is installed, then the add-ons every launch
94
- loads, so that browsers started side by side do not each download them."""
91
+ """Fetches the pinned browser build unless it is installed, then builds the blocker every
92
+ launch loads, so that browsers started side by side do not each build it."""
95
93
  if version() != BUILD:
96
- subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
94
+ subprocess.call([sys.executable, "-m", "camoufox", "fetch"]) # the package's paired build
97
95
  if version() != BUILD: # `camoufox fetch` exits 0 when it fails
98
96
  print(f"the Camoufox build {BUILD} is not installed", file=sys.stderr)
99
97
  return 1
100
- maybe_download_addons(list(DefaultAddons))
101
98
  try:
102
99
  _build_blocker()
103
100
  except OSError as e:
@@ -109,13 +106,13 @@ def install() -> int:
109
106
  def _build_blocker() -> None:
110
107
  """Copies Camoufox's uBlock Origin to `BLOCKER` with only its annoyance lists on, read
111
108
  from disk: fetched by each launch, they would hold its first page for seconds, and each
112
- list on is compiled by each launch before the blocker starts. Its updates stay off: a fresh
113
- profile would drop them with the browser anyway."""
114
- ADDONS_DIR.mkdir(parents=True, exist_ok=True)
115
- with FileLock(ADDONS_DIR / f"{BLOCKER.name}.lock"):
109
+ list on is compiled by each launch before the blocker starts. Camoufox's policy keeps it
110
+ from updating them: its first update, 37s into a launch, left it hiding nothing."""
111
+ BLOCKER.parent.mkdir(parents=True, exist_ok=True)
112
+ with FileLock(BLOCKER.with_name(f"{BLOCKER.name}.lock")):
116
113
  build = BLOCKER.with_name(f"{BLOCKER.name}.build")
117
114
  shutil.rmtree(build, ignore_errors=True)
118
- shutil.copytree(get_addon_path(DefaultAddons.UBO.name), build)
115
+ shutil.copytree(get_addon_path(DefaultAddons.UBO.name, launch_path()), build)
119
116
  assets = build / "assets" / "assets.json"
120
117
  entries = json.loads(assets.read_text())
121
118
  for key, entry in entries.items():
@@ -135,10 +132,6 @@ def _build_blocker() -> None:
135
132
  (build / FILTERS).parent.mkdir()
136
133
  (build / FILTERS).touch()
137
134
  assets.write_text(json.dumps(entries))
138
- settings = build / "js" / "background.js"
139
- defaults = settings.read_text()
140
- assert BLOCKER_AUTO_UPDATE in defaults # gone from a new uBlock Origin: find its new form
141
- settings.write_text(defaults.replace(BLOCKER_AUTO_UPDATE, "autoUpdate: false,"))
142
135
  shutil.rmtree(BLOCKER, ignore_errors=True)
143
136
  build.rename(BLOCKER)
144
137
 
@@ -193,7 +186,6 @@ def lock(data_dir: Path, deadline: float) -> Generator[None]:
193
186
  @contextmanager
194
187
  def _launch(data_dir: Path, headless: bool) -> Generator[Browser]:
195
188
  if not (BLOCKER / FILTERS).exists(): # installed before the blocker, or its filters, were
196
- maybe_download_addons(list(DefaultAddons))
197
189
  _build_blocker()
198
190
  _write_filters()
199
191
  with Camoufox(
File without changes
File without changes