atsfill 0.0.4.dev22__tar.gz → 0.0.4.dev24__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.dev22 → atsfill-0.0.4.dev24}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/README.md +1 -1
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev24/src/atsfill/adapters/manatal.py +294 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/registry.py +1 -1
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/registry.toml +3 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/LICENSE +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev22 → atsfill-0.0.4.dev24}/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.dev24
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
     
|
|
56
|
+
      
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
     
|
|
32
|
+
      
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
2
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.manatal import Manatal
|
|
3
4
|
from atsfill.adapters.oracle import Oracle
|
|
4
5
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
5
6
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
@@ -13,4 +14,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
13
14
|
WelcomeToTheJungle(),
|
|
14
15
|
SmartRecruiters(),
|
|
15
16
|
Oracle(),
|
|
17
|
+
Manatal(),
|
|
16
18
|
]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Manatal (`www.careers-page.com`), as docs/adapters/manatal.md observes.
|
|
2
|
+
|
|
3
|
+
The apply page renders the fields its `application-form` endpoint lists and reads the inputs
|
|
4
|
+
back only when Apply is clicked, so the inputs are the read-back.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from atsfill.core.adapter import Blocked, Unsupported
|
|
15
|
+
from atsfill.core.browser import Page
|
|
16
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
|
+
|
|
18
|
+
HOST = "www.careers-page.com"
|
|
19
|
+
API = f"https://{HOST}/api/v1.0/"
|
|
20
|
+
HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
|
21
|
+
JOB_ID = re.compile(r'const selectedJobId = "(\d+)"')
|
|
22
|
+
TERMS_REQUIRED = re.compile(r'const isTCPPRequired = "True"')
|
|
23
|
+
NUMBER = re.compile(r"\d+(\.\d+)?") # what a number input with `min="0"` takes
|
|
24
|
+
TERMS = "terms_and_condition"
|
|
25
|
+
TERMS_LABEL = "I agree to the terms and conditions & privacy policy"
|
|
26
|
+
YES_NO = ["Yes", "No"]
|
|
27
|
+
GENDERS = ["Male", "Female", "Other"]
|
|
28
|
+
NOTICE_PERIODS = ["Immediately", *(f"{d} days" for d in range(10, 130, 10)), "120+ days"]
|
|
29
|
+
YEARS = ["No experience", *(f"{n}+ year" for n in range(1, 21))]
|
|
30
|
+
# Slugs shown as a multiple select of a list fetched apart: its path and an item's label.
|
|
31
|
+
LISTS: dict[str, tuple[str, Callable[[dict[str, Any]], str]]] = {
|
|
32
|
+
"nationalities": ("nationalities/", lambda n: f"{n['demonym']} ({n['common_name']})"),
|
|
33
|
+
"languages": ("languages/", lambda n: n["name"]),
|
|
34
|
+
"industries": ("clients/{client}/industries/", lambda n: n["name"]),
|
|
35
|
+
}
|
|
36
|
+
ENTRIES = ("educations", "experiences") # a sub-form of entries: left out when optional
|
|
37
|
+
SHORT = {"full_name", "email", "phone_number", "city", "address", "latest_degree",
|
|
38
|
+
"latest_university", "current_company", "current_department", "current_position",
|
|
39
|
+
"current_benefits", "expected_benefits", "skype", "contact"} # fmt: skip
|
|
40
|
+
RESUME_TYPES = [".pdf", ".doc", ".docx", ".rtf"]
|
|
41
|
+
LOAD_TIMEOUT_MS = 30_000
|
|
42
|
+
SUBMIT_TIMEOUT_MS = 120_000
|
|
43
|
+
|
|
44
|
+
# The answer each input holds, by field id, read as the page reads them on Apply.
|
|
45
|
+
_READ = """fields => Object.fromEntries(fields.map(([id, kind]) => {
|
|
46
|
+
const el = document.getElementById('field' + id);
|
|
47
|
+
const named = [...document.querySelectorAll(`input[name="${id}"]`)];
|
|
48
|
+
const texts = s => [...s.selectedOptions].filter(o => o.value).map(o => o.text.trim());
|
|
49
|
+
switch (kind) {
|
|
50
|
+
case 'file': return [id, el && el.files.length ? el.files[0].name : null];
|
|
51
|
+
case 'boolean': return [id, el.checked ? 'Yes' : 'No'];
|
|
52
|
+
case 'radio': return [id, (named.find(i => i.checked) || {}).value || null];
|
|
53
|
+
case 'checkbox': return [id, named.filter(i => i.checked).map(i => i.value)];
|
|
54
|
+
case 'select': return [id, texts(el)[0] || null];
|
|
55
|
+
case 'multiple': return [id, texts(el)];
|
|
56
|
+
case 'terms': return [id, document.querySelector(`input[name="${id}"]`).checked ? 'Yes' : 'No'];
|
|
57
|
+
default: return [id, el ? el.value || null : null];
|
|
58
|
+
}
|
|
59
|
+
}))"""
|
|
60
|
+
# Selects the options with these labels on a select2 multiple select, which select2 follows.
|
|
61
|
+
_SELECT = """(el, labels) => {
|
|
62
|
+
for (const o of el.options) o.selected = labels.includes(o.text.trim());
|
|
63
|
+
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
64
|
+
}"""
|
|
65
|
+
# Clicks a radio or a box when its state differs: the styled mark over it can sit outside the
|
|
66
|
+
# viewport, out of reach of a pointer click.
|
|
67
|
+
_CHECK = """(el, checked) => { if (el.checked !== checked) el.click(); }"""
|
|
68
|
+
# Types a date into flatpickr's read-only input, in the `Y-m-d` format it is set up with.
|
|
69
|
+
_DATE = """(el, value) => {
|
|
70
|
+
el.value = value;
|
|
71
|
+
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
72
|
+
}"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _posting(url: str) -> str:
|
|
76
|
+
"""`https://www.careers-page.com/<client>/job/<code>`, without `/apply` or a query."""
|
|
77
|
+
client, _, code = urlsplit(url).path.strip("/").split("/")[:3]
|
|
78
|
+
return f"https://{HOST}/{client}/job/{code}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _kind(entry: dict[str, Any]) -> str:
|
|
82
|
+
"""How the page renders the field: the input `_READ` and `fill` work on."""
|
|
83
|
+
slug, ftype = entry["client_field_slug"], entry["client_field_field_type"]
|
|
84
|
+
if entry["field_type"] in ("resume", "attachment"):
|
|
85
|
+
return "file"
|
|
86
|
+
if entry["field_type"] in ENTRIES:
|
|
87
|
+
return "entries"
|
|
88
|
+
if entry["field_type"] == "candidate_field":
|
|
89
|
+
if slug in ("gender", "current_notice_period", "years_of_experience"):
|
|
90
|
+
return "select"
|
|
91
|
+
if slug in LISTS or ftype == "multiple_select_dropdown":
|
|
92
|
+
return "multiple"
|
|
93
|
+
if ftype == "dropdown":
|
|
94
|
+
return "select"
|
|
95
|
+
if ftype == "multiple_choice":
|
|
96
|
+
return "radio"
|
|
97
|
+
if ftype == "checkbox":
|
|
98
|
+
return "checkbox"
|
|
99
|
+
match entry["response_type"]:
|
|
100
|
+
case "boolean":
|
|
101
|
+
return "boolean"
|
|
102
|
+
case "datetime":
|
|
103
|
+
return "date"
|
|
104
|
+
case "integer":
|
|
105
|
+
return "number"
|
|
106
|
+
return "text"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _field(entry: dict[str, Any], options: list[str] | None) -> Field | None:
|
|
110
|
+
"""The field an entry of the endpoint lists, None for optional entries."""
|
|
111
|
+
kind, slug = _kind(entry), entry["client_field_slug"]
|
|
112
|
+
fid, label, required = str(entry["id"]), entry["label"], bool(entry["is_required"])
|
|
113
|
+
match kind:
|
|
114
|
+
case "entries" if required:
|
|
115
|
+
raise Unsupported(f"form_shape: {entry['field_type']} entries are required")
|
|
116
|
+
case "entries":
|
|
117
|
+
return None
|
|
118
|
+
case "file":
|
|
119
|
+
accept = RESUME_TYPES if entry["field_type"] == "resume" else None
|
|
120
|
+
return Field(fid, label, FieldType.FILE, required, accept=accept)
|
|
121
|
+
case "boolean":
|
|
122
|
+
return Field(fid, label, FieldType.CHOICE, required, YES_NO)
|
|
123
|
+
case "date":
|
|
124
|
+
return Field(fid, label, FieldType.DATE, required)
|
|
125
|
+
case "select" | "radio":
|
|
126
|
+
return Field(fid, label, FieldType.CHOICE, required, options)
|
|
127
|
+
case "multiple" | "checkbox":
|
|
128
|
+
return Field(fid, label, FieldType.MULTI_CHOICE, required, options)
|
|
129
|
+
case "number":
|
|
130
|
+
return Field(fid, f"{label} (a number)", FieldType.TEXT, required)
|
|
131
|
+
if slug == "phone_number":
|
|
132
|
+
return Field(fid, label, FieldType.PHONE, required)
|
|
133
|
+
short = 255 if slug in SHORT or entry["field_type"] == "other" else 1000
|
|
134
|
+
return Field(fid, label, FieldType.TEXT, required, max_length=short)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get(path: str) -> Any:
|
|
138
|
+
request = urllib.request.Request(API + path, headers=HEADERS)
|
|
139
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
140
|
+
return json.load(response)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _options(entry: dict[str, Any], client: str) -> list[str] | None:
|
|
144
|
+
"""The options the endpoint's entry names, the page's fixed lists or a list fetched apart."""
|
|
145
|
+
match entry["client_field_slug"]:
|
|
146
|
+
case "gender":
|
|
147
|
+
return GENDERS
|
|
148
|
+
case "current_notice_period":
|
|
149
|
+
return NOTICE_PERIODS
|
|
150
|
+
case "years_of_experience":
|
|
151
|
+
return YEARS
|
|
152
|
+
case slug if slug in LISTS:
|
|
153
|
+
path, label = LISTS[slug]
|
|
154
|
+
return [label(n) for n in _get(path.format(client=client))]
|
|
155
|
+
return entry["client_field_answer_choices"] or None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Manatal:
|
|
159
|
+
name = "manatal"
|
|
160
|
+
side_effects = [
|
|
161
|
+
SideEffect("submit", "hiring_system", "the resume and attachments are uploaded"),
|
|
162
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def __init__(self) -> None:
|
|
166
|
+
self._entries: list[dict[str, Any]] = []
|
|
167
|
+
|
|
168
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
169
|
+
parts = urlsplit(url).path.strip("/").split("/")
|
|
170
|
+
return urlsplit(url).hostname == HOST and len(parts) >= 3 and parts[1] == "job"
|
|
171
|
+
|
|
172
|
+
def posting(self, url: str) -> str:
|
|
173
|
+
return _posting(url)
|
|
174
|
+
|
|
175
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
176
|
+
"""The fields from the endpoint the apply page fetches, its job id from that page."""
|
|
177
|
+
client = urlsplit(url).path.strip("/").split("/")[0]
|
|
178
|
+
try:
|
|
179
|
+
apply = urllib.request.Request(_posting(url) + "/apply", headers=HEADERS)
|
|
180
|
+
with urllib.request.urlopen(apply, timeout=20) as response:
|
|
181
|
+
html = response.read().decode()
|
|
182
|
+
job = JOB_ID.search(html)
|
|
183
|
+
if job is None:
|
|
184
|
+
return None
|
|
185
|
+
entries = _get(f"jobs/{job.group(1)}/application-form/")
|
|
186
|
+
terms = bool(TERMS_REQUIRED.search(html))
|
|
187
|
+
return self._fields(entries, terms, lambda e: _options(e, client))
|
|
188
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
192
|
+
with page.expect_response(
|
|
193
|
+
lambda r: r.request.method == "GET" and "/application-form/" in r.url,
|
|
194
|
+
timeout=LOAD_TIMEOUT_MS,
|
|
195
|
+
) as loaded:
|
|
196
|
+
page.goto(_posting(page.url) + "/apply")
|
|
197
|
+
response = loaded.value
|
|
198
|
+
if response.headers.get("x-amzn-waf-action") == "challenge":
|
|
199
|
+
raise Blocked("captcha: AWS WAF challenge")
|
|
200
|
+
if not response.ok:
|
|
201
|
+
raise Unsupported(f"posting_gone: the form answered {response.status}")
|
|
202
|
+
self._entries = response.json()
|
|
203
|
+
page.wait_for_selector("#submit-id-submit", timeout=LOAD_TIMEOUT_MS)
|
|
204
|
+
# The lists of a multiple select come in their own requests, once the form is drawn.
|
|
205
|
+
page.wait_for_function(
|
|
206
|
+
"() => [...document.querySelectorAll('select.multiselect')]"
|
|
207
|
+
".every(s => s.options.length && s.classList.contains('select2-hidden-accessible'))",
|
|
208
|
+
timeout=LOAD_TIMEOUT_MS,
|
|
209
|
+
)
|
|
210
|
+
terms = page.locator(f'input[name="{TERMS}"]').count() > 0
|
|
211
|
+
return self._fields(self._entries, terms, lambda e: self._shown(page, e))
|
|
212
|
+
|
|
213
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
214
|
+
kinds = {str(e["id"]): _kind(e) for e in self._entries} | {TERMS: "terms"}
|
|
215
|
+
for field_id, answer in answers.items():
|
|
216
|
+
box = page.locator(f"#field{field_id}")
|
|
217
|
+
named = page.locator(f'input[name="{field_id}"]')
|
|
218
|
+
match kinds.get(field_id), answer:
|
|
219
|
+
case "text", str():
|
|
220
|
+
box.fill(answer)
|
|
221
|
+
case "number", str() if NUMBER.fullmatch(answer.strip()):
|
|
222
|
+
box.fill(answer.strip())
|
|
223
|
+
case "number", str():
|
|
224
|
+
pass # a number input holds no text: it reads back empty
|
|
225
|
+
case "date", str():
|
|
226
|
+
box.evaluate(_DATE, answer)
|
|
227
|
+
case "file", str():
|
|
228
|
+
box.set_input_files(answer)
|
|
229
|
+
case "select", str():
|
|
230
|
+
box.select_option(label=answer)
|
|
231
|
+
case "multiple", list():
|
|
232
|
+
box.evaluate(_SELECT, answer)
|
|
233
|
+
case "radio", str():
|
|
234
|
+
named.and_(
|
|
235
|
+
page.locator(f"[value={json.dumps(answer, ensure_ascii=False)}]")
|
|
236
|
+
).evaluate(_CHECK, True)
|
|
237
|
+
case "checkbox", list():
|
|
238
|
+
for value in answer:
|
|
239
|
+
named.and_(
|
|
240
|
+
page.locator(f"[value={json.dumps(value, ensure_ascii=False)}]")
|
|
241
|
+
).evaluate(_CHECK, True)
|
|
242
|
+
case "boolean", str():
|
|
243
|
+
box.evaluate(_CHECK, answer == "Yes")
|
|
244
|
+
case "terms", str():
|
|
245
|
+
named.evaluate(_CHECK, answer == "Yes")
|
|
246
|
+
case kind, _:
|
|
247
|
+
raise Unsupported(
|
|
248
|
+
f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
252
|
+
kinds = [[str(e["id"]), _kind(e)] for e in self._entries if _kind(e) != "entries"]
|
|
253
|
+
if page.locator(f'input[name="{TERMS}"]').count():
|
|
254
|
+
kinds.append([TERMS, "terms"])
|
|
255
|
+
held = page.evaluate(_READ, kinds)
|
|
256
|
+
return {k: v for k, v in held.items() if v not in (None, [])}
|
|
257
|
+
|
|
258
|
+
def submit(self, page: Page) -> str | None:
|
|
259
|
+
with page.expect_response(
|
|
260
|
+
lambda r: r.request.method == "POST" and "/application-form/" in r.url,
|
|
261
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
262
|
+
) as submitted:
|
|
263
|
+
page.locator("#submit-id-submit").click()
|
|
264
|
+
response = submitted.value
|
|
265
|
+
if response.headers.get("x-amzn-waf-action") in ("challenge", "captcha"):
|
|
266
|
+
raise Blocked("captcha: AWS WAF challenge")
|
|
267
|
+
if not response.ok:
|
|
268
|
+
raise Blocked(f"form_errors: {response.status} {response.text()[:300]}")
|
|
269
|
+
return f"POST application-form → {response.status} ({page.url})"
|
|
270
|
+
|
|
271
|
+
def _fields(
|
|
272
|
+
self,
|
|
273
|
+
entries: list[dict[str, Any]],
|
|
274
|
+
terms: bool,
|
|
275
|
+
options: Callable[[dict[str, Any]], list[str] | None],
|
|
276
|
+
) -> list[Field]:
|
|
277
|
+
fields = [f for e in entries if (f := _field(e, options(e))) is not None]
|
|
278
|
+
if terms:
|
|
279
|
+
fields.append(Field(TERMS, TERMS_LABEL, FieldType.CHOICE, True, YES_NO))
|
|
280
|
+
return fields
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _shown(page: Page, entry: dict[str, Any]) -> list[str] | None:
|
|
284
|
+
"""The options the page shows for a choice, without the empty placeholder."""
|
|
285
|
+
match _kind(entry):
|
|
286
|
+
case "select" | "multiple":
|
|
287
|
+
return page.locator(f"#field{entry['id']} option").evaluate_all(
|
|
288
|
+
"os => os.filter(o => o.value).map(o => o.text.trim())"
|
|
289
|
+
)
|
|
290
|
+
case "radio" | "checkbox":
|
|
291
|
+
return page.locator(f'input[name="{entry["id"]}"]').evaluate_all(
|
|
292
|
+
"is => is.map(i => i.value)"
|
|
293
|
+
)
|
|
294
|
+
return None
|
|
@@ -9,7 +9,7 @@ from importlib.resources import files
|
|
|
9
9
|
KINDS = ("ats", "board", "aggregator", "custom")
|
|
10
10
|
ACCOUNTS = ("none", "to_send", "to_read") # also the backlog order (ADR 0013)
|
|
11
11
|
HOSTING = ("hosted", "embedded")
|
|
12
|
-
VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "unknown")
|
|
12
|
+
VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "hCaptcha", "unknown")
|
|
13
13
|
STEPS = ("posting", "form", "upload")
|
|
14
14
|
STATES = ("mapped", "dry_run_verified", "send_verified")
|
|
15
15
|
CAUSES = ("system", "code", "unknown")
|
|
@@ -102,6 +102,7 @@ kind = "ats"
|
|
|
102
102
|
share = 0.0244
|
|
103
103
|
account = "none"
|
|
104
104
|
hosting = ["hosted"]
|
|
105
|
+
block = { vendor = "hCaptcha", signal = "image challenge on Continue To Application, the step before the form, 3 of 3 companies headless, and again on a virtual display and headed", step = "form", date = 2026-10-20, version = "0.0.3", camoufox = "0.5.8", browser = "156.0.1-beta.36" }
|
|
105
106
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
106
107
|
|
|
107
108
|
[icims]
|
|
@@ -116,7 +117,9 @@ kind = "ats"
|
|
|
116
117
|
share = 0.0187
|
|
117
118
|
account = "none"
|
|
118
119
|
hosting = ["hosted"]
|
|
120
|
+
adapter = "manatal"
|
|
119
121
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
122
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "e682656" }
|
|
120
123
|
|
|
121
124
|
[paylocity]
|
|
122
125
|
kind = "ats"
|
|
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
|