atsfill 0.0.4.dev98__tar.gz → 0.0.4.dev100__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 (47) hide show
  1. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/ashby.py +4 -4
  5. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/bamboohr.py +30 -14
  6. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/breezy.py +3 -3
  7. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/dayforce.py +2 -2
  8. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/eightfold.py +3 -3
  9. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/greenhouse.py +3 -3
  10. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/hireology.py +3 -3
  11. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/hiringthing.py +2 -2
  12. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/jazzhr.py +1 -1
  13. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/lever.py +1 -1
  14. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/manatal.py +2 -2
  15. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/oracle.py +13 -4
  16. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/paycor.py +50 -3
  17. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/paylocity.py +4 -4
  18. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/personio.py +3 -3
  19. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/phenom.py +76 -31
  20. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/pinpoint.py +4 -4
  21. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/recruitee.py +1 -1
  22. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/rippling.py +3 -3
  23. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/smartrecruiters.py +3 -3
  24. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/teamtailor.py +1 -1
  25. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/workable.py +3 -3
  26. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/workday.py +1 -1
  27. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/wttj.py +1 -1
  28. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/zohorecruit.py +3 -3
  29. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/cli.py +7 -2
  30. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/adapter.py +8 -8
  31. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/commands.py +11 -10
  32. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/conditions.py +17 -4
  33. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/matching.py +26 -10
  34. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/results.py +9 -0
  35. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/LICENSE +0 -0
  36. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/README.md +0 -0
  37. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/__init__.py +0 -0
  38. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/__init__.py +0 -0
  39. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/__init__.py +0 -0
  40. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/accounts.py +0 -0
  41. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/attempts.py +0 -0
  42. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/browser.py +0 -0
  43. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/py.typed +0 -0
  47. {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev98
3
+ Version: 0.0.4.dev100
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.dev98"
3
+ version = "0.0.4.dev100"
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.dev98"
3
+ version = "0.0.4.dev100"
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"
@@ -19,7 +19,7 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
19
 
20
20
  HOST = "jobs.ashbyhq.com"
21
21
  GRAPHQL = f"https://{HOST}/api/non-user-graphql?op=ApiJobPosting"
22
- # The fields of the page's own ApiJobPosting query that `read_form` uses.
22
+ # The fields of the page's own ApiJobPosting query that `browser_read` uses.
23
23
  JOB_POSTING = """query ApiJobPosting($org: String!, $job: String!) {
24
24
  jobPosting(organizationHostedJobsPageName: $org, jobPostingId: $job) {
25
25
  applicationForm { ...Form }
@@ -78,7 +78,7 @@ class Ashby:
78
78
 
79
79
  def __init__(self) -> None:
80
80
  self._forms: list[dict[str, Any]] = [] # the application form, then each survey
81
- self._pending: set[Any] = set() # GraphQL requests in flight, since `read_form`
81
+ self._pending: set[Any] = set() # GraphQL requests in flight, since `browser_read`
82
82
  self._last_change = 0.0
83
83
  self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
84
84
 
@@ -100,7 +100,7 @@ class Ashby:
100
100
  def posting(self, url: str) -> str:
101
101
  return _hosted(url) if urlsplit(url).hostname == HOST else self._hosted or url
102
102
 
103
- def read_api(self, url: str) -> list[Field] | None:
103
+ def request_read(self, url: str) -> list[Field] | None:
104
104
  """The fields from the GraphQL query the job page itself sends, for a hosted URL."""
105
105
  if urlsplit(url).hostname != HOST:
106
106
  return None
@@ -125,7 +125,7 @@ class Ashby:
125
125
  except OSError, ValueError, KeyError, TypeError, Unsupported:
126
126
  return None
127
127
 
128
- def read_form(self, page: Page) -> list[Field]:
128
+ def browser_read(self, page: Page) -> list[Field]:
129
129
  page.on("request", self._on_request)
130
130
  page.on("requestfinished", self._on_finished)
131
131
  page.on("requestfailed", self._on_finished)
@@ -1,8 +1,8 @@
1
1
  """BambooHR (`<employer>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
2
2
 
3
3
  The job page renders its application form, behind Apply for This Job, from a public JSON
4
- answer that lists every field with its label, requirement and options. `read_api` fetches it
5
- over HTTP, `read_form` through the page's own browser context, and both list the fields from
4
+ answer that lists every field with its label, requirement and options. `request_read` fetches it
5
+ over HTTP, `browser_read` through the page's own browser context, and both list the fields from
6
6
  it. Dropdowns are Fabric menus over a hidden `select`; a file is uploaded once attached.
7
7
  """
8
8
 
@@ -17,7 +17,7 @@ from urllib.parse import urlsplit
17
17
  from atsfill.core import browser
18
18
  from atsfill.core.adapter import Blocked, Gap, Unsupported
19
19
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
20
- from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
20
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
21
21
 
22
22
  HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
23
23
  JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
@@ -159,7 +159,7 @@ class BambooHR:
159
159
  job = _job(url)
160
160
  return f"https://{job[0]}/careers/{job[1]}" if job else url
161
161
 
162
- def read_api(self, url: str) -> list[Field] | None:
162
+ def request_read(self, url: str) -> list[Field] | None:
163
163
  if (job := _job(url)) is None:
164
164
  return None
165
165
  try:
@@ -169,7 +169,7 @@ class BambooHR:
169
169
  except OSError, ValueError, KeyError, TypeError, Unsupported:
170
170
  return None
171
171
 
172
- def read_form(self, page: Page) -> list[Field]:
172
+ def browser_read(self, page: Page) -> list[Field]:
173
173
  host, job = _job(page.url) or ("", "")
174
174
  form = page.locator(FORM)
175
175
  try:
@@ -411,25 +411,41 @@ def _form(
411
411
  if key == "countryId" and country:
412
412
  by_id = {str(o["id"]): o["text"] for o in entry.get("options") or []}
413
413
  shown = by_id.get(str(country), country if country in by_id.values() else None)
414
+ option_sets = None
414
415
  if key == "state" and options:
415
- options = _states(states, country)
416
- if key == "state" and not options:
416
+ countries = result["formFields"].get("countryId") or {}
417
+ options, option_sets = None, _states(states, countries.get("options") or [])
418
+ if key == "state" and not option_sets:
417
419
  kind = "text"
418
420
  if kind == "veteran":
419
421
  extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
420
422
  options = list(VETERAN) + list(extra.values())
421
423
  accept = FILE_TYPES if kind == "file" else None
422
- field = Field(key, label, TYPES[kind], required, options, None, accept, default=shown)
424
+ field = Field(
425
+ key,
426
+ label,
427
+ TYPES[kind],
428
+ required,
429
+ options,
430
+ None,
431
+ accept,
432
+ default=shown,
433
+ option_sets=option_sets,
434
+ )
423
435
  out.append((field, (kind, name, extra)))
424
436
  return out
425
437
 
426
438
 
427
- def _states(states: dict[str, Any], default: str | None) -> list[str]:
428
- """The state lists of every country that has one, the default country's (an id) first:
429
- the list the page shows follows the country answered."""
430
- order = sorted(STATE_LISTS, key=lambda c: c != default)
431
- names = [s["full_name"] for c in order for s in (states.get(c) or {}).get("data") or []]
432
- return list(dict.fromkeys(names))
439
+ def _states(states: dict[str, Any], countries: list[dict[str, Any]]) -> list[OptionSet]:
440
+ """The state list of each country that has one, under that country as the form lists it;
441
+ any other country's state is typed."""
442
+ by_id = {str(o["id"]): o["text"] for o in countries}
443
+ return [
444
+ OptionSet(Is("countryId", [by_id[c]]), names)
445
+ for c in STATE_LISTS
446
+ if c in by_id
447
+ and (names := [s["full_name"] for s in (states.get(c) or {}).get("data") or []])
448
+ ]
433
449
 
434
450
 
435
451
  def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
@@ -2,8 +2,8 @@
2
2
  docs/adapters/breezy.md observes.
3
3
 
4
4
  Two page engines, an AngularJS one and a React one, render the same markup: named controls
5
- under `h3` headings, the questionnaire's as `section_<section>_question_<n>`. The fields are
6
- read from the page. Files upload to Breezy when attached; the rest reaches it on submit.
5
+ under `h3` headings, the questionnaire's as `section_<section>_question_<n>`. Only a browser
6
+ read lists the fields. Files upload to Breezy when attached; the rest reaches it on submit.
7
7
  """
8
8
 
9
9
  import re
@@ -236,7 +236,7 @@ class Breezy:
236
236
  match = JOB.match(parts.path)
237
237
  return f"https://{parts.hostname}/p/{match[1]}" if match else url
238
238
 
239
- def read_form(self, page: Page) -> list[Field]:
239
+ def browser_read(self, page: Page) -> list[Field]:
240
240
  if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
241
241
  page.goto(f"{self.posting(page.url)}/apply")
242
242
  form = page.locator(FORM)
@@ -197,7 +197,7 @@ class Dayforce:
197
197
  found = _job(url)
198
198
  return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
199
199
 
200
- def read_form(self, page: Page) -> list[Field]:
200
+ def browser_read(self, page: Page) -> list[Field]:
201
201
  found = _job(page.url)
202
202
  if found is None:
203
203
  raise Unsupported("posting_gone: not a Dayforce job page")
@@ -481,7 +481,7 @@ class Dayforce:
481
481
  option = box_id.removeprefix(f"{base}answerFollowup").removeprefix("s_")
482
482
  followup = f"{field_id}.followup" + (f".{option}" if option else "")
483
483
  if box.is_visible() and followup not in self._fields:
484
- self._shown.append(box_id) # a comment box `read_form` did not list
484
+ self._shown.append(box_id) # a comment box `browser_read` did not list
485
485
 
486
486
  def _set_question(
487
487
  self, page: Page, target: Locator, field: Field, question: dict[str, Any], answer: Answer
@@ -3,7 +3,7 @@ the employer's own domain), as docs/adapters/eightfold.md observes.
3
3
 
4
4
  A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
5
5
  sections of questions by id, type, requirement and options, and the questions whose answer
6
- shows each follow-up. `read_api` fetches it over HTTP, `read_form` through the page's browser
6
+ shows each follow-up. `request_read` fetches it over HTTP, `browser_read` through the page's browser
7
7
  context, and both list the fields from it. Each question renders as a control whose element id
8
8
  is its section's title and its id, with every other character an underscore.
9
9
  """
@@ -162,7 +162,7 @@ class Eightfold:
162
162
  parts, pid = urlsplit(url), _pid(url)
163
163
  return f"https://{parts.hostname}/careers/job/{pid}" if pid else url
164
164
 
165
- def read_api(self, url: str) -> list[Field] | None:
165
+ def request_read(self, url: str) -> list[Field] | None:
166
166
  if (pid := _pid(url)) is None:
167
167
  return None
168
168
  try:
@@ -185,7 +185,7 @@ class Eightfold:
185
185
  except OSError, ValueError, KeyError, TypeError, Unsupported, Blocked:
186
186
  return None
187
187
 
188
- def read_form(self, page: Page) -> list[Field]:
188
+ def browser_read(self, page: Page) -> list[Field]:
189
189
  pid = _pid(page.url)
190
190
  if pid is None:
191
191
  raise Unsupported(f"posting_gone: {page.url} is not a job page")
@@ -44,7 +44,7 @@ API_TYPES = {
44
44
  }
45
45
  YES_NO = ["Yes", "No"]
46
46
  # Sections the page adds or names its own way, or shows a question of only on another answer
47
- # (EEOC race after Hispanic/Latino): `read_api` declines a posting with any (ADR 0011).
47
+ # (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
48
48
  UNMAPPED = ("education", "compliance", "demographic_questions")
49
49
 
50
50
  # The fields in page order. A select's options are read by opening it, from Python.
@@ -167,7 +167,7 @@ class Greenhouse:
167
167
  board, job = _board_job(url) or _board_job(self._embed or "") or ("", url)
168
168
  return f"https://job-boards.greenhouse.io/{board}/jobs/{job}"
169
169
 
170
- def read_api(self, url: str) -> list[Field] | None:
170
+ def request_read(self, url: str) -> list[Field] | None:
171
171
  """The fields from the Job Board API, unless the page holds some the API leaves out."""
172
172
  found = _board_job(url)
173
173
  if found is None:
@@ -177,7 +177,7 @@ class Greenhouse:
177
177
  except OSError, ValueError, KeyError, TypeError:
178
178
  return None
179
179
 
180
- def read_form(self, page: Page) -> list[Field]:
180
+ def browser_read(self, page: Page) -> list[Field]:
181
181
  if self._embed:
182
182
  page.goto(self._embed)
183
183
  form = page.locator(FORM)
@@ -2,7 +2,7 @@
2
2
  docs/adapters/hireology.md observes.
3
3
 
4
4
  The job page renders its application form from a public form API, which lists every field by
5
- id, type and requirement. `read_api` fetches it over HTTP, `read_form` through the page's own
5
+ id, type and requirement. `request_read` fetches it over HTTP, `browser_read` through the page's own
6
6
  browser context, and both list the fields from it. Each field renders as one control whose id is
7
7
  `<field id>-<n>`; nothing but the submit reaches Hireology.
8
8
  """
@@ -202,7 +202,7 @@ class Hireology:
202
202
  job = _job(url)
203
203
  return API.format(job=job) if job else url
204
204
 
205
- def read_api(self, url: str) -> list[Field] | None:
205
+ def request_read(self, url: str) -> list[Field] | None:
206
206
  if (job := _job(url)) is None:
207
207
  return None
208
208
  request = urllib.request.Request(API.format(job=job), headers={"User-Agent": "atsfill"})
@@ -212,7 +212,7 @@ class Hireology:
212
212
  except OSError, ValueError, KeyError, TypeError, Unsupported:
213
213
  return None
214
214
 
215
- def read_form(self, page: Page) -> list[Field]:
215
+ def browser_read(self, page: Page) -> list[Field]:
216
216
  job = _job(page.url)
217
217
  try:
218
218
  page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
@@ -4,7 +4,7 @@ observes.
4
4
  The job page carries the posting's configuration in a React component's props, and its Apply
5
5
  button renders the form from them in the page: MUI inputs named by field id, radios and boxes,
6
6
  react-select comboboxes for the country and state, Dropzone areas that upload a file as soon as
7
- it is attached. `read_form` lists the rendered controls; there is no `read_api`, since which
7
+ it is attached. `browser_read` lists the rendered controls; there is no `request_read`, since which
8
8
  controls render is the page's logic over the props, not the props alone.
9
9
  """
10
10
 
@@ -157,7 +157,7 @@ class HiringThing:
157
157
  job = JOB.match(parts.path)
158
158
  return f"https://{parts.hostname}/job/{job[1]}" if job else url
159
159
 
160
- def read_form(self, page: Page) -> list[Field]:
160
+ def browser_read(self, page: Page) -> list[Field]:
161
161
  props = page.locator(PROPS).first
162
162
  try:
163
163
  props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
@@ -140,7 +140,7 @@ class JazzHR:
140
140
  job = _job(url)
141
141
  return f"https://{job[0]}/apply/{job[1]}" if job else url
142
142
 
143
- def read_form(self, page: Page) -> list[Field]:
143
+ def browser_read(self, page: Page) -> list[Field]:
144
144
  form = page.locator(FORM)
145
145
  try:
146
146
  form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
@@ -133,7 +133,7 @@ class Lever:
133
133
  parts, job = urlsplit(url), _job(url)
134
134
  return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
135
135
 
136
- def read_form(self, page: Page) -> list[Field]:
136
+ def browser_read(self, page: Page) -> list[Field]:
137
137
  if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
138
138
  page.goto(f"{self.posting(page.url)}/apply")
139
139
  form = page.locator(FORM)
@@ -191,7 +191,7 @@ class Manatal:
191
191
  def posting(self, url: str) -> str:
192
192
  return _posting(url)
193
193
 
194
- def read_api(self, url: str) -> list[Field] | None:
194
+ def request_read(self, url: str) -> list[Field] | None:
195
195
  """The fields from the endpoint the apply page fetches, its job id from that page."""
196
196
  client = urlsplit(url).path.strip("/").split("/")[0]
197
197
  try:
@@ -210,7 +210,7 @@ class Manatal:
210
210
  except OSError, ValueError, KeyError, TypeError:
211
211
  return None
212
212
 
213
- def read_form(self, page: Page) -> list[Field]:
213
+ def browser_read(self, page: Page) -> list[Field]:
214
214
  with page.expect_response(
215
215
  lambda r: r.request.method == "GET" and "/application-form/" in r.url,
216
216
  timeout=LOAD_TIMEOUT_MS,
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
18
18
  from atsfill.core import browser, matching
19
19
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
20
20
  from atsfill.core.browser import Locator, Page
21
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
22
22
 
23
23
  JOB = re.compile(r"/hcmUI/CandidateExperience/[\w-]+/sites/[^/]+/job/(\d+)")
24
24
  EMAIL = "email"
@@ -266,7 +266,7 @@ class Oracle:
266
266
  assert match, url
267
267
  return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
268
268
 
269
- def read_form(self, page: Page) -> list[Field]:
269
+ def browser_read(self, page: Page) -> list[Field]:
270
270
  parts = urlsplit(page.url)
271
271
  match = JOB.search(parts.path)
272
272
  assert match, page.url # detected on a job URL
@@ -517,15 +517,24 @@ def _click(page: Page, target: Locator) -> None:
517
517
  def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
518
518
  """Leaves one field of the address's geography (state, city, postal code), the most
519
519
  specific: its items name the levels above (`0200, Acton, ACT`), and picking one fills
520
- them. It is required when any level is."""
520
+ them. It is required when any level is, and a dependent list of the country."""
521
521
  levels = [(r, f) for r, f in described if r.get("geo") and f.id != COUNTRY]
522
522
  if len(levels) < 2 or not any(f.options for _, f in levels):
523
523
  return described
524
524
  parts = lambda f: max(len(o.split(",")) for o in f.options or [""]) # noqa: E731
525
525
  kept = max(levels, key=lambda rf: parts(rf[1]))
526
526
  required = any(f.required for _, f in levels)
527
+ asked = replace(kept[1], required=required)
528
+ country = next((f.default for _, f in described if f.id == COUNTRY), None)
529
+ if isinstance(country, str):
530
+ # its items are the country's the page shows; another's cost a request
531
+ asked = replace(
532
+ asked,
533
+ options=None,
534
+ option_sets=[OptionSet(Is(COUNTRY, [country]), asked.options or [])],
535
+ )
527
536
  return [
528
- (r, replace(f, required=required) if (r, f) == kept else f)
537
+ (r, asked if (r, f) == kept else f)
529
538
  for r, f in described
530
539
  if (r, f) == kept or (r, f) not in levels
531
540
  ]
@@ -3,17 +3,19 @@
3
3
  The apply page is server-rendered. Before the form it may show pages of their own: the
4
4
  federal contractor's self-identification (VEVRAA) and the Yes/No qualifying questions. Each
5
5
  posts its answers to the next, which carries them as hidden inputs; the form posts everything
6
- to `ApplyJob.action` on Submit. `read_form` walks those pages to the form, `fill` walks them
6
+ to `ApplyJob.action` on Submit. `browser_read` walks those pages to the form, `fill` walks them
7
7
  again with the answers.
8
8
  """
9
9
 
10
+ import json
10
11
  import re
12
+ from dataclasses import replace
11
13
  from typing import Any
12
14
  from urllib.parse import parse_qs, urlencode, urlsplit
13
15
 
14
16
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
15
17
  from atsfill.core.browser import Page
16
- from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
17
19
 
18
20
  HOST = "recruitingbypaycor.com"
19
21
  CAREER = f"https://{HOST}/career/"
@@ -120,6 +122,20 @@ _HIDDEN = """name => {
120
122
  }"""
121
123
 
122
124
 
125
+ # Each country select with its state select, and the page scripts' text, which holds every
126
+ # country's state list (`var ca_country_states = [...]`).
127
+ _COUNTRIES = """() => ({
128
+ countries: [...document.querySelectorAll('#gnewtonForm select[data-state-id]')].map(c => ({
129
+ name: c.name,
130
+ state: document.getElementById(c.dataset.stateId)?.name,
131
+ options: [...c.options].filter(o => o.value)
132
+ .map(o => [o.value, o.textContent.replace(/\\s+/g, ' ').trim()]),
133
+ })),
134
+ scripts: [...document.scripts].map(s => s.textContent).join('\\n'),
135
+ })"""
136
+ STATE_LIST = re.compile(r"var (\w+)_country_states\s*=\s*(\[[^\]]*\])")
137
+
138
+
123
139
  def _ids(url: str) -> tuple[str, str] | None:
124
140
  """The client and job ids of a Paycor career URL, or of an employer page opening it."""
125
141
  query = parse_qs(urlsplit(url).query)
@@ -138,6 +154,36 @@ def _field(control: dict[str, Any]) -> Field:
138
154
  return Field(fid, label, KINDS[kind], control["required"], control["options"], control["max"])
139
155
 
140
156
 
157
+ def _dependent(controls: list[tuple[Field, str]], page: dict[str, Any]) -> list[tuple[Field, str]]:
158
+ """A state select under its country: the page's list for each country that has one, none
159
+ for any other, where the page disables the state and drops its `required`. Unanswered, the
160
+ country leaves the list the page starts with."""
161
+ lists = {
162
+ code: [s if isinstance(s, str) else s.get("value", "") for s in json.loads(names)]
163
+ for code, names in STATE_LIST.findall(page["scripts"])
164
+ }
165
+ under = {c["state"]: c for c in page["countries"] if c["state"]}
166
+ out = []
167
+ for field, kind in controls:
168
+ if (country := under.get(field.id)) and kind == "select":
169
+ sets = [OptionSet(Is(country["name"], negated=True), field.options or [])]
170
+ sets += [
171
+ OptionSet(Is(country["name"], [text]), lists[code.lower()])
172
+ for code, text in country["options"]
173
+ if code.lower() in lists
174
+ ]
175
+ having = [text for code, text in country["options"] if code.lower() in lists]
176
+ field = replace(
177
+ field,
178
+ required=False,
179
+ required_when=Is(country["name"], having) if field.required else None,
180
+ options=[],
181
+ option_sets=sets,
182
+ )
183
+ out.append((field, kind))
184
+ return out
185
+
186
+
141
187
  class Paycor:
142
188
  name = "paycor"
143
189
  known_gaps: dict[str, Gap] = {}
@@ -171,10 +217,11 @@ class Paycor:
171
217
  job = (query.get("id") or query.get("gni") or [url])[0]
172
218
  return f"{CAREER}JobIntroduction.action?id={job}"
173
219
 
174
- def read_form(self, page: Page) -> list[Field]:
220
+ def browser_read(self, page: Page) -> list[Field]:
175
221
  self._before, self._codes = [], {}
176
222
  self._walk(page, None)
177
223
  controls = [(_field(c), c["kind"]) for c in page.evaluate(_CONTROLS)]
224
+ controls = _dependent(controls, page.evaluate(_COUNTRIES))
178
225
  self._controls = [(f, kind) for f, kind in controls if not f.unfillable]
179
226
  return [f for f, _ in self._before] + [f for f, _ in controls]
180
227
 
@@ -2,8 +2,8 @@
2
2
  docs/adapters/paylocity.md observes.
3
3
 
4
4
  The apply page carries the whole form's configuration in `window.pageData`: the sections and
5
- their fields, the screener questions and the option lists. `read_api` fetches it over HTTP,
6
- `read_form` takes it from the page, and both list the fields from it. The form is one page or
5
+ their fields, the screener questions and the option lists. `request_read` fetches it over HTTP,
6
+ `browser_read` takes it from the page, and both list the fields from it. The form is one page or
7
7
  a wizard of steps that render one at a time, so `fill` keeps each step's read-back before
8
8
  moving on; nothing reaches the employer before Submit.
9
9
  """
@@ -326,7 +326,7 @@ class Paylocity:
326
326
  job = _job(url)
327
327
  return f"https://{HOST}/Recruiting/Jobs/Details/{job}" if job else url
328
328
 
329
- def read_api(self, url: str) -> list[Field] | None:
329
+ def request_read(self, url: str) -> list[Field] | None:
330
330
  if (job := _job(url)) is None:
331
331
  return None
332
332
  request = urllib.request.Request(APPLY + job, headers={"User-Agent": "atsfill"})
@@ -337,7 +337,7 @@ class Paylocity:
337
337
  except OSError, ValueError, KeyError, TypeError, Unsupported:
338
338
  return None
339
339
 
340
- def read_form(self, page: Page) -> list[Field]:
340
+ def browser_read(self, page: Page) -> list[Field]:
341
341
  job = _job(page.url)
342
342
  if job and "/apply/" not in page.url.lower():
343
343
  page.goto(APPLY + job)
@@ -2,7 +2,7 @@
2
2
 
3
3
  The apply page is rendered on the server, its form whole in the HTML: every field is one native
4
4
  control named by its id, labelled by a `label[for]` or its group's label, a star marking it
5
- required. `read_api` fetches that HTML over HTTP, `read_form` takes the page's form, and both
5
+ required. `request_read` fetches that HTML over HTTP, `browser_read` takes the page's form, and both
6
6
  list the fields from it with one parser. A document is uploaded to Personio once attached.
7
7
  """
8
8
 
@@ -72,7 +72,7 @@ class Personio:
72
72
  job = _job(url)
73
73
  return f"https://{job[0]}.jobs.personio.com/job/{job[1]}" if job else url
74
74
 
75
- def read_api(self, url: str) -> list[Field] | None:
75
+ def request_read(self, url: str) -> list[Field] | None:
76
76
  if _job(url) is None:
77
77
  return None
78
78
  request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
@@ -84,7 +84,7 @@ class Personio:
84
84
  form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
85
85
  return [f for f, _ in _form(form)] if form else None
86
86
 
87
- def read_form(self, page: Page) -> list[Field]:
87
+ def browser_read(self, page: Page) -> list[Field]:
88
88
  if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
89
89
  page.goto(_apply(page.url))
90
90
  try: