atsfill 0.0.4.dev122__tar.gz → 0.0.4.dev123__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.dev122 → atsfill-0.0.4.dev123}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/README.md +1 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev123/src/atsfill/adapters/gupy.py +294 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/registry.toml +3 -1
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/LICENSE +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/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.dev123
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                          
|
|
60
|
+
                           
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| 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 |
|
|
@@ -4,6 +4,7 @@ from atsfill.adapters.breezy import Breezy
|
|
|
4
4
|
from atsfill.adapters.dayforce import Dayforce
|
|
5
5
|
from atsfill.adapters.eightfold import Eightfold
|
|
6
6
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
7
|
+
from atsfill.adapters.gupy import Gupy
|
|
7
8
|
from atsfill.adapters.hireology import Hireology
|
|
8
9
|
from atsfill.adapters.hiringthing import HiringThing
|
|
9
10
|
from atsfill.adapters.jazzhr import JazzHR
|
|
@@ -55,6 +56,7 @@ def adapters() -> list[Adapter]:
|
|
|
55
56
|
HiringThing(),
|
|
56
57
|
Eightfold(),
|
|
57
58
|
Workday(),
|
|
59
|
+
Gupy(),
|
|
58
60
|
UKG(),
|
|
59
61
|
SuccessFactors(), # it claims a career site's job path on any host
|
|
60
62
|
Phenom(), # last: it claims a URL by its path alone
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Gupy (`<employer>.gupy.io/jobs/<id>`), as docs/adapters/gupy.md observes.
|
|
2
|
+
|
|
3
|
+
Applying needs an account, one for the whole system, that atsfill signs in to; its sign-up asks
|
|
4
|
+
more than `login` takes. Apply opens the application's register step: the account's resume,
|
|
5
|
+
filled once and shared by every application, then the posting's Additional information and the
|
|
6
|
+
employer's questions, which the page lists from `question-forms`. A fill saves Additional
|
|
7
|
+
information; the questions are saved by submit, for good.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import browser
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^([a-z0-9-]+)\.gupy\.io$")
|
|
21
|
+
JOB = re.compile(r"^/(?:candidates/)?jobs/(\d+)")
|
|
22
|
+
API = "https://private-api.gupy.io"
|
|
23
|
+
ACCOUNT = f"{API}/authentication/candidate/account/current"
|
|
24
|
+
LOGIN = "https://login.gupy.io/candidates/signin"
|
|
25
|
+
FORMS = re.compile(r"/question-forms/candidates/applications/\d+/steps/\d+/forms$")
|
|
26
|
+
ADDITIONAL = "input[name=radioGroupIsIndicatedTitle]"
|
|
27
|
+
ANSWER_NOW = "button:has-text('Answer now')" # Additional information saved on an earlier visit
|
|
28
|
+
QUESTIONS = "[name^=question-], [name^=checkbox-], textarea, input[id^=input-]"
|
|
29
|
+
STEP_TIMEOUT_S = 40
|
|
30
|
+
STEP_POLL_MS = 500
|
|
31
|
+
RESUME_WAIT_S = 15
|
|
32
|
+
SAVE_TIMEOUT_MS = 30_000
|
|
33
|
+
SIGN_IN_TIMEOUT_MS = 30_000
|
|
34
|
+
FORMS_TIMEOUT_S = 10
|
|
35
|
+
YES_NO = ["Yes", "No"]
|
|
36
|
+
TYPES = {
|
|
37
|
+
"SELECT": FieldType.CHOICE,
|
|
38
|
+
"MULTI_SELECT": FieldType.MULTI_CHOICE,
|
|
39
|
+
"TEXT": FieldType.TEXT,
|
|
40
|
+
"TEXT_AREA": FieldType.TEXT,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Additional information: a radio group by its name, under its fieldset's legend; a text input.
|
|
44
|
+
_READ_ADDITIONAL = """() => {
|
|
45
|
+
const groups = {};
|
|
46
|
+
for (const e of document.querySelectorAll("main input")) {
|
|
47
|
+
if (e.type === "radio") {
|
|
48
|
+
const g = groups[e.name] ??= {id: e.name, kind: "radio", options: [], value: null,
|
|
49
|
+
label: e.closest("fieldset")?.querySelector("legend")?.textContent ?? e.name};
|
|
50
|
+
const text = e.labels[0]?.innerText.trim() ?? e.value;
|
|
51
|
+
g.options.push(text);
|
|
52
|
+
if (e.checked) g.value = text;
|
|
53
|
+
} else if (e.type === "text" && e.offsetParent !== null) {
|
|
54
|
+
groups[e.name || e.id] = {id: e.name || e.id, kind: e.getAttribute("role") || "text",
|
|
55
|
+
label: e.labels[0]?.innerText.trim() ?? e.name, options: null, value: e.value || null};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Object.values(groups).map(g => ({...g, label: g.label.replace(/\\s+/g, " ").trim()}));
|
|
59
|
+
}"""
|
|
60
|
+
|
|
61
|
+
# The employer's questions, by question id: the checked labels, or the text typed.
|
|
62
|
+
_READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
|
|
63
|
+
const radios = [...document.querySelectorAll(`input[name="question-${id}"]`)];
|
|
64
|
+
const boxes = [...document.querySelectorAll(`input[name^="checkbox-${id}-"]`)];
|
|
65
|
+
const picked = [...radios, ...boxes].filter(e => e.checked)
|
|
66
|
+
.map(e => e.labels[0]?.innerText.trim());
|
|
67
|
+
if (boxes.length) return [`question-${id}`, picked];
|
|
68
|
+
if (radios.length) return [`question-${id}`, picked[0] ?? null];
|
|
69
|
+
const text = [...document.querySelectorAll("textarea, input[type=text]")]
|
|
70
|
+
.find(e => e.name === title || e.id === `input-${title}`);
|
|
71
|
+
return [`question-${id}`, text?.value ?? null];
|
|
72
|
+
}))"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Gupy:
|
|
76
|
+
name = "gupy"
|
|
77
|
+
per_employer = False
|
|
78
|
+
known_gaps = {"resume": Gap.FLOW}
|
|
79
|
+
side_effects = [
|
|
80
|
+
SideEffect("load", "employer", "opening apply creates the application, incomplete"),
|
|
81
|
+
SideEffect("fill", "hiring_system", "a fill saves the Additional information"),
|
|
82
|
+
SideEffect("submit", "employer", "the answers are saved for good, the application sent"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
self._questions: dict[str, dict[str, Any]] = {}
|
|
87
|
+
self._fields: dict[str, Field] = {}
|
|
88
|
+
self._additional: dict[str, Answer] = {}
|
|
89
|
+
|
|
90
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
91
|
+
parts = urlsplit(url)
|
|
92
|
+
return bool(HOST.match(parts.hostname or "")) and bool(JOB.match(parts.path))
|
|
93
|
+
|
|
94
|
+
def posting(self, url: str) -> str:
|
|
95
|
+
parts = urlsplit(url)
|
|
96
|
+
match = JOB.match(parts.path)
|
|
97
|
+
return f"https://{parts.hostname}/jobs/{match[1]}" if match else url
|
|
98
|
+
|
|
99
|
+
def account_scope(self, url: str) -> None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def login_url(self, scope: str | None) -> str:
|
|
103
|
+
return LOGIN
|
|
104
|
+
|
|
105
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
106
|
+
"""The account endpoint answers the account, 401 to a visitor; without an Origin it
|
|
107
|
+
answers 500."""
|
|
108
|
+
return request.get(ACCOUNT, headers={"origin": "https://login.gupy.io"}).status == 200
|
|
109
|
+
|
|
110
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
111
|
+
form = page.locator("main, body").first
|
|
112
|
+
try:
|
|
113
|
+
form.locator("input[type=password]").wait_for(timeout=SIGN_IN_TIMEOUT_MS)
|
|
114
|
+
except Timeout:
|
|
115
|
+
raise Unsupported(f"unhandled_rendering: no sign-in form on {page.url}") from None
|
|
116
|
+
form.locator("input:not([type=password]):not([type=hidden])").first.fill(email)
|
|
117
|
+
form.locator("input[type=password]").fill(password)
|
|
118
|
+
on_form = page.url
|
|
119
|
+
browser.click(page.locator("button:has-text('Access account')"))
|
|
120
|
+
try:
|
|
121
|
+
page.wait_for_url(lambda url: url != on_form, timeout=SIGN_IN_TIMEOUT_MS)
|
|
122
|
+
except Timeout:
|
|
123
|
+
said = page.locator("main").inner_text().strip().replace("\n", " ")
|
|
124
|
+
raise Blocked(f"sign_in: the sign-in stayed on its page: {said[:200]}") from None
|
|
125
|
+
|
|
126
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
127
|
+
"""Opens apply and walks to the posting's Additional information, the employer's
|
|
128
|
+
questions listed from the page's `question-forms` answer."""
|
|
129
|
+
forms: list[Response] = []
|
|
130
|
+
|
|
131
|
+
def on_response(response: Response) -> None:
|
|
132
|
+
if FORMS.search(urlsplit(response.url).path) and response.request.method == "GET":
|
|
133
|
+
forms.append(response)
|
|
134
|
+
|
|
135
|
+
parts = urlsplit(page.url)
|
|
136
|
+
match = JOB.match(parts.path)
|
|
137
|
+
if not match:
|
|
138
|
+
raise RuntimeError(f"{page.url} is not a Gupy job")
|
|
139
|
+
page.on("response", on_response)
|
|
140
|
+
try:
|
|
141
|
+
page.goto(f"https://{parts.hostname}/candidates/jobs/{match[1]}/apply")
|
|
142
|
+
self._walk(page)
|
|
143
|
+
deadline = time.monotonic() + FORMS_TIMEOUT_S
|
|
144
|
+
while not forms and time.monotonic() < deadline:
|
|
145
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
146
|
+
finally:
|
|
147
|
+
page.remove_listener("response", on_response)
|
|
148
|
+
fields = [_additional(raw) for raw in page.evaluate(_READ_ADDITIONAL)]
|
|
149
|
+
questions = (forms[-1].json().get("questionForm") or {}).get("questions") if forms else []
|
|
150
|
+
self._questions = {f"question-{q['questionId']}": q for q in questions or []}
|
|
151
|
+
fields += [_question(q) for q in self._questions.values()]
|
|
152
|
+
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
153
|
+
return fields
|
|
154
|
+
|
|
155
|
+
def _walk(self, page: Page) -> None:
|
|
156
|
+
"""Past the interstitials to Additional information: the dialog asking to confirm the
|
|
157
|
+
account's contact details, Complete resume, Continue Application, Continue."""
|
|
158
|
+
deadline, disabled_since = time.monotonic() + STEP_TIMEOUT_S, None
|
|
159
|
+
while time.monotonic() < deadline:
|
|
160
|
+
if "/candidates/signin" in page.url:
|
|
161
|
+
raise Blocked("login_required: run `atsfill login gupy`")
|
|
162
|
+
if page.locator(f"{ADDITIONAL}, {ANSWER_NOW}").count():
|
|
163
|
+
return
|
|
164
|
+
for name in ("Remind me later", "Complete resume", "Continue Application", "Continue"):
|
|
165
|
+
button = page.locator(f"button:has-text('{name}'):visible").first
|
|
166
|
+
if not button.count():
|
|
167
|
+
continue
|
|
168
|
+
if button.is_enabled():
|
|
169
|
+
disabled_since = None
|
|
170
|
+
browser.click(button)
|
|
171
|
+
# Continue Application is disabled while the resume loads, for good when the
|
|
172
|
+
# resume is incomplete
|
|
173
|
+
elif (disabled_since := disabled_since or time.monotonic()) < (
|
|
174
|
+
time.monotonic() - RESUME_WAIT_S
|
|
175
|
+
):
|
|
176
|
+
detail = "the account's resume is incomplete; complete it once in Gupy"
|
|
177
|
+
raise Unsupported(known_gap(self, "resume", detail))
|
|
178
|
+
break
|
|
179
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
180
|
+
raise Unsupported(f"unhandled_rendering: no Additional information on {page.url}")
|
|
181
|
+
|
|
182
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
183
|
+
"""Fills Additional information and, where the posting has questions, saves it and
|
|
184
|
+
opens them; the questions are filled and left unsaved for submit."""
|
|
185
|
+
for field_id, answer in answers.items():
|
|
186
|
+
if field_id not in self._questions:
|
|
187
|
+
_set(page, self._fields[field_id], field_id, answer)
|
|
188
|
+
self._additional = self._read_additional(page)
|
|
189
|
+
if not self._questions:
|
|
190
|
+
return
|
|
191
|
+
if page.locator(ADDITIONAL).count():
|
|
192
|
+
browser.click(page.locator("button:has-text('Save and continue')").first)
|
|
193
|
+
answer_now = page.locator(ANSWER_NOW).first
|
|
194
|
+
answer_now.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
195
|
+
browser.click(answer_now)
|
|
196
|
+
page.locator(QUESTIONS).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
197
|
+
for field_id, answer in answers.items():
|
|
198
|
+
if (question := self._questions.get(field_id)) is not None:
|
|
199
|
+
_answer(page, self._fields[field_id], question, answer)
|
|
200
|
+
|
|
201
|
+
def _read_additional(self, page: Page) -> dict[str, Answer]:
|
|
202
|
+
values = {raw["id"]: raw["value"] for raw in page.evaluate(_READ_ADDITIONAL)}
|
|
203
|
+
return {k: v for k, v in values.items() if k in self._fields and v}
|
|
204
|
+
|
|
205
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
206
|
+
"""Additional information as it was saved, or as the page holds it; the questions'."""
|
|
207
|
+
additional = self._additional or self._read_additional(page)
|
|
208
|
+
ids = [[q["questionId"], q["title"]] for q in self._questions.values()]
|
|
209
|
+
values = page.evaluate(_READ_QUESTIONS, ids) if page.locator(QUESTIONS).count() else {}
|
|
210
|
+
return {**additional, **{k: v for k, v in values.items() if v not in (None, "", [])}}
|
|
211
|
+
|
|
212
|
+
def submit(self, page: Page) -> str | None:
|
|
213
|
+
"""Save and continue on the last page: the questions, or Additional information."""
|
|
214
|
+
with page.expect_response(
|
|
215
|
+
lambda r: r.request.method in ("PUT", "POST", "PATCH") and API in r.url,
|
|
216
|
+
timeout=SAVE_TIMEOUT_MS,
|
|
217
|
+
) as saved:
|
|
218
|
+
browser.click(page.locator("button:has-text('Save and continue')").first)
|
|
219
|
+
response = saved.value
|
|
220
|
+
if response.ok:
|
|
221
|
+
return f"{response.request.method} {urlsplit(response.url).path} {response.status}"
|
|
222
|
+
raise RuntimeError(f"{response.request.method} {response.url} answered {response.status}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _additional(raw: dict[str, Any]) -> Field:
|
|
226
|
+
"""A radio group is a choice; the source list, a combobox, is not filled."""
|
|
227
|
+
name, label = raw["id"], raw["label"]
|
|
228
|
+
match raw["kind"]:
|
|
229
|
+
case "radio":
|
|
230
|
+
options, value = raw["options"], raw["value"]
|
|
231
|
+
return Field(name, label, FieldType.CHOICE, True, options, prefilled=value)
|
|
232
|
+
case "text":
|
|
233
|
+
return Field(name, label, FieldType.TEXT, "optional" not in label.lower())
|
|
234
|
+
reason = f"unhandled_rendering: {label!r} is a {raw['kind']}"
|
|
235
|
+
return Field(name, label, FieldType.TEXT, "optional" not in label.lower(), unfillable=reason)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _question(q: dict[str, Any]) -> Field:
|
|
239
|
+
field_id, kind = f"question-{q['questionId']}", TYPES.get(q["type"])
|
|
240
|
+
options = [o["label"] for o in q.get("options") or []] or None
|
|
241
|
+
shown = None
|
|
242
|
+
if q.get("parentQuestionId"):
|
|
243
|
+
shown = Is(f"question-{q['parentQuestionId']}", q.get("conditionalTriggerOptions") or None)
|
|
244
|
+
if kind is None:
|
|
245
|
+
reason = f"unhandled_rendering: {q['title']!r} is a {q['type']} question"
|
|
246
|
+
return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason)
|
|
247
|
+
return Field(
|
|
248
|
+
field_id, q["title"], kind, bool(q["required"]),
|
|
249
|
+
options if kind is not FieldType.TEXT else None, prefilled=q.get("answer") or None,
|
|
250
|
+
shown_when=shown,
|
|
251
|
+
) # fmt: skip
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _label(page: Page, selector: str, text: str) -> Locator:
|
|
255
|
+
"""The label, wrapping it, of the input `selector` matches whose text is `text`."""
|
|
256
|
+
inputs = page.locator(selector)
|
|
257
|
+
for n in range(inputs.count()):
|
|
258
|
+
if inputs.nth(n).evaluate("e => e.labels[0]?.innerText.trim()") == text:
|
|
259
|
+
return inputs.nth(n).locator("xpath=ancestor::label[1]")
|
|
260
|
+
raise NotTaken(f"no option {text!r} in {selector}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _set(page: Page, field: Field, field_id: str, answer: Answer) -> None:
|
|
264
|
+
match field.type, answer:
|
|
265
|
+
case FieldType.CHOICE, str():
|
|
266
|
+
browser.click(_label(page, f'main input[name="{field_id}"]', answer))
|
|
267
|
+
case FieldType.TEXT, str():
|
|
268
|
+
page.locator(f'main input[name="{field_id}"], main input[id="{field_id}"]').fill(answer)
|
|
269
|
+
case kind, _:
|
|
270
|
+
raise Unsupported(f"unhandled_rendering: cannot fill {field_id}, a {kind}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer) -> None:
|
|
274
|
+
qid = question["questionId"]
|
|
275
|
+
match field.type, answer:
|
|
276
|
+
case FieldType.CHOICE, str():
|
|
277
|
+
browser.click(_label(page, f'input[name="question-{qid}"]', answer))
|
|
278
|
+
case FieldType.MULTI_CHOICE, list():
|
|
279
|
+
for option in answer:
|
|
280
|
+
box = _label(page, f'input[name^="checkbox-{qid}-"]', option)
|
|
281
|
+
if not box.locator("input").is_checked():
|
|
282
|
+
browser.click(box)
|
|
283
|
+
case FieldType.TEXT, str():
|
|
284
|
+
title = question["title"]
|
|
285
|
+
target = page.locator(f'textarea[name="{_css(title)}"], input[name="{_css(title)}"]')
|
|
286
|
+
if not target.count():
|
|
287
|
+
target = page.locator(f'[id="input-{_css(title)}"]')
|
|
288
|
+
target.first.fill(answer)
|
|
289
|
+
case kind, _:
|
|
290
|
+
raise Unsupported(f"unhandled_rendering: cannot fill question-{qid}, a {kind}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _css(text: str) -> str:
|
|
294
|
+
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -358,9 +358,11 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
|
|
|
358
358
|
[gupy]
|
|
359
359
|
kind = "ats"
|
|
360
360
|
share = 0.0040
|
|
361
|
-
account = "to_read"
|
|
361
|
+
account = "to_read" # one account for the whole system; its sign-up asks an ID document number, a mobile number and a date of birth
|
|
362
362
|
hosting = ["hosted"]
|
|
363
|
+
adapter = "gupy"
|
|
363
364
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
365
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "566668d" }
|
|
364
366
|
|
|
365
367
|
[eightfold]
|
|
366
368
|
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
|
|
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
|