atsfill 0.0.5.dev147__tar.gz → 0.0.5.dev150__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 (60) hide show
  1. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/README.md +1 -0
  3. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.5.dev150/src/atsfill/adapters/jobaffinity.py +455 -0
  7. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/registry.toml +7 -0
  8. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/LICENSE +0 -0
  9. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/gupy.py +0 -0
  17. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/isolvedhire.py +0 -0
  21. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/jazzhr.py +0 -0
  22. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/join.py +0 -0
  23. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/lever.py +0 -0
  24. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/manatal.py +0 -0
  25. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/oracle.py +0 -0
  26. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/paycor.py +0 -0
  27. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/paylocity.py +0 -0
  28. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/personio.py +0 -0
  29. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/phenom.py +0 -0
  30. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/pinpoint.py +0 -0
  31. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/recruitee.py +0 -0
  32. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/rippling.py +0 -0
  33. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/smartrecruiters.py +0 -0
  34. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/successfactors.js +0 -0
  35. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/successfactors.py +0 -0
  36. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/taleo.py +0 -0
  37. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/teamtailor.py +0 -0
  38. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/ukg.py +0 -0
  39. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/workable.py +0 -0
  40. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/workday.py +0 -0
  41. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/wttj.py +0 -0
  42. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/zohorecruit.py +0 -0
  43. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/cli.py +0 -0
  44. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/__init__.py +0 -0
  45. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/accounts.py +0 -0
  46. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/adapter.py +0 -0
  47. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/attempts.py +0 -0
  48. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/browser.py +0 -0
  49. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/commands.py +0 -0
  50. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/conditions.py +0 -0
  51. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/html.py +0 -0
  52. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/matching.py +0 -0
  53. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/otp.py +0 -0
  54. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/passwords.py +0 -0
  55. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/paths.py +0 -0
  56. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/registry.py +0 -0
  57. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/results.py +0 -0
  58. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/snapshot.py +0 -0
  59. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/telemetry.py +0 -0
  60. {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.5.dev147
3
+ Version: 0.0.5.dev150
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
@@ -81,6 +81,7 @@ It is built for agents with a shell: every command prints one JSON document and
81
81
  [![eightfold run history](https://shbernal.github.io/atsfill/run-history/eightfold.svg)](https://shbernal.github.io/atsfill/run-history/)
82
82
  [![pinpoint run history](https://shbernal.github.io/atsfill/run-history/pinpoint.svg)](https://shbernal.github.io/atsfill/run-history/)
83
83
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
84
+ [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
84
85
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
85
86
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
86
87
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -52,6 +52,7 @@ It is built for agents with a shell: every command prints one JSON document and
52
52
  [![eightfold run history](https://shbernal.github.io/atsfill/run-history/eightfold.svg)](https://shbernal.github.io/atsfill/run-history/)
53
53
  [![pinpoint run history](https://shbernal.github.io/atsfill/run-history/pinpoint.svg)](https://shbernal.github.io/atsfill/run-history/)
54
54
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
55
+ [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
55
56
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
56
57
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
57
58
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev147"
3
+ version = "0.0.5.dev150"
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.5.dev147"
3
+ version = "0.0.5.dev150"
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"
@@ -9,6 +9,7 @@ from atsfill.adapters.hireology import Hireology
9
9
  from atsfill.adapters.hiringthing import HiringThing
10
10
  from atsfill.adapters.isolvedhire import IsolvedHire
11
11
  from atsfill.adapters.jazzhr import JazzHR
12
+ from atsfill.adapters.jobaffinity import JobAffinity
12
13
  from atsfill.adapters.join import Join
13
14
  from atsfill.adapters.lever import Lever
14
15
  from atsfill.adapters.manatal import Manatal
@@ -39,6 +40,7 @@ def adapters() -> list[Adapter]:
39
40
  Greenhouse(),
40
41
  WelcomeToTheJungle(),
41
42
  Join(),
43
+ JobAffinity(),
42
44
  SmartRecruiters(),
43
45
  Oracle(),
44
46
  Manatal(),
@@ -0,0 +1,455 @@
1
+ """JobAffinity (`jobaffinity.fr/apply/<token>`), as docs/adapters/jobaffinity.md observes.
2
+
3
+ The apply page is rendered on the server in one of two generations: the older form
4
+ (`form#identity`, its documents in forms of their own) and the newer one
5
+ (`form.candidate-application-form`). The older one may have a questionnaire page after it, also
6
+ rendered on the server and reachable by its URL before the first page is posted. `request_read`
7
+ fetches both pages over HTTP, `browser_read` takes them from the browser, and both list the
8
+ fields with one parser. The dry run fills the questionnaire in a second tab; a send posts the
9
+ first page, then fills and posts the questionnaire the server answers with.
10
+ """
11
+
12
+ import json
13
+ import re
14
+ import urllib.request
15
+ from dataclasses import dataclass, field
16
+ from urllib.parse import parse_qs, urljoin, urlsplit
17
+
18
+ from atsfill.core import html
19
+ from atsfill.core.adapter import Gap, Unsupported, known_gap
20
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
21
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
22
+
23
+ ORIGIN = "https://jobaffinity.fr"
24
+ HOST = re.compile(r"^(?:www\.)?jobaffinity\.fr$")
25
+ APPLY = re.compile(r"^/apply/([a-z0-9]+)/?$")
26
+ OLD = "form#identity"
27
+ NEW = "form.candidate-application-form"
28
+ QUESTIONNAIRE = "form#questionnaire"
29
+ APPLIED = "/application_form/submission/candidate_application"
30
+ FORM_TIMEOUT_MS = 30_000
31
+ UPLOAD_TIMEOUT_MS = 30_000
32
+ ANALYSIS_TIMEOUT_MS = 30_000
33
+ SUBMIT_TIMEOUT_MS = 90_000
34
+ YES_NO = ["Yes", "No"]
35
+ CONSENT = "gdpr_agreement"
36
+ AVAILABILITY, AVAILABLE_FROM, FROM_DATE = "availability", "availability_date", "date"
37
+ DMY = "JJ/MM/AAAA" # the hint under the older form's text date input
38
+ KINDS = {
39
+ "text": FieldType.TEXT,
40
+ "number": FieldType.TEXT,
41
+ "textarea": FieldType.TEXT,
42
+ "date": FieldType.DATE,
43
+ "select": FieldType.CHOICE,
44
+ "radio": FieldType.CHOICE,
45
+ }
46
+ # A rule of the page's own stylesheet that hides the elements of a class.
47
+ HIDDEN_CLASS = re.compile(r"\.([\w-]+)\s*\{\s*display:\s*none;?\s*\}")
48
+ # The classes JobAffinity's stylesheet hides on every page: `none` is a newer form's hidden row.
49
+ HIDDEN = {"experience-field", "none"}
50
+ NUMBER = re.compile(r"-?\d+(?:[.,]\d+)?")
51
+
52
+ # What each control holds, by name: a value, a ticked radio's or box's id, a document's names.
53
+ _READ_BACK = """(form, fields) => {
54
+ const out = {};
55
+ for (const [id, kind] of fields) {
56
+ if (kind === "old_file") {
57
+ out[id] = [...document.querySelectorAll(`#js-${id}-form .js-newcandidate__uploaded__name`)]
58
+ .map(e => e.textContent.trim()).filter(Boolean);
59
+ } else if (kind === "new_file") {
60
+ const kept = form.querySelector(`input[type=hidden][name="${id}"]`);
61
+ out[id] = JSON.parse(kept?.value || "[]").map(f => f.filename);
62
+ } else {
63
+ const els = [...form.querySelectorAll(`[name="${id}"]`)];
64
+ if (kind === "radio") out[id] = els.find(e => e.checked)?.id ?? "";
65
+ else if (kind === "checkbox") out[id] = els[0]?.checked ? "Yes" : "No";
66
+ else out[id] = els[0]?.value ?? "";
67
+ }
68
+ }
69
+ return out;
70
+ }"""
71
+
72
+
73
+ @dataclass
74
+ class _Control:
75
+ field: Field
76
+ kind: str # text | number | select | radio | checkbox | dmy | old_file | new_file
77
+ options: dict[str, str] = field(default_factory=dict) # option text by value, or by radio id
78
+ questionnaire: bool = False
79
+
80
+
81
+ @dataclass
82
+ class _Form:
83
+ new: bool
84
+ controls: list[_Control]
85
+ questionnaire_url: str | None = None # the older form's second page
86
+ new_questionnaire: bool = False # the newer form's questionnaire step
87
+
88
+
89
+ class JobAffinity:
90
+ name = "jobaffinity"
91
+ known_gaps = {"questionnaire_step": Gap.FLOW}
92
+ side_effects = [
93
+ SideEffect("upload", "hiring_system", "a document is uploaded to JobAffinity at once"),
94
+ SideEffect("submit", "employer", "the application reaches the employer"),
95
+ ]
96
+
97
+ def __init__(self) -> None:
98
+ self._form = _Form(False, [])
99
+ self._answers: dict[str, Answer] = {}
100
+ self._questions: Page | None = None # the questionnaire's tab, in a dry run
101
+
102
+ def detect(self, url: str, page: Page | None) -> bool:
103
+ return _token(url) is not None
104
+
105
+ def posting(self, url: str) -> str:
106
+ token = _token(url)
107
+ return f"{ORIGIN}/apply/{token}" if token else url
108
+
109
+ def request_read(self, url: str) -> list[Field] | None:
110
+ if _token(url) is None:
111
+ return None
112
+ try:
113
+ form = _parse(_get(self.posting(url)), _get)
114
+ except OSError, ValueError:
115
+ return None
116
+ if form is None or form.new_questionnaire:
117
+ return None
118
+ return [c.field for c in form.controls]
119
+
120
+ def browser_read(self, page: Page) -> list[Field]:
121
+ try:
122
+ page.locator(f"{OLD}, {NEW}").first.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
123
+ except Timeout:
124
+ raise Unsupported("posting_gone: no application form on the page") from None
125
+ form = _parse(page.content(), lambda url: page.evaluate(_FETCH, url))
126
+ assert form is not None
127
+ if form.new_questionnaire:
128
+ detail = "the newer form's questionnaire step, after a validation of the first"
129
+ raise Unsupported(known_gap(self, "questionnaire_step", detail))
130
+ self._form = form
131
+ return [c.field for c in form.controls]
132
+
133
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
134
+ self._answers = answers
135
+ shown = [c for c in self._form.controls if c.field.id in answers and not c.field.unfillable]
136
+ for c in shown: # documents first: the older form's CV analysis fills only empty fields
137
+ if c.kind in ("old_file", "new_file"):
138
+ _attach(page, c, _one(answers[c.field.id]))
139
+ if any(c.kind == "old_file" for c in shown):
140
+ page.locator("#newcandidate__analyze__progress").wait_for(
141
+ state="hidden", timeout=ANALYSIS_TIMEOUT_MS
142
+ )
143
+ form = page.locator(NEW if self._form.new else OLD)
144
+ for c in shown:
145
+ if not c.questionnaire and c.kind not in ("old_file", "new_file"):
146
+ _set(form, c, answers[c.field.id])
147
+ if self._form.questionnaire_url and any(c.questionnaire for c in shown):
148
+ self._questions = page.context.new_page()
149
+ self._questions.goto(self._form.questionnaire_url)
150
+ questions = self._questions.locator(QUESTIONNAIRE)
151
+ for c in shown:
152
+ if c.questionnaire:
153
+ _set(questions, c, answers[c.field.id])
154
+
155
+ def read_back(self, page: Page) -> dict[str, Answer]:
156
+ main = [c for c in self._form.controls if not c.questionnaire]
157
+ values = _values(page, NEW if self._form.new else OLD, main)
158
+ if self._questions:
159
+ asked = [c for c in self._form.controls if c.questionnaire]
160
+ values |= _values(self._questions, QUESTIONNAIRE, asked)
161
+ return values
162
+
163
+ def submit(self, page: Page) -> str | None:
164
+ """Posts the form; the older one's questionnaire, when the server answers with it, is
165
+ filled again with the dry run's answers and posted. The evidence is the last post's
166
+ answer."""
167
+ if self._questions:
168
+ self._questions.close()
169
+ self._questions = None
170
+ if self._form.new:
171
+ with page.expect_response(
172
+ lambda r: r.request.method == "POST" and urlsplit(r.url).path == APPLIED,
173
+ timeout=SUBMIT_TIMEOUT_MS,
174
+ ) as applied:
175
+ page.locator(f"{NEW} .js-cap-submit-actions [type=submit]").click()
176
+ answer = applied.value.json()
177
+ if not answer.get("ok"):
178
+ raise RuntimeError(f"apply answered {json.dumps(answer.get('errors'))[:200]}")
179
+ return f"{applied.value.url} {applied.value.status}"
180
+ with page.expect_navigation(timeout=SUBMIT_TIMEOUT_MS):
181
+ page.locator(f"{OLD} [type=submit]").click()
182
+ if page.locator(OLD).count():
183
+ raise RuntimeError(f"the first page was not taken: {_errors(page)}")
184
+ if page.locator(QUESTIONNAIRE).count():
185
+ questions = page.locator(QUESTIONNAIRE)
186
+ for c in self._form.controls:
187
+ if c.questionnaire and c.field.id in self._answers:
188
+ _set(questions, c, self._answers[c.field.id])
189
+ with page.expect_navigation(timeout=SUBMIT_TIMEOUT_MS):
190
+ questions.locator("[type=submit]").click()
191
+ if page.locator(QUESTIONNAIRE).count():
192
+ raise RuntimeError(f"the questionnaire was not taken: {_errors(page)}")
193
+ return page.url
194
+
195
+
196
+ # A page's HTML fetched from inside it, so it is the browser's session that asks.
197
+ _FETCH = "url => fetch(url, {credentials: 'include'}).then(r => r.text())"
198
+
199
+
200
+ def _token(url: str) -> str | None:
201
+ parts = urlsplit(url)
202
+ path = APPLY.match(parts.path)
203
+ return path[1] if HOST.match(parts.hostname or "") and path else None
204
+
205
+
206
+ def _get(url: str) -> str:
207
+ request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
208
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
209
+ return response.read().decode()
210
+
211
+
212
+ def _parse(source: str, fetch) -> _Form | None:
213
+ """The apply page's fields, the older form's questionnaire fetched by `fetch` when its
214
+ stepper names a page after the first."""
215
+ page = html.parse(source)
216
+ nodes = page.walk()
217
+ hidden = HIDDEN | {
218
+ m[1] for n in nodes if n.tag == "style" for m in HIDDEN_CLASS.finditer(n.text())
219
+ }
220
+ labels = {n.attrs["for"]: n for n in nodes if n.tag == "label" and "for" in n.attrs}
221
+ new = next(
222
+ (n for n in nodes if n.tag == "form" and _has(n, "candidate-application-form")), None
223
+ )
224
+ if new is not None:
225
+ files = [
226
+ _new_file(n, labels) for n in new.walk() if "data-form-field-name" in n.attrs
227
+ ] # fmt: skip
228
+ panel = new.closest(lambda n: "data-cap-wizard" in n.attrs)
229
+ return _Form(
230
+ True,
231
+ files + _controls(new, labels, hidden),
232
+ new_questionnaire=panel is not None and panel.attrs["data-cap-wizard"] != "0",
233
+ )
234
+ old = next((n for n in nodes if n.tag == "form" and n.attrs.get("id") == "identity"), None)
235
+ if old is None:
236
+ return None
237
+ files = [
238
+ _old_file(n)
239
+ for n in nodes
240
+ if n.tag == "form" and re.fullmatch(r"js-\w+-form", n.attrs.get("id", ""))
241
+ ]
242
+ form = _Form(False, files + _controls(old, labels, hidden))
243
+ stepper = page.find(lambda n: n.attrs.get("id") == "stabilo-haut")
244
+ if stepper and stepper.find(lambda n: _has(n, "step-separator")):
245
+ form.questionnaire_url = _questionnaire_url(old)
246
+ questions = html.parse(fetch(form.questionnaire_url))
247
+ asked = questions.find(lambda n: n.tag == "form" and n.attrs.get("id") == "questionnaire")
248
+ if asked is not None:
249
+ q_labels = {
250
+ n.attrs["for"]: n for n in asked.walk() if n.tag == "label" and "for" in n.attrs
251
+ }
252
+ for c in _controls(asked, q_labels, HIDDEN):
253
+ c.questionnaire = True
254
+ form.controls.append(c)
255
+ return form
256
+
257
+
258
+ def _questionnaire_url(form: html.Node) -> str:
259
+ """The older form's questionnaire, by its wizard, job and session."""
260
+ values = {
261
+ n.attrs.get("name"): n.attrs.get("value", "") for n in form.walk() if n.tag == "input"
262
+ }
263
+ session = parse_qs(urlsplit(form.attrs.get("action", "")).query).get("session_id", [""])[0]
264
+ path = f"/newcandidate/questionnaire/{values['wizard_id']}/{values['job_id']}"
265
+ return urljoin(ORIGIN, f"{path}?session_id={session}&ui=False")
266
+
267
+
268
+ def _controls(form: html.Node, labels: dict[str, html.Node], hidden: set[str]) -> list[_Control]:
269
+ """Each named control of the form, a radio group once; those its page's styles hide left out."""
270
+ out: list[_Control] = []
271
+ seen: set[str] = set()
272
+ for node in form.walk():
273
+ name = node.attrs.get("name")
274
+ if node.tag not in ("input", "select", "textarea") or not name or name in seen:
275
+ continue
276
+ kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
277
+ if kind in ("hidden", "submit", "file", "button"):
278
+ continue
279
+ # the date's row is hidden until its availability shows it
280
+ if _hidden(node, hidden - {"none"} if name == AVAILABLE_FROM else hidden):
281
+ continue
282
+ seen.add(name)
283
+ row = node.closest(lambda n: _has(n, "form-row-wrapper") or _has(n, "form-group"))
284
+ own = labels.get(node.attrs.get("id", ""))
285
+ if kind in ("radio", "checkbox") and name != CONSENT:
286
+ block = node.closest(lambda n: _has(n, "questionnaire-question-block"))
287
+ group = block.find(lambda n: _has(n, "input-control-label")) if block else None
288
+ own = group
289
+ label = _label(own) if own is not None else name
290
+ required = own is not None and _starred(own)
291
+ if name == CONSENT and kind == "checkbox":
292
+ f = Field(name, label, FieldType.CHOICE, True, YES_NO)
293
+ out.append(_Control(f, "checkbox"))
294
+ continue
295
+ if kind not in KINDS or "multiple" in node.attrs:
296
+ reason = f"unhandled_rendering: {name} is a {kind}"
297
+ out.append(
298
+ _Control(Field(name, label, FieldType.TEXT, required, unfillable=reason), kind)
299
+ )
300
+ continue
301
+ options: dict[str, str] = {}
302
+ if kind == "select":
303
+ options = {
304
+ o.attrs["value"]: " ".join(o.text().split())
305
+ for o in node.walk()
306
+ if o.tag == "option" and o.attrs.get("value")
307
+ }
308
+ elif kind == "radio":
309
+ options = {
310
+ r.attrs["id"]: _label(labels[r.attrs["id"]])
311
+ for r in form.walk()
312
+ if r.tag == "input" and r.attrs.get("name") == name and r.attrs.get("id") in labels
313
+ }
314
+ field_type = KINDS[kind]
315
+ hint = row.find(lambda n: _has(n, "form-text")) if row else None
316
+ dmy = kind == "text" and hint is not None and DMY in hint.text()
317
+ if dmy:
318
+ field_type = FieldType.DATE
319
+ elif re.search("phone", name, re.IGNORECASE):
320
+ field_type = FieldType.PHONE
321
+ shown_when = None
322
+ if name == AVAILABLE_FROM:
323
+ shown_when = Is(
324
+ AVAILABILITY, [t for v, t in _availability(form).items() if v == FROM_DATE]
325
+ )
326
+ max_length = int(node.attrs.get("maxlength", 0)) or None
327
+ f = Field(
328
+ name,
329
+ label,
330
+ field_type,
331
+ required,
332
+ list(options.values()) or None,
333
+ max_length if field_type in (FieldType.TEXT, FieldType.PHONE) else None,
334
+ shown_when=shown_when,
335
+ )
336
+ kept = "dmy" if dmy else kind if kind in ("radio", "select", "number") else "text"
337
+ out.append(_Control(f, kept, options))
338
+ return out
339
+
340
+
341
+ def _hidden(node: html.Node, classes: set[str]) -> bool:
342
+ return node.closest(lambda n: any(c in classes for c in n.classes())) is not None
343
+
344
+
345
+ def _availability(form: html.Node) -> dict[str, str]:
346
+ select = form.find(lambda n: n.tag == "select" and n.attrs.get("name") == AVAILABILITY)
347
+ if select is None:
348
+ return {}
349
+ return {
350
+ o.attrs["value"]: " ".join(o.text().split())
351
+ for o in select.walk()
352
+ if o.tag == "option" and o.attrs.get("value")
353
+ }
354
+
355
+
356
+ def _old_file(form: html.Node) -> _Control:
357
+ """A document of the older form, in a form of its own: `js-cv-form` is `cv`."""
358
+ name = form.attrs["id"].removeprefix("js-").removesuffix("-form")
359
+ label = form.find(lambda n: n.tag == "label" and _has(n, "description"))
360
+ text = _label(label) if label is not None else name
361
+ required = label is not None and _starred(label)
362
+ return _Control(Field(name, text, FieldType.FILE, required), "old_file")
363
+
364
+
365
+ def _new_file(node: html.Node, labels: dict[str, html.Node]) -> _Control:
366
+ """A document of the newer form: an upload widget named by `data-form-field-name`."""
367
+ name = node.attrs["data-form-field-name"]
368
+ label = labels.get(f"_field-{name}")
369
+ text = _label(label) if label is not None else name
370
+ required = label is not None and _starred(label)
371
+ return _Control(Field(name, text, FieldType.FILE, required), "new_file")
372
+
373
+
374
+ def _has(node: html.Node, cls: str) -> bool:
375
+ return cls in node.classes()
376
+
377
+
378
+ def _label(node: html.Node) -> str:
379
+ """A label's own text, without its required marker and its help popup."""
380
+ parts = (
381
+ c
382
+ for c in node.children
383
+ if isinstance(c, str) or c.tag not in ("span", "div", "script", "button")
384
+ )
385
+ return " ".join("".join(c if isinstance(c, str) else c.text() for c in parts).split())
386
+
387
+
388
+ def _starred(node: html.Node) -> bool:
389
+ """A required marker, which the newer form renders hidden (`required none`) when not."""
390
+ return any(
391
+ isinstance(c, html.Node) and _has(c, "required") and not _has(c, "none")
392
+ for c in node.children
393
+ )
394
+
395
+
396
+ def _one(answer: Answer) -> str:
397
+ return answer if isinstance(answer, str) else answer[0]
398
+
399
+
400
+ def _set(form, c: _Control, answer: Answer) -> None:
401
+ """Puts an answer in its control, inside the form locator `form`."""
402
+ value = _one(answer)
403
+ control = form.locator(f"[name='{c.field.id}']")
404
+ match c.kind:
405
+ case "select":
406
+ by_text = {t: v for v, t in c.options.items()}
407
+ control.select_option(value=by_text.get(value, value))
408
+ case "radio":
409
+ radio = next((i for i, t in c.options.items() if t == value), value)
410
+ form.locator(f"label[for='{radio}']").click()
411
+ case "checkbox": # under its label, whose notice link takes a click without ticking it
412
+ if control.is_checked() != (value == "Yes"):
413
+ control.evaluate("box => box.click()")
414
+ case "number" if not NUMBER.fullmatch(value.strip()):
415
+ pass # the input takes no text: left empty, it reads back as a mismatch
416
+ case "dmy":
417
+ year, month, day = value.split("-")
418
+ control.fill(f"{day}/{month}/{year}")
419
+ case _:
420
+ control.fill(value)
421
+
422
+
423
+ def _attach(page: Page, c: _Control, path: str) -> None:
424
+ """Attaches a document and waits for JobAffinity's upload to list its name."""
425
+ name = path.rsplit("/", 1)[-1]
426
+ if c.kind == "old_file":
427
+ page.locator(f"#js-{c.field.id}-form input[type=file]").set_input_files(path)
428
+ shown = page.locator(f"#js-{c.field.id}-form .js-newcandidate__uploaded__name")
429
+ else:
430
+ widget = page.locator(f".js-file-upload__{c.field.id}")
431
+ widget.locator("input[type=file]").set_input_files(path)
432
+ shown = widget.locator(".file-item")
433
+ shown.filter(has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
434
+
435
+
436
+ def _values(page: Page, form: str, controls: list[_Control]) -> dict[str, Answer]:
437
+ kinds = [[c.field.id, c.kind] for c in controls if not c.field.unfillable]
438
+ values = page.locator(form).evaluate(_READ_BACK, kinds)
439
+ out: dict[str, Answer] = {}
440
+ for c in controls:
441
+ value = values.get(c.field.id)
442
+ if c.kind in ("old_file", "new_file"):
443
+ value = value[-1] if value else None
444
+ elif c.kind in ("select", "radio"):
445
+ value = c.options.get(value, value)
446
+ elif c.kind == "dmy" and value and (d := re.fullmatch(r"(\d\d)/(\d\d)/(\d{4})", value)):
447
+ value = f"{d[3]}-{d[2]}-{d[1]}"
448
+ if value not in (None, ""):
449
+ out[c.field.id] = value
450
+ return out
451
+
452
+
453
+ def _errors(page: Page) -> str:
454
+ shown = page.locator(".error, .small-error:visible, .invalid-feedback:visible")
455
+ return " ".join(" ".join(shown.all_inner_texts()).split())[:200] or "no error shown"
@@ -396,3 +396,10 @@ account = "none"
396
396
  kind = "aggregator"
397
397
  share = 0.0024
398
398
  account = "none"
399
+
400
+ [jobaffinity]
401
+ kind = "ats"
402
+ account = "none"
403
+ hosting = ["hosted"]
404
+ adapter = "jobaffinity"
405
+ scope = { is = "supported", date = 2026-10-28, commit = "02939fd" }
File without changes