atsfill 0.0.4.dev126__tar.gz → 0.0.4.dev128__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 (55) hide show
  1. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/README.md +1 -1
  3. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/hrmdirect.py +21 -10
  6. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/join.py +12 -4
  7. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/successfactors.py +48 -6
  8. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/taleo.py +3 -3
  9. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/ukg.py +21 -15
  10. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/workday.py +11 -3
  11. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/wttj.py +4 -4
  12. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/cli.py +14 -3
  13. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/accounts.py +11 -6
  14. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/adapter.py +51 -4
  15. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/commands.py +19 -14
  16. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/registry.toml +3 -3
  17. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/LICENSE +0 -0
  18. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/__init__.py +0 -0
  19. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/__init__.py +0 -0
  20. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/ashby.py +0 -0
  21. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/bamboohr.py +0 -0
  22. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/breezy.py +0 -0
  23. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/dayforce.py +0 -0
  24. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/eightfold.py +0 -0
  25. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/greenhouse.py +0 -0
  26. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/gupy.py +0 -0
  27. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/hireology.py +0 -0
  28. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/hiringthing.py +0 -0
  29. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/jazzhr.py +0 -0
  30. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/lever.py +0 -0
  31. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/manatal.py +0 -0
  32. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/oracle.py +0 -0
  33. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/paylocity.py +0 -0
  35. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/phenom.py +0 -0
  37. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/pinpoint.py +0 -0
  38. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/recruitee.py +0 -0
  39. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/rippling.py +0 -0
  40. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/smartrecruiters.py +0 -0
  41. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/successfactors.js +0 -0
  42. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/workable.py +0 -0
  44. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/adapters/zohorecruit.py +0 -0
  45. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/__init__.py +0 -0
  46. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/attempts.py +0 -0
  47. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/browser.py +0 -0
  48. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/conditions.py +0 -0
  49. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/html.py +0 -0
  50. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/matching.py +0 -0
  51. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/passwords.py +0 -0
  52. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/paths.py +0 -0
  53. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/registry.py +0 -0
  54. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/src/atsfill/core/results.py +0 -0
  55. {atsfill-0.0.4.dev126 → atsfill-0.0.4.dev128}/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.dev126
3
+ Version: 0.0.4.dev128
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
@@ -137,7 +137,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
137
137
 
138
138
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
139
139
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
140
- Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
140
+ Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
141
141
 
142
142
  ## How it works
143
143
 
@@ -109,7 +109,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
109
109
 
110
110
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
111
111
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
112
- Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
112
+ Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
113
113
 
114
114
  ## How it works
115
115
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev126"
3
+ version = "0.0.4.dev128"
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.dev126"
3
+ version = "0.0.4.dev128"
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"
@@ -13,7 +13,15 @@ import urllib.parse
13
13
  from typing import Any
14
14
  from urllib.parse import parse_qs, urlsplit
15
15
 
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
16
+ from atsfill.core.adapter import (
17
+ Blocked,
18
+ Gap,
19
+ NotTaken,
20
+ Refused,
21
+ SignUpDetails,
22
+ Unsupported,
23
+ known_gap,
24
+ )
17
25
  from atsfill.core.browser import Locator, Page, Request, Timeout
18
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
27
 
@@ -88,8 +96,8 @@ _FIELDS = """form => [...form.querySelectorAll('.form-field')].flatMap(f => {
88
96
  class HRMDirect:
89
97
  name = "hrmdirect"
90
98
  per_employer = True
91
- asks_name = True
92
- known_gaps = {"screening_first": Gap.FLOW, "sign_up_phone": Gap.FLOW}
99
+ asks = {"name"}
100
+ known_gaps = {"screening_first": Gap.FLOW}
93
101
  side_effects = [
94
102
  SideEffect(
95
103
  "sign_up", "employer", "the account is the employer's candidate, emailed a link"
@@ -174,27 +182,30 @@ class HRMDirect:
174
182
  except Timeout:
175
183
  raise Refused("wrong_password") from None
176
184
 
177
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
185
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
178
186
  """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`."""
187
+ password: the email that follows holds a link to set one, `blocked: email_link`. The
188
+ mobile phone is given only where the org requires it; a missing one is refused before
189
+ anything is sent."""
180
190
  _to_apply(page)
181
191
  if not page.locator(IDENTITY).is_visible():
182
192
  raise Unsupported(
183
193
  known_gap(self, "screening_first", "the form's first page comes before sign-up")
184
194
  )
185
195
  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
- )
196
+ phone_asked = phone.count() and not phone.locator(".common-option-label").count()
197
+ if phone_asked and not details.phone:
198
+ raise Refused("sign_up_details: no phone")
190
199
  if page.locator(RECAPTCHA).count():
191
200
  raise Blocked("captcha: a reCAPTCHA box keeps the sign-up's Continue disabled")
192
- first, _, last = (name or "").strip().partition(" ")
201
+ first, _, last = (details.name or "").strip().partition(" ")
193
202
  form = page.locator(IDENTITY)
194
203
  form.locator("input.first-name").fill(first)
195
204
  form.locator("input.last-name").fill(last or first)
196
205
  form.locator("input.email").fill(email)
197
206
  form.locator("input.email-confirm").fill(email)
207
+ if phone_asked:
208
+ form.locator("input.mobile-number").fill(details.phone or "")
198
209
  for group in form.locator(".radio-group:has(input[required])").all():
199
210
  group.locator("label:has(input[value=false])").click() # declines, the least given
200
211
  form.locator("button.submit-button").click()
@@ -13,7 +13,15 @@ from typing import Any
13
13
  from urllib.parse import urlsplit
14
14
 
15
15
  from atsfill.core import browser
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
16
+ from atsfill.core.adapter import (
17
+ Blocked,
18
+ Gap,
19
+ NotTaken,
20
+ Refused,
21
+ SignUpDetails,
22
+ Unsupported,
23
+ known_gap,
24
+ )
17
25
  from atsfill.core.browser import Locator, Page, Request, Response, Timeout
18
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
27
 
@@ -66,7 +74,7 @@ _REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
66
74
  class Join:
67
75
  name = "join"
68
76
  per_employer = False
69
- asks_name = True # the account's first and last name
77
+ asks = {"name"} # the account's first and last name
70
78
  known_gaps = {"profile_incomplete": Gap.FLOW}
71
79
  side_effects = [
72
80
  SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
@@ -110,10 +118,10 @@ class Join:
110
118
  if urlsplit(page.url).path == UNVERIFIED:
111
119
  raise Blocked("email_link: the account is not confirmed")
112
120
 
113
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
121
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
114
122
  """Creates the account, signed in, then waits for its confirmation link: until it is
115
123
  clicked the wizard does not open."""
116
- first, _, last = (name or "").strip().rpartition(" ")
124
+ first, _, last = (details.name or "").strip().rpartition(" ")
117
125
  if not first or not last:
118
126
  raise Unsupported("unhandled_rendering: the sign-up asks a first and a last name")
119
127
  if _email_step(page, email):
@@ -13,7 +13,15 @@ from pathlib import Path
13
13
  from typing import Any
14
14
  from urllib.parse import urlsplit
15
15
 
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
16
+ from atsfill.core.adapter import (
17
+ Blocked,
18
+ Gap,
19
+ NotTaken,
20
+ Refused,
21
+ SignUpDetails,
22
+ Unsupported,
23
+ known_gap,
24
+ )
17
25
  from atsfill.core.browser import Locator, Page, Request, Timeout
18
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
27
 
@@ -26,6 +34,8 @@ SIGNED_IN = re.compile(r"\bsignOut|\blogout\b|_logout", re.IGNORECASE)
26
34
  FIELDS = (Path(__file__).with_suffix(".js")).read_text()
27
35
  # What the candidate pages show once loaded
28
36
  SIGN_IN = "#username"
37
+ COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
38
+ PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
29
39
  SIGN_UP = "#fbclc_userName"
30
40
  QUICK_APPLY = "#fbqa_apply"
31
41
  FORM = ".RCMFormField"
@@ -78,10 +88,26 @@ def _company(request: Request) -> list[tuple[str, str]]:
78
88
  )
79
89
 
80
90
 
91
+ def _calling_code(select: Locator, details: SignUpDetails) -> tuple[str, str]:
92
+ """The calling code's option for the E.164 phone, the one of `country` among the countries
93
+ sharing it, and the national number that follows the code."""
94
+ options = select.evaluate("s => [...s.options].map(o => [o.value, o.textContent])")
95
+ phone = (details.phone or "").removeprefix("+")
96
+ codes = [
97
+ (len(m[1]), value == details.country, value)
98
+ for value, text in options
99
+ if (m := re.search(r"\(\+(\d+)\)", text)) and phone.startswith(m[1])
100
+ ]
101
+ if not codes:
102
+ raise Unsupported(f"unhandled_rendering: the sign-up offers no calling code for {phone}")
103
+ length, _, value = max(codes, key=lambda c: c[:2]) # the first of the best
104
+ return value, phone[length:]
105
+
106
+
81
107
  class SuccessFactors:
82
108
  name = "successfactors"
83
109
  per_employer = True
84
- asks_name = True
110
+ asks = {"name"}
85
111
  known_gaps = {"quick_apply_sign_up": Gap.FLOW, "sign_up_fields": Gap.FLOW}
86
112
  side_effects = [
87
113
  SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
@@ -137,7 +163,11 @@ class SuccessFactors:
137
163
  raise Blocked(f"email_link: {message}") from None
138
164
  raise Refused(f"wrong_password: {message}") from None
139
165
 
140
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
166
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
167
+ """Creates the account from the career site's sign-up: the name, the country of
168
+ residence from `country` (the list's first without it), and the phone where the site
169
+ requires one, its calling code picked from the number; a missing phone is refused
170
+ before anything is sent."""
141
171
  self._candidate(page)
142
172
  if page.locator(SIGN_IN).count():
143
173
  page.locator(REGISTER).first.click()
@@ -152,7 +182,10 @@ class SuccessFactors:
152
182
  ) from None
153
183
  register.first.click()
154
184
  page.locator(SIGN_UP).wait_for(timeout=LANDING_TIMEOUT_MS)
155
- first, _, last = (name or "").strip().partition(" ")
185
+ phone_asked = page.locator(f"{PHONE}[aria-required=true]:visible").count() > 0
186
+ if phone_asked and not details.phone:
187
+ raise Refused("sign_up_details: no phone")
188
+ first, _, last = (details.name or "").strip().partition(" ")
156
189
  for selector, value in [
157
190
  (SIGN_UP, email),
158
191
  ("#fbclc_emailConf", email),
@@ -162,10 +195,17 @@ class SuccessFactors:
162
195
  ("#fbclc_lName", last or first),
163
196
  ]:
164
197
  page.fill(selector, value)
165
- for select in page.locator("select:visible").all(): # the country, a phone code
198
+ if phone_asked and details.phone:
199
+ code, national = _calling_code(page.locator(CALLING_CODE), details)
200
+ page.locator(CALLING_CODE).select_option(code)
201
+ page.fill(PHONE, national)
202
+ for select in page.locator("select:visible").all(): # the country, any other
166
203
  if not select.input_value():
167
204
  values = select.evaluate("s => [...s.options].map(o => o.value).filter(v => v)")
168
- select.select_option(values[0])
205
+ wanted = details.country if select.get_attribute("id") == COUNTRY else None
206
+ if wanted and wanted not in values:
207
+ raise Unsupported(f"unhandled_rendering: the sign-up's countries lack {wanted}")
208
+ select.select_option(wanted or values[0])
169
209
  if page.locator(PRIVACY).count():
170
210
  page.locator(PRIVACY).click()
171
211
  dialog = page.locator("[role=dialog]:visible").last
@@ -193,6 +233,8 @@ class SuccessFactors:
193
233
  if page.locator(f"{SIGN_IN}:visible").count(): # created, signed out
194
234
  self.sign_in(page, email, password)
195
235
  return
236
+ if self.signed_in(page.request, None): # created, back on the career site
237
+ return
196
238
  raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
197
239
 
198
240
  def browser_read(self, page: Page) -> list[Field]:
@@ -15,7 +15,7 @@ from dataclasses import dataclass
15
15
  from typing import Any
16
16
  from urllib.parse import parse_qs, quote, urlsplit
17
17
 
18
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
18
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, SignUpDetails, Unsupported
19
19
  from atsfill.core.browser import Page, Request, Timeout
20
20
  from atsfill.core.results import Answer, AnyOf, Field, FieldType, Is, OptionSet, SideEffect
21
21
 
@@ -209,7 +209,7 @@ def posting(url: str) -> Posting | None:
209
209
  class Taleo:
210
210
  name = "taleo"
211
211
  per_employer = True
212
- asks_name = False
212
+ asks: set[str] = set()
213
213
  known_gaps = {LOCATION_GAP: Gap.RENDERING, DATE_PICKER_GAP: Gap.RENDERING}
214
214
  side_effects = [
215
215
  SideEffect("sign_up", "employer", "the account is created in the employer's zone"),
@@ -264,7 +264,7 @@ class Taleo:
264
264
  page.locator(f"#{FORM_ID}-login-defaultCmd").click()
265
265
  _left_sign_in(page)
266
266
 
267
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
267
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
268
268
  page.locator(f"#{FORM_ID}-login-register").click()
269
269
  page.wait_for_load_state()
270
270
  accept = page.locator(f"#{FORM_ID}-StatementBeforeAuthentificationContent-ContinueButton")
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from typing import Any
14
14
  from urllib.parse import parse_qs, quote, urlsplit
15
15
 
16
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, SignUpDetails, Unsupported
17
17
  from atsfill.core.browser import Locator, Page, Request, Timeout
18
18
  from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
19
19
 
@@ -36,7 +36,6 @@ YES_NO = ["Yes", "No"]
36
36
  DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
37
37
  COUNTRY, STATE = "Country", "State"
38
38
  DAYS, DOCUMENTS = "availability_days", "documents"
39
- PHONE_GAP = "sign_up_phone"
40
39
 
41
40
  # Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
42
41
  ERRORS = [
@@ -218,8 +217,8 @@ def board(url: str) -> Board | None:
218
217
  class UKG:
219
218
  name = "ukg"
220
219
  per_employer = True
221
- asks_name = True
222
- known_gaps = {PHONE_GAP: Gap.FLOW}
220
+ asks = {"name", "phone"} # the phone only some tenants require, asked before the sign-up
221
+ known_gaps: dict[str, Gap] = {}
223
222
  side_effects = [
224
223
  SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
225
224
  SideEffect("upload", "hiring_system", "a document is stored in the account's documents"),
@@ -275,7 +274,7 @@ class UKG:
275
274
  if "/AuthCode/Register" in page.url:
276
275
  raise Unsupported("unhandled_rendering: the tenant asks the account's name")
277
276
 
278
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
277
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
279
278
  page.locator("a[href*='/u/signup']").first.click()
280
279
  page.locator("input#email").wait_for(timeout=FORM_TIMEOUT_MS)
281
280
  page.locator("input#email").fill(email)
@@ -284,7 +283,7 @@ class UKG:
284
283
  _left_sign_in(page)
285
284
  if "/AuthCode/Register" not in page.url:
286
285
  return
287
- _register(page, name or "")
286
+ _register(page, details)
288
287
 
289
288
  def browser_read(self, page: Page) -> list[Field]:
290
289
  found = board(page.url)
@@ -397,26 +396,33 @@ def _left_sign_in(page: Page) -> None:
397
396
  raise Unsupported("unhandled_rendering: the sign-in page answered nothing")
398
397
 
399
398
 
400
- def _register(page: Page, name: str) -> None:
401
- """The tenant's own step after the sign-up: the name, and the privacy policy's box on some
402
- tenants; the box for text messages is left. Its inputs take typed keys only: a filled value
399
+ def _register(page: Page, details: SignUpDetails) -> None:
400
+ """The tenant's own step after the sign-up: the name, the phone where the tenant requires
401
+ it, and the privacy policy's box on some tenants; the optional consents (text messages, AI
402
+ scoring, ticked by default) are declined. Its inputs take typed keys only: a filled value
403
403
  leaves Create account disabled."""
404
404
  page.locator("input[name=firstName]").wait_for(timeout=FORM_TIMEOUT_MS)
405
405
  page.wait_for_load_state("networkidle")
406
+ first, _, last = (details.name or "").strip().partition(" ")
407
+ values = [("firstName", first), ("lastName", last or first)]
406
408
  if page.locator("ukg-input[name=phoneNumber][required=true]").count():
407
- raise Unsupported(f"{Gap.FLOW}: {PHONE_GAP}: the tenant's sign-up asks a phone number")
408
- first, _, last = name.strip().partition(" ")
409
- for field, value in (("firstName", first), ("lastName", last or first)):
409
+ values.append(("phoneNumber", details.phone or ""))
410
+ for field, value in values:
410
411
  page.locator(f"input[name={field}]").click()
411
412
  page.locator(f"input[name={field}]").press_sequentially(value)
412
- privacy = page.locator("ukg-checkbox[data-automation*=privacy]:visible")
413
- for box in privacy.all():
414
- if box.get_attribute("aria-checked") != "true":
413
+ for box in page.locator("ukg-checkbox[data-automation*=-checkbox]:visible").all():
414
+ wanted = "privacy" in (box.get_attribute("data-automation") or "")
415
+ if _ticked(box) != wanted:
415
416
  box.click()
416
417
  page.get_by_role("button", name="Create account").click()
417
418
  page.wait_for_url(lambda u: "/AuthCode/Register" not in u, timeout=FORM_TIMEOUT_MS)
418
419
 
419
420
 
421
+ def _ticked(box: Locator) -> bool:
422
+ """A `ukg-checkbox`'s state: `aria-checked` once clicked, `checked` as the page set it."""
423
+ return "true" in (box.get_attribute("aria-checked"), box.get_attribute("checked"))
424
+
425
+
420
426
  def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
421
427
  kind, field_id = raw["kind"], raw["id"]
422
428
  label = raw["label"] or field_id
@@ -17,7 +17,15 @@ from typing import Any
17
17
  from urllib.parse import urlsplit
18
18
 
19
19
  from atsfill.core import matching
20
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
20
+ from atsfill.core.adapter import (
21
+ Blocked,
22
+ Gap,
23
+ NotTaken,
24
+ Refused,
25
+ SignUpDetails,
26
+ Unsupported,
27
+ known_gap,
28
+ )
21
29
  from atsfill.core.browser import Locator, Page, PageRequest, Request, Response, Timeout
22
30
  from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
23
31
 
@@ -187,7 +195,7 @@ def _profile_hosts(request: Request, scope: str | None) -> list[str]:
187
195
  class Workday:
188
196
  name = "workday"
189
197
  per_employer = True
190
- asks_name = False
198
+ asks: set[str] = set()
191
199
  known_gaps = {
192
200
  "experience_entries": Gap.FLOW,
193
201
  "long_wizard": Gap.FLOW,
@@ -244,7 +252,7 @@ class Workday:
244
252
  _email_form(page)
245
253
  _submit(page, "signInSubmitButton", email, password)
246
254
 
247
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
255
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
248
256
  _email_form(page)
249
257
  page.locator("[data-automation-id=createAccountLink]").click()
250
258
  page.locator("[data-automation-id=verifyPassword]").fill(password)
@@ -12,7 +12,7 @@ from typing import Any
12
12
  from urllib.parse import urlsplit
13
13
 
14
14
  from atsfill.core import browser, matching
15
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
15
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, SignUpDetails, Unsupported
16
16
  from atsfill.core.browser import Locator, Page, PageRequest, Request, Response, Timeout
17
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
18
 
@@ -64,7 +64,7 @@ _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
64
64
  class WelcomeToTheJungle:
65
65
  name = "wttj"
66
66
  per_employer = False
67
- asks_name = True # the profile's first name
67
+ asks = {"name"} # the profile's first name
68
68
  known_gaps: dict[str, Gap] = {}
69
69
  side_effects = [
70
70
  SideEffect("sign_up", "hiring_system", "login --create creates the account"),
@@ -98,11 +98,11 @@ class WelcomeToTheJungle:
98
98
  def sign_in(self, page: Page, email: str, password: str) -> None:
99
99
  _account_form(page, "sign-in-form", {"email": email, "password": password}, "/sessions")
100
100
 
101
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
101
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
102
102
  """Creates the account, signed in, then waits for its confirmation link: clicking it
103
103
  signs every session out."""
104
104
  page.goto(SIGN_UP)
105
- values = {"firstname": name or "", "email": email, "password": password}
105
+ values = {"firstname": details.name or "", "email": email, "password": password}
106
106
  _account_form(page, "sign-up-form", values, "/registrations")
107
107
  raise Blocked("email_link: the sign-up sent a confirmation link")
108
108
 
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from atsfill import core
8
8
  from atsfill.adapters import adapters
9
9
  from atsfill.core import browser, paths
10
- from atsfill.core.adapter import NeedsAccount
10
+ from atsfill.core.adapter import NeedsAccount, SignUpDetails
11
11
  from atsfill.core.results import Result, SessionReport, Status, StatusReport
12
12
 
13
13
  OK = {Status.READY, Status.SUBMITTED}
@@ -105,7 +105,11 @@ def parser() -> argparse.ArgumentParser:
105
105
  action="store_true",
106
106
  help="create the account with --email and a password atsfill generates and stores",
107
107
  )
108
- login.add_argument("--name", help="the user's name, for a sign-up that asks it")
108
+ login.add_argument(
109
+ "--details",
110
+ type=details_file,
111
+ help="a JSON file of the user's sign-up details (name, phone, address), for --create",
112
+ )
109
113
  for name in ("read", "fill", "send", "status", "login", "logout"):
110
114
  sub.choices[name].add_argument(
111
115
  "-v", "--verbose", action="store_true", help="the whole result, every key, indented"
@@ -123,6 +127,13 @@ def answers_file(path: str) -> dict:
123
127
  return answers
124
128
 
125
129
 
130
+ def details_file(path: str) -> SignUpDetails:
131
+ try:
132
+ return SignUpDetails.parse(json.loads(Path(path).read_text()))
133
+ except (OSError, ValueError) as e:
134
+ raise argparse.ArgumentTypeError(str(e)) from e
135
+
136
+
126
137
  def run(args: argparse.Namespace) -> int:
127
138
  try:
128
139
  paths.config(getattr(args, "user", paths.DEFAULT_USER))
@@ -171,7 +182,7 @@ def run(args: argparse.Namespace) -> int:
171
182
  password=password,
172
183
  window=args.window,
173
184
  create=args.create,
174
- name=args.name,
185
+ details=args.details,
175
186
  **where,
176
187
  )
177
188
  except ValueError as e:
@@ -6,7 +6,13 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  from atsfill.core import browser
9
- from atsfill.core.adapter import CreatesAccount, NeedsAccount, SignsIn, Unsupported
9
+ from atsfill.core.adapter import (
10
+ CreatesAccount,
11
+ NeedsAccount,
12
+ SignsIn,
13
+ SignUpDetails,
14
+ Unsupported,
15
+ )
10
16
  from atsfill.core.browser import Page
11
17
  from atsfill.core.passwords import account_key
12
18
 
@@ -53,17 +59,16 @@ def sign_in(
53
59
  address: str,
54
60
  password: str,
55
61
  session: Path,
56
- create: bool = False,
57
- name: str | None = None,
62
+ details: SignUpDetails | None = None,
58
63
  ) -> None:
59
- """Signs in, or creates the account (with the user's `name` where it asks one), from the
64
+ """Signs in, or creates the account from the user's sign-up `details` when given, from the
60
65
  system's login page and stores the session.
61
66
  Raises what the adapter raises, without the password in it."""
62
67
  page.goto(adapter.login_url(scope))
63
68
  try:
64
- if create:
69
+ if details is not None:
65
70
  assert isinstance(adapter, CreatesAccount)
66
- adapter.sign_up(page, address, password, name)
71
+ adapter.sign_up(page, address, password, details)
67
72
  else:
68
73
  adapter.sign_in(page, address, password)
69
74
  except Exception as e:
@@ -1,5 +1,7 @@
1
+ import re
2
+ from dataclasses import dataclass, fields
1
3
  from enum import StrEnum
2
- from typing import Protocol, runtime_checkable
4
+ from typing import Protocol, Self, runtime_checkable
3
5
 
4
6
  from atsfill.core.browser import Page, Request
5
7
  from atsfill.core.results import Answer, Field, SideEffect
@@ -123,16 +125,61 @@ class SignsIn(NeedsAccount, Protocol):
123
125
  ...
124
126
 
125
127
 
128
+ ADDRESS = ("street", "city", "state", "zip", "country")
129
+ PHONE = re.compile(r"\+[1-9][0-9]{6,14}") # E.164
130
+ COUNTRY = re.compile(r"[A-Z]{2}") # ISO 3166 alpha-2
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class SignUpDetails:
135
+ """What a sign-up asks of the user besides the email and the password, from the file
136
+ `login --create --details` takes (ADR 0015). atsfill stores none of it."""
137
+
138
+ name: str | None = None
139
+ phone: str | None = None
140
+ street: str | None = None
141
+ city: str | None = None
142
+ state: str | None = None
143
+ zip: str | None = None
144
+ country: str | None = None
145
+
146
+ @classmethod
147
+ def parse(cls, data: object) -> Self:
148
+ """From `{"name", "phone", "address": {"street", "city", "state", "zip", "country"}}`,
149
+ every key optional. Raises `ValueError` naming what is wrong."""
150
+ if not isinstance(data, dict):
151
+ raise ValueError("sign-up details: expected a JSON object")
152
+ address = data.get("address", {})
153
+ if not isinstance(address, dict):
154
+ raise ValueError("sign-up details: address: expected a JSON object")
155
+ if unknown := (data.keys() - {"name", "phone", "address"}) | (address.keys() - {*ADDRESS}):
156
+ raise ValueError(f"sign-up details: unknown keys {', '.join(sorted(unknown))}")
157
+ flat = {k: v for k, v in data.items() if k != "address"} | address
158
+ for key, value in flat.items():
159
+ if not isinstance(value, str) or not value.strip():
160
+ raise ValueError(f"sign-up details: {key}: expected a non-empty string")
161
+ if "phone" in flat and not PHONE.fullmatch(flat["phone"]):
162
+ raise ValueError("sign-up details: phone: expected E.164, as +15551234567")
163
+ if "country" in flat and not COUNTRY.fullmatch(flat["country"]):
164
+ raise ValueError("sign-up details: country: expected ISO 3166 alpha-2, as US")
165
+ return cls(**flat)
166
+
167
+ def missing(self, asks: set[str]) -> list[str]:
168
+ """The details `asks` names that these lack, in the file's order."""
169
+ return [f.name for f in fields(self) if f.name in asks and getattr(self, f.name) is None]
170
+
171
+
126
172
  @runtime_checkable
127
173
  class CreatesAccount(SignsIn, Protocol):
128
174
  """An adapter atsfill can create an account through, where the system's sign-up allows it.
129
175
  Account creation is a side effect it declares (ADR 0008)."""
130
176
 
131
- asks_name: bool # the sign-up asks the user's name
177
+ asks: set[str] # the `SignUpDetails` fields its sign-up requires
132
178
 
133
- def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
179
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
134
180
  """Creates the account from the page `login_url` loaded, signed in once it returns.
135
- Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed."""
181
+ Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed. The
182
+ details in `asks` are given; optional consents the sign-up offers stay declined."""
136
183
  ...
137
184
 
138
185
 
@@ -25,6 +25,7 @@ from atsfill.core.adapter import (
25
25
  ReadsByRequest,
26
26
  Refused,
27
27
  SignsIn,
28
+ SignUpDetails,
28
29
  Unsupported,
29
30
  )
30
31
  from atsfill.core.browser import OutOfTime, Overlaid, Page, open_page
@@ -62,7 +63,7 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
62
63
 
63
64
  type Credentials = tuple[str, str] # email, password
64
65
  # Refusals the page states before any account exists: the generated password goes.
65
- NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule"}
66
+ NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
66
67
 
67
68
 
68
69
  def _email_link(reason: str | None, email: str, command: str) -> str | None:
@@ -481,7 +482,7 @@ def login(
481
482
  password: str | None = None,
482
483
  window: bool = False,
483
484
  create: bool = False,
484
- name: str | None = None,
485
+ details: SignUpDetails | None = None,
485
486
  data_dir: Path | None = None,
486
487
  ) -> SessionReport:
487
488
  """Signs in with the account's password source, headless, and stores the session; without
@@ -489,7 +490,7 @@ def login(
489
490
 
490
491
  `email` is remembered for the account; `password` is used for this sign-in only. `create`
491
492
  creates the account with a password atsfill generates and stores first (a side effect),
492
- giving `name` where the sign-up asks it; with a password already stored, the account may
493
+ giving the sign-up the user's `details`; with a password already stored, the account may
493
494
  exist, so it signs in with that password and keeps the account's email instead."""
494
495
  account, data = _account(adapter), paths.data_dir(data_dir)
495
496
  scope = _scope(account, url)
@@ -498,6 +499,8 @@ def login(
498
499
  report = SessionReport(user, adapter, scope, "missing", session)
499
500
  if create and not email:
500
501
  raise ValueError("creating an account needs --email")
502
+ if details and not create:
503
+ raise ValueError("--details goes with --create")
501
504
  try:
502
505
  kept = create and passwords.stored(mine, adapter, scope) is not None
503
506
  except passwords.PasswordError as e:
@@ -507,7 +510,7 @@ def login(
507
510
  accounts.remember(mine, adapter, scope, email)
508
511
  if create and not kept:
509
512
  assert email
510
- return _create(report, account, url, mine, (email, name), data)
513
+ return _create(report, account, url, mine, email, details or SignUpDetails(), data)
511
514
  if kept:
512
515
  report.password = "kept"
513
516
  if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
@@ -539,25 +542,28 @@ def _create(
539
542
  account: NeedsAccount,
540
543
  url: str | None,
541
544
  mine: Path,
542
- who: tuple[str, str | None],
545
+ email: str,
546
+ details: SignUpDetails,
543
547
  data: Path,
544
548
  ) -> SessionReport:
545
- """Creates the account: the generated password is stored before the click, and deleted
546
- only on a refusal the page states before any account exists."""
549
+ """Creates the account: refused before the browser opens when `details` lack one the
550
+ sign-up asks; the generated password is stored before the click, and deleted only on a
551
+ refusal the page states before any account exists."""
547
552
  if not isinstance(account, CreatesAccount):
548
553
  report.status = Status.UNSUPPORTED
549
554
  report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
550
555
  return report
551
- email, name = who
552
- if account.asks_name and not name:
553
- raise ValueError(f"creating a {account.name} account needs --name")
556
+ if missing := details.missing(account.asks):
557
+ report.status = Status.REFUSED
558
+ report.reason = f"sign_up_details: no {', '.join(missing)}"
559
+ return report
554
560
  password = passwords.generate()
555
561
  try:
556
562
  passwords.store(mine, account.name, report.scope, password)
557
563
  except passwords.PasswordError as e:
558
564
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
559
565
  return report
560
- result = _headless(data, account, report.scope, (email, password), report.path, True, name)
566
+ result = _headless(data, account, report.scope, (email, password), report.path, details)
561
567
  command = login_command(account.name, report.user, url)
562
568
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
563
569
  report.password = "stored"
@@ -575,8 +581,7 @@ def _headless(
575
581
  scope: str | None,
576
582
  credentials: Credentials,
577
583
  session: Path,
578
- create: bool = False,
579
- name: str | None = None,
584
+ details: SignUpDetails | None = None,
580
585
  ) -> Result:
581
586
  """Signs in, or creates the account, through the adapter: `ready` once the session is
582
587
  stored."""
@@ -586,7 +591,7 @@ def _headless(
586
591
  def run(_: list[str]) -> Result:
587
592
  result = Result(Status.READY, url, account.name)
588
593
  with open_page(data, None) as page, _stops(result):
589
- accounts.sign_in(page, account, scope, *credentials, session, create, name)
594
+ accounts.sign_in(page, account, scope, *credentials, session, details)
590
595
  return result
591
596
 
592
597
  return _in_budget(url, run)
@@ -96,7 +96,7 @@ share = 0.0350
96
96
  account = "to_read"
97
97
  hosting = ["hosted"]
98
98
  adapter = "successfactors"
99
- unsupported = { date = 2026-10-26, version = "0.0.3", commit = "0d8baea", by = "agent", reason = "quick_apply_sign_up: a Quick Apply page creates the account and sends the application in one submit, its sign-in offering no sign-up; sign_up_fields: a sign-up asking a phone number" }
99
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "0d8baea", by = "agent", reason = "quick_apply_sign_up: a Quick Apply page creates the account and sends the application in one submit, its sign-in offering no sign-up; sign_up_fields: a sign-up asking who may see the profile" }
100
100
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
101
101
 
102
102
  [ukg]
@@ -106,7 +106,7 @@ account = "to_read"
106
106
  hosting = ["hosted"]
107
107
  adapter = "ukg"
108
108
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
109
- unsupported = { date = 2026-10-26, version = "0.0.3", commit = "a60a17d", by = "agent", reason = "unsupported_flow: sign_up_phone: the tenant's details step after the sign-up requires a phone number, which login does not take (10 of 17 tenants)" }
109
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "a60a17d", by = "agent", reason = "unhandled on the forms the sign-up now reaches, 3 of 8 tenants ready: no gender decline box to tick (3), a State whose condition names no Country (1), a numeric answer read back differently (1)" }
110
110
 
111
111
  [paycom]
112
112
  kind = "ats"
@@ -306,7 +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
+ 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); a reCAPTCHA box on the sign-up step (1 of 17); 3 orgs signed in, under the 5 dry runs the gate needs" }
310
310
 
311
311
  [applicantpro]
312
312
  kind = "ats"
File without changes