atsfill 0.0.4.dev129__tar.gz → 0.0.4.dev130__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.dev129 → atsfill-0.0.4.dev130}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/README.md +1 -1
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev130/src/atsfill/adapters/isolvedhire.py +613 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/registry.toml +3 -2
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/LICENSE +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev130}/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.dev130
|
|
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 |
|
|
@@ -7,6 +7,7 @@ from atsfill.adapters.greenhouse import Greenhouse
|
|
|
7
7
|
from atsfill.adapters.gupy import Gupy
|
|
8
8
|
from atsfill.adapters.hireology import Hireology
|
|
9
9
|
from atsfill.adapters.hiringthing import HiringThing
|
|
10
|
+
from atsfill.adapters.isolvedhire import IsolvedHire
|
|
10
11
|
from atsfill.adapters.jazzhr import JazzHR
|
|
11
12
|
from atsfill.adapters.join import Join
|
|
12
13
|
from atsfill.adapters.lever import Lever
|
|
@@ -60,6 +61,7 @@ def adapters() -> list[Adapter]:
|
|
|
60
61
|
Workday(),
|
|
61
62
|
Gupy(),
|
|
62
63
|
UKG(),
|
|
64
|
+
IsolvedHire(),
|
|
63
65
|
SuccessFactors(), # it claims a career site's job path on any host
|
|
64
66
|
Phenom(), # last: it claims a URL by its path alone
|
|
65
67
|
]
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""isolved Hire (`<employer>.isolvedhire.com/jobs/<id>`), as docs/adapters/isolvedhire.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
Applying needs an account, one for the whole system, kept signed in per employer's site: a form
|
|
5
|
+
on a site not signed in yet signs in there again. The form is a wizard at `/apply/<id>-0.php`
|
|
6
|
+
whose steps the page loads from `/apply/view.php`; the read fetches each step from there, the
|
|
7
|
+
fill walks the steps to Submit Application, saving each with its Proceed to Next Step, and the
|
|
8
|
+
read-back fetches the steps again, as the system saved them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import parse_qs, urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core.adapter import (
|
|
18
|
+
Blocked,
|
|
19
|
+
Gap,
|
|
20
|
+
NotTaken,
|
|
21
|
+
Refused,
|
|
22
|
+
SignUpDetails,
|
|
23
|
+
Unsupported,
|
|
24
|
+
known_gap,
|
|
25
|
+
)
|
|
26
|
+
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
27
|
+
from atsfill.core.results import (
|
|
28
|
+
Answer,
|
|
29
|
+
AnyOf,
|
|
30
|
+
Condition,
|
|
31
|
+
Field,
|
|
32
|
+
FieldType,
|
|
33
|
+
Is,
|
|
34
|
+
OptionSet,
|
|
35
|
+
SideEffect,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
HOST = re.compile(r"^(?!(?:www|app|admin)\.)([a-z0-9-]+)\.isolvedhire\.com$")
|
|
39
|
+
JOB = re.compile(r"^/(?:jobs/(\d+)|apply/(\d+)-\d+\.php)/?$")
|
|
40
|
+
LOGIN, SIGN_UP, LOGOUT = "/account/login.php", "/account/", "/account/logout.php"
|
|
41
|
+
SIGNED_IN = f"a[href='{LOGOUT}']"
|
|
42
|
+
THANKS = "/apply/thanks.php"
|
|
43
|
+
ERRORS = "#landingStrip [id^=message_]:visible, #landingStrip .text-danger:visible"
|
|
44
|
+
PAGE_TIMEOUT_MS = 30_000
|
|
45
|
+
STEP_TIMEOUT_MS = 30_000
|
|
46
|
+
LIST_TIMEOUT_MS = 10_000 # a dependent list's options, a follow-up question
|
|
47
|
+
UPLOAD_TIMEOUT_MS = 60_000
|
|
48
|
+
SUBMIT_TIMEOUT_MS = 60_000
|
|
49
|
+
SETTLE_TIMEOUT_MS = 5_000 # an answer's save, sent as it is entered
|
|
50
|
+
ACCOUNT_TIMEOUT_S = 30
|
|
51
|
+
POLL_MS = 250
|
|
52
|
+
MAX_DEPTH = 3 # follow-up questions under follow-up questions
|
|
53
|
+
|
|
54
|
+
MOBILE = "2" # the phone type's option for a cell phone
|
|
55
|
+
NO_TEXTS = "2" # the text message consent's option that declines it
|
|
56
|
+
NANP = "+1" # the calling code a US or Canadian number goes without
|
|
57
|
+
NANP_COUNTRIES = ("US", "CA")
|
|
58
|
+
YES_NO = ["Yes", "No"]
|
|
59
|
+
OTHER_SOURCE = "1" # the source Other's value, added to every type's list
|
|
60
|
+
REFERRAL = "4" # the source type whose source the applicant describes
|
|
61
|
+
FILES = [".pdf", ".doc", ".docx", ".txt"]
|
|
62
|
+
SKIPPED = ("instructions", "submit") # steps without fields: text, then the send
|
|
63
|
+
ENTRY_LISTS = ("employment", "education") # a list of entries, each added with its own form
|
|
64
|
+
|
|
65
|
+
# A step's fields from its HTML, in page order: each control's key (a group's name), kind,
|
|
66
|
+
# label, whether required, options, value, and for a question that opens follow-ups, the
|
|
67
|
+
# option that opens them.
|
|
68
|
+
_READ = """html => {
|
|
69
|
+
const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, "text/html");
|
|
70
|
+
const root = doc.getElementById("root");
|
|
71
|
+
const clean = s => (s || "").replace(/\\s+/g, " ").trim();
|
|
72
|
+
const bare = s => clean(s).replace(/[*:]+$/, "").trim();
|
|
73
|
+
const heading = el => {
|
|
74
|
+
const item = el.closest("li.list-group-item");
|
|
75
|
+
let prev = item && item.previousElementSibling;
|
|
76
|
+
return prev ? clean(prev.querySelector("h2, h4")?.textContent) : "";
|
|
77
|
+
};
|
|
78
|
+
const labelOf = el => {
|
|
79
|
+
const byFor = el.id && root.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
80
|
+
if (byFor && bare(byFor.textContent)) return bare(byFor.textContent);
|
|
81
|
+
if (el.dataset.questionTitle) return clean(el.dataset.questionTitle);
|
|
82
|
+
const legend = el.closest("fieldset")?.querySelector("legend");
|
|
83
|
+
if (legend) return clean(legend.textContent);
|
|
84
|
+
return heading(el) || el.name || el.id;
|
|
85
|
+
};
|
|
86
|
+
const placeholder = o => !o.value || /^[.\\-\\s]*$/.test(o.textContent) ||
|
|
87
|
+
/^--/.test(clean(o.textContent));
|
|
88
|
+
const out = [], seen = new Set();
|
|
89
|
+
if (root.querySelector("canvas.pad")) {
|
|
90
|
+
out.push({key: "signature", kind: "signature", label: "Signature", required: true,
|
|
91
|
+
options: [], value: "", disabled: false});
|
|
92
|
+
}
|
|
93
|
+
for (const el of root.querySelectorAll("input, select, textarea")) {
|
|
94
|
+
const type = (el.type || el.tagName).toLowerCase();
|
|
95
|
+
if (["hidden", "submit", "button"].includes(type)) continue;
|
|
96
|
+
if (el.closest("#faq_bar_form, #text_resume, #verify_contact_info_popup")) continue;
|
|
97
|
+
if (el.name === "email_pls") continue;
|
|
98
|
+
const group = ["radio", "checkbox"].includes(type) &&
|
|
99
|
+
root.querySelectorAll(`input[type=${type}][name="${CSS.escape(el.name)}"]`).length > 1;
|
|
100
|
+
const key = group || type === "file" ? el.name : el.id || el.name;
|
|
101
|
+
if (!key || seen.has(key)) continue;
|
|
102
|
+
seen.add(key);
|
|
103
|
+
const field = {key, kind: type, label: labelOf(el), required: el.classList.contains("required"),
|
|
104
|
+
options: [], value: el.value || "", disabled: el.disabled,
|
|
105
|
+
maxLength: Number(el.dataset.maxlength) || null};
|
|
106
|
+
if (el.tagName === "SELECT") {
|
|
107
|
+
field.kind = "select";
|
|
108
|
+
const options = [...el.options];
|
|
109
|
+
field.options = options.filter(o => !placeholder(o)).map(o => clean(o.textContent));
|
|
110
|
+
field.values = options.filter(o => !placeholder(o)).map(o => o.value);
|
|
111
|
+
const picked = options.find(o => o.hasAttribute("selected"));
|
|
112
|
+
field.value = picked && !placeholder(picked) ? clean(picked.textContent) : "";
|
|
113
|
+
field.picked = picked?.value || "";
|
|
114
|
+
field.saved = root.querySelector(`input[type=hidden][name="${CSS.escape(el.name)}"]`)
|
|
115
|
+
?.value || "";
|
|
116
|
+
if (el.classList.contains("conditional-answer-select")) {
|
|
117
|
+
const at = Number(el.dataset.conditional_answer);
|
|
118
|
+
field.opens = {option: clean(options[at]?.textContent), question: el.dataset.id,
|
|
119
|
+
group: el.dataset.group_id};
|
|
120
|
+
}
|
|
121
|
+
} else if (el.tagName === "TEXTAREA") {
|
|
122
|
+
field.kind = "text";
|
|
123
|
+
field.value = el.textContent;
|
|
124
|
+
} else if (group) {
|
|
125
|
+
const all = [...root.querySelectorAll(`input[type=${type}][name="${CSS.escape(el.name)}"]`)];
|
|
126
|
+
field.kind = type === "radio" ? "radio" : "checkboxes";
|
|
127
|
+
field.options = all.map(r => clean(r.closest("label")?.textContent || r.value));
|
|
128
|
+
field.value = all.filter(r => r.hasAttribute("checked"))
|
|
129
|
+
.map(r => clean(r.closest("label")?.textContent || r.value));
|
|
130
|
+
field.required = all.some(r => r.classList.contains("required"));
|
|
131
|
+
if (type === "radio") field.value = field.value[0] || "";
|
|
132
|
+
} else if (type === "checkbox") {
|
|
133
|
+
field.label = clean(el.closest("label")?.textContent) || field.label;
|
|
134
|
+
field.value = el.hasAttribute("checked") ? "Yes" : "No";
|
|
135
|
+
} else if (type === "file") {
|
|
136
|
+
const files = [...root.querySelectorAll(".uploaded-resumes-container .file p")];
|
|
137
|
+
field.label = "Resume";
|
|
138
|
+
field.value = clean(files[files.length - 1]?.textContent);
|
|
139
|
+
} else field.kind = "text";
|
|
140
|
+
out.push(field);
|
|
141
|
+
}
|
|
142
|
+
const app = root.querySelector("input[name=applications_id]")?.value;
|
|
143
|
+
return {fields: out, app};
|
|
144
|
+
}"""
|
|
145
|
+
|
|
146
|
+
# The page's own request for a step, a follow-up question or a source list.
|
|
147
|
+
_VIEW = """async ([f, params]) => {
|
|
148
|
+
const token = document.querySelector("input[name=csrf_token]")?.value || "";
|
|
149
|
+
const query = new URLSearchParams({n: "apply", f, csrf_token: token, ...params});
|
|
150
|
+
const response = await fetch(`/apply/view.php?${query}`, {credentials: "include"});
|
|
151
|
+
return response.ok ? await response.text() : null;
|
|
152
|
+
}"""
|
|
153
|
+
|
|
154
|
+
_OPTIONS = """html => {
|
|
155
|
+
const doc = new DOMParser().parseFromString(`<select>${html}</select>`, "text/html");
|
|
156
|
+
return [...doc.querySelectorAll("option")].filter(o => o.value)
|
|
157
|
+
.map(o => [o.value, o.textContent.replace(/\\s+/g, " ").trim()]);
|
|
158
|
+
}"""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
162
|
+
"""The employer's host and the job's id."""
|
|
163
|
+
parts = urlsplit(url)
|
|
164
|
+
host = (parts.hostname or "").lower()
|
|
165
|
+
match = JOB.match(parts.path)
|
|
166
|
+
if not HOST.match(host) or not match:
|
|
167
|
+
return None
|
|
168
|
+
return host, match[1] or match[2]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class IsolvedHire:
|
|
172
|
+
name = "isolvedhire"
|
|
173
|
+
per_employer = False
|
|
174
|
+
asks = {"name", "phone", "street", "city", "state", "zip", "country"}
|
|
175
|
+
known_gaps = {
|
|
176
|
+
"sign_up_otp": Gap.FLOW, # why the sign-up goes through the sign-in, not Apply now
|
|
177
|
+
"entry_list": Gap.RENDERING,
|
|
178
|
+
"drawn_signature": Gap.RENDERING,
|
|
179
|
+
}
|
|
180
|
+
side_effects = [
|
|
181
|
+
SideEffect("sign_up", "hiring_system", "login --create creates the job profile"),
|
|
182
|
+
SideEffect("load", "employer", "opening the form starts an application, unfinished"),
|
|
183
|
+
SideEffect("fill", "employer", "each step's answers are saved to the application"),
|
|
184
|
+
SideEffect("upload", "employer", "a resume is attached to the application on upload"),
|
|
185
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
def __init__(self) -> None:
|
|
189
|
+
self._host: str | None = None # the last posting's employer site
|
|
190
|
+
self._job: str | None = None
|
|
191
|
+
self._steps: list[str] = []
|
|
192
|
+
self._fields: dict[str, Field] = {}
|
|
193
|
+
self._raws: dict[str, dict[str, Any]] = {} # by field id, as the read found it
|
|
194
|
+
|
|
195
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
196
|
+
return _job(url) is not None
|
|
197
|
+
|
|
198
|
+
def posting(self, url: str) -> str:
|
|
199
|
+
found = _job(url)
|
|
200
|
+
return f"https://{found[0]}/jobs/{found[1]}" if found else url
|
|
201
|
+
|
|
202
|
+
def account_scope(self, url: str) -> None:
|
|
203
|
+
if found := _job(url):
|
|
204
|
+
self._host, self._job = found
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def login_url(self, scope: str | None) -> str:
|
|
208
|
+
"""The sign-in on the last posting's employer site: the system has none of its own."""
|
|
209
|
+
if self._host is None:
|
|
210
|
+
raise ValueError("isolvedhire signs in on an employer's site: pass --url <posting>")
|
|
211
|
+
return f"https://{self._host}{LOGIN}"
|
|
212
|
+
|
|
213
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
214
|
+
"""The last posting's employer site shows the account's Logout link."""
|
|
215
|
+
if self._host is None:
|
|
216
|
+
return False
|
|
217
|
+
response = request.get(f"https://{self._host}{SIGN_UP}")
|
|
218
|
+
return response.ok and f'href="{LOGOUT}"' in response.text()
|
|
219
|
+
|
|
220
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
221
|
+
page.locator("#login #email").wait_for(timeout=PAGE_TIMEOUT_MS)
|
|
222
|
+
page.locator("#login #email").fill(email)
|
|
223
|
+
page.locator("#login #password").fill(password)
|
|
224
|
+
page.locator("#login [type=submit]").click()
|
|
225
|
+
_signed(page)
|
|
226
|
+
|
|
227
|
+
def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
228
|
+
"""Creates the job profile from the sign-in's "Need an account?", signed in once
|
|
229
|
+
submitted: the name, the phone as a cell phone, the address, the country by its English
|
|
230
|
+
name and the state as the country's list names it; text messages declined, the privacy
|
|
231
|
+
policy agreed to."""
|
|
232
|
+
page.locator(f"a[href='{SIGN_UP}']").first.click()
|
|
233
|
+
form = page.locator("form#apply")
|
|
234
|
+
form.wait_for(timeout=PAGE_TIMEOUT_MS)
|
|
235
|
+
first, _, last = (details.name or "").strip().rpartition(" ")
|
|
236
|
+
country = page.evaluate(
|
|
237
|
+
"code => new Intl.DisplayNames(['en'], {type: 'region'}).of(code)", details.country
|
|
238
|
+
)
|
|
239
|
+
if country not in _texts(form.locator("#country_id option")):
|
|
240
|
+
raise Refused(f"sign_up_details: no country {country} in the sign-up's list")
|
|
241
|
+
form.locator("#country_id").select_option(label=country)
|
|
242
|
+
state = form.locator("#state_id option", has_text=re.compile(rf"^\s*{details.state}\s*$"))
|
|
243
|
+
try:
|
|
244
|
+
state.first.wait_for(state="attached", timeout=LIST_TIMEOUT_MS)
|
|
245
|
+
except Timeout:
|
|
246
|
+
detail = f"no state {details.state} in {country}'s list"
|
|
247
|
+
raise Refused(f"sign_up_details: {detail}") from None
|
|
248
|
+
phone = details.phone or ""
|
|
249
|
+
if details.country in NANP_COUNTRIES:
|
|
250
|
+
phone = phone.removeprefix(NANP)
|
|
251
|
+
for field, value in [
|
|
252
|
+
("first_name", first or last), ("last_name", last), ("email", email),
|
|
253
|
+
("email_confirm", email), ("contact_number", phone),
|
|
254
|
+
("street1", details.street), ("city", details.city), ("zip_code", details.zip),
|
|
255
|
+
("password", password), ("password_confirm", password),
|
|
256
|
+
]: # fmt: skip
|
|
257
|
+
form.locator(f"#{field}").fill(value or "")
|
|
258
|
+
form.locator("#state_id").select_option(state.first.get_attribute("value"))
|
|
259
|
+
form.locator("#contact_number_type").select_option(MOBILE)
|
|
260
|
+
form.locator("#applicant_consent_approval").select_option(NO_TEXTS)
|
|
261
|
+
form.locator("#info_use_consent").check()
|
|
262
|
+
form.locator("#submit_apply").click()
|
|
263
|
+
_signed(page)
|
|
264
|
+
|
|
265
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
266
|
+
"""Opens the wizard, then lists each step's fields as `/apply/view.php` serves it."""
|
|
267
|
+
self._open(page)
|
|
268
|
+
self._steps = [
|
|
269
|
+
s for s in page.locator("#steps a.step").evaluate_all(
|
|
270
|
+
"as => as.map(a => a.dataset.file)") if s not in SKIPPED
|
|
271
|
+
] # fmt: skip
|
|
272
|
+
fields: list[Field] = []
|
|
273
|
+
for step in self._steps:
|
|
274
|
+
fields += self._step_fields(page, step)
|
|
275
|
+
self._fields = {f.id: f for f in fields}
|
|
276
|
+
return fields
|
|
277
|
+
|
|
278
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
279
|
+
"""Confirms the account's contact details, then walks the steps in order: each answered
|
|
280
|
+
and saved with Proceed to Next Step, which goes no further while one is invalid."""
|
|
281
|
+
_confirm_contact(page)
|
|
282
|
+
for step in self._steps:
|
|
283
|
+
_go(page, step)
|
|
284
|
+
for field_id, field in self._fields.items():
|
|
285
|
+
if field_id.startswith(f"{step}.") and field_id in answers:
|
|
286
|
+
self._answer(page, field, answers[field_id])
|
|
287
|
+
_settle(page)
|
|
288
|
+
_next(page, step)
|
|
289
|
+
|
|
290
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
291
|
+
"""Each step as the system saved it, fetched again."""
|
|
292
|
+
values: dict[str, Answer] = {}
|
|
293
|
+
for step in self._steps:
|
|
294
|
+
if step in ENTRY_LISTS:
|
|
295
|
+
continue
|
|
296
|
+
raws, app = _read_step(page, step)
|
|
297
|
+
for raw in list(raws):
|
|
298
|
+
raws += self._children(page, raw, app, 1, values_only=True)
|
|
299
|
+
for raw in raws:
|
|
300
|
+
field_id = f"{step}.{raw['key']}"
|
|
301
|
+
if field_id in self._fields and raw["value"] not in ("", [], None):
|
|
302
|
+
values[field_id] = raw["value"]
|
|
303
|
+
return values
|
|
304
|
+
|
|
305
|
+
def submit(self, page: Page) -> str | None:
|
|
306
|
+
"""Submit Application, on the last step once every required one is complete; the page
|
|
307
|
+
moves to the thanks page, the evidence."""
|
|
308
|
+
_go(page, "submit")
|
|
309
|
+
button = page.locator("#landingStrip #next")
|
|
310
|
+
if not button.count():
|
|
311
|
+
text = " ".join(page.locator("#landingStrip").inner_text().split())
|
|
312
|
+
raise RuntimeError(f"the Submit Application step is not ready: {text}")
|
|
313
|
+
button.click()
|
|
314
|
+
page.wait_for_url(f"**{THANKS}*", timeout=SUBMIT_TIMEOUT_MS)
|
|
315
|
+
return page.url
|
|
316
|
+
|
|
317
|
+
def _open(self, page: Page) -> None:
|
|
318
|
+
found = _job(page.url) or ((self._host, self._job) if self._host and self._job else None)
|
|
319
|
+
if found is None:
|
|
320
|
+
raise Unsupported("unhandled_rendering: not an isolved Hire posting")
|
|
321
|
+
self._host, self._job = found
|
|
322
|
+
page.goto(f"https://{self._host}/apply/{self._job}-0.php")
|
|
323
|
+
if urlsplit(page.url).path == LOGIN:
|
|
324
|
+
raise Blocked("login_wall: the application redirects to the sign-in")
|
|
325
|
+
try:
|
|
326
|
+
page.locator("#steps a.step").first.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
|
|
327
|
+
except Timeout:
|
|
328
|
+
text = " ".join(page.locator("body").inner_text().split())[:300]
|
|
329
|
+
raise Unsupported(f"posting_gone: no application wizard: {text}") from None
|
|
330
|
+
|
|
331
|
+
def _step_fields(self, page: Page, step: str) -> list[Field]:
|
|
332
|
+
if step in ENTRY_LISTS:
|
|
333
|
+
reason = known_gap(self, "entry_list", f"the {step} step adds entries one by one")
|
|
334
|
+
return [Field(f"{step}.entries", step.title(), FieldType.TEXT, True,
|
|
335
|
+
unfillable=reason)] # fmt: skip
|
|
336
|
+
raws, app = _read_step(page, step)
|
|
337
|
+
fields = []
|
|
338
|
+
for raw in raws:
|
|
339
|
+
fields.append(self._field(page, step, raw))
|
|
340
|
+
for child, condition in self._children(page, raw, app, 1):
|
|
341
|
+
fields.append(self._field(page, step, child, condition))
|
|
342
|
+
return fields
|
|
343
|
+
|
|
344
|
+
def _children(
|
|
345
|
+
self,
|
|
346
|
+
page: Page,
|
|
347
|
+
raw: dict[str, Any],
|
|
348
|
+
app: str | None,
|
|
349
|
+
depth: int,
|
|
350
|
+
values_only: bool = False,
|
|
351
|
+
) -> list[Any]:
|
|
352
|
+
"""The follow-up questions a question opens, fetched as the page fetches them, each with
|
|
353
|
+
the condition that shows it; only the raws, for the read-back."""
|
|
354
|
+
opens = raw.get("opens")
|
|
355
|
+
if not opens or depth > MAX_DEPTH or (values_only and raw["value"] != opens["option"]):
|
|
356
|
+
return []
|
|
357
|
+
params = {"parent_question_id": opens["question"], "group_id": opens["group"],
|
|
358
|
+
"app_id": app or ""} # fmt: skip
|
|
359
|
+
html = page.evaluate(_VIEW, ["buildAdditionalQuestions", params]) or ""
|
|
360
|
+
found = []
|
|
361
|
+
for child in page.evaluate(_READ, html)["fields"]:
|
|
362
|
+
found.append(child if values_only else (child, Is(raw["key"], [opens["option"]])))
|
|
363
|
+
found += self._children(page, child, app, depth + 1, values_only)
|
|
364
|
+
return found
|
|
365
|
+
|
|
366
|
+
def _field(self, page: Page, step: str, raw: dict[str, Any], shown: Is | None = None) -> Field:
|
|
367
|
+
field_id, label, required = f"{step}.{raw['key']}", raw["label"], raw["required"]
|
|
368
|
+
if shown is not None:
|
|
369
|
+
shown = Is(f"{step}.{shown.field_id}", shown.values)
|
|
370
|
+
self._raws[field_id] = raw
|
|
371
|
+
locked = raw["value"] if raw["disabled"] and raw["value"] else None # a saved source
|
|
372
|
+
match raw["kind"]:
|
|
373
|
+
case "signature":
|
|
374
|
+
reason = known_gap(self, "drawn_signature", "the step asks a drawn signature")
|
|
375
|
+
return Field(field_id, label, FieldType.TEXT, True, unfillable=reason)
|
|
376
|
+
case "select" if raw["key"] == "source_id":
|
|
377
|
+
return Field(field_id, label, FieldType.CHOICE, required, [],
|
|
378
|
+
option_sets=_sources(page, step), prefilled=locked) # fmt: skip
|
|
379
|
+
case "select" | "radio":
|
|
380
|
+
return Field(field_id, label, FieldType.CHOICE, required, raw["options"],
|
|
381
|
+
prefilled=locked, default=raw["value"] or None,
|
|
382
|
+
shown_when=shown) # fmt: skip
|
|
383
|
+
case "checkboxes":
|
|
384
|
+
return Field(field_id, label, FieldType.MULTI_CHOICE, required, raw["options"],
|
|
385
|
+
shown_when=shown) # fmt: skip
|
|
386
|
+
case "checkbox":
|
|
387
|
+
return Field(field_id, label, FieldType.CHOICE, required, YES_NO, default="No",
|
|
388
|
+
shown_when=shown) # fmt: skip
|
|
389
|
+
case "file":
|
|
390
|
+
return Field(field_id, label, FieldType.FILE, required, accept=FILES,
|
|
391
|
+
prefilled=raw["value"] or None) # fmt: skip
|
|
392
|
+
case "text" if raw["key"] == "source_other":
|
|
393
|
+
return Field(field_id, label, FieldType.TEXT, True, None, raw["maxLength"],
|
|
394
|
+
prefilled=locked, shown_when=self._other_source(step)) # fmt: skip
|
|
395
|
+
case "text":
|
|
396
|
+
required = required and not raw["disabled"]
|
|
397
|
+
return Field(field_id, label, FieldType.TEXT, required, None, raw["maxLength"],
|
|
398
|
+
shown_when=shown) # fmt: skip
|
|
399
|
+
reason = f"unhandled_rendering: {field_id} is a {raw['kind']} input"
|
|
400
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
401
|
+
|
|
402
|
+
def _other_source(self, step: str) -> Condition:
|
|
403
|
+
"""Where the source asks to describe it: Other, or a referral (its type's value 4)."""
|
|
404
|
+
other: Condition = Is(f"{step}.source_id", ["Other"])
|
|
405
|
+
types = self._raws.get(f"{step}.source_type_id", {})
|
|
406
|
+
referral = dict(zip(types.get("values", []), types.get("options", []), strict=False))
|
|
407
|
+
if REFERRAL in referral:
|
|
408
|
+
return AnyOf([other, Is(f"{step}.source_type_id", [referral[REFERRAL]])])
|
|
409
|
+
return other
|
|
410
|
+
|
|
411
|
+
def _answer(self, page: Page, field: Field, answer: Answer) -> None:
|
|
412
|
+
raw = self._raws[field.id]
|
|
413
|
+
key = raw["key"]
|
|
414
|
+
strip = page.locator("#landingStrip")
|
|
415
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
416
|
+
if raw["disabled"] and raw["value"]:
|
|
417
|
+
if answer != raw["value"]:
|
|
418
|
+
raise NotTaken(f"{field.id} keeps {raw['value']!r}, saved before")
|
|
419
|
+
return
|
|
420
|
+
match raw["kind"]:
|
|
421
|
+
case "select":
|
|
422
|
+
control = strip.locator(f'select[id="{key}"], select[name="{key}"]').first
|
|
423
|
+
if key == "source_id": # its list loads once a source type is picked
|
|
424
|
+
_wait_option(control, values[0])
|
|
425
|
+
control.select_option(_option_value(control, values[0], field.id))
|
|
426
|
+
if raw.get("opens"):
|
|
427
|
+
_follow_ups(page, raw["opens"]["question"], values[0] == raw["opens"]["option"])
|
|
428
|
+
case "radio" | "checkboxes":
|
|
429
|
+
boxes = f'input:is([type=radio], [type=checkbox])[name="{key}"]'
|
|
430
|
+
for box in strip.locator(boxes).all():
|
|
431
|
+
text = " ".join((box.locator("xpath=ancestor::label[1]").text_content()
|
|
432
|
+
or box.get_attribute("value") or "").split()) # fmt: skip
|
|
433
|
+
if raw["kind"] == "radio" and text == values[0]:
|
|
434
|
+
box.check()
|
|
435
|
+
break
|
|
436
|
+
if raw["kind"] == "checkboxes":
|
|
437
|
+
box.set_checked(text in values)
|
|
438
|
+
else:
|
|
439
|
+
if raw["kind"] == "radio":
|
|
440
|
+
raise NotTaken(f"no option {values[0]!r} in {field.id}")
|
|
441
|
+
case "checkbox":
|
|
442
|
+
strip.locator(f'input[type=checkbox][id="{key}"]').set_checked(values[0] == "Yes")
|
|
443
|
+
case "file":
|
|
444
|
+
_upload(page, values[0])
|
|
445
|
+
case _:
|
|
446
|
+
control = strip.locator(f'[id="{key}"], [name="{key}"]').first
|
|
447
|
+
if control.is_disabled():
|
|
448
|
+
raise NotTaken(f"{field.id} is disabled for the answers given")
|
|
449
|
+
control.fill(values[0])
|
|
450
|
+
control.blur() # a question's answer is saved on blur
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _read_step(page: Page, step: str) -> tuple[list[dict[str, Any]], str | None]:
|
|
454
|
+
"""A step's raw fields as `/apply/view.php` serves it, and its application's id. A saved
|
|
455
|
+
source is locked, its source kept in a hidden input: its name is looked up in its type's
|
|
456
|
+
list."""
|
|
457
|
+
html = page.evaluate(_VIEW, [step, {}])
|
|
458
|
+
if html is None:
|
|
459
|
+
raise RuntimeError(f"the {step} step did not load")
|
|
460
|
+
read = page.evaluate(_READ, html)
|
|
461
|
+
raws = read["fields"]
|
|
462
|
+
by_key = {r["key"]: r for r in raws}
|
|
463
|
+
source, kind = by_key.get("source_id"), by_key.get("source_type_id")
|
|
464
|
+
if source and kind and source.get("saved"):
|
|
465
|
+
if source["saved"] == OTHER_SOURCE:
|
|
466
|
+
source["value"] = "Other"
|
|
467
|
+
else:
|
|
468
|
+
params = {"source_type_id": kind["picked"], "selected_id": "", "hide_internal": "1"}
|
|
469
|
+
listed = page.evaluate(_OPTIONS, page.evaluate(_VIEW, ["getSources", params]) or "")
|
|
470
|
+
source["value"] = dict(listed).get(source["saved"], "")
|
|
471
|
+
return raws, read["app"]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _texts(options: Locator) -> list[str]:
|
|
475
|
+
return [" ".join(t.split()) for t in options.all_text_contents()]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _option_value(select: Locator, text: str, field_id: str) -> str:
|
|
479
|
+
for option in select.locator("option").all():
|
|
480
|
+
if " ".join((option.text_content() or "").split()) == text:
|
|
481
|
+
return option.get_attribute("value") or ""
|
|
482
|
+
raise NotTaken(f"no option {text!r} in {field_id}")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _sources(page: Page, step: str) -> list[OptionSet]:
|
|
486
|
+
"""The sources of each source type, fetched as the page fetches them once one is picked;
|
|
487
|
+
Other ends every list."""
|
|
488
|
+
html = page.evaluate(_VIEW, [step, {}]) or ""
|
|
489
|
+
types = page.evaluate(
|
|
490
|
+
"""html => [...new DOMParser().parseFromString(html, "text/html")
|
|
491
|
+
.querySelectorAll("#source_type_id option")].filter(o => o.value)
|
|
492
|
+
.map(o => [o.value, o.textContent.replace(/\\s+/g, " ").trim()])""",
|
|
493
|
+
html,
|
|
494
|
+
)
|
|
495
|
+
sets = []
|
|
496
|
+
for value, text in types:
|
|
497
|
+
params = {"source_type_id": value, "selected_id": "", "hide_internal": "1"}
|
|
498
|
+
listed = page.evaluate(_OPTIONS, page.evaluate(_VIEW, ["getSources", params]) or "")
|
|
499
|
+
options = [name for _, name in listed]
|
|
500
|
+
sets.append(OptionSet(Is(f"{step}.source_type_id", [text]), [*options, "Other"]))
|
|
501
|
+
return sets
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _wait_option(select: Locator, text: str) -> None:
|
|
505
|
+
with suppress(Timeout):
|
|
506
|
+
select.locator("option", has_text=text).first.wait_for(
|
|
507
|
+
state="attached", timeout=LIST_TIMEOUT_MS
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _follow_ups(page: Page, question: str, opened: bool) -> None:
|
|
512
|
+
"""Waits for the follow-up questions an answer opens to load, or for them to go."""
|
|
513
|
+
div = page.locator(f"#conditional_questions_div_{question}")
|
|
514
|
+
with suppress(Timeout):
|
|
515
|
+
if opened:
|
|
516
|
+
div.locator("input, select, textarea").first.wait_for(timeout=LIST_TIMEOUT_MS)
|
|
517
|
+
else:
|
|
518
|
+
div.locator("input, select, textarea").first.wait_for(
|
|
519
|
+
state="detached", timeout=LIST_TIMEOUT_MS
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _confirm_contact(page: Page) -> None:
|
|
524
|
+
"""The wizard opens on the account's contact details, asking on each employer's site to
|
|
525
|
+
agree to the privacy policy again: saved as they are, text messages declined where no
|
|
526
|
+
choice is saved yet."""
|
|
527
|
+
popup = page.locator("#verify_contact_info_popup")
|
|
528
|
+
with suppress(Timeout): # shown once faded in
|
|
529
|
+
page.locator("#verify_contact_info_popup.in").wait_for(timeout=SETTLE_TIMEOUT_MS)
|
|
530
|
+
if not popup.is_visible():
|
|
531
|
+
return
|
|
532
|
+
consent = popup.locator("#applicant_consent_approval")
|
|
533
|
+
if consent.count() and not consent.input_value():
|
|
534
|
+
consent.select_option(NO_TEXTS)
|
|
535
|
+
popup.locator("#info_use_consent").check()
|
|
536
|
+
popup.locator("#verify_contact_info_button").click()
|
|
537
|
+
try:
|
|
538
|
+
popup.wait_for(state="hidden", timeout=STEP_TIMEOUT_MS)
|
|
539
|
+
except Timeout:
|
|
540
|
+
said = " ".join(" ".join(popup.locator("[id^=message_]:visible").all_inner_texts()).split())
|
|
541
|
+
raise NotTaken(f"the contact details were not saved: {said or 'no error shown'}") from None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _current(page: Page) -> str | None:
|
|
545
|
+
return page.locator("#steps a.step.current").first.get_attribute("data-file")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _go(page: Page, step: str) -> None:
|
|
549
|
+
"""Opens a step from the steps list, once it has loaded."""
|
|
550
|
+
if _current(page) != step:
|
|
551
|
+
page.locator(f"#steps a.step[data-file='{step}']").click()
|
|
552
|
+
page.locator(f"#steps a.step.current[data-file='{step}']").wait_for(
|
|
553
|
+
state="attached", timeout=STEP_TIMEOUT_MS
|
|
554
|
+
)
|
|
555
|
+
page.locator("#landingStrip #next, #landingStrip #previous").first.wait_for(
|
|
556
|
+
timeout=STEP_TIMEOUT_MS
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _next(page: Page, step: str) -> None:
|
|
561
|
+
"""Proceed to Next Step: the step is saved once the next one is current, and stays with its
|
|
562
|
+
errors shown while an answer is missing or invalid."""
|
|
563
|
+
page.locator("#landingStrip #next").click()
|
|
564
|
+
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
|
|
565
|
+
errors = page.locator(ERRORS)
|
|
566
|
+
while time.monotonic() < deadline:
|
|
567
|
+
if _current(page) != step:
|
|
568
|
+
page.locator("#landingStrip #previous").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
569
|
+
return
|
|
570
|
+
if errors.count():
|
|
571
|
+
said = " ".join(" ".join(errors.all_inner_texts()).split())
|
|
572
|
+
raise NotTaken(f"the {step} step was not saved: {said}")
|
|
573
|
+
page.wait_for_timeout(POLL_MS)
|
|
574
|
+
raise RuntimeError(f"Proceed to Next Step on the {step} step went nowhere")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _settle(page: Page) -> None:
|
|
578
|
+
"""Lets the answers saved as they were entered reach the system."""
|
|
579
|
+
with suppress(Timeout):
|
|
580
|
+
page.wait_for_load_state("networkidle", timeout=SETTLE_TIMEOUT_MS)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _upload(page: Page, path: str) -> None:
|
|
584
|
+
"""Chooses the file, then Upload Files, which the step shows once a file is chosen; the step
|
|
585
|
+
loads again listing the file."""
|
|
586
|
+
name = path.rsplit("/", 1)[-1]
|
|
587
|
+
listed = page.locator(".uploaded-resumes-container .file p", has_text=name)
|
|
588
|
+
before = listed.count()
|
|
589
|
+
page.locator("#default_resume").set_input_files(path)
|
|
590
|
+
page.locator("#new_upload_button").click(timeout=STEP_TIMEOUT_MS)
|
|
591
|
+
deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
|
|
592
|
+
while time.monotonic() < deadline:
|
|
593
|
+
with suppress(Exception):
|
|
594
|
+
if listed.count() > before:
|
|
595
|
+
return
|
|
596
|
+
page.wait_for_timeout(POLL_MS)
|
|
597
|
+
raise RuntimeError(f"the upload of {name} was not listed")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _signed(page: Page) -> None:
|
|
601
|
+
"""Waits for the page to show the account signed in, or for the error the sign-in shows."""
|
|
602
|
+
deadline = time.monotonic() + ACCOUNT_TIMEOUT_S
|
|
603
|
+
while time.monotonic() < deadline:
|
|
604
|
+
if parse_qs(urlsplit(page.url).query).get("error"):
|
|
605
|
+
raise Refused(f"wrong_password: {_said(page)}")
|
|
606
|
+
if page.locator(SIGNED_IN).count():
|
|
607
|
+
return
|
|
608
|
+
page.wait_for_timeout(POLL_MS)
|
|
609
|
+
raise Unsupported(f"unhandled_rendering: the account page says {_said(page)!r}")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _said(page: Page) -> str:
|
|
613
|
+
return " ".join(page.locator("body").inner_text().split())[:500]
|
|
@@ -327,10 +327,11 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
|
|
|
327
327
|
[isolvedhire]
|
|
328
328
|
kind = "ats"
|
|
329
329
|
share = 0.0047
|
|
330
|
-
account = "to_read" # one account for the whole system
|
|
330
|
+
account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with a one-time code, the sign-in's sign-up sets a password instead
|
|
331
331
|
hosting = ["hosted"]
|
|
332
|
+
adapter = "isolvedhire"
|
|
332
333
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
333
|
-
|
|
334
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "867de7d" }
|
|
334
335
|
|
|
335
336
|
[jobstreet]
|
|
336
337
|
kind = "board"
|
|
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
|