atsfill 0.0.5.dev180__tar.gz → 0.0.5.dev182__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.dev180 → atsfill-0.0.5.dev182}/PKG-INFO +1 -1
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/ashby.py +20 -8
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/breezy.py +9 -2
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/lever.py +9 -2
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/manatal.py +12 -4
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/phenom.py +115 -32
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/recruitee.py +9 -2
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/teamtailor.py +14 -5
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/workable.py +9 -2
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/adapter.py +10 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/commands.py +11 -2
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/LICENSE +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/README.md +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.5.dev180 → atsfill-0.0.5.dev182}/src/atsfill/registry.toml +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.dev182
|
|
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
|
|
@@ -78,7 +78,9 @@ class Ashby:
|
|
|
78
78
|
|
|
79
79
|
def __init__(self) -> None:
|
|
80
80
|
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
81
|
-
self._pending: set[Any] = set() # GraphQL requests in flight, since `
|
|
81
|
+
self._pending: set[Any] = set() # GraphQL requests in flight, since `open_form`
|
|
82
|
+
self._listening = False
|
|
83
|
+
self._loaded: Response | None = None # the ApiJobPosting response `open_form` caught
|
|
82
84
|
self._last_change = 0.0
|
|
83
85
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
84
86
|
|
|
@@ -125,14 +127,24 @@ class Ashby:
|
|
|
125
127
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
126
128
|
return None
|
|
127
129
|
|
|
128
|
-
def
|
|
129
|
-
page.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
131
|
+
"""Loads `/application`, listening first for the page's own ApiJobPosting response."""
|
|
132
|
+
if not self._listening:
|
|
133
|
+
page.on("request", self._on_request)
|
|
134
|
+
page.on("requestfinished", self._on_finished)
|
|
135
|
+
page.on("requestfailed", self._on_finished)
|
|
136
|
+
page.on("response", self._on_response)
|
|
137
|
+
self._listening = True
|
|
133
138
|
with page.expect_response(lambda r: _op(r) == "ApiJobPosting") as posting:
|
|
134
|
-
page.goto(self.posting(
|
|
135
|
-
|
|
139
|
+
page.goto(self.posting(url) + "/application")
|
|
140
|
+
self._loaded = posting.value
|
|
141
|
+
|
|
142
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
143
|
+
if self._loaded is None: # a page claimed once loaded: an embed
|
|
144
|
+
self.open_form(page, page.url)
|
|
145
|
+
assert self._loaded
|
|
146
|
+
loaded, self._loaded = self._loaded, None
|
|
147
|
+
job = loaded.json()["data"]["jobPosting"]
|
|
136
148
|
if job is None:
|
|
137
149
|
raise Unsupported("posting_gone")
|
|
138
150
|
self._forms = [job["applicationForm"], *job["surveyForms"]]
|
|
@@ -211,6 +211,10 @@ _FILE_SHOWN = """e => {
|
|
|
211
211
|
}"""
|
|
212
212
|
|
|
213
213
|
|
|
214
|
+
def _on_form(url: str) -> bool:
|
|
215
|
+
return urlsplit(url).path.rstrip("/").endswith("/apply")
|
|
216
|
+
|
|
217
|
+
|
|
214
218
|
class Breezy:
|
|
215
219
|
name = "breezy"
|
|
216
220
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -236,9 +240,12 @@ class Breezy:
|
|
|
236
240
|
match = JOB.match(parts.path)
|
|
237
241
|
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
238
242
|
|
|
243
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
244
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/apply")
|
|
245
|
+
|
|
239
246
|
def browser_read(self, page: Page) -> list[Field]:
|
|
240
|
-
if not
|
|
241
|
-
|
|
247
|
+
if not _on_form(page.url):
|
|
248
|
+
self.open_form(page, page.url)
|
|
242
249
|
form = page.locator(FORM)
|
|
243
250
|
try:
|
|
244
251
|
form.locator("[name=cName]").wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -116,6 +116,10 @@ _READ_BACK = """(form, kinds) => {
|
|
|
116
116
|
}"""
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def _on_form(url: str) -> bool:
|
|
120
|
+
return urlsplit(url).path.rstrip("/").endswith("/apply")
|
|
121
|
+
|
|
122
|
+
|
|
119
123
|
class Lever:
|
|
120
124
|
name = "lever"
|
|
121
125
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -134,9 +138,12 @@ class Lever:
|
|
|
134
138
|
parts, job = urlsplit(url), _job(url)
|
|
135
139
|
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
136
140
|
|
|
141
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
142
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/apply")
|
|
143
|
+
|
|
137
144
|
def browser_read(self, page: Page) -> list[Field]:
|
|
138
|
-
if not
|
|
139
|
-
|
|
145
|
+
if not _on_form(page.url):
|
|
146
|
+
self.open_form(page, page.url)
|
|
140
147
|
form = page.locator(FORM)
|
|
141
148
|
try:
|
|
142
149
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
13
|
|
|
14
14
|
from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
|
|
15
|
-
from atsfill.core.browser import HTTP_TIMEOUT_S, Page
|
|
15
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Response
|
|
16
16
|
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
17
17
|
|
|
18
18
|
HOST = "www.careers-page.com"
|
|
@@ -183,6 +183,7 @@ class Manatal:
|
|
|
183
183
|
|
|
184
184
|
def __init__(self) -> None:
|
|
185
185
|
self._entries: list[dict[str, Any]] = []
|
|
186
|
+
self._loaded: Response | None = None # the form's GET `open_form` caught
|
|
186
187
|
|
|
187
188
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
188
189
|
parts = urlsplit(url).path.strip("/").split("/")
|
|
@@ -210,13 +211,20 @@ class Manatal:
|
|
|
210
211
|
except OSError, ValueError, KeyError, TypeError:
|
|
211
212
|
return None
|
|
212
213
|
|
|
213
|
-
def
|
|
214
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
215
|
+
"""Loads `/apply`, listening first for the form's GET."""
|
|
214
216
|
with page.expect_response(
|
|
215
217
|
lambda r: r.request.method == "GET" and "/application-form/" in r.url,
|
|
216
218
|
timeout=LOAD_TIMEOUT_MS,
|
|
217
219
|
) as loaded:
|
|
218
|
-
page.goto(_posting(
|
|
219
|
-
|
|
220
|
+
page.goto(_posting(url) + "/apply")
|
|
221
|
+
self._loaded = loaded.value
|
|
222
|
+
|
|
223
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
224
|
+
if self._loaded is None:
|
|
225
|
+
self.open_form(page, page.url)
|
|
226
|
+
assert self._loaded
|
|
227
|
+
response, self._loaded = self._loaded, None
|
|
220
228
|
if response.headers.get("x-amzn-waf-action") == "challenge":
|
|
221
229
|
raise Blocked("captcha: AWS WAF challenge")
|
|
222
230
|
if not response.ok:
|
|
@@ -15,6 +15,7 @@ import time
|
|
|
15
15
|
import urllib.request
|
|
16
16
|
import uuid
|
|
17
17
|
from collections.abc import Callable, Generator, Iterable, Iterator, Sequence
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
19
|
from contextlib import contextmanager, suppress
|
|
19
20
|
from datetime import date
|
|
20
21
|
from html import unescape
|
|
@@ -64,6 +65,7 @@ FILL_ROUNDS = 6 # an answer shows a follow-up, which can show another
|
|
|
64
65
|
PICK_TRIES = 3
|
|
65
66
|
MAX_STEPS = 12
|
|
66
67
|
OPTIONS_TIMEOUT_S = 8
|
|
68
|
+
LOADERS_AT_ONCE = 8 # a loader request a country: 245 on one site
|
|
67
69
|
YES_NO = ["Yes", "No"]
|
|
68
70
|
NO_YES = ["No", "Yes"]
|
|
69
71
|
MONTH = " (MM/YYYY)"
|
|
@@ -107,7 +109,7 @@ ANSWER = re.compile(r"%%formData\.(\w+)%%")
|
|
|
107
109
|
SEARCHING = re.compile(r"Searching", re.IGNORECASE) # a typeahead's menu before its results
|
|
108
110
|
ACCEPTED = re.compile(r"\b(DOCX?|PDF|TXT|HTML?|RTF)\b", re.IGNORECASE)
|
|
109
111
|
|
|
110
|
-
type Post = Callable[[dict[str, Any]], Any]
|
|
112
|
+
type Post = Callable[[list[dict[str, Any]]], list[Any]] # None for a failed request
|
|
111
113
|
|
|
112
114
|
# Each field the current step shows: its control's kind, id, label and requirement. A field is
|
|
113
115
|
# a `.form-group.field` with no field inside it; a checkbox group is named by its boxes' id.
|
|
@@ -226,14 +228,30 @@ _SHOWS = """text => [...document.querySelectorAll('body *')].some(el =>
|
|
|
226
228
|
|
|
227
229
|
# A request from the page, as its own scripts make them: the CDN's configuration, or a post to
|
|
228
230
|
# `/applySubmit` with the session's token.
|
|
229
|
-
_FETCH = """async
|
|
230
|
-
const
|
|
231
|
-
headers: {'Content-Type': 'application/json', 'x-csrf-token': token}} : {};
|
|
232
|
-
const response = await fetch(url, init);
|
|
231
|
+
_FETCH = """async url => {
|
|
232
|
+
const response = await fetch(url);
|
|
233
233
|
if (!response.ok) throw new Error(`${response.status} from ${url}`);
|
|
234
234
|
return response.json();
|
|
235
235
|
}"""
|
|
236
236
|
|
|
237
|
+
# Posts each body to `/applySubmit`, `most` at a time; null for one that fails.
|
|
238
|
+
_POSTS = """async ([bodies, token, most]) => {
|
|
239
|
+
const out = bodies.map(() => null);
|
|
240
|
+
let next = 0;
|
|
241
|
+
const post = async () => {
|
|
242
|
+
for (let i = next++; i < bodies.length; i = next++) {
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch('/applySubmit', {method: 'POST',
|
|
245
|
+
body: JSON.stringify(bodies[i]),
|
|
246
|
+
headers: {'Content-Type': 'application/json', 'x-csrf-token': token}});
|
|
247
|
+
if (response.ok) out[i] = await response.json();
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
await Promise.all(Array.from({length: most}, post));
|
|
252
|
+
return out;
|
|
253
|
+
}"""
|
|
254
|
+
|
|
237
255
|
|
|
238
256
|
class Phenom:
|
|
239
257
|
name = "phenom"
|
|
@@ -292,6 +310,16 @@ class Phenom:
|
|
|
292
310
|
with opener.open(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
293
311
|
return response.read()
|
|
294
312
|
|
|
313
|
+
def post(body: dict[str, Any]) -> Any:
|
|
314
|
+
try:
|
|
315
|
+
return json.loads(load(f"{origin}/applySubmit", body, token))
|
|
316
|
+
except OSError, ValueError:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def posts(bodies: list[dict[str, Any]]) -> list[Any]:
|
|
320
|
+
with ThreadPoolExecutor(LOADERS_AT_ONCE) as pool:
|
|
321
|
+
return list(pool.map(post, bodies))
|
|
322
|
+
|
|
295
323
|
try:
|
|
296
324
|
if prefix[2] == "job":
|
|
297
325
|
job = _phapp_of(load(url).decode())
|
|
@@ -299,11 +327,7 @@ class Phenom:
|
|
|
299
327
|
return None
|
|
300
328
|
ph = _phapp_of(load(f"{origin}{prefix[1]}/apply?jobSeqNo={seq}").decode())
|
|
301
329
|
token = get(ph, "sessionParams.csrfToken") or ""
|
|
302
|
-
fields, _ = _read(
|
|
303
|
-
ph,
|
|
304
|
-
lambda url: json.loads(load(url)),
|
|
305
|
-
lambda body: json.loads(load(f"{origin}/applySubmit", body, token)),
|
|
306
|
-
)
|
|
330
|
+
fields, _ = _read(ph, lambda url: json.loads(load(url)), posts)
|
|
307
331
|
return fields
|
|
308
332
|
except OSError, ValueError, KeyError, TypeError, RuntimeError, Unsupported:
|
|
309
333
|
return None
|
|
@@ -320,8 +344,8 @@ class Phenom:
|
|
|
320
344
|
try:
|
|
321
345
|
fields, raws = _read(
|
|
322
346
|
ph,
|
|
323
|
-
lambda url: page.evaluate(_FETCH,
|
|
324
|
-
lambda
|
|
347
|
+
lambda url: page.evaluate(_FETCH, url),
|
|
348
|
+
lambda bodies: page.evaluate(_POSTS, [bodies, token, LOADERS_AT_ONCE]),
|
|
325
349
|
)
|
|
326
350
|
except (BrowserError, ValueError, KeyError) as e:
|
|
327
351
|
raise RuntimeError(f"no configuration for the wizard ({e})") from None
|
|
@@ -653,7 +677,7 @@ class _Config:
|
|
|
653
677
|
out = [raw]
|
|
654
678
|
if html == "country":
|
|
655
679
|
raw["loads"] = "getCountrySpecificFields" in (get(u, "apiConfig.payload") or "")
|
|
656
|
-
out += self.country_fields(
|
|
680
|
+
out += self.country_fields(u, path, nav)
|
|
657
681
|
return out
|
|
658
682
|
|
|
659
683
|
def options(
|
|
@@ -870,18 +894,24 @@ class _Config:
|
|
|
870
894
|
return out
|
|
871
895
|
|
|
872
896
|
def call(self, payload: str, **extra: Any) -> Any:
|
|
873
|
-
|
|
874
|
-
|
|
897
|
+
return self.calls(payload, [extra])[0]
|
|
898
|
+
|
|
899
|
+
def calls(self, payload: str, extras: list[dict[str, Any]]) -> list[Any]:
|
|
900
|
+
"""Posts a loader's payload to `/applySubmit` with each of `extras`, the page's values
|
|
901
|
+
put in; None where it names an answer, or the request fails."""
|
|
875
902
|
if ANSWER.search(payload):
|
|
876
|
-
return None
|
|
903
|
+
return [None] * len(extras)
|
|
877
904
|
filled = PAGE_VALUE.sub(lambda m: _json_text(get({"phApp": self.ph}, m[1])), payload)
|
|
905
|
+
bodies = []
|
|
878
906
|
try:
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
907
|
+
for extra in extras:
|
|
908
|
+
body = json.loads(filled)
|
|
909
|
+
body.setdefault("formData", {}).update(extra)
|
|
910
|
+
body["formData"].setdefault("applyAtsSource", self.ddo.get("applyATS"))
|
|
911
|
+
bodies.append(body)
|
|
912
|
+
return self.post(bodies)
|
|
883
913
|
except Exception:
|
|
884
|
-
return None
|
|
914
|
+
return [None] * len(extras)
|
|
885
915
|
|
|
886
916
|
def loaded(self, u: dict, nav: Any) -> list[dict[str, Any]]:
|
|
887
917
|
"""A part of the form its loader serves: a questionnaire of the job's, where the job
|
|
@@ -945,19 +975,26 @@ class _Config:
|
|
|
945
975
|
}
|
|
946
976
|
return raw
|
|
947
977
|
|
|
948
|
-
def country_fields(self,
|
|
949
|
-
"""The fields
|
|
950
|
-
|
|
978
|
+
def country_fields(self, u: dict, path: str, nav: Any) -> list[dict[str, Any]]:
|
|
979
|
+
"""The fields each country adds under `cntryFields` (names, address), a request a
|
|
980
|
+
country, in the order of the country's answer before any."""
|
|
951
981
|
payload = (u.get("apiConfig") or {}).get("payload") or ""
|
|
952
|
-
|
|
953
|
-
if "getCountrySpecificFields" not in payload or code is None:
|
|
982
|
+
if "getCountrySpecificFields" not in payload:
|
|
954
983
|
return []
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
)
|
|
984
|
+
values, texts = self.listed.get(path) or ([], [])
|
|
985
|
+
held, _ = self.prefilled(path)
|
|
986
|
+
codes = sorted(values, key=lambda c: c != held)
|
|
987
|
+
parts = self.calls(payload, [{"countryCode": c} for c in codes])
|
|
988
|
+
by_id: dict[str, dict[Any, dict[str, Any]]] = {}
|
|
989
|
+
for code, part in zip(codes, parts, strict=True):
|
|
990
|
+
part = get(part, "applyGetReferences.data")
|
|
991
|
+
if not isinstance(get(part, "schema"), dict):
|
|
992
|
+
continue
|
|
993
|
+
ui = part.get("uiSchema") or {}
|
|
994
|
+
for raw in self.object(part["schema"], ui, "cntryFields.", nav, False, None):
|
|
995
|
+
by_id.setdefault(raw["id"], {})[code] = raw
|
|
996
|
+
shown = dict(zip(values, texts, strict=False))
|
|
997
|
+
return [_by_country(path, rows, codes, shown) for rows in by_id.values()]
|
|
961
998
|
|
|
962
999
|
def date_range(
|
|
963
1000
|
self, p: dict, u: dict, path: str, nav: Any, when: Condition | None
|
|
@@ -1037,6 +1074,52 @@ class _Config:
|
|
|
1037
1074
|
return None if joined is True else joined
|
|
1038
1075
|
|
|
1039
1076
|
|
|
1077
|
+
def _by_country(
|
|
1078
|
+
path: str, rows: dict[Any, dict[str, Any]], codes: list, shown: dict[Any, str]
|
|
1079
|
+
) -> dict[str, Any]:
|
|
1080
|
+
"""One field from its raw form under each country that lists it: shown, required and
|
|
1081
|
+
listing its options under the countries whose fields say so."""
|
|
1082
|
+
raw = dict(next(iter(rows.values())))
|
|
1083
|
+
raw["when"] = _under(path, {c: r["when"] or True for c, r in rows.items()}, codes)
|
|
1084
|
+
required = _under(
|
|
1085
|
+
path,
|
|
1086
|
+
{c: True if r["required"] else r.get("required_when") or False for c, r in rows.items()},
|
|
1087
|
+
list(rows),
|
|
1088
|
+
)
|
|
1089
|
+
raw["required"] = required is True
|
|
1090
|
+
raw["required_when"] = None if isinstance(required, bool) else required
|
|
1091
|
+
lists = {c: r["options"] for c, r in rows.items() if r["options"]}
|
|
1092
|
+
if lists:
|
|
1093
|
+
raw["kind"] = next(rows[c]["kind"] for c in lists)
|
|
1094
|
+
if lists and (len(lists) < len(rows) or len({tuple(o) for o in lists.values()}) > 1):
|
|
1095
|
+
groups: dict[tuple[str, ...], list[str]] = {}
|
|
1096
|
+
for code, options in lists.items():
|
|
1097
|
+
groups.setdefault(tuple(options), []).append(shown.get(code, str(code)))
|
|
1098
|
+
raw["options"], raw["parent"] = None, path
|
|
1099
|
+
raw["option_sets"] = [OptionSet(Is(path, names), list(o)) for o, names in groups.items()]
|
|
1100
|
+
return raw
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _under(path: str, by_value: dict[Any, bool | Condition], values: list) -> bool | Condition:
|
|
1104
|
+
"""What holds under each of a list's `values`, as one condition on its answer, False under
|
|
1105
|
+
a value `by_value` leaves out. A part that holds under most values names the others."""
|
|
1106
|
+
groups: dict[str, tuple[bool | Condition, list]] = {}
|
|
1107
|
+
for value, holds in by_value.items():
|
|
1108
|
+
if holds is not False:
|
|
1109
|
+
groups.setdefault(repr(holds), (holds, []))[1].append(value)
|
|
1110
|
+
if len(groups) == 1 and len(next(iter(groups.values()))[1]) == len(values):
|
|
1111
|
+
return next(iter(groups.values()))[0]
|
|
1112
|
+
parts: list[Condition] = []
|
|
1113
|
+
for holds, under in groups.values():
|
|
1114
|
+
on = (
|
|
1115
|
+
Is(path, under)
|
|
1116
|
+
if 2 * len(under) <= len(values)
|
|
1117
|
+
else Is(path, [v for v in values if v not in under], negated=True)
|
|
1118
|
+
)
|
|
1119
|
+
parts.append(AllOf([on, holds]) if not isinstance(holds, bool) else on)
|
|
1120
|
+
return _join(parts, any)
|
|
1121
|
+
|
|
1122
|
+
|
|
1040
1123
|
def _join(
|
|
1041
1124
|
parts: Sequence[bool | Condition | None], kind: Callable[[Iterable[object]], bool]
|
|
1042
1125
|
) -> bool | Condition:
|
|
@@ -62,6 +62,10 @@ _READ_BACK = """(form, kinds) => {
|
|
|
62
62
|
}"""
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def _on_form(url: str) -> bool:
|
|
66
|
+
return urlsplit(url).path.rstrip("/").endswith("/c/new")
|
|
67
|
+
|
|
68
|
+
|
|
65
69
|
class Recruitee:
|
|
66
70
|
name = "recruitee"
|
|
67
71
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -84,9 +88,12 @@ class Recruitee:
|
|
|
84
88
|
match = JOB.match(parts.path)
|
|
85
89
|
return f"https://{parts.hostname}/o/{match[1]}" if match else url
|
|
86
90
|
|
|
91
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
92
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/c/new")
|
|
93
|
+
|
|
87
94
|
def browser_read(self, page: Page) -> list[Field]:
|
|
88
|
-
if not
|
|
89
|
-
|
|
95
|
+
if not _on_form(page.url):
|
|
96
|
+
self.open_form(page, page.url)
|
|
90
97
|
form = page.locator(FORM)
|
|
91
98
|
try:
|
|
92
99
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -125,6 +125,7 @@ class Teamtailor:
|
|
|
125
125
|
self._follow_ups: set[str] = set()
|
|
126
126
|
self._uploads: dict[str, str] = {} # file field id: the answer's file name
|
|
127
127
|
self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
|
|
128
|
+
self._form: str | None = None # the form `open_form` loaded
|
|
128
129
|
|
|
129
130
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
130
131
|
"""A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
|
|
@@ -141,13 +142,21 @@ class Teamtailor:
|
|
|
141
142
|
assert match, url
|
|
142
143
|
return f"https://{parts.hostname}/jobs/{match[1]}"
|
|
143
144
|
|
|
144
|
-
def
|
|
145
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
145
146
|
"""Opens the form's own page, never the overlay nor "Apply with LinkedIn"."""
|
|
146
|
-
parts = urlsplit(
|
|
147
|
+
parts = urlsplit(url)
|
|
147
148
|
job = JOB.search(parts.path)
|
|
148
|
-
assert job,
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
assert job, url # detected on a job URL
|
|
150
|
+
form = f"{parts.scheme}://{parts.netloc}{parts.path[: job.end()]}/applications/new"
|
|
151
|
+
page.goto(form)
|
|
152
|
+
self._form = form
|
|
153
|
+
|
|
154
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
155
|
+
if self._form is None: # a page claimed once loaded: a custom domain
|
|
156
|
+
self.open_form(page, page.url)
|
|
157
|
+
assert self._form
|
|
158
|
+
opened, self._form = self._form, None
|
|
159
|
+
if urlsplit(page.url).hostname != urlsplit(opened).hostname:
|
|
151
160
|
raise Unsupported(f"external_step: the job applies on another site ({page.url})")
|
|
152
161
|
form = page.locator(FORM)
|
|
153
162
|
try:
|
|
@@ -141,6 +141,10 @@ _READ_BACK = """(form, kinds) => {
|
|
|
141
141
|
}"""
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def _on_form(url: str) -> bool:
|
|
145
|
+
return "/apply" in urlsplit(url).path
|
|
146
|
+
|
|
147
|
+
|
|
144
148
|
class Workable:
|
|
145
149
|
name = "workable"
|
|
146
150
|
known_gaps = {"entries": Gap.RENDERING}
|
|
@@ -174,9 +178,12 @@ class Workable:
|
|
|
174
178
|
except OSError, ValueError, KeyError, TypeError:
|
|
175
179
|
return None
|
|
176
180
|
|
|
181
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
182
|
+
page.goto(url if _on_form(url) else f"{self.posting(url)}/apply/")
|
|
183
|
+
|
|
177
184
|
def browser_read(self, page: Page) -> list[Field]:
|
|
178
|
-
if
|
|
179
|
-
|
|
185
|
+
if not _on_form(page.url):
|
|
186
|
+
self.open_form(page, page.url)
|
|
180
187
|
form = page.locator(FORM)
|
|
181
188
|
try:
|
|
182
189
|
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
@@ -280,6 +280,16 @@ class SendsToOpen(Adapter, Protocol):
|
|
|
280
280
|
...
|
|
281
281
|
|
|
282
282
|
|
|
283
|
+
@runtime_checkable
|
|
284
|
+
class OpensForm(Adapter, Protocol):
|
|
285
|
+
"""An adapter whose form is another page than the posting: the core has it load the form
|
|
286
|
+
in place of the posting, when it claims the posting by its URL (ADR 0003)."""
|
|
287
|
+
|
|
288
|
+
def open_form(self, page: Page, url: str) -> None:
|
|
289
|
+
"""Loads the form of the posting at `url`, so that `browser_read` finds it open."""
|
|
290
|
+
...
|
|
291
|
+
|
|
292
|
+
|
|
283
293
|
@runtime_checkable
|
|
284
294
|
class ReadsByRequest(Adapter, Protocol):
|
|
285
295
|
"""An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
|
|
@@ -29,6 +29,7 @@ from atsfill.core.adapter import (
|
|
|
29
29
|
ListsShownFields,
|
|
30
30
|
NeedsAccount,
|
|
31
31
|
NotTaken,
|
|
32
|
+
OpensForm,
|
|
32
33
|
ProfileIncomplete,
|
|
33
34
|
ReadsByRequest,
|
|
34
35
|
Refused,
|
|
@@ -173,7 +174,7 @@ def _opened(
|
|
|
173
174
|
session = None
|
|
174
175
|
with open_page(data, session, seconds) as page:
|
|
175
176
|
try:
|
|
176
|
-
page
|
|
177
|
+
_load(page, adapter, url)
|
|
177
178
|
except browser.Timeout:
|
|
178
179
|
if adapter is None:
|
|
179
180
|
raise
|
|
@@ -207,6 +208,14 @@ def _opened(
|
|
|
207
208
|
browser.save_session(page, session)
|
|
208
209
|
|
|
209
210
|
|
|
211
|
+
def _load(page: Page, adapter: object, url: str) -> None:
|
|
212
|
+
"""Loads the posting, or its form where the adapter that claims it opens it (ADR 0003)."""
|
|
213
|
+
if isinstance(adapter, OpensForm):
|
|
214
|
+
adapter.open_form(page, url)
|
|
215
|
+
else:
|
|
216
|
+
page.goto(url)
|
|
217
|
+
|
|
218
|
+
|
|
210
219
|
_again: ContextVar[Callable[[], Result | None] | None] = ContextVar("again", default=None)
|
|
211
220
|
|
|
212
221
|
|
|
@@ -281,7 +290,7 @@ def _sign_in_again(
|
|
|
281
290
|
return _login_required(url, adapter, user)
|
|
282
291
|
session = accounts.session_file(mine, adapter.name, scope)
|
|
283
292
|
accounts.sign_in(page, adapter, scope, *credentials, session)
|
|
284
|
-
page
|
|
293
|
+
_load(page, adapter, url)
|
|
285
294
|
return None
|
|
286
295
|
email = credentials[0] if credentials else ""
|
|
287
296
|
scoped = url if adapter.per_employer else None
|
|
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
|