atsfill 0.0.5.dev192__tar.gz → 0.0.5.dev193__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.dev193}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/README.md +1 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev193/src/atsfill/adapters/werecruit.py +230 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/registry.toml +8 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/LICENSE +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev193}/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.dev193
|
|
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(),
|
|
@@ -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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|