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.
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/README.md +1 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev150/src/atsfill/adapters/jobaffinity.py +455 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/registry.toml +7 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/LICENSE +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev147 → atsfill-0.0.5.dev150}/src/atsfill/core/telemetry.py +0 -0
- {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.
|
|
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
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
82
82
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
83
83
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
85
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
85
86
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
86
87
|
[](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
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
53
53
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
54
54
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
56
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
56
57
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
57
58
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|