atsfill 0.0.4.dev92__tar.gz → 0.0.4.dev93__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.4.dev92 → atsfill-0.0.4.dev93}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/eightfold.py +41 -47
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/teamtailor.py +21 -16
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/adapter.py +10 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/commands.py +59 -8
- atsfill-0.0.4.dev93/src/atsfill/core/conditions.py +92 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/matching.py +19 -4
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/results.py +26 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/LICENSE +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/README.md +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev92 → atsfill-0.0.4.dev93}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev93
|
|
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
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
the employer's own domain), as docs/adapters/eightfold.md observes.
|
|
3
3
|
|
|
4
4
|
A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
|
|
5
|
-
sections of questions by id, type, requirement and options, and the questions
|
|
6
|
-
|
|
7
|
-
and both list the fields from it. Each question renders as a control whose element id
|
|
8
|
-
section's title and its id, with every other character an underscore.
|
|
5
|
+
sections of questions by id, type, requirement and options, and the questions whose answer
|
|
6
|
+
shows each follow-up. `read_api` fetches it over HTTP, `read_form` through the page's browser
|
|
7
|
+
context, and both list the fields from it. Each question renders as a control whose element id
|
|
8
|
+
is its section's title and its id, with every other character an underscore.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import http.cookiejar
|
|
@@ -13,15 +13,15 @@ import json
|
|
|
13
13
|
import re
|
|
14
14
|
import urllib.request
|
|
15
15
|
from contextlib import suppress
|
|
16
|
-
from dataclasses import dataclass,
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
17
|
from datetime import date, datetime
|
|
18
18
|
from typing import Any
|
|
19
19
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
20
20
|
|
|
21
|
-
from atsfill.core import browser
|
|
21
|
+
from atsfill.core import browser, conditions
|
|
22
22
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
23
23
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
24
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
24
|
+
from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
|
|
25
25
|
|
|
26
26
|
HOST = re.compile(r"^[\w-]+\.eightfold\.ai$")
|
|
27
27
|
JOB = re.compile(r"^/careers/job/(\d+)")
|
|
@@ -33,7 +33,6 @@ CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
|
|
|
33
33
|
SUBMIT = "[data-test-id=submitApplicationButton]"
|
|
34
34
|
AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
|
|
35
35
|
PICKER = "[data-testid=picker-dialog]"
|
|
36
|
-
MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
|
|
37
36
|
FORM_TIMEOUT_MS = 30_000
|
|
38
37
|
SHOWN_TIMEOUT_MS = 2_000
|
|
39
38
|
OPTIONS_TIMEOUT_MS = 5_000
|
|
@@ -131,8 +130,6 @@ class _Control:
|
|
|
131
130
|
field: Field
|
|
132
131
|
kind: str
|
|
133
132
|
ends: list[str] # what the control's element id ends with
|
|
134
|
-
# the questions whose answer renders it, each with the answers that do (any when empty)
|
|
135
|
-
parents: list[tuple[str, list[str]]] = field(default_factory=list)
|
|
136
133
|
resume: bool = False
|
|
137
134
|
dom: str | None = None
|
|
138
135
|
|
|
@@ -152,6 +149,7 @@ class Eightfold:
|
|
|
152
149
|
|
|
153
150
|
def __init__(self) -> None:
|
|
154
151
|
self._controls: list[_Control] = [] # in the form's order
|
|
152
|
+
self._fields: list[Field] = []
|
|
155
153
|
|
|
156
154
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
157
155
|
if _pid(url) is None:
|
|
@@ -210,14 +208,14 @@ class Eightfold:
|
|
|
210
208
|
control.dom = doms.get(control.field.id)
|
|
211
209
|
if control.field.unfillable:
|
|
212
210
|
continue
|
|
213
|
-
if control.dom is None and not control.
|
|
211
|
+
if control.dom is None and not control.field.shown_when:
|
|
214
212
|
reason = f"unhandled_rendering: {control.field.id} is not on the page"
|
|
215
213
|
control.field = replace(control.field, unfillable=reason)
|
|
216
214
|
elif control.dom is not None and _prefilled(page, control):
|
|
217
215
|
control.field = replace(control.field, prefilled=True)
|
|
218
|
-
|
|
216
|
+
self._fields = [c.field for c in self._controls]
|
|
219
217
|
self._controls = [c for c in self._controls if not c.field.unfillable]
|
|
220
|
-
return
|
|
218
|
+
return self._fields
|
|
221
219
|
|
|
222
220
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
223
221
|
# the resume first: what its parse finds is written over the fields filled before it
|
|
@@ -226,8 +224,8 @@ class Eightfold:
|
|
|
226
224
|
for control in order:
|
|
227
225
|
if (answer := answers.get(control.field.id)) is None:
|
|
228
226
|
continue
|
|
229
|
-
if control.dom is None: #
|
|
230
|
-
if not
|
|
227
|
+
if control.dom is None: # a follow-up, rendered once its condition holds
|
|
228
|
+
if not conditions.Form(self._fields, given).shown(control.field):
|
|
231
229
|
continue
|
|
232
230
|
with suppress(Timeout):
|
|
233
231
|
_box_of(page, control).wait_for(timeout=SHOWN_TIMEOUT_MS)
|
|
@@ -320,21 +318,23 @@ def _plain(label: str) -> str:
|
|
|
320
318
|
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", label)).strip()
|
|
321
319
|
|
|
322
320
|
|
|
323
|
-
def _label(q: dict[str, Any]
|
|
324
|
-
"""The question's text; one some of a parent's answers render says so, naming the first
|
|
325
|
-
parent by its own label, so that a parent asked for other answers names it apart."""
|
|
321
|
+
def _label(q: dict[str, Any]) -> str:
|
|
326
322
|
label = _plain(q.get("label") or "")
|
|
327
323
|
if (q.get("textOptions") or {}).get("type") == "number": # a number input takes no text
|
|
328
324
|
label += " (a number)"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
325
|
+
return label
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _shown_when(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> Condition | None:
|
|
329
|
+
"""What renders a follow-up: any of its parents' answers it lists, any answer where it
|
|
330
|
+
lists none."""
|
|
331
|
+
parents: list[Condition] = []
|
|
332
|
+
for dependency in q.get("parentDependencies") or []:
|
|
333
|
+
parent = by_id.get(str(dependency["questionId"]), {})
|
|
334
|
+
labels = {str(c["value"]): c["label"] for c in _choices(parent)}
|
|
335
|
+
values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
|
|
336
|
+
parents.append(Is(str(dependency["questionId"]), values or None))
|
|
337
|
+
return parents[0] if len(parents) == 1 else AnyOf(parents) if parents else None
|
|
338
338
|
|
|
339
339
|
|
|
340
340
|
def _choices(q: dict[str, Any]) -> list[dict[str, Any]]:
|
|
@@ -356,24 +356,17 @@ def _form(data: dict[str, Any]) -> list[_Control]:
|
|
|
356
356
|
|
|
357
357
|
|
|
358
358
|
def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
|
|
359
|
-
qid, label = str(q["questionId"]), _label(q, by_id)
|
|
359
|
+
qid, label, shown_when = str(q["questionId"]), _label(q), _shown_when(q, by_id)
|
|
360
360
|
kind = KINDS.get(q.get("type", ""))
|
|
361
361
|
choices = _choices(q)
|
|
362
362
|
if kind == "select" and not choices and (q.get("selectOptions") or {}).get("apiEndpoint"):
|
|
363
363
|
kind = "lookup" # options load once the question it depends on is answered
|
|
364
364
|
required = bool(q.get("required"))
|
|
365
|
-
parents = []
|
|
366
|
-
for dependency in q.get("parentDependencies") or []:
|
|
367
|
-
parent = by_id.get(str(dependency["questionId"]), {})
|
|
368
|
-
labels = {str(c["value"]): c["label"] for c in _choices(parent)}
|
|
369
|
-
values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
|
|
370
|
-
parents.append((str(dependency["questionId"]), values))
|
|
371
|
-
if parents and parents[0][1]: # asked for some of its parent's answers only
|
|
372
|
-
required = False
|
|
373
365
|
ends = list(dict.fromkeys([_norm(qid), qid]))
|
|
374
366
|
if kind is None:
|
|
375
367
|
reason = f"unhandled_rendering: {qid} is a question of type {q.get('type')}"
|
|
376
|
-
|
|
368
|
+
unfillable = Field(qid, label, FieldType.TEXT, required, unfillable=reason)
|
|
369
|
+
return _Control(replace(unfillable, shown_when=shown_when), "", ends)
|
|
377
370
|
options = [c["label"] for c in choices] or None
|
|
378
371
|
accept = None
|
|
379
372
|
if kind == "file":
|
|
@@ -381,8 +374,17 @@ def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
|
|
|
381
374
|
types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
|
|
382
375
|
accept = [t.strip() for t in types if t.strip()] or None
|
|
383
376
|
today = bool((q.get("dateOptions") or {}).get("today")) # today's date, read-only
|
|
384
|
-
question = Field(
|
|
385
|
-
|
|
377
|
+
question = Field(
|
|
378
|
+
qid,
|
|
379
|
+
label,
|
|
380
|
+
TYPES[kind],
|
|
381
|
+
required,
|
|
382
|
+
options,
|
|
383
|
+
accept=accept,
|
|
384
|
+
prefilled=today,
|
|
385
|
+
shown_when=shown_when,
|
|
386
|
+
)
|
|
387
|
+
return _Control(question, kind, ends, q.get("type") == "resumeUpload")
|
|
386
388
|
|
|
387
389
|
|
|
388
390
|
def _find(page: Page, control: _Control) -> str | None:
|
|
@@ -533,13 +535,5 @@ def _iso(shown: str) -> str:
|
|
|
533
535
|
return shown
|
|
534
536
|
|
|
535
537
|
|
|
536
|
-
def _shows(answer: Answer | None, values: list[str]) -> bool:
|
|
537
|
-
"""Whether a parent's answer renders its dependent: any answer when `values` is empty."""
|
|
538
|
-
if answer in (None, "", []):
|
|
539
|
-
return False
|
|
540
|
-
picks = [answer] if isinstance(answer, str) else answer
|
|
541
|
-
return not values or any(p in values for p in picks)
|
|
542
|
-
|
|
543
|
-
|
|
544
538
|
def _exact(value: str) -> re.Pattern[str]:
|
|
545
539
|
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
@@ -9,6 +9,7 @@ video Teamtailor holds under the id the form keeps.
|
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
import unicodedata
|
|
12
|
+
from dataclasses import replace
|
|
12
13
|
from pathlib import PurePath
|
|
13
14
|
from typing import Any
|
|
14
15
|
from urllib.parse import urlsplit
|
|
@@ -16,7 +17,7 @@ from urllib.parse import urlsplit
|
|
|
16
17
|
from atsfill.core import browser, matching
|
|
17
18
|
from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
|
|
18
19
|
from atsfill.core.browser import Locator, Page, Response
|
|
19
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
20
21
|
|
|
21
22
|
FORM = "#job-application-form"
|
|
22
23
|
# A custom question, or a qualifying one: the rest of the form stays disabled until it is
|
|
@@ -73,6 +74,7 @@ _READ = """(form, QUESTION) => {
|
|
|
73
74
|
const fields = [];
|
|
74
75
|
for (const el of form.querySelectorAll("input, textarea")) {
|
|
75
76
|
if (el.closest(QUESTION) || el.disabled || fields.some(f => f.id === el.name)) continue;
|
|
77
|
+
if (el.type !== "file" && /_remote_url\\]$/.test(el.name)) continue; // an upload's address
|
|
76
78
|
const kind = el.tagName === "TEXTAREA" ? "textarea"
|
|
77
79
|
: el.name === "candidate[location][query]" ? "location"
|
|
78
80
|
: el.type === "checkbox" && el.name.endsWith("[]") ? "choices" : el.type;
|
|
@@ -106,12 +108,13 @@ _READ = """(form, QUESTION) => {
|
|
|
106
108
|
&& form.querySelector(`[data-question-uuid="${q.dataset.questionShowIfUuid}"]`);
|
|
107
109
|
const shownBy = parent && [...parent.querySelectorAll(PICKS)]
|
|
108
110
|
.find(e => e.value === q.dataset.questionShowIfValue);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
const parentId = parent?.querySelector("input[name$='[question_id]']")?.value;
|
|
112
|
+
fields.push({id: `question:${qid}`, kind, question: true, qualifying,
|
|
113
|
+
shownBy: parent && shownBy
|
|
114
|
+
? {id: `question:${parentId}`, option: options(parent, [shownBy])[0]} : null,
|
|
115
|
+
visible: q.checkVisibility(),
|
|
116
|
+
label: label(q.querySelector("legend, label")),
|
|
117
|
+
required: qualifying || q.dataset.questionMandatory === "true",
|
|
115
118
|
accept: kind === "video"
|
|
116
119
|
? q.querySelector("input[type=file]")?.accept.split(",").filter(Boolean) : null,
|
|
117
120
|
options: picks.length ? picks : menu.length ? menu : null});
|
|
@@ -176,7 +179,7 @@ class Teamtailor:
|
|
|
176
179
|
def __init__(self) -> None:
|
|
177
180
|
self._fields: dict[str, Field] = {}
|
|
178
181
|
self._qualifying: set[str] = set()
|
|
179
|
-
self._follow_ups:
|
|
182
|
+
self._follow_ups: set[str] = set()
|
|
180
183
|
self._uploads: dict[str, str] = {} # file field id: the answer's file name
|
|
181
184
|
self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
|
|
182
185
|
|
|
@@ -208,13 +211,15 @@ class Teamtailor:
|
|
|
208
211
|
form.wait_for()
|
|
209
212
|
except browser.Timeout:
|
|
210
213
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
211
|
-
fields, self._qualifying, self._follow_ups = [], set(),
|
|
214
|
+
fields, self._qualifying, self._follow_ups = [], set(), set()
|
|
212
215
|
for raw in form.evaluate(_READ, QUESTION):
|
|
213
216
|
if raw.get("qualifying"):
|
|
214
217
|
self._qualifying.add(raw["id"])
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
field = self._field(raw)
|
|
219
|
+
if shown_by := raw.get("shownBy"): # a follow-up of an earlier question
|
|
220
|
+
self._follow_ups.add(raw["id"])
|
|
221
|
+
field = replace(field, shown_when=Is(shown_by["id"], [shown_by["option"]]))
|
|
222
|
+
fields.append(field)
|
|
218
223
|
self._fields = {f.id: f for f in fields}
|
|
219
224
|
return fields
|
|
220
225
|
|
|
@@ -229,10 +234,6 @@ class Teamtailor:
|
|
|
229
234
|
if field_id in self._follow_ups and not self._shown(form, field_id):
|
|
230
235
|
continue # not asked: the read-back leaves it out
|
|
231
236
|
self._fill(form, field_id, answers[field_id])
|
|
232
|
-
for field_id, required in self._follow_ups.items():
|
|
233
|
-
if required and field_id not in answers and self._shown(form, field_id):
|
|
234
|
-
label = self._fields[field_id].label
|
|
235
|
-
raise Unsupported(f"missing_answer: an earlier answer asks {label!r}")
|
|
236
237
|
self._uploads = {
|
|
237
238
|
i: PurePath(a).name
|
|
238
239
|
for i, a in answers.items()
|
|
@@ -240,6 +241,10 @@ class Teamtailor:
|
|
|
240
241
|
}
|
|
241
242
|
self._wait_for_uploads(form)
|
|
242
243
|
|
|
244
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
245
|
+
raws = page.locator(FORM).evaluate(_READ, QUESTION)
|
|
246
|
+
return [raw["id"] for raw in raws if raw.get("visible", True)]
|
|
247
|
+
|
|
243
248
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
244
249
|
values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
|
|
245
250
|
for field_id, (video_id, url) in self._videos.items():
|
|
@@ -143,3 +143,13 @@ class ReadsOverHttp(Adapter, Protocol):
|
|
|
143
143
|
def read_api(self, url: str) -> list[Field] | None:
|
|
144
144
|
"""The same fields as `read_form`; None when it cannot for this URL."""
|
|
145
145
|
...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@runtime_checkable
|
|
149
|
+
class ListsShownFields(Adapter, Protocol):
|
|
150
|
+
"""An adapter whose form can show fields after an answer: once filled, it says which the
|
|
151
|
+
page shows, so that one `read_form` did not list stops the fill (ADR 0005)."""
|
|
152
|
+
|
|
153
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
154
|
+
"""The ids of the fields the filled page shows, each as `read_form` would name it."""
|
|
155
|
+
...
|
|
@@ -5,11 +5,21 @@ from importlib.metadata import version
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from atsfill.adapters import adapters
|
|
8
|
-
from atsfill.core import
|
|
8
|
+
from atsfill.core import (
|
|
9
|
+
accounts,
|
|
10
|
+
attempts,
|
|
11
|
+
browser,
|
|
12
|
+
conditions,
|
|
13
|
+
matching,
|
|
14
|
+
passwords,
|
|
15
|
+
paths,
|
|
16
|
+
registry,
|
|
17
|
+
)
|
|
9
18
|
from atsfill.core.adapter import (
|
|
10
19
|
Adapter,
|
|
11
20
|
Blocked,
|
|
12
21
|
CreatesAccount,
|
|
22
|
+
ListsShownFields,
|
|
13
23
|
NeedsAccount,
|
|
14
24
|
NotTaken,
|
|
15
25
|
ReadsOverHttp,
|
|
@@ -21,6 +31,7 @@ from atsfill.core.browser import OutOfTime, Overlaid, Page, open_page
|
|
|
21
31
|
from atsfill.core.results import (
|
|
22
32
|
AdapterStatus,
|
|
23
33
|
Answer,
|
|
34
|
+
Field,
|
|
24
35
|
Result,
|
|
25
36
|
SessionReport,
|
|
26
37
|
Status,
|
|
@@ -160,17 +171,51 @@ def _result(adapter: Adapter, url: str, user: str) -> Result:
|
|
|
160
171
|
return Result(Status.READY, url, adapter.name, side_effects=shown)
|
|
161
172
|
|
|
162
173
|
|
|
163
|
-
def _unfillable(result: Result) -> bool:
|
|
174
|
+
def _unfillable(result: Result, answers: dict[str, Answer] | None = None) -> bool:
|
|
164
175
|
"""Stops the form on a required field atsfill cannot fill; an optional one stays listed,
|
|
165
|
-
left empty (ADR 0014).
|
|
176
|
+
left empty (ADR 0014). A follow-up counts once the answers show it and require it, and
|
|
177
|
+
without answers never."""
|
|
178
|
+
form = conditions.Form(result.fields, answers or {})
|
|
166
179
|
field = next(
|
|
167
|
-
(
|
|
180
|
+
(
|
|
181
|
+
f
|
|
182
|
+
for f in result.fields
|
|
183
|
+
if f.unfillable
|
|
184
|
+
and not f.prefilled
|
|
185
|
+
and (form.required(f) if answers is not None else f.required and not f.shown_when)
|
|
186
|
+
),
|
|
187
|
+
None,
|
|
168
188
|
)
|
|
169
189
|
if field:
|
|
170
190
|
result.status, result.reason = Status.UNSUPPORTED, field.unfillable
|
|
171
191
|
return field is not None
|
|
172
192
|
|
|
173
193
|
|
|
194
|
+
def _listed(result: Result, fields: list[Field]) -> None:
|
|
195
|
+
"""Sets the fields `read_form` or `read_api` listed; their conditions must name listed
|
|
196
|
+
fields, never a loop."""
|
|
197
|
+
if faults := conditions.faults(fields):
|
|
198
|
+
raise ValueError("; ".join(faults))
|
|
199
|
+
result.fields = fields
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _shown_as_listed(
|
|
203
|
+
page: Page, adapter: Adapter, result: Result, answers: dict[str, Answer]
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Stops a fill that showed a field `read_form` did not list, or a follow-up whose
|
|
206
|
+
condition the answers leave unmet: that condition was read wrong (ADR 0005)."""
|
|
207
|
+
if not isinstance(adapter, ListsShownFields):
|
|
208
|
+
return
|
|
209
|
+
by_id, form = {f.id: f for f in result.fields}, conditions.Form(result.fields, answers)
|
|
210
|
+
shown = adapter.shown_fields(page)
|
|
211
|
+
if new := [i for i in shown if i not in by_id]:
|
|
212
|
+
result.status = Status.UNSUPPORTED
|
|
213
|
+
result.reason = f"unhandled_rendering: an answer showed {', '.join(new)}, not listed"
|
|
214
|
+
elif wrong := [i for i in shown if not form.shown(by_id[i])]:
|
|
215
|
+
result.status = Status.REFUSED
|
|
216
|
+
result.reason = f"mismatch: {', '.join(wrong)} shows though its condition is unmet"
|
|
217
|
+
|
|
218
|
+
|
|
174
219
|
def _no_adapter(url: str) -> Result:
|
|
175
220
|
return Result(Status.UNSUPPORTED, url, reason="no_adapter: no adapter for this hiring system")
|
|
176
221
|
|
|
@@ -210,13 +255,16 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
|
|
|
210
255
|
"""Fills and reads back; `ready` when every answer reads back as given."""
|
|
211
256
|
result = _result(adapter, url, user)
|
|
212
257
|
with _stops(result):
|
|
213
|
-
result
|
|
214
|
-
if _unfillable(result):
|
|
258
|
+
_listed(result, adapter.read_form(page))
|
|
259
|
+
if _unfillable(result, answers):
|
|
215
260
|
return result
|
|
216
261
|
if problems := matching.problems(result.fields, answers):
|
|
217
262
|
result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
|
|
218
263
|
return result
|
|
219
264
|
adapter.fill(page, answers)
|
|
265
|
+
_shown_as_listed(page, adapter, result, answers)
|
|
266
|
+
if result.status is not Status.READY:
|
|
267
|
+
return result
|
|
220
268
|
result.checks = matching.check(result.fields, answers, adapter.read_back(page))
|
|
221
269
|
if mismatched := [c.field_id for c in result.checks if not c.match]:
|
|
222
270
|
result.status, result.reason = Status.REFUSED, f"mismatch: {', '.join(mismatched)}"
|
|
@@ -232,7 +280,7 @@ def _read_page(
|
|
|
232
280
|
page, adapter = opened
|
|
233
281
|
result = _result(adapter, url, user)
|
|
234
282
|
with _stops(result):
|
|
235
|
-
result
|
|
283
|
+
_listed(result, adapter.read_form(page))
|
|
236
284
|
_unfillable(result)
|
|
237
285
|
return result
|
|
238
286
|
|
|
@@ -301,7 +349,10 @@ def read(
|
|
|
301
349
|
continue
|
|
302
350
|
if (fields := adapter.read_api(url)) is not None:
|
|
303
351
|
result = _result(adapter, url, user)
|
|
304
|
-
|
|
352
|
+
try:
|
|
353
|
+
_listed(result, fields)
|
|
354
|
+
except ValueError as e:
|
|
355
|
+
return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
|
|
305
356
|
_unfillable(result)
|
|
306
357
|
return result
|
|
307
358
|
data = paths.data_dir(data_dir)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Whether a field shows, and whether it is required, from the answers alone (ADR 0005)."""
|
|
2
|
+
|
|
3
|
+
from atsfill.core.results import AllOf, Answer, AnyOf, Condition, Field, Is
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _parents(condition: Condition) -> list[str]:
|
|
7
|
+
match condition:
|
|
8
|
+
case Is():
|
|
9
|
+
return [condition.field_id]
|
|
10
|
+
case AnyOf():
|
|
11
|
+
return [p for c in condition.any_of for p in _parents(c)]
|
|
12
|
+
case AllOf():
|
|
13
|
+
return [p for c in condition.all_of for p in _parents(c)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def faults(fields: list[Field]) -> list[str]:
|
|
17
|
+
"""What makes the fields' conditions unusable: a field they name that is not listed, or a
|
|
18
|
+
field shown by its own answer, through any chain."""
|
|
19
|
+
by_id = {f.id: f for f in fields}
|
|
20
|
+
out = [
|
|
21
|
+
f"{f.id}: its condition names no field {p}"
|
|
22
|
+
for f in fields
|
|
23
|
+
for c in (f.shown_when, f.required_when)
|
|
24
|
+
if c
|
|
25
|
+
for p in _parents(c)
|
|
26
|
+
if p not in by_id
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def loops(field_id: str, seen: tuple[str, ...]) -> bool:
|
|
30
|
+
if field_id in seen:
|
|
31
|
+
return True
|
|
32
|
+
field = by_id.get(field_id)
|
|
33
|
+
parents = _parents(field.shown_when) if field and field.shown_when else []
|
|
34
|
+
return any(loops(p, (*seen, field_id)) for p in parents)
|
|
35
|
+
|
|
36
|
+
out += [f"{f.id}: shown by its own answer" for f in fields if loops(f.id, ())]
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Form:
|
|
41
|
+
"""The fields under one set of answers: an answer to a field that stays hidden counts as
|
|
42
|
+
none."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, fields: list[Field], answers: dict[str, Answer]) -> None:
|
|
45
|
+
self.by_id = {f.id: f for f in fields}
|
|
46
|
+
self.answers = answers
|
|
47
|
+
|
|
48
|
+
def holds(self, condition: Condition) -> bool:
|
|
49
|
+
match condition:
|
|
50
|
+
case Is():
|
|
51
|
+
value = self._value(condition.field_id)
|
|
52
|
+
if condition.values is None:
|
|
53
|
+
return (value is not None) != condition.negated
|
|
54
|
+
if value is None:
|
|
55
|
+
return False
|
|
56
|
+
picks = [value] if isinstance(value, str) else value
|
|
57
|
+
return any(p in condition.values for p in picks) != condition.negated
|
|
58
|
+
case AnyOf():
|
|
59
|
+
return any(self.holds(c) for c in condition.any_of)
|
|
60
|
+
case AllOf():
|
|
61
|
+
return all(self.holds(c) for c in condition.all_of)
|
|
62
|
+
|
|
63
|
+
def shown(self, field: Field) -> bool:
|
|
64
|
+
return field.shown_when is None or self.holds(field.shown_when)
|
|
65
|
+
|
|
66
|
+
def required(self, field: Field) -> bool:
|
|
67
|
+
"""Required as the form asks it: shown, and always required or required by an answer."""
|
|
68
|
+
if not self.shown(field):
|
|
69
|
+
return False
|
|
70
|
+
when = field.required_when
|
|
71
|
+
return field.required or (when is not None and self.holds(when))
|
|
72
|
+
|
|
73
|
+
def _value(self, field_id: str) -> Answer | None:
|
|
74
|
+
field = self.by_id[field_id]
|
|
75
|
+
value = self.answers.get(field_id)
|
|
76
|
+
return value if value not in (None, "", []) and self.shown(field) else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def describe(condition: Condition, by_id: dict[str, Field]) -> str:
|
|
80
|
+
"""The condition in words, each field by its label: `"Visa" is "Yes"`."""
|
|
81
|
+
match condition:
|
|
82
|
+
case Is():
|
|
83
|
+
label = by_id[condition.field_id].label if condition.field_id in by_id else ""
|
|
84
|
+
name = f'"{label or condition.field_id}"'
|
|
85
|
+
if condition.values is None:
|
|
86
|
+
return f"{name} is {'not ' if condition.negated else ''}answered"
|
|
87
|
+
values = " or ".join(f'"{v}"' for v in condition.values)
|
|
88
|
+
return f"{name} is {'not ' if condition.negated else ''}{values}"
|
|
89
|
+
case AnyOf():
|
|
90
|
+
return " or ".join(f"({describe(c, by_id)})" for c in condition.any_of)
|
|
91
|
+
case AllOf():
|
|
92
|
+
return " and ".join(f"({describe(c, by_id)})" for c in condition.all_of)
|
|
@@ -5,6 +5,7 @@ import unicodedata
|
|
|
5
5
|
from pathlib import Path, PurePath
|
|
6
6
|
from urllib.parse import urlsplit
|
|
7
7
|
|
|
8
|
+
from atsfill.core.conditions import Form, describe
|
|
8
9
|
from atsfill.core.results import Answer, Check, Field, FieldType
|
|
9
10
|
|
|
10
11
|
|
|
@@ -60,12 +61,19 @@ def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
|
|
|
60
61
|
def check(
|
|
61
62
|
fields: list[Field], answers: dict[str, Answer], read_back: dict[str, Answer]
|
|
62
63
|
) -> list[Check]:
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
"""Each answer against its read-back; a follow-up the answers keep hidden that reads back a
|
|
65
|
+
value is a mismatch too, its condition read wrong."""
|
|
66
|
+
by_id, form = {f.id: f for f in fields}, Form(fields, answers)
|
|
67
|
+
checks = [
|
|
65
68
|
Check(f.id, a, read_back.get(f.id), matches(f, a, read_back.get(f.id)))
|
|
66
69
|
for f_id, a in answers.items()
|
|
67
70
|
if (f := by_id.get(f_id))
|
|
68
71
|
]
|
|
72
|
+
return checks + [
|
|
73
|
+
Check(f.id, None, read_back[f.id], False)
|
|
74
|
+
for f in fields
|
|
75
|
+
if not form.shown(f) and f.id not in answers and read_back.get(f.id) not in (None, "", [])
|
|
76
|
+
]
|
|
69
77
|
|
|
70
78
|
|
|
71
79
|
def _answer_problem(field: Field, answer: object) -> str | None:
|
|
@@ -100,13 +108,20 @@ def _answer_problem(field: Field, answer: object) -> str | None:
|
|
|
100
108
|
|
|
101
109
|
|
|
102
110
|
def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
103
|
-
"""What refuses a fill before typing: unknown ids, missing required answers,
|
|
111
|
+
"""What refuses a fill before typing: unknown ids, missing required answers, answers to
|
|
112
|
+
follow-ups the answers keep hidden, bad values."""
|
|
104
113
|
by_id = {f.id: f for f in fields}
|
|
105
114
|
out = [f"no field {i}" for i in answers if i not in by_id]
|
|
115
|
+
form = Form(fields, {i: a for i, a in answers.items() if i in by_id})
|
|
106
116
|
out += [
|
|
107
117
|
f"no answer for required {f.id} ({f.label})"
|
|
108
118
|
for f in fields
|
|
109
|
-
if
|
|
119
|
+
if form.required(f) and not f.prefilled and not f.unfillable and f.id not in answers
|
|
120
|
+
]
|
|
121
|
+
out += [
|
|
122
|
+
f"{i}: not asked, it shows when {describe(f.shown_when, by_id)}"
|
|
123
|
+
for i in answers
|
|
124
|
+
if (f := by_id.get(i)) and f.shown_when and not form.shown(f)
|
|
110
125
|
]
|
|
111
126
|
out += [
|
|
112
127
|
f"{i}: cannot be filled ({f.unfillable})"
|
|
@@ -26,6 +26,30 @@ class FieldType(StrEnum):
|
|
|
26
26
|
type Answer = str | list[str]
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Is:
|
|
31
|
+
"""Holds when the field's answer is one of `values` (one of them ticked, for a
|
|
32
|
+
multi_choice), or, without `values`, when the field is answered. Against values, it never
|
|
33
|
+
holds for an unanswered field, negated or not."""
|
|
34
|
+
|
|
35
|
+
field_id: str
|
|
36
|
+
values: list[str] | None = None
|
|
37
|
+
negated: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class AnyOf:
|
|
42
|
+
any_of: list[Condition]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AllOf:
|
|
47
|
+
all_of: list[Condition]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
type Condition = Is | AnyOf | AllOf
|
|
51
|
+
|
|
52
|
+
|
|
29
53
|
@dataclass(frozen=True)
|
|
30
54
|
class Field:
|
|
31
55
|
id: str
|
|
@@ -37,6 +61,8 @@ class Field:
|
|
|
37
61
|
accept: list[str] | None = None
|
|
38
62
|
prefilled: bool = False # holds a value before any answer, from the account profile
|
|
39
63
|
unfillable: str | None = None # why atsfill cannot fill it, a reason (ADR 0014)
|
|
64
|
+
shown_when: Condition | None = None # a follow-up: shown only when it holds (ADR 0005)
|
|
65
|
+
required_when: Condition | None = None # required once shown when it holds, if not always
|
|
40
66
|
|
|
41
67
|
|
|
42
68
|
@dataclass(frozen=True)
|
|
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
|