atsfill 0.0.4.dev117__tar.gz → 0.0.4.dev118__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 (52) hide show
  1. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/pyproject.toml.orig +1 -1
  4. atsfill-0.0.4.dev118/src/atsfill/adapters/taleo.py +613 -0
  5. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/registry.toml +1 -0
  6. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/LICENSE +0 -0
  7. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/README.md +0 -0
  8. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/hiringthing.py +0 -0
  18. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/jazzhr.py +0 -0
  19. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/lever.py +0 -0
  20. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/manatal.py +0 -0
  21. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/oracle.py +0 -0
  22. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/paycor.py +0 -0
  23. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/paylocity.py +0 -0
  24. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/personio.py +0 -0
  25. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/phenom.py +0 -0
  26. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/pinpoint.py +0 -0
  27. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/recruitee.py +0 -0
  28. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/rippling.py +0 -0
  29. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/smartrecruiters.py +0 -0
  30. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/successfactors.js +0 -0
  31. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/successfactors.py +0 -0
  32. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/teamtailor.py +0 -0
  33. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/ukg.py +0 -0
  34. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/workable.py +0 -0
  35. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/workday.py +0 -0
  36. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/wttj.py +0 -0
  37. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/adapters/zohorecruit.py +0 -0
  38. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/cli.py +0 -0
  39. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/__init__.py +0 -0
  40. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/accounts.py +0 -0
  41. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/adapter.py +0 -0
  42. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/attempts.py +0 -0
  43. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/browser.py +0 -0
  44. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/commands.py +0 -0
  45. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/conditions.py +0 -0
  46. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/html.py +0 -0
  47. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/matching.py +0 -0
  48. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/passwords.py +0 -0
  49. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/paths.py +0 -0
  50. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/registry.py +0 -0
  51. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/src/atsfill/core/results.py +0 -0
  52. {atsfill-0.0.4.dev117 → atsfill-0.0.4.dev118}/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.dev117
3
+ Version: 0.0.4.dev118
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.dev117"
3
+ version = "0.0.4.dev118"
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.dev117"
3
+ version = "0.0.4.dev118"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,613 @@
1
+ """Taleo Enterprise career sections
2
+ (`<zone>.taleo.net/careersection/<section>/jobdetail.ftl?job=…`), as docs/adapters/taleo.md
3
+ observes.
4
+
5
+ Applying needs an account, one per zone: the account scope. The form is a wizard of
6
+ server-rendered pages, one in the DOM at a time, each saved before the next shows.
7
+ """
8
+
9
+ import html
10
+ import re
11
+ import secrets
12
+ import time
13
+ from contextlib import suppress
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+ from urllib.parse import parse_qs, quote, urlsplit
17
+
18
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
19
+ from atsfill.core.browser import Page, Request, Timeout
20
+ from atsfill.core.results import Answer, AnyOf, Field, FieldType, Is, OptionSet, SideEffect
21
+
22
+ HOST = re.compile(r"^(?P<zone>[a-z0-9-]+)\.taleo\.net$")
23
+ PATH = re.compile(r"^/careersection/(?P<section>[^/]+)/(jobdetail|jobapply)\.ftl$")
24
+ LOGIN_PATH = "/careersection/iam/accessmanagement/"
25
+ SECURITY_PATH = "/accessmanagement/changeQuestionsAnswers.jsf"
26
+ FLOW_PATH = "/candidateacquisition/flow.jsf"
27
+ SECURITY_QUESTION = "Which value did atsfill generate?"
28
+ FORM_TIMEOUT_MS = 30_000
29
+ SAVE_TIMEOUT_MS = 60_000
30
+ LEVEL_TIMEOUT_MS = 10_000
31
+ UPLOAD_TIMEOUT_MS = 60_000
32
+ SUBMIT_TIMEOUT_MS = 90_000
33
+ SETTLE_MS = 500
34
+ SIGN_IN_TIMEOUT_S = 30
35
+ SIGN_IN_POLL_MS = 500
36
+ MAX_GATES = 4 # pages apply can pass before the wizard: an agreement, security questions
37
+
38
+ FORM_ID = "dialogTemplate-dialogForm"
39
+ NEXT = "input[type=button][id$='-saveContinueCmdBottom']"
40
+ CONFIRM = "input[type=button][id$='-saveContinueCmdFlowPopUp']:visible"
41
+ SUBMIT = "input[type=button][value='Submit']:visible"
42
+ ACCEPT = re.compile(r"^\s*(I )?(accept|agree)", re.IGNORECASE)
43
+ FIRST_PAGE = "a[id$='-0-dtGotoPageLink']"
44
+ ERRORS_HEADING = "Errors occurred"
45
+ SKIPPED_STEPS = {"Resume Parsing"} # its upload parses a resume into the profile, not used
46
+ SKIPPED_TITLES = {"Authentication"}
47
+ SOURCE_TYPE, SOURCES = "recruitmentSourceType", "recruitmentSourceDP"
48
+ ERROR_TEXT = re.compile(r'class="[^"]*[eE]rror[^"]*"[^>]*>(.*?)</', re.DOTALL)
49
+ LOGGED = re.compile(r'id="[^"]*:Logged"[^>]*value="(true|false)"')
50
+ LOCATION_GAP = "location_levels"
51
+ DATE_PICKER_GAP = "date_picker"
52
+ YES_NO = ["Yes", "No"]
53
+ PLACEHOLDER = re.compile(r"^(not specified|select one\.*|no selection|month|year|)$", re.I)
54
+ NANP = re.compile(r"^\+?1?\D*(\d{3})\D*(\d{3})\D*(\d{4})$")
55
+ NANP_MASKED = re.compile(r"^\(\d{3}\)\d{3}-\d{4}$")
56
+
57
+ ERRORS = [
58
+ (re.compile(r"already (in use|exists|used)|is not available|already registered"),
59
+ Refused, "email_in_use"),
60
+ (re.compile(r"(user name|username) (and|or) (the )?password|does not correspond|"
61
+ r"invalid (user|credentials)"), Refused, "wrong_password"),
62
+ (re.compile(r"password"), Refused, "password_rule"),
63
+ (re.compile(r"captcha"), Blocked, "captcha"),
64
+ (re.compile(r"locked|too many"), Blocked, "rate_limited"),
65
+ ] # fmt: skip
66
+
67
+ # Each control of the current page, in page order, by how it renders. A question's radios or
68
+ # boxes are one field, named by their fieldset's legend; a month and a year select, one.
69
+ _READ = r"""() => {
70
+ const clean = s => (s || "").replace(/\s+/g, " ").trim();
71
+ const shown = e => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
72
+ const text = el => {
73
+ if (!el) return "";
74
+ const copy = el.cloneNode(true);
75
+ copy.querySelectorAll(".hidden-audible, img, .index").forEach(n => n.remove());
76
+ return clean(copy.textContent).replace(/[\s.*:]+$/, "");
77
+ };
78
+ const marked = el => !!el && (/Required/.test(el.textContent) ||
79
+ !!el.querySelector?.("img.requiredimg"));
80
+ const labelOf = id => document.querySelector(`label[for="${CSS.escape(id)}"]`);
81
+ const placeholder = t => /^(not specified|select one\.*|no selection|month|year|)$/i.test(t);
82
+ const boxLabel = b => text(b.closest("label") || labelOf(b.id));
83
+ const out = [], seen = new Set();
84
+ for (const el of document.querySelectorAll("input, select, textarea")) {
85
+ const type = (el.type || "").toLowerCase();
86
+ if (["hidden", "button", "submit", "password", "image"].includes(type)) continue;
87
+ if (/sendMeJobs|ResumeParsing/i.test(el.id)) continue;
88
+ if (/AttachedFilesBlock/.test(el.id) && !/uploadedFile$/.test(el.id)) continue;
89
+ const explanation = /questionExplanationText$/.test(el.id);
90
+ if (!shown(el) && !explanation) continue;
91
+ const set = el.closest("fieldset");
92
+ const group = set && /PossibleAnswer/.test(el.id);
93
+ if (type === "radio" || (type === "checkbox" && group)) {
94
+ const legend = set?.querySelector("legend label") || set?.querySelector("legend");
95
+ const id = legend?.getAttribute("for") || el.name;
96
+ if (seen.has(id)) continue;
97
+ seen.add(id);
98
+ const boxes = [...(set || document).querySelectorAll(`input[type=${type}]`)]
99
+ .filter(b => type === "checkbox" || b.name === el.name);
100
+ out.push({id, kind: type === "radio" ? "radio" : "checks",
101
+ label: text(legend?.querySelector(".description") || legend) || id,
102
+ required: marked(legend), options: boxes.map(boxLabel),
103
+ value: boxes.filter(b => b.checked).map(boxLabel), controls: boxes.map(b => b.id)});
104
+ continue;
105
+ }
106
+ if (el.tagName === "SELECT" && /\.(month|year)$/.test(el.id)) {
107
+ const base = el.id.replace(/\.(month|year)$/, "");
108
+ if (seen.has(base)) continue;
109
+ seen.add(base);
110
+ const month = document.getElementById(base + ".month");
111
+ const year = document.getElementById(base + ".year");
112
+ const label = labelOf(base) || el.closest("fieldset")?.querySelector("legend");
113
+ const pick = s => s && !placeholder(clean(s.selectedOptions[0]?.textContent)) ?
114
+ clean(s.selectedOptions[0].textContent) : "";
115
+ const m = pick(month), y = pick(year);
116
+ const number = m ? String(month.selectedIndex).padStart(2, "0") : "";
117
+ out.push({id: base, kind: "monthyear", required: marked(label),
118
+ label: text(label) || base.split(/[_-]/).pop(), options: [],
119
+ value: m && y ? `${number}/${y}` : ""});
120
+ continue;
121
+ }
122
+ const id = el.id;
123
+ if (!id || seen.has(id)) continue;
124
+ seen.add(id);
125
+ const label = labelOf(id);
126
+ const field = {id, label: text(label) || el.title || id, required: marked(label),
127
+ kind: el.tagName === "TEXTAREA" ? "text" : type, options: [],
128
+ value: el.value, maxLength: el.maxLength > 0 ? el.maxLength : null};
129
+ if (explanation) {
130
+ const legend = el.closest(".questionnaire")?.querySelector("fieldset legend label");
131
+ field.parent = legend?.getAttribute("for") || null;
132
+ field.kind = "text";
133
+ }
134
+ if (el.tagName === "SELECT") {
135
+ field.level = +((id.match(/Location-(\d+)$/) || [0, -1])[1]);
136
+ field.kind = field.level >= 0 ? "level" : "select";
137
+ field.options = [...el.options].map(o => clean(o.textContent)).filter(t => !placeholder(t))
138
+ .filter(t => !(/recruitmentSourceType$/.test(id) && /event ID/i.test(t)));
139
+ const chosen = clean(el.selectedOptions[0]?.textContent);
140
+ field.value = placeholder(chosen) ? "" : chosen;
141
+ } else if (type === "checkbox") {
142
+ field.value = el.checked ? "Yes" : "No";
143
+ } else if (type === "file") {
144
+ field.accept = (el.accept || "").split(",").filter(Boolean);
145
+ field.value = "";
146
+ } else if (/Phone$/i.test(id)) field.kind = "phone";
147
+ else if (["text", "email", "number", "tel", ""].includes(type)) field.kind = "text";
148
+ out.push(field);
149
+ }
150
+ for (const a of document.querySelectorAll("a[id$='.display']")) {
151
+ if (!shown(a)) continue;
152
+ const id = a.id.replace(/\.display$/, "");
153
+ const label = labelOf(id);
154
+ out.push({id, kind: "datepicker", label: text(label) || "Date", required: marked(label),
155
+ options: [], value: ""});
156
+ }
157
+ const levels = out.filter(f => f.kind === "level");
158
+ for (const f of levels) { // the section marks the levels required, not each select
159
+ const section = document.getElementById(f.id)?.closest("fieldset, table");
160
+ const mark = section?.querySelector("legend, .requiredimg");
161
+ f.required = f.required || marked(mark?.parentElement);
162
+ }
163
+ return out;
164
+ }"""
165
+
166
+ KINDS = {
167
+ "text": FieldType.TEXT,
168
+ "phone": FieldType.PHONE,
169
+ "select": FieldType.CHOICE,
170
+ "level": FieldType.CHOICE,
171
+ "radio": FieldType.CHOICE,
172
+ "checkbox": FieldType.CHOICE,
173
+ "checks": FieldType.MULTI_CHOICE,
174
+ "monthyear": FieldType.TEXT,
175
+ "file": FieldType.FILE,
176
+ "datepicker": FieldType.DATE,
177
+ }
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class Posting:
182
+ zone: str
183
+ section: str
184
+ job: str
185
+
186
+ @property
187
+ def base(self) -> str:
188
+ return f"https://{self.zone}.taleo.net/careersection/{self.section}"
189
+
190
+ @property
191
+ def detail(self) -> str:
192
+ return f"{self.base}/jobdetail.ftl?job={self.job}"
193
+
194
+ @property
195
+ def apply(self) -> str:
196
+ return f"{self.base}/jobapply.ftl?job={self.job}&lang=en"
197
+
198
+
199
+ def posting(url: str) -> Posting | None:
200
+ parts = urlsplit(url)
201
+ host = HOST.match((parts.hostname or "").lower())
202
+ path = PATH.match(parts.path)
203
+ job = parse_qs(parts.query).get("job", [""])[0]
204
+ if not host or not path or not job:
205
+ return None
206
+ return Posting(host["zone"], path["section"], job)
207
+
208
+
209
+ class Taleo:
210
+ name = "taleo"
211
+ per_employer = True
212
+ asks_name = False
213
+ known_gaps = {LOCATION_GAP: Gap.RENDERING, DATE_PICKER_GAP: Gap.RENDERING}
214
+ side_effects = [
215
+ SideEffect("sign_up", "employer", "the account is created in the employer's zone"),
216
+ SideEffect("fill", "employer", "each page is saved to a draft application"),
217
+ SideEffect("upload", "employer", "an attachment is stored with the draft"),
218
+ SideEffect("submit", "employer", "the application reaches the employer"),
219
+ ]
220
+
221
+ def __init__(self) -> None:
222
+ self._postings: dict[str, Posting] = {} # by zone, the last posting's
223
+ self._kinds: dict[str, str] = {} # each read field's rendering, by its id
224
+ self._read_back: dict[str, Answer] = {}
225
+
226
+ def detect(self, url: str, page: Page | None) -> bool:
227
+ return posting(url) is not None
228
+
229
+ def posting(self, url: str) -> str:
230
+ found = posting(url)
231
+ return found.detail if found else url
232
+
233
+ def account_scope(self, url: str) -> str | None:
234
+ found = posting(url)
235
+ if found is None:
236
+ return None
237
+ self._postings[found.zone] = found
238
+ return found.zone
239
+
240
+ def login_url(self, scope: str | None) -> str:
241
+ """The zone's sign-in, back to the last posting's form once signed in."""
242
+ if scope not in self._postings:
243
+ raise ValueError(f"no Taleo posting seen for the zone {scope!r}")
244
+ found = self._postings[scope]
245
+ return (f"https://{scope}.taleo.net{LOGIN_PATH}login.jsf?lang=en"
246
+ f"&redirectionURI={quote(found.apply, safe='')}") # fmt: skip
247
+
248
+ def signed_in(self, request: Request, scope: str | None) -> bool:
249
+ """The posting's form loads instead of redirecting to the sign-in."""
250
+ if scope not in self._postings:
251
+ return False
252
+ response = request.get(self._postings[scope].apply)
253
+ path = urlsplit(response.url).path
254
+ if not response.ok or LOGIN_PATH in path or SECURITY_PATH in path:
255
+ return False
256
+ logged = LOGGED.search(response.text()) # a privacy agreement shows to a visitor too
257
+ return logged is None or logged[1] == "true"
258
+
259
+ def sign_in(self, page: Page, email: str, password: str) -> None:
260
+ name = page.locator(f"#{FORM_ID}-login-name1")
261
+ name.wait_for(timeout=FORM_TIMEOUT_MS)
262
+ name.fill(email)
263
+ page.locator(f"#{FORM_ID}-login-password").fill(password)
264
+ page.locator(f"#{FORM_ID}-login-defaultCmd").click()
265
+ _left_sign_in(page)
266
+
267
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
268
+ page.locator(f"#{FORM_ID}-login-register").click()
269
+ page.wait_for_load_state()
270
+ accept = page.locator(f"#{FORM_ID}-StatementBeforeAuthentificationContent-ContinueButton")
271
+ if accept.count():
272
+ accept.click()
273
+ page.wait_for_load_state()
274
+ page.locator(f"#{FORM_ID}-userName").wait_for(timeout=FORM_TIMEOUT_MS)
275
+ if not page.locator(f"#{FORM_ID}-password").count():
276
+ raise Unsupported("unhandled_rendering: the zone's sign-up mails the password")
277
+ for field, value in (("userName", email), ("password", password),
278
+ ("passwordConfirm", password), ("email", email),
279
+ ("emailConfirm", email)): # fmt: skip
280
+ control = page.locator(f"#{FORM_ID}-{field}")
281
+ if control.count():
282
+ control.fill(value)
283
+ page.locator(f"#{FORM_ID}-defaultCmd").click()
284
+ _left_sign_in(page)
285
+
286
+ def browser_read(self, page: Page) -> list[Field]:
287
+ """Walks the wizard from its first page, saving each page it can: a page that does not
288
+ save with what it holds (a fresh draft's Personal Information) ends the walk, its
289
+ fields listed. Reading saves pages, as a fill does."""
290
+ self._open(page)
291
+ fields: list[Field] = []
292
+ self._kinds = {}
293
+ while not _on_review(page):
294
+ step = _step(page)
295
+ if step not in SKIPPED_STEPS:
296
+ for raw in _read(page):
297
+ field = _field(step, raw)
298
+ self._kinds[field.id] = raw["kind"]
299
+ fields.append(field)
300
+ if raw["id"].endswith(SOURCE_TYPE):
301
+ fields.append(_sources(page, step, raw))
302
+ if not _save(page):
303
+ break
304
+ return fields
305
+
306
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
307
+ """Fills each page from the first and saves it, until Review and Submit. A page that
308
+ does not save stops the fill with its messages."""
309
+ self._open(page)
310
+ self._read_back = {}
311
+ while not _on_review(page):
312
+ step = _step(page)
313
+ raws = [] if step in SKIPPED_STEPS else _read(page)
314
+ filled: set[str] = set()
315
+ for shown in (raws, _read(page) if raws else []): # a list an answer shows
316
+ for raw in sorted(shown, key=_order):
317
+ field_id = _field_id(step, raw)
318
+ if field_id in answers and field_id not in filled:
319
+ _fill(page, raw, answers[field_id])
320
+ filled.add(field_id)
321
+ for raw in _read(page) if raws else []:
322
+ value = _value(raw)
323
+ if value not in (None, "", []):
324
+ self._read_back[_field_id(step, raw)] = value
325
+ if not _save(page):
326
+ unread = any(_field_id(step, r) not in self._kinds for r in raws)
327
+ errors = "; ".join(_errors(page))
328
+ raise NotTaken(f"{step} was not saved: {errors}" + ("; read again" * unread))
329
+
330
+ def read_back(self, page: Page) -> dict[str, Answer]:
331
+ return dict(self._read_back)
332
+
333
+ def submit(self, page: Page) -> str | None:
334
+ """Clicks Review and Submit's Submit; Taleo moves to its confirmation page."""
335
+ page.locator(SUBMIT).first.click()
336
+ page.wait_for_load_state()
337
+ page.locator(SUBMIT).first.wait_for(state="detached", timeout=SUBMIT_TIMEOUT_MS)
338
+ return page.url
339
+
340
+ def _open(self, page: Page) -> None:
341
+ """The wizard's first page, from the posting's apply URL."""
342
+ found = posting(page.url) or next(reversed(self._postings.values()), None)
343
+ if found is None:
344
+ raise Unsupported("unhandled_rendering: not a Taleo posting page")
345
+ page.goto(found.apply)
346
+ page.wait_for_load_state()
347
+ for _ in range(MAX_GATES):
348
+ path = urlsplit(page.url).path
349
+ if LOGIN_PATH in path:
350
+ raise Blocked("login_wall: the form redirects to the sign-in")
351
+ if SECURITY_PATH in path:
352
+ _security_question(page)
353
+ continue
354
+ if FLOW_PATH in path or page.locator(f"{NEXT}, {SUBMIT}, {FIRST_PAGE}").count():
355
+ break
356
+ accept = page.locator("input[type=button]:visible", has_text=ACCEPT)
357
+ accept = accept if accept.count() else page.get_by_role("button", name=ACCEPT)
358
+ if not accept.count():
359
+ if PATH.match(path) and path.endswith("jobdetail.ftl"):
360
+ raise Unsupported("posting_gone: the posting does not take applications")
361
+ raise Unsupported(f"unhandled_rendering: apply lands on {path}")
362
+ accept.first.click()
363
+ page.wait_for_load_state()
364
+ first = page.locator(FIRST_PAGE)
365
+ if first.count():
366
+ first.first.click()
367
+ page.wait_for_load_state()
368
+ try:
369
+ page.locator(NEXT).first.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
370
+ except Timeout:
371
+ raise Unsupported("posting_gone: no application form on the page") from None
372
+
373
+
374
+ def _left_sign_in(page: Page) -> None:
375
+ """Waits to leave the zone's sign-in pages, or for the error they show."""
376
+ deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
377
+ said = page.locator(".errorMessage:visible, .error:visible, [role=alert]:visible")
378
+ while time.monotonic() < deadline:
379
+ path = urlsplit(page.url).path
380
+ if SECURITY_PATH in path:
381
+ _security_question(page)
382
+ continue
383
+ if LOGIN_PATH not in path:
384
+ page.wait_for_load_state()
385
+ logged = LOGGED.search(page.content())
386
+ if logged and logged[1] == "false":
387
+ text = " ".join(page.locator("body").inner_text().split())[:200]
388
+ raise Unsupported(f"unhandled_rendering: not signed in after the sign-in: {text}")
389
+ return
390
+ if said.count():
391
+ text = " ".join(t.strip() for t in said.all_inner_texts() if t.strip())
392
+ if text:
393
+ for pattern, error, code in ERRORS:
394
+ if pattern.search(text.lower()):
395
+ raise error(f"{code}: {text}")
396
+ raise Unsupported(f"unhandled_rendering: the sign-in page says {text!r}")
397
+ page.wait_for_timeout(SIGN_IN_POLL_MS)
398
+ raise Unsupported("unhandled_rendering: the sign-in page answered nothing")
399
+
400
+
401
+ def _security_question(page: Page) -> None:
402
+ """The zone's security questions, asked once signed in: each gets a question of atsfill's
403
+ and a random answer it does not keep, so a lost password goes through the email reset."""
404
+ page.wait_for_load_state()
405
+ for question in page.locator("input[id$=-question]:visible").all():
406
+ if not question.input_value():
407
+ question.fill(SECURITY_QUESTION)
408
+ for answer in page.locator("input[id$=-answer]:visible").all():
409
+ answer.fill(secrets.token_urlsafe(16))
410
+ page.locator(f"#{FORM_ID}-defaultCmd").click()
411
+ page.wait_for_url(lambda url: SECURITY_PATH not in url, timeout=FORM_TIMEOUT_MS)
412
+
413
+
414
+ def _slug(step: str) -> str:
415
+ return re.sub(r"[^a-z0-9]+", "_", step.lower()).strip("_")
416
+
417
+
418
+ def _short(dom_id: str) -> str:
419
+ """A control's DOM id without the page's generated segments."""
420
+ short = re.sub(r"^.*?-ftf-(gp-)?", "", dom_id)
421
+ short = re.sub(r"j_id_id\d+pc\d+[-:]?|page_+\d+[-:]?", "", short)
422
+ return re.sub(r"[-:]+", "-", short).strip("-")
423
+
424
+
425
+ def _field_id(step: str, raw: dict[str, Any]) -> str:
426
+ return f"{_slug(step)}/{_short(raw['id'])}"
427
+
428
+
429
+ def _field(step: str, raw: dict[str, Any]) -> Field:
430
+ kind, field_id = raw["kind"], _field_id(step, raw)
431
+ label, required = raw["label"], raw["required"]
432
+ if kind not in KINDS:
433
+ reason = f"unhandled_rendering: {field_id} is a {kind} input"
434
+ return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
435
+ if kind == "datepicker":
436
+ reason = f"{Gap.RENDERING}: {DATE_PICKER_GAP}: {field_id} is a date picker"
437
+ return Field(field_id, label, FieldType.DATE, required, unfillable=reason)
438
+ if kind == "checkbox":
439
+ return Field(field_id, label, FieldType.CHOICE, required, YES_NO, default="No")
440
+ if kind == "monthyear":
441
+ return Field(field_id, f"{label} (MM/YYYY)", FieldType.TEXT, required)
442
+ if kind == "file":
443
+ return Field(field_id, label, FieldType.FILE, required, accept=raw.get("accept") or None)
444
+ if kind == "level" and raw["level"] > 0:
445
+ if raw.get("value"): # the profile's place, its list loaded for the level above
446
+ return Field(field_id, label, FieldType.CHOICE, required, raw["options"],
447
+ prefilled=raw["value"]) # fmt: skip
448
+ reason = (f"{Gap.RENDERING}: {LOCATION_GAP}: {field_id} lists its options only once "
449
+ "the level above is picked") # fmt: skip
450
+ return Field(field_id, label, FieldType.CHOICE, required, [], unfillable=reason)
451
+ if raw.get("parent"): # a question's explanation, asked once it is answered Yes
452
+ parent = Is(f"{_slug(step)}/{_short(raw['parent'])}", ["Yes"])
453
+ return Field(field_id, label, FieldType.TEXT, False, max_length=raw.get("maxLength"),
454
+ shown_when=parent, required_when=parent) # fmt: skip
455
+ options = raw["options"] if kind in ("select", "level", "radio", "checks") else None
456
+ value = raw.get("value") or None
457
+ return Field(
458
+ field_id, label, KINDS[kind], required, options, raw.get("maxLength"),
459
+ default=value if kind in ("select", "level", "radio") else None,
460
+ ) # fmt: skip
461
+
462
+
463
+ def _sources(page: Page, step: str, raw: dict[str, Any]) -> Field:
464
+ """The sources a source type lists once picked, loaded by picking each type in turn; the
465
+ page's own pick is put back."""
466
+ source_type = page.locator(f'[id="{raw["id"]}"]')
467
+ kept = source_type.input_value()
468
+ sources = page.locator(f"select[id$='{SOURCES}']")
469
+ sets = []
470
+ for option in raw["options"]:
471
+ source_type.select_option(label=option)
472
+ page.wait_for_load_state("networkidle", timeout=SAVE_TIMEOUT_MS)
473
+ page.wait_for_timeout(SETTLE_MS)
474
+ if sources.count() and sources.first.is_visible():
475
+ names = sources.first.evaluate(
476
+ "e => [...e.options].map(o => o.textContent.replace(/\\s+/g, ' ').trim())"
477
+ )
478
+ names = [n for n in names if not PLACEHOLDER.match(n)]
479
+ if names:
480
+ sets.append(OptionSet(Is(_field_id(step, raw), [option]), names))
481
+ source_type.select_option(kept)
482
+ page.wait_for_load_state("networkidle", timeout=SAVE_TIMEOUT_MS)
483
+ field_id = f"{_slug(step)}/{_short(raw['id']).replace(SOURCE_TYPE, SOURCES)}"
484
+ shown = AnyOf([s.when for s in sets]) if sets else Is(_field_id(step, raw), [])
485
+ return Field(field_id, "Sources", FieldType.CHOICE, True, [], option_sets=sets,
486
+ shown_when=shown) # fmt: skip
487
+
488
+
489
+ def _order(raw: dict[str, Any]) -> tuple[bool, int, bool]:
490
+ """Location levels top down before the rest of the page; explanations last."""
491
+ return raw["kind"] != "level", raw.get("level", 0), bool(raw.get("parent"))
492
+
493
+
494
+ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
495
+ values = [answer] if isinstance(answer, str) else answer
496
+ control = page.locator(f'[id="{raw["id"]}"]')
497
+ match raw["kind"]:
498
+ case "select" | "level":
499
+ labels = control.evaluate("e => [...e.options].map(o => o.textContent)")
500
+ label = next((o for o in labels if " ".join(o.split()) == values[0]), None)
501
+ if label is None:
502
+ raise NotTaken(f"no option {values[0]!r} in {raw['label']}")
503
+ if raw["value"] == values[0]:
504
+ return # picking it again would reload the levels below
505
+ control.select_option(label=label.strip())
506
+ if raw["kind"] == "level":
507
+ _wait_for_level(page, raw["id"])
508
+ case "radio" | "checks":
509
+ for box_id, option in zip(raw["controls"], raw["options"], strict=True):
510
+ box = page.locator(f'[id="{box_id}"]')
511
+ if box.is_checked() != (option in values):
512
+ box.click()
513
+ page.wait_for_timeout(SETTLE_MS)
514
+ case "checkbox":
515
+ control.set_checked(values[0] == "Yes")
516
+ case "monthyear":
517
+ month, _, year = values[0].partition("/")
518
+ page.locator(f'[id="{raw["id"]}.month"]').select_option(index=int(month))
519
+ page.locator(f'[id="{raw["id"]}.year"]').select_option(label=year)
520
+ case "file":
521
+ _upload(page, raw["id"], values[0])
522
+ case "datepicker":
523
+ pass
524
+ case "phone":
525
+ control.fill(_masked(values[0]))
526
+ case _:
527
+ control.fill(values[0])
528
+
529
+
530
+ def _masked(number: str) -> str:
531
+ """A +1 number in the North American mask zones validate, `(999)999-9999`; another as given,
532
+ for the `+999999999999999` mask."""
533
+ number = number.strip()
534
+ match = NANP.match(number)
535
+ if not match or (number.startswith("+") and not number.startswith("+1")):
536
+ return number
537
+ return f"({match[1]}){match[2]}-{match[3]}"
538
+
539
+
540
+ def _value(raw: dict[str, Any]) -> Answer | None:
541
+ match raw["kind"]:
542
+ case "checks":
543
+ return raw["value"]
544
+ case "radio":
545
+ return raw["value"][0] if raw["value"] else None
546
+ case "file" | "datepicker":
547
+ return None
548
+ case "phone" if NANP_MASKED.match(raw["value"]):
549
+ return f"+1 {raw['value']}"
550
+ case _:
551
+ return raw["value"]
552
+
553
+
554
+ def _wait_for_level(page: Page, level_id: str) -> None:
555
+ """Waits for the next location level's options, where there is one."""
556
+ following = re.sub(r"-(\d+)$", lambda m: f"-{int(m[1]) + 1}", level_id)
557
+ if not page.locator(f'[id="{following}"]').count():
558
+ return
559
+ with suppress(Timeout): # a place with nothing below it
560
+ page.wait_for_function(
561
+ "id => (document.getElementById(id)?.options.length || 0) > 1",
562
+ arg=following, timeout=LEVEL_TIMEOUT_MS,
563
+ ) # fmt: skip
564
+
565
+
566
+ def _upload(page: Page, input_id: str, path: str) -> None:
567
+ """Attaches the file with the page's Attach button, then waits for it in the list."""
568
+ name = path.rsplit("/", 1)[-1]
569
+ page.locator(f'[id="{input_id}"]').set_input_files(path)
570
+ page.locator("input[type=button][id$='-attachFileCommand']").first.click()
571
+ page.wait_for_load_state()
572
+ page.get_by_text(name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
573
+
574
+
575
+ def _read(page: Page) -> list[dict[str, Any]]:
576
+ page.wait_for_timeout(SETTLE_MS)
577
+ return page.evaluate(_READ)
578
+
579
+
580
+ def _step(page: Page) -> str:
581
+ """The current page's title, the wizard step it is."""
582
+ titles = [" ".join(h.split()) for h in page.locator("h1").all_inner_texts()]
583
+ titles = [t for t in titles if t and t not in SKIPPED_TITLES and ERRORS_HEADING not in t]
584
+ return titles[-1] if titles else "page" # after a site's own heading
585
+
586
+
587
+ def _on_review(page: Page) -> bool:
588
+ return page.locator(SUBMIT).count() > 0
589
+
590
+
591
+ def _errors(page: Page) -> list[str]:
592
+ """The messages a page that did not save lists, each in an element of an error class."""
593
+ found = [
594
+ " ".join(html.unescape(re.sub(r"<[^>]+>", " ", m)).split())
595
+ for m in ERROR_TEXT.findall(page.content())
596
+ ]
597
+ said = [t for t in dict.fromkeys(found) if t and not t.startswith(ERRORS_HEADING)]
598
+ return said[:5] or ["the page shows errors"]
599
+
600
+
601
+ def _save(page: Page) -> bool:
602
+ """Presses Save and Continue; whether the page saved and moved on."""
603
+ before = _step(page)
604
+ page.locator(NEXT).first.click()
605
+ page.wait_for_load_state()
606
+ confirm = page.locator(CONFIRM)
607
+ if confirm.count():
608
+ confirm.first.click()
609
+ page.wait_for_load_state()
610
+ page.wait_for_load_state("networkidle", timeout=SAVE_TIMEOUT_MS)
611
+ if page.locator(f"h1:has-text('{ERRORS_HEADING}')").count():
612
+ return False
613
+ return _on_review(page) or _step(page) != before
@@ -196,6 +196,7 @@ share = 0.0125
196
196
  account = "to_read"
197
197
  hosting = ["hosted"]
198
198
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
199
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "284a5e5", by = "agent", reason = "unsupported_rendering: location_levels: a place's state and city list their options only once the level above is picked, so a fresh profile's are not listed (3 of 4 zones walked)" }
199
200
 
200
201
  [jazzhr]
201
202
  kind = "ats"
File without changes
File without changes