atsfill 0.0.5.dev192__tar.gz → 0.0.5.dev194__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.dev192 → atsfill-0.0.5.dev194}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/README.md +1 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/__init__.py +2 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/teamtailor.py +6 -2
- atsfill-0.0.5.dev194/src/atsfill/adapters/werecruit.py +230 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/adapter.py +9 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/commands.py +3 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/registry.toml +8 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/LICENSE +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/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.dev194
|
|
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
|
|
@@ -82,6 +82,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
82
82
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
83
83
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
84
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
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/)
|
|
87
88
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -53,6 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
54
54
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
55
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
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/)
|
|
58
59
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -30,6 +30,7 @@ from atsfill.adapters.successfactors import SuccessFactors
|
|
|
30
30
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
31
31
|
from atsfill.adapters.ukg import UKG
|
|
32
32
|
from atsfill.adapters.welcomekit import WelcomeKit
|
|
33
|
+
from atsfill.adapters.werecruit import WeRecruit
|
|
33
34
|
from atsfill.adapters.workable import Workable
|
|
34
35
|
from atsfill.adapters.workatastartup import WorkAtAStartup
|
|
35
36
|
from atsfill.adapters.workday import Workday
|
|
@@ -49,6 +50,7 @@ def adapters() -> list[Adapter]:
|
|
|
49
50
|
Join(),
|
|
50
51
|
JobAffinity(),
|
|
51
52
|
WelcomeKit(),
|
|
53
|
+
WeRecruit(),
|
|
52
54
|
SmartRecruiters(),
|
|
53
55
|
Oracle(),
|
|
54
56
|
Manatal(),
|
|
@@ -16,7 +16,7 @@ from typing import Any
|
|
|
16
16
|
from urllib.parse import urlsplit
|
|
17
17
|
|
|
18
18
|
from atsfill.core import browser, html, matching
|
|
19
|
-
from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
|
|
19
|
+
from atsfill.core.adapter import Gap, Held, NotTaken, Unsupported, known_gap
|
|
20
20
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Response
|
|
21
21
|
from atsfill.core.html import Node
|
|
22
22
|
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
@@ -31,6 +31,8 @@ VIDEO = "[data-controller~='questions--tt-video-answer']"
|
|
|
31
31
|
UPLOAD = "forms--inputs--upload" # a drop zone's controller
|
|
32
32
|
CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
|
|
33
33
|
JOB = re.compile(r"/jobs/(\d+)[^/]*")
|
|
34
|
+
EMAIL_LINK = "/applications/email_verification_needed"
|
|
35
|
+
SENT = re.compile(rf"/applications/[^/]+/thanks|{EMAIL_LINK}")
|
|
34
36
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
35
37
|
FOLLOW_UP_TIMEOUT_MS = 1_000
|
|
36
38
|
UPLOAD_TIMEOUT_S = 60
|
|
@@ -235,7 +237,9 @@ class Teamtailor:
|
|
|
235
237
|
|
|
236
238
|
def submit(self, page: Page) -> str | None:
|
|
237
239
|
browser.click(page.locator(f"{FORM} [type=submit]"))
|
|
238
|
-
page.wait_for_url(
|
|
240
|
+
page.wait_for_url(SENT, timeout=SUBMIT_TIMEOUT_MS, wait_until="domcontentloaded")
|
|
241
|
+
if page.url.split("?")[0].endswith(EMAIL_LINK):
|
|
242
|
+
raise Held("click the link emailed to the applicant to complete it")
|
|
239
243
|
return page.url
|
|
240
244
|
|
|
241
245
|
def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""WeRecruit (`careers.werecruit.io/<lang>/<company>/offres/<slug>-<id>`), as
|
|
2
|
+
docs/adapters/werecruit.md observes.
|
|
3
|
+
|
|
4
|
+
The apply form is on the posting page, rendered on the server: `request_read` fetches it over
|
|
5
|
+
HTTP, `browser_read` takes it from the browser, and both list the fields with one parser. The
|
|
6
|
+
documents go with the form's one multipart post, back to the posting's URL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.error import HTTPError
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import html
|
|
16
|
+
from atsfill.core.adapter import Gap, Unsupported
|
|
17
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = "careers.werecruit.io"
|
|
21
|
+
# A posting on WeRecruit's host names its company; on an employer's own domain it does not.
|
|
22
|
+
HOSTED = re.compile(r"^/(?:fr|en)/([\w-]+)/(?:offres|offers)/(?:[\w-]*-)?([0-9a-f]{6})/?$")
|
|
23
|
+
OWN_DOMAIN = re.compile(r"^/(?:fr|en)/(?:offres|offers)/(?:[\w-]*-)?([0-9a-f]{6})/?$")
|
|
24
|
+
FORM = "form#form"
|
|
25
|
+
PORTAL = f"{FORM} input[name=isEmployerPortal]" # WeRecruit's form, whatever the domain
|
|
26
|
+
FORM_TIMEOUT_MS = 30_000
|
|
27
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
28
|
+
KINDS = {
|
|
29
|
+
"text": FieldType.TEXT,
|
|
30
|
+
"email": FieldType.TEXT,
|
|
31
|
+
"tel": FieldType.PHONE,
|
|
32
|
+
"textarea": FieldType.TEXT,
|
|
33
|
+
"select": FieldType.CHOICE,
|
|
34
|
+
"radio": FieldType.CHOICE,
|
|
35
|
+
"file": FieldType.FILE,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# What each control holds, by name: a value, a ticked radio's id, a document's file name.
|
|
39
|
+
_READ_BACK = """(form, names) => {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const els = [...form.querySelectorAll(`[name="${name}"]`)];
|
|
43
|
+
const el = els[0];
|
|
44
|
+
if (!el) continue;
|
|
45
|
+
if (el.type === "radio") out[name] = els.find(e => e.checked)?.id ?? "";
|
|
46
|
+
else if (el.type === "file") out[name] = el.files[0]?.name ?? "";
|
|
47
|
+
else out[name] = el.value ?? "";
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _Control:
|
|
55
|
+
field: Field
|
|
56
|
+
name: str # the control's own name, which may carry spaces (` photo`)
|
|
57
|
+
kind: str
|
|
58
|
+
options: dict[str, str] # option text by value, or by radio id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WeRecruit:
|
|
62
|
+
name = "werecruit"
|
|
63
|
+
known_gaps: dict[str, Gap] = {}
|
|
64
|
+
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._controls: list[_Control] = []
|
|
68
|
+
|
|
69
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
70
|
+
"""A posting on WeRecruit's host, or one on an employer's domain that serves its form."""
|
|
71
|
+
parts = urlsplit(url)
|
|
72
|
+
if parts.hostname == HOST:
|
|
73
|
+
return HOSTED.match(parts.path) is not None
|
|
74
|
+
if not OWN_DOMAIN.match(parts.path):
|
|
75
|
+
return False
|
|
76
|
+
return page is not None and page.locator(PORTAL).count() > 0
|
|
77
|
+
|
|
78
|
+
def posting(self, url: str) -> str:
|
|
79
|
+
"""The company and the id: the slug and the language do not change the posting."""
|
|
80
|
+
parts = urlsplit(url)
|
|
81
|
+
if parts.hostname == HOST and (m := HOSTED.match(parts.path)):
|
|
82
|
+
return f"https://{HOST}/fr/{m[1]}/offres/{m[2]}"
|
|
83
|
+
if m := OWN_DOMAIN.match(parts.path):
|
|
84
|
+
return f"https://{parts.hostname}/fr/offres/{m[1]}"
|
|
85
|
+
return url
|
|
86
|
+
|
|
87
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
88
|
+
if not self.detect(url, None):
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
controls = _parse(_get(url.split("?")[0]))
|
|
92
|
+
except HTTPError, OSError, ValueError:
|
|
93
|
+
return None
|
|
94
|
+
return [c.field for c in controls] if controls is not None else None
|
|
95
|
+
|
|
96
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
97
|
+
try:
|
|
98
|
+
page.locator(FORM).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
99
|
+
except Timeout:
|
|
100
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
101
|
+
controls = _parse(page.content())
|
|
102
|
+
assert controls is not None
|
|
103
|
+
self._controls = controls
|
|
104
|
+
return [c.field for c in controls]
|
|
105
|
+
|
|
106
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
107
|
+
form = page.locator(FORM)
|
|
108
|
+
for c in self._controls:
|
|
109
|
+
if c.field.id not in answers or c.field.unfillable:
|
|
110
|
+
continue
|
|
111
|
+
value = _one(answers[c.field.id])
|
|
112
|
+
control = form.locator(f'[name="{c.name}"]')
|
|
113
|
+
match c.kind:
|
|
114
|
+
case "select":
|
|
115
|
+
by_text = {t: v for v, t in c.options.items()}
|
|
116
|
+
control.select_option(value=by_text.get(value, value))
|
|
117
|
+
case "radio":
|
|
118
|
+
radio = next((i for i, t in c.options.items() if t == value), value)
|
|
119
|
+
form.locator(f'label[for="{radio}"]').click()
|
|
120
|
+
case "file":
|
|
121
|
+
control.set_input_files(value)
|
|
122
|
+
case _:
|
|
123
|
+
control.fill(value)
|
|
124
|
+
|
|
125
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
126
|
+
shown = [c for c in self._controls if not c.field.unfillable]
|
|
127
|
+
values = page.locator(FORM).evaluate(_READ_BACK, [c.name for c in shown])
|
|
128
|
+
out: dict[str, Answer] = {}
|
|
129
|
+
for c in shown:
|
|
130
|
+
value = values.get(c.name)
|
|
131
|
+
if c.kind in ("select", "radio"):
|
|
132
|
+
value = c.options.get(value, value)
|
|
133
|
+
if value not in (None, ""):
|
|
134
|
+
out[c.field.id] = value
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
def submit(self, page: Page) -> str | None:
|
|
138
|
+
"""Clicks Je postule; the evidence is the URL and status of the form's post. The page
|
|
139
|
+
validates first: no post, and its errors are the failure."""
|
|
140
|
+
action = page.locator(FORM).get_attribute("action") or page.url
|
|
141
|
+
try:
|
|
142
|
+
with page.expect_response(
|
|
143
|
+
lambda r: r.request.method == "POST" and r.url.split("?")[0] == action,
|
|
144
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
145
|
+
) as posted:
|
|
146
|
+
page.locator("#submit-button").click()
|
|
147
|
+
except Timeout:
|
|
148
|
+
raise RuntimeError(f"the form was not posted: {_errors(page)}") from None
|
|
149
|
+
if posted.value.status >= 400:
|
|
150
|
+
raise RuntimeError(f"the post answered {posted.value.status}")
|
|
151
|
+
return f"{posted.value.url} {posted.value.status}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _get(url: str) -> str:
|
|
155
|
+
request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
|
|
156
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
157
|
+
return response.read().decode()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse(source: str) -> list[_Control] | None:
|
|
161
|
+
"""Each named control of the apply form, a radio group once; None without the form."""
|
|
162
|
+
nodes = html.parse(source).walk()
|
|
163
|
+
form = next((n for n in nodes if n.tag == "form" and n.attrs.get("id") == "form"), None)
|
|
164
|
+
if form is None:
|
|
165
|
+
return None
|
|
166
|
+
labels = {n.attrs["for"]: n for n in form.walk() if n.tag == "label" and "for" in n.attrs}
|
|
167
|
+
out: list[_Control] = []
|
|
168
|
+
seen: set[str] = set()
|
|
169
|
+
for node in form.walk():
|
|
170
|
+
name = node.attrs.get("name")
|
|
171
|
+
if node.tag not in ("input", "select", "textarea") or not name or name in seen:
|
|
172
|
+
continue
|
|
173
|
+
kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
|
|
174
|
+
if kind in ("hidden", "submit", "button"):
|
|
175
|
+
continue
|
|
176
|
+
seen.add(name)
|
|
177
|
+
group = node.closest(lambda n: "form-group" in n.classes())
|
|
178
|
+
own = group.find(lambda n: n.tag == "label") if group else None
|
|
179
|
+
label = _label(own) if own is not None else name.strip()
|
|
180
|
+
required = "required" in node.attrs
|
|
181
|
+
fid = name.strip()
|
|
182
|
+
if kind not in KINDS:
|
|
183
|
+
reason = f"unhandled_rendering: {fid} is a {kind}"
|
|
184
|
+
field = Field(fid, label, FieldType.TEXT, required, unfillable=reason)
|
|
185
|
+
out.append(_Control(field, name, kind, {}))
|
|
186
|
+
continue
|
|
187
|
+
options: dict[str, str] = {}
|
|
188
|
+
if kind == "select":
|
|
189
|
+
options = {
|
|
190
|
+
o.attrs["value"]: " ".join(o.text().split())
|
|
191
|
+
for o in node.walk()
|
|
192
|
+
if o.tag == "option" and o.attrs.get("value")
|
|
193
|
+
}
|
|
194
|
+
elif kind == "radio":
|
|
195
|
+
options = {
|
|
196
|
+
r.attrs["id"]: _label(labels[r.attrs["id"]])
|
|
197
|
+
for r in form.walk()
|
|
198
|
+
if r.tag == "input" and r.attrs.get("name") == name and r.attrs.get("id") in labels
|
|
199
|
+
}
|
|
200
|
+
accept = node.attrs.get("accept", "").split(",") if kind == "file" else []
|
|
201
|
+
field = Field(
|
|
202
|
+
fid,
|
|
203
|
+
label,
|
|
204
|
+
KINDS[kind],
|
|
205
|
+
required,
|
|
206
|
+
list(options.values()) or None,
|
|
207
|
+
accept=[a.strip() for a in accept if a.strip()] or None,
|
|
208
|
+
)
|
|
209
|
+
out.append(_Control(field, name, kind, options))
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _label(node: html.Node) -> str:
|
|
214
|
+
"""A label's text, without its required marker."""
|
|
215
|
+
|
|
216
|
+
def text(n: html.Node | str) -> str:
|
|
217
|
+
if isinstance(n, str):
|
|
218
|
+
return n
|
|
219
|
+
return "" if "required" in n.classes() else "".join(text(c) for c in n.children)
|
|
220
|
+
|
|
221
|
+
return " ".join(text(node).split())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _one(answer: Answer) -> str:
|
|
225
|
+
return answer if isinstance(answer, str) else answer[0]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _errors(page: Page) -> str:
|
|
229
|
+
shown = page.locator(f"{FORM} .invalid-feedback:visible, #errorMessageDiv:visible")
|
|
230
|
+
return " ".join(" ".join(shown.all_inner_texts()).split())[:200] or "no error shown"
|
|
@@ -15,6 +15,13 @@ class Blocked(Exception):
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class Held(Exception):
|
|
19
|
+
"""Raised by `submit`: the system has the application but holds it until the applicant
|
|
20
|
+
acts, e.g. clicks an emailed link. The send counts as `submitted`, the message is the
|
|
21
|
+
reason's detail (ADR 0006).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
18
25
|
class SignedOut(Blocked):
|
|
19
26
|
"""The system's page asks to sign in although `signed_in` found the session holding: the
|
|
20
27
|
system ended it its own way. The core signs in again and reads once more (ADR 0015).
|
|
@@ -94,7 +101,8 @@ class Adapter(Protocol):
|
|
|
94
101
|
def submit(self, page: Page) -> str | None:
|
|
95
102
|
"""Clicks submit; returns the evidence (a URL or a network response), None without.
|
|
96
103
|
|
|
97
|
-
Raises `Blocked` when the system refuses the send
|
|
104
|
+
Raises `Blocked` when the system refuses the send, `Held` when it has the application but
|
|
105
|
+
holds it until the applicant acts.
|
|
98
106
|
"""
|
|
99
107
|
...
|
|
100
108
|
|
|
@@ -25,6 +25,7 @@ from atsfill.core.adapter import (
|
|
|
25
25
|
Blocked,
|
|
26
26
|
CreatesAccount,
|
|
27
27
|
CreatesAccountByOtp,
|
|
28
|
+
Held,
|
|
28
29
|
KeepsProfile,
|
|
29
30
|
ListsShownFields,
|
|
30
31
|
NeedsAccount,
|
|
@@ -503,6 +504,8 @@ def _on_page(
|
|
|
503
504
|
result.status = Status.SUBMITTED
|
|
504
505
|
else:
|
|
505
506
|
result.status, result.reason = Status.UNKNOWN, "no_evidence"
|
|
507
|
+
except Held as e:
|
|
508
|
+
result.status, result.evidence, result.reason = Status.SUBMITTED, page.url, f"held: {e}"
|
|
506
509
|
except Blocked as e:
|
|
507
510
|
result.status, result.reason = Status.BLOCKED, str(e)
|
|
508
511
|
except Exception as e: # after the click, nothing tells a failed send from a sent one
|
|
@@ -429,3 +429,11 @@ account = "none"
|
|
|
429
429
|
hosting = ["hosted"]
|
|
430
430
|
adapter = "welcomekit"
|
|
431
431
|
scope = { is = "supported", date = 2026-10-29, commit = "238798d" }
|
|
432
|
+
|
|
433
|
+
[werecruit]
|
|
434
|
+
kind = "ats"
|
|
435
|
+
share = 0.0007
|
|
436
|
+
account = "none"
|
|
437
|
+
hosting = ["hosted"]
|
|
438
|
+
adapter = "werecruit"
|
|
439
|
+
scope = { is = "supported", date = 2026-10-29, commit = "cf7634f" }
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|