atsfill 0.0.4.dev49__tar.gz → 0.0.4.dev50__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.dev49 → atsfill-0.0.4.dev50}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/ashby.py +11 -6
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/bamboohr.py +25 -9
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/breezy.py +9 -9
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/dayforce.py +31 -25
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/greenhouse.py +6 -2
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/hireology.py +17 -10
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/jazzhr.py +6 -2
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/lever.py +4 -2
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/manatal.py +19 -11
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/oracle.py +8 -8
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/paycor.py +20 -22
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/paylocity.py +46 -15
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/phenom.py +23 -16
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/recruitee.py +7 -8
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/smartrecruiters.py +40 -15
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/teamtailor.py +24 -29
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/workable.py +52 -46
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/wttj.py +17 -17
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/adapter.py +27 -2
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/commands.py +15 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/matching.py +7 -2
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/registry.py +25 -9
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/registry.toml +3 -1
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/LICENSE +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/README.md +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev49 → atsfill-0.0.4.dev50}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev50
|
|
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
|
|
@@ -12,7 +12,7 @@ from functools import partial
|
|
|
12
12
|
from typing import Any
|
|
13
13
|
from urllib.parse import parse_qs, urlsplit
|
|
14
14
|
|
|
15
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
15
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
|
|
16
16
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Response, Timeout
|
|
17
17
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
18
|
|
|
@@ -67,6 +67,7 @@ def _op(response: Response) -> str:
|
|
|
67
67
|
|
|
68
68
|
class Ashby:
|
|
69
69
|
name = "ashby"
|
|
70
|
+
known_gaps = {"education_history": Gap.RENDERING}
|
|
70
71
|
side_effects = [
|
|
71
72
|
SideEffect(
|
|
72
73
|
"fill", "hiring_system", "each answer and the resume are saved to Ashby as typed"
|
|
@@ -166,7 +167,7 @@ class Ashby:
|
|
|
166
167
|
self._saved(page, partial(upload.set_input_files, answer))
|
|
167
168
|
case kind, _:
|
|
168
169
|
raise Unsupported(
|
|
169
|
-
f"
|
|
170
|
+
f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
170
171
|
)
|
|
171
172
|
self._answered = list(answers)
|
|
172
173
|
|
|
@@ -227,16 +228,20 @@ class Ashby:
|
|
|
227
228
|
if not e.get("isHidden")
|
|
228
229
|
]
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
def _field(entry: dict[str, Any]) -> Field:
|
|
231
|
+
def _field(self, entry: dict[str, Any]) -> Field:
|
|
232
232
|
f = entry["field"]
|
|
233
|
+
path, title, required = f["path"], f["title"], bool(entry["isRequired"])
|
|
234
|
+
if f["type"] == "EducationHistory":
|
|
235
|
+
gap = known_gap(self, "education_history", f"{path} is a list of schools")
|
|
236
|
+
return Field(path, title, FieldType.TEXT, required, unfillable=gap)
|
|
233
237
|
kind = TYPES.get(f["type"])
|
|
234
238
|
if kind is None:
|
|
235
|
-
|
|
239
|
+
reason = f"unhandled_rendering: {path} is an Ashby {f['type']} field"
|
|
240
|
+
return Field(path, title, FieldType.TEXT, required, unfillable=reason)
|
|
236
241
|
options = [v["label"] for v in f.get("selectableValues") or []] or None
|
|
237
242
|
if f["type"] == "Boolean":
|
|
238
243
|
options = ["Yes", "No"]
|
|
239
|
-
return Field(
|
|
244
|
+
return Field(path, title, kind, required, options)
|
|
240
245
|
|
|
241
246
|
def _values(self) -> dict[str, Answer | None]:
|
|
242
247
|
return {e["field"]["path"]: self._value(e) for e in self._entries()}
|
|
@@ -10,11 +10,12 @@ import json
|
|
|
10
10
|
import re
|
|
11
11
|
import urllib.request
|
|
12
12
|
from contextlib import suppress
|
|
13
|
+
from dataclasses import replace
|
|
13
14
|
from typing import Any
|
|
14
15
|
from urllib.parse import urlsplit
|
|
15
16
|
|
|
16
17
|
from atsfill.core import browser
|
|
17
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
18
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
18
19
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
19
20
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
21
|
|
|
@@ -140,6 +141,7 @@ _READ_BACK = """(form, specs) => {
|
|
|
140
141
|
|
|
141
142
|
class BambooHR:
|
|
142
143
|
name = "bamboohr"
|
|
144
|
+
known_gaps: dict[str, Gap] = {}
|
|
143
145
|
side_effects = [
|
|
144
146
|
SideEffect("upload", "hiring_system", "an attached file is stored by BambooHR at once"),
|
|
145
147
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -186,10 +188,11 @@ class BambooHR:
|
|
|
186
188
|
country = form.locator("select[name='countryId.value']")
|
|
187
189
|
default = (_shown(country) or "") if country.count() else None
|
|
188
190
|
fields = _form(detail["json"], states, default)
|
|
189
|
-
|
|
191
|
+
out = []
|
|
190
192
|
for field, (kind, name, _) in fields:
|
|
191
|
-
if field.id.endswith("_other"):
|
|
192
|
-
|
|
193
|
+
if field.unfillable or field.id.endswith("_other"):
|
|
194
|
+
out.append(field) # an `_other` shows once its question is answered Other
|
|
195
|
+
continue
|
|
193
196
|
present = (
|
|
194
197
|
form.locator("input[placeholder*=yyyy]")
|
|
195
198
|
if kind == "date"
|
|
@@ -198,8 +201,14 @@ class BambooHR:
|
|
|
198
201
|
else form.locator(f"[name='{name}']")
|
|
199
202
|
)
|
|
200
203
|
if not present.count():
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
field = replace(
|
|
205
|
+
field, unfillable=f"unhandled_rendering: {field.id} is not on the page"
|
|
206
|
+
)
|
|
207
|
+
out.append(field)
|
|
208
|
+
self._specs = {
|
|
209
|
+
f.id: spec for f, (_, spec) in zip(out, fields, strict=True) if not f.unfillable
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
203
212
|
|
|
204
213
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
205
214
|
form = page.locator(FORM)
|
|
@@ -388,7 +397,12 @@ def _form(
|
|
|
388
397
|
if not entry: # a demographic question the employer does not ask
|
|
389
398
|
continue
|
|
390
399
|
if key not in STANDARD:
|
|
391
|
-
|
|
400
|
+
reason = f"unhandled_rendering: {key} is a field it does not know"
|
|
401
|
+
meta = entry if isinstance(entry, dict) else {}
|
|
402
|
+
label, required = str(meta.get("label") or key).strip(), bool(meta.get("isRequired"))
|
|
403
|
+
field = Field(key, label, FieldType.TEXT, required, unfillable=reason)
|
|
404
|
+
out.append((field, ("text", None, None)))
|
|
405
|
+
continue
|
|
392
406
|
kind, name = STANDARD[key]
|
|
393
407
|
label, required = entry["label"].strip(), entry["isRequired"]
|
|
394
408
|
options = [o["text"] for o in entry.get("options") or []] or None
|
|
@@ -417,11 +431,13 @@ def _states(states: dict[str, Any], default: str | None) -> list[str]:
|
|
|
417
431
|
|
|
418
432
|
def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
|
|
419
433
|
qid, kind_api = question["id"], question["type"]
|
|
434
|
+
label, required = question["question"].strip(), question["isRequired"]
|
|
420
435
|
if kind_api not in CUSTOM:
|
|
421
|
-
|
|
436
|
+
reason = f"unhandled_rendering: question {qid} is a {kind_api}"
|
|
437
|
+
field = Field(f"question_{qid}", label, FieldType.TEXT, required, unfillable=reason)
|
|
438
|
+
return [(field, ("text", None, None))]
|
|
422
439
|
kind = CUSTOM[kind_api]
|
|
423
440
|
name = f"customQuestionAnswers.{kind_api}_{qid}"
|
|
424
|
-
label, required = question["question"].strip(), question["isRequired"]
|
|
425
441
|
options, extra = None, None
|
|
426
442
|
if kind_api == "yes_no" or kind == "checkbox":
|
|
427
443
|
options = YES_NO
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
|
13
13
|
from urllib.parse import urlsplit
|
|
14
14
|
|
|
15
15
|
from atsfill.core import browser
|
|
16
|
-
from atsfill.core.adapter import Unsupported
|
|
16
|
+
from atsfill.core.adapter import Gap, Unsupported, out_of_scope
|
|
17
17
|
from atsfill.core.browser import Locator, Page, Timeout
|
|
18
18
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
19
|
|
|
@@ -95,7 +95,7 @@ ENTRIES = {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
# The fields in page order, one per control name, with its heading's text and how it renders;
|
|
98
|
-
# a
|
|
98
|
+
# a live video question is kept as the kind `video`.
|
|
99
99
|
_READ = """form => {
|
|
100
100
|
const text = el => {
|
|
101
101
|
if (!el) return "";
|
|
@@ -210,6 +210,7 @@ _FILE_SHOWN = """e => {
|
|
|
210
210
|
|
|
211
211
|
class Breezy:
|
|
212
212
|
name = "breezy"
|
|
213
|
+
known_gaps: dict[str, Gap] = {}
|
|
213
214
|
side_effects = [
|
|
214
215
|
SideEffect("upload", "hiring_system", "an attached file is uploaded to Breezy at once"),
|
|
215
216
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -242,8 +243,9 @@ class Breezy:
|
|
|
242
243
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
243
244
|
fields, self._kinds = [], {}
|
|
244
245
|
for raw in form.evaluate(_READ):
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
field = _field(raw)
|
|
247
|
+
fields.append(field)
|
|
248
|
+
if not field.unfillable:
|
|
247
249
|
self._kinds[field.id] = raw["kind"]
|
|
248
250
|
return fields + self._entries(form)
|
|
249
251
|
|
|
@@ -341,13 +343,11 @@ class Breezy:
|
|
|
341
343
|
return f"{response.url} {response.status}"
|
|
342
344
|
|
|
343
345
|
|
|
344
|
-
def _field(raw: dict[str, Any]) -> Field
|
|
345
|
-
"""The field, or None for one the page asks optionally and atsfill cannot fill."""
|
|
346
|
+
def _field(raw: dict[str, Any]) -> Field:
|
|
346
347
|
kind = raw["kind"]
|
|
347
348
|
if kind == "video":
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return None
|
|
349
|
+
reason = out_of_scope(raw["id"], "a video recorded live")
|
|
350
|
+
return Field(raw["id"], raw["label"], FieldType.FILE, raw["required"], unfillable=reason)
|
|
351
351
|
label = LABELS.get(raw["id"]) or raw.get("label", "")
|
|
352
352
|
if "part" in raw:
|
|
353
353
|
label = (
|
|
@@ -16,7 +16,7 @@ from contextlib import suppress
|
|
|
16
16
|
from typing import Any
|
|
17
17
|
from urllib.parse import urlsplit
|
|
18
18
|
|
|
19
|
-
from atsfill.core.adapter import Unsupported
|
|
19
|
+
from atsfill.core.adapter import Gap, Unsupported, known_gap
|
|
20
20
|
from atsfill.core.browser import Locator, Page, Timeout
|
|
21
21
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
22
22
|
|
|
@@ -167,6 +167,7 @@ _CLICK = """([selector, n, text]) => {
|
|
|
167
167
|
|
|
168
168
|
class Dayforce:
|
|
169
169
|
name = "dayforce"
|
|
170
|
+
known_gaps = {"references": Gap.RENDERING}
|
|
170
171
|
side_effects = [
|
|
171
172
|
SideEffect(
|
|
172
173
|
"load",
|
|
@@ -212,8 +213,8 @@ class Dayforce:
|
|
|
212
213
|
accepted = [f".{a.strip()}" for a in accept.split(",") if a.strip()] or None
|
|
213
214
|
fields[i] = _with(field, accept=accepted)
|
|
214
215
|
for prefix, _ in ENTRIES.values(): # an entry's controls show once it is added
|
|
215
|
-
texts = [i for i, f in enumerate(fields)
|
|
216
|
-
|
|
216
|
+
texts = [i for i, f in enumerate(fields) if f.id.startswith(f"{prefix}.")
|
|
217
|
+
and f.type is FieldType.TEXT and not f.unfillable] # fmt: skip
|
|
217
218
|
if texts and page.locator(f"[test-id=add-{prefix.lower()}-record]").count():
|
|
218
219
|
base = _open_entry(page, prefix)
|
|
219
220
|
for i in texts:
|
|
@@ -280,8 +281,9 @@ class Dayforce:
|
|
|
280
281
|
for f in _visible(personal):
|
|
281
282
|
key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
|
|
282
283
|
if key is None or kind is None:
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
reason = f"unhandled_rendering: personal field {f['fieldName']!r}"
|
|
285
|
+
fields.append(Field(f["fieldName"], f["displayName"], FieldType.TEXT,
|
|
286
|
+
bool(f["isRequired"]), unfillable=reason)) # fmt: skip
|
|
285
287
|
continue
|
|
286
288
|
options = {
|
|
287
289
|
"candidateSource": sources,
|
|
@@ -316,14 +318,17 @@ class Dayforce:
|
|
|
316
318
|
]
|
|
317
319
|
for section in sections:
|
|
318
320
|
ref = section.get("xRefCode")
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
321
|
+
if section.get("questionnaire") or ref in CANDIDATE_INFO:
|
|
322
|
+
continue
|
|
323
|
+
name = section["displayName"]
|
|
324
|
+
if ref == "REFERENCES":
|
|
325
|
+
field_id = "references"
|
|
326
|
+
reason = known_gap(self, "references", f"{name!r} needs references added")
|
|
327
|
+
else:
|
|
328
|
+
field_id = f"section.{ref}"
|
|
329
|
+
reason = f"unhandled_rendering: {name!r} section {ref}"
|
|
330
|
+
fields.append(Field(field_id, name, FieldType.TEXT, bool(section.get("isRequired")),
|
|
331
|
+
unfillable=reason)) # fmt: skip
|
|
327
332
|
self._sections = [
|
|
328
333
|
s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
|
|
329
334
|
]
|
|
@@ -407,7 +412,9 @@ class Dayforce:
|
|
|
407
412
|
def _fill_entry(self, page: Page, prefix: str, answers: dict[str, Answer]) -> None:
|
|
408
413
|
"""Adds one entry to a history section and fills it."""
|
|
409
414
|
base = _open_entry(page, prefix)
|
|
410
|
-
fields = [
|
|
415
|
+
fields = [
|
|
416
|
+
f for f in self._fields.values() if f.id.startswith(f"{prefix}.") and not f.unfillable
|
|
417
|
+
]
|
|
411
418
|
for field in fields:
|
|
412
419
|
if field.id in answers:
|
|
413
420
|
_fill_control(page, _id(base + field.id.split(".")[1]), field, answers[field.id])
|
|
@@ -432,7 +439,7 @@ class Dayforce:
|
|
|
432
439
|
) -> None:
|
|
433
440
|
field_id = f"q{question['questionId']}"
|
|
434
441
|
field = self._fields.get(field_id)
|
|
435
|
-
if field is None:
|
|
442
|
+
if field is None or field.unfillable:
|
|
436
443
|
return
|
|
437
444
|
target = _question_target(page, base, question)
|
|
438
445
|
if target is None:
|
|
@@ -549,13 +556,14 @@ def _entry_fields(
|
|
|
549
556
|
fields = []
|
|
550
557
|
for f in _visible(section):
|
|
551
558
|
key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
|
|
559
|
+
required = bool(f["isRequired"] and section["isRequired"])
|
|
560
|
+
label = f"{section['displayName']}: {f['displayName']}"
|
|
552
561
|
if key is None or kind is None:
|
|
553
|
-
|
|
554
|
-
|
|
562
|
+
reason = f"unhandled_rendering: {prefix} field {f['fieldName']!r}"
|
|
563
|
+
field_id = f"{prefix}.{f['fieldName']}"
|
|
564
|
+
fields.append(Field(field_id, label, FieldType.TEXT, required, unfillable=reason))
|
|
555
565
|
continue
|
|
556
|
-
required = bool(f["isRequired"] and section["isRequired"])
|
|
557
566
|
options = NO_YES if kind == "boolean" else None
|
|
558
|
-
label = f"{section['displayName']}: {f['displayName']}"
|
|
559
567
|
fields.append(Field(f"{prefix}.{key}", label, ENTRY_KINDS[kind], required, options))
|
|
560
568
|
return fields
|
|
561
569
|
|
|
@@ -594,13 +602,11 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
|
|
|
594
602
|
label = _plain(question.get("description") or "")
|
|
595
603
|
if not label or len(label) > LONG_LABEL:
|
|
596
604
|
label = _plain(question["displayName"])
|
|
597
|
-
if kind is None:
|
|
598
|
-
if question["isRequired"]:
|
|
599
|
-
raise Unsupported(
|
|
600
|
-
f"unsupported_rendering: {label!r} has answer type {question['answerType']}"
|
|
601
|
-
)
|
|
602
|
-
return []
|
|
603
605
|
field_id = f"q{question['questionId']}"
|
|
606
|
+
if kind is None:
|
|
607
|
+
reason = f"unhandled_rendering: {field_id} has answer type {question['answerType']}"
|
|
608
|
+
required = bool(question["isRequired"])
|
|
609
|
+
return [Field(field_id, label, FieldType.TEXT, required, unfillable=reason)]
|
|
604
610
|
options = None
|
|
605
611
|
if question["answerType"] == CHECKBOX:
|
|
606
612
|
options = YES_NO
|
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
|
14
14
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
15
15
|
|
|
16
16
|
from atsfill.core import browser
|
|
17
|
-
from atsfill.core.adapter import Unsupported
|
|
17
|
+
from atsfill.core.adapter import Gap, Unsupported
|
|
18
18
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
19
19
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
20
|
|
|
@@ -120,6 +120,7 @@ _READ_BACK = """(form, ids) => {
|
|
|
120
120
|
|
|
121
121
|
class Greenhouse:
|
|
122
122
|
name = "greenhouse"
|
|
123
|
+
known_gaps: dict[str, Gap] = {}
|
|
123
124
|
side_effects = [
|
|
124
125
|
SideEffect("upload", "hiring_system", "an attached file is stored by Greenhouse at once"),
|
|
125
126
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -242,7 +243,10 @@ class Greenhouse:
|
|
|
242
243
|
else:
|
|
243
244
|
field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
|
|
244
245
|
case _: # a radio input, not seen on a posting yet
|
|
245
|
-
|
|
246
|
+
reason = f"unhandled_rendering: {raw['id']} is a {kind}"
|
|
247
|
+
return Field(
|
|
248
|
+
raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason
|
|
249
|
+
)
|
|
246
250
|
return Field(
|
|
247
251
|
raw["id"],
|
|
248
252
|
raw["label"],
|
|
@@ -11,11 +11,12 @@ import json
|
|
|
11
11
|
import re
|
|
12
12
|
import urllib.request
|
|
13
13
|
from contextlib import suppress
|
|
14
|
+
from dataclasses import replace
|
|
14
15
|
from typing import Any
|
|
15
16
|
from urllib.parse import urlsplit
|
|
16
17
|
|
|
17
18
|
from atsfill.core import browser
|
|
18
|
-
from atsfill.core.adapter import Unsupported
|
|
19
|
+
from atsfill.core.adapter import Gap, Unsupported
|
|
19
20
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
20
21
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
22
|
|
|
@@ -187,6 +188,7 @@ _READ_BACK = """(form, kinds) => {
|
|
|
187
188
|
|
|
188
189
|
class Hireology:
|
|
189
190
|
name = "hireology"
|
|
191
|
+
known_gaps: dict[str, Gap] = {}
|
|
190
192
|
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
191
193
|
|
|
192
194
|
def __init__(self) -> None:
|
|
@@ -218,13 +220,15 @@ class Hireology:
|
|
|
218
220
|
api = page.evaluate(_FETCH, API.format(job=job))
|
|
219
221
|
if "status" in api:
|
|
220
222
|
raise Unsupported(f"posting_gone: the form API answered {api['status']}")
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
223
|
+
form_el, out = page.locator(FORM), []
|
|
224
|
+
for field, kind in _form(api["json"]):
|
|
225
|
+
skip = field.unfillable or field.id in SHOWN_BY
|
|
226
|
+
if not skip and form_el.evaluate(_CONTROL, field.id) is None:
|
|
227
|
+
reason = f"unhandled_rendering: {field.id} is not on the page"
|
|
228
|
+
field = replace(field, unfillable=reason)
|
|
229
|
+
out.append((field, kind))
|
|
230
|
+
self._kinds = {field.id: kind for field, kind in out if not field.unfillable}
|
|
231
|
+
return [f for f, _ in out]
|
|
228
232
|
|
|
229
233
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
230
234
|
form = page.locator(FORM)
|
|
@@ -303,14 +307,17 @@ def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
|
303
307
|
def _field(entry: dict[str, Any], section: str) -> Field:
|
|
304
308
|
field_id, attributes = entry["id"], entry["attributes"]
|
|
305
309
|
kind = attributes["type"]
|
|
306
|
-
if kind not in TYPES:
|
|
307
|
-
raise Unsupported(f"unsupported_rendering: {field_id} is a {kind}")
|
|
308
310
|
label = (
|
|
309
311
|
section
|
|
310
312
|
+ (
|
|
311
313
|
LABELS.get(field_id) or attributes.get("label") or entry.get("label") or field_id
|
|
312
314
|
).strip()
|
|
313
315
|
)
|
|
316
|
+
if kind not in TYPES:
|
|
317
|
+
reason = f"unhandled_rendering: {field_id} is a {kind}"
|
|
318
|
+
return Field(
|
|
319
|
+
field_id, label, FieldType.TEXT, bool(attributes.get("required")), unfillable=reason
|
|
320
|
+
)
|
|
314
321
|
required = attributes["required"] and kind != "checkbox" # a box left clear is an answer
|
|
315
322
|
options = [o["name"] for o in attributes["options"]] or None
|
|
316
323
|
if kind == "checkbox":
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
13
|
|
|
14
14
|
from atsfill.core import browser
|
|
15
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
15
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
16
16
|
from atsfill.core.browser import Page, Timeout
|
|
17
17
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
18
|
|
|
@@ -122,6 +122,7 @@ _READ_BACK = """(form, fields) => {
|
|
|
122
122
|
|
|
123
123
|
class JazzHR:
|
|
124
124
|
name = "jazzhr"
|
|
125
|
+
known_gaps: dict[str, Gap] = {}
|
|
125
126
|
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
126
127
|
|
|
127
128
|
def __init__(self) -> None:
|
|
@@ -238,7 +239,10 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
238
239
|
case "consent":
|
|
239
240
|
field_type, options = FieldType.CHOICE, YES_NO
|
|
240
241
|
case _:
|
|
241
|
-
|
|
242
|
+
reason = f"unhandled_rendering: {raw['id']} is a {kind}"
|
|
243
|
+
return Field(
|
|
244
|
+
raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason
|
|
245
|
+
)
|
|
242
246
|
accept = RESUME_TYPES if kind == "file" else None
|
|
243
247
|
return Field(
|
|
244
248
|
raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
13
|
|
|
14
14
|
from atsfill.core import browser
|
|
15
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
15
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
16
16
|
from atsfill.core.browser import Locator, Page, Timeout
|
|
17
17
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
18
|
|
|
@@ -117,6 +117,7 @@ _READ_BACK = """(form, kinds) => {
|
|
|
117
117
|
|
|
118
118
|
class Lever:
|
|
119
119
|
name = "lever"
|
|
120
|
+
known_gaps: dict[str, Gap] = {}
|
|
120
121
|
side_effects = [
|
|
121
122
|
SideEffect("upload", "hiring_system", "the resume is uploaded to Lever and parsed at once"),
|
|
122
123
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -205,7 +206,8 @@ def _job(url: str) -> tuple[str, str] | None:
|
|
|
205
206
|
def _field(raw: dict[str, Any]) -> Field:
|
|
206
207
|
kind = raw["kind"]
|
|
207
208
|
if kind not in KINDS:
|
|
208
|
-
|
|
209
|
+
reason = f"unhandled_rendering: {raw['id']} is a {kind}"
|
|
210
|
+
return Field(raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason)
|
|
209
211
|
options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
|
|
210
212
|
if kind in ("radio", "checkboxes"):
|
|
211
213
|
options = raw["options"]
|
|
@@ -11,7 +11,7 @@ from collections.abc import Callable
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
13
|
|
|
14
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
14
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
|
|
15
15
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Page
|
|
16
16
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
17
|
|
|
@@ -33,7 +33,7 @@ LISTS: dict[str, tuple[str, Callable[[dict[str, Any]], str]]] = {
|
|
|
33
33
|
"languages": ("languages/", lambda n: n["name"]),
|
|
34
34
|
"industries": ("clients/{client}/industries/", lambda n: n["name"]),
|
|
35
35
|
}
|
|
36
|
-
ENTRIES = ("educations", "experiences") # a sub-form of entries
|
|
36
|
+
ENTRIES = ("educations", "experiences") # a sub-form of entries
|
|
37
37
|
SHORT = {"full_name", "email", "phone_number", "city", "address", "latest_degree",
|
|
38
38
|
"latest_university", "current_company", "current_department", "current_position",
|
|
39
39
|
"current_benefits", "expected_benefits", "skype", "contact"} # fmt: skip
|
|
@@ -106,15 +106,11 @@ def _kind(entry: dict[str, Any]) -> str:
|
|
|
106
106
|
return "text"
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def _field(entry: dict[str, Any], options: list[str] | None) -> Field
|
|
110
|
-
"""The field an entry of the endpoint lists,
|
|
109
|
+
def _field(entry: dict[str, Any], options: list[str] | None) -> Field:
|
|
110
|
+
"""The field an entry of the endpoint lists, a sub-form of entries aside."""
|
|
111
111
|
kind, slug = _kind(entry), entry["client_field_slug"]
|
|
112
112
|
fid, label, required = str(entry["id"]), entry["label"], bool(entry["is_required"])
|
|
113
113
|
match kind:
|
|
114
|
-
case "entries" if required:
|
|
115
|
-
raise Unsupported(f"unsupported_rendering: {entry['field_type']} entries are required")
|
|
116
|
-
case "entries":
|
|
117
|
-
return None
|
|
118
114
|
case "file":
|
|
119
115
|
accept = RESUME_TYPES if entry["field_type"] == "resume" else None
|
|
120
116
|
return Field(fid, label, FieldType.FILE, required, accept=accept)
|
|
@@ -157,6 +153,7 @@ def _options(entry: dict[str, Any], client: str) -> list[str] | None:
|
|
|
157
153
|
|
|
158
154
|
class Manatal:
|
|
159
155
|
name = "manatal"
|
|
156
|
+
known_gaps = {"entries": Gap.RENDERING}
|
|
160
157
|
side_effects = [
|
|
161
158
|
SideEffect("submit", "hiring_system", "the resume and attachments are uploaded"),
|
|
162
159
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -185,7 +182,7 @@ class Manatal:
|
|
|
185
182
|
entries = _get(f"jobs/{job.group(1)}/application-form/")
|
|
186
183
|
terms = bool(TERMS_REQUIRED.search(html))
|
|
187
184
|
return self._fields(entries, terms, lambda e: _options(e, client))
|
|
188
|
-
except OSError, ValueError, KeyError, TypeError
|
|
185
|
+
except OSError, ValueError, KeyError, TypeError:
|
|
189
186
|
return None
|
|
190
187
|
|
|
191
188
|
def read_form(self, page: Page) -> list[Field]:
|
|
@@ -245,7 +242,7 @@ class Manatal:
|
|
|
245
242
|
named.evaluate(_CHECK, answer == "Yes")
|
|
246
243
|
case kind, _:
|
|
247
244
|
raise Unsupported(
|
|
248
|
-
f"
|
|
245
|
+
f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
249
246
|
)
|
|
250
247
|
|
|
251
248
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
@@ -274,11 +271,22 @@ class Manatal:
|
|
|
274
271
|
terms: bool,
|
|
275
272
|
options: Callable[[dict[str, Any]], list[str] | None],
|
|
276
273
|
) -> list[Field]:
|
|
277
|
-
fields = [
|
|
274
|
+
fields = [
|
|
275
|
+
self._entries_field(e) if _kind(e) == "entries" else _field(e, options(e))
|
|
276
|
+
for e in entries
|
|
277
|
+
]
|
|
278
278
|
if terms:
|
|
279
279
|
fields.append(Field(TERMS, TERMS_LABEL, FieldType.CHOICE, True, YES_NO))
|
|
280
280
|
return fields
|
|
281
281
|
|
|
282
|
+
def _entries_field(self, entry: dict[str, Any]) -> Field:
|
|
283
|
+
"""An "Add Education" or "Add Experience" sub-form of entries."""
|
|
284
|
+
fid = str(entry["id"])
|
|
285
|
+
gap = known_gap(self, "entries", f"{fid} is a list of {entry['field_type']}")
|
|
286
|
+
return Field(
|
|
287
|
+
fid, entry["label"], FieldType.TEXT, bool(entry["is_required"]), unfillable=gap
|
|
288
|
+
)
|
|
289
|
+
|
|
282
290
|
@staticmethod
|
|
283
291
|
def _shown(page: Page, entry: dict[str, Any]) -> list[str] | None:
|
|
284
292
|
"""The options the page shows for a choice, without the empty placeholder."""
|
|
@@ -16,7 +16,7 @@ from typing import Any
|
|
|
16
16
|
from urllib.parse import urlsplit
|
|
17
17
|
|
|
18
18
|
from atsfill.core import browser, matching
|
|
19
|
-
from atsfill.core.adapter import Blocked, NotTaken, Unsupported
|
|
19
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
20
20
|
from atsfill.core.browser import Locator, Page
|
|
21
21
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
22
22
|
|
|
@@ -230,6 +230,7 @@ _READ_BACK = """(ids) => {
|
|
|
230
230
|
|
|
231
231
|
class Oracle:
|
|
232
232
|
name = "oracle"
|
|
233
|
+
known_gaps = {"entries": Gap.RENDERING}
|
|
233
234
|
side_effects = [
|
|
234
235
|
SideEffect(
|
|
235
236
|
"load",
|
|
@@ -358,7 +359,7 @@ class Oracle:
|
|
|
358
359
|
except browser.Timeout:
|
|
359
360
|
if page.locator(CAPTCHA).count():
|
|
360
361
|
raise Blocked("captcha: the email step asks for an hCaptcha") from None
|
|
361
|
-
raise
|
|
362
|
+
raise RuntimeError("the email step did not open the form") from None
|
|
362
363
|
if page.locator(CODE_PAGE).count():
|
|
363
364
|
raise Blocked(
|
|
364
365
|
f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
|
|
@@ -370,9 +371,8 @@ class Oracle:
|
|
|
370
371
|
def _described(self, page: Page, raw: dict[str, Any]) -> Field | None:
|
|
371
372
|
"""The field a raw description is, None for one atsfill leaves out."""
|
|
372
373
|
if raw["kind"] == "tiles":
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return None
|
|
374
|
+
gap = known_gap(self, "entries", f"{raw['label']!r} needs entries added")
|
|
375
|
+
return Field(raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=gap)
|
|
376
376
|
if raw["kind"] == "email":
|
|
377
377
|
self._kinds[EMAIL] = "email"
|
|
378
378
|
return None
|
|
@@ -388,7 +388,7 @@ class Oracle:
|
|
|
388
388
|
"""The innermost row holding the field."""
|
|
389
389
|
selector = page.evaluate(_ROW, field_id)
|
|
390
390
|
if selector is None:
|
|
391
|
-
raise
|
|
391
|
+
raise RuntimeError(f"{field_id} left the form while it was filled")
|
|
392
392
|
return page.locator(".input-row").filter(has=page.locator(selector)).last
|
|
393
393
|
|
|
394
394
|
def _options(self, page: Page, field_id: str) -> list[str]:
|
|
@@ -455,7 +455,7 @@ class Oracle:
|
|
|
455
455
|
_choose(page, boxes.nth(2), year, exact=True)
|
|
456
456
|
case _:
|
|
457
457
|
raise Unsupported(
|
|
458
|
-
f"
|
|
458
|
+
f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
459
459
|
)
|
|
460
460
|
|
|
461
461
|
def _phone(self, page: Page, row: Locator, answer: str) -> None:
|
|
@@ -543,7 +543,7 @@ def _go_to(page: Page, number: int) -> None:
|
|
|
543
543
|
)
|
|
544
544
|
_settle(page)
|
|
545
545
|
return
|
|
546
|
-
raise
|
|
546
|
+
raise RuntimeError(f"page {number} of the form did not open")
|
|
547
547
|
|
|
548
548
|
|
|
549
549
|
def _upload(field_id: str) -> str:
|