atsfill 0.0.5.dev178__tar.gz → 0.0.5.dev180__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.dev178 → atsfill-0.0.5.dev180}/PKG-INFO +1 -1
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/phenom.py +76 -32
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/LICENSE +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/README.md +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.5.dev178 → atsfill-0.0.5.dev180}/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.dev180
|
|
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
|
|
@@ -14,8 +14,8 @@ import re
|
|
|
14
14
|
import time
|
|
15
15
|
import urllib.request
|
|
16
16
|
import uuid
|
|
17
|
-
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
18
|
-
from contextlib import suppress
|
|
17
|
+
from collections.abc import Callable, Generator, Iterable, Iterator, Sequence
|
|
18
|
+
from contextlib import contextmanager, suppress
|
|
19
19
|
from datetime import date
|
|
20
20
|
from html import unescape
|
|
21
21
|
from pathlib import PurePath
|
|
@@ -57,7 +57,8 @@ UPLOAD_TIMEOUT_MS = 30_000
|
|
|
57
57
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
58
58
|
SETTLE_S = 1.0
|
|
59
59
|
QUIET_S = 2.0 # no request in flight this long: a parsed resume is back
|
|
60
|
-
|
|
60
|
+
LOADS_QUIET_S = 1.0 # longer than the page waits (0.3 s) to send a country's request
|
|
61
|
+
POLL_S = 0.25
|
|
61
62
|
LINK_TIMEOUT_S = 15
|
|
62
63
|
FILL_ROUNDS = 6 # an answer shows a follow-up, which can show another
|
|
63
64
|
PICK_TRIES = 3
|
|
@@ -339,7 +340,13 @@ class Phenom:
|
|
|
339
340
|
for _ in range(FILL_ROUNDS): # an answer can show a field, or reset one
|
|
340
341
|
step = self._step(page, answers)
|
|
341
342
|
for raw in [raw for raw in step if not self._holds(raw, answers, kept)]:
|
|
342
|
-
|
|
343
|
+
if not self._raws[raw["id"]].get("loads"):
|
|
344
|
+
_fill(page, raw, answers[raw["id"]])
|
|
345
|
+
continue
|
|
346
|
+
# the page sends its request a moment later; two answers before drop it
|
|
347
|
+
with _requests(page) as pending:
|
|
348
|
+
_fill(page, raw, answers[raw["id"]])
|
|
349
|
+
_quiet(pending, LOADS_QUIET_S, STEP_TIMEOUT_MS / 1000)
|
|
343
350
|
time.sleep(2 * SETTLE_S) # a reset comes a moment after the answer
|
|
344
351
|
again = self._step(page, answers)
|
|
345
352
|
kept = _read_step(page, again)
|
|
@@ -645,6 +652,7 @@ class _Config:
|
|
|
645
652
|
)
|
|
646
653
|
out = [raw]
|
|
647
654
|
if html == "country":
|
|
655
|
+
raw["loads"] = "getCountrySpecificFields" in (get(u, "apiConfig.payload") or "")
|
|
648
656
|
out += self.country_fields(p, u, path, nav)
|
|
649
657
|
return out
|
|
650
658
|
|
|
@@ -1198,21 +1206,38 @@ def _has_next(page: Page) -> bool:
|
|
|
1198
1206
|
|
|
1199
1207
|
|
|
1200
1208
|
def _next(page: Page, number: int) -> None:
|
|
1201
|
-
"""Clicks Next and waits for the step after `number`; a step that shows errors
|
|
1209
|
+
"""Clicks Next and waits for the step after `number`; a step that shows errors, or that
|
|
1210
|
+
`/applySubmit` turns down, raises: an answer it refuses (`VALIDATION_ERROR`) is not taken."""
|
|
1202
1211
|
form = page.locator(FORM)
|
|
1203
1212
|
before = page.url
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1213
|
+
answered: list[dict[str, Any]] = []
|
|
1214
|
+
|
|
1215
|
+
def on_response(response: Any) -> None:
|
|
1216
|
+
with suppress(Exception): # another request, or no JSON
|
|
1217
|
+
if json.loads(response.request.post_data)["ddoKey"] == "applySubmit":
|
|
1218
|
+
answered.append(get(response.json(), "applySubmit.response") or {})
|
|
1219
|
+
|
|
1220
|
+
page.on("response", on_response)
|
|
1221
|
+
try:
|
|
1222
|
+
_click(page, form.locator(NEXT))
|
|
1223
|
+
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
|
|
1224
|
+
while time.monotonic() < deadline:
|
|
1225
|
+
time.sleep(SETTLE_S)
|
|
1226
|
+
if page.url != before and page.locator(FORM).count():
|
|
1227
|
+
with_controls = page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]")
|
|
1228
|
+
if with_controls.count():
|
|
1229
|
+
return
|
|
1230
|
+
if refused := next((a for a in answered if a.get("status") == "failure"), None):
|
|
1231
|
+
code, detail = refused.get("statusCode"), refused.get("statusMessage")
|
|
1232
|
+
if refused.get("statusType") == "VALIDATION_ERROR": # the page shows nothing
|
|
1233
|
+
raise NotTaken(f"step {number}: {detail}")
|
|
1234
|
+
raise RuntimeError(f"step {number} did not pass: {code}: {detail}")
|
|
1235
|
+
if errors := page.locator(FORM).evaluate(_ERRORS) if page.locator(FORM).count() else []:
|
|
1236
|
+
shown = "; ".join(f"{dom}: {message}" for dom, message in errors)
|
|
1237
|
+
raise RuntimeError(f"step {number} did not pass: {shown}")
|
|
1238
|
+
raise RuntimeError(f"step {number} did not pass in time")
|
|
1239
|
+
finally:
|
|
1240
|
+
page.remove_listener("response", on_response)
|
|
1216
1241
|
|
|
1217
1242
|
|
|
1218
1243
|
def _click(page: Page, target: Locator) -> None:
|
|
@@ -1318,40 +1343,59 @@ def _upload(page: Page, path: str) -> None:
|
|
|
1318
1343
|
"""Attaches the resume, taken once Phenom's `/fileUpload` answers or the page shows its
|
|
1319
1344
|
name, and waits for its parse, which fills and clears fields when it comes back: until
|
|
1320
1345
|
the page's requests have settled."""
|
|
1321
|
-
pending: set[Any] = set()
|
|
1322
1346
|
uploaded: list[bool] = [] # Phenom's `/fileUpload` took it
|
|
1323
1347
|
|
|
1348
|
+
def ended(request: Any) -> None:
|
|
1349
|
+
if "/fileUpload" in request.url and (response := request.response()) and response.ok:
|
|
1350
|
+
uploaded.append(True)
|
|
1351
|
+
|
|
1352
|
+
page.on("requestfinished", ended)
|
|
1353
|
+
try:
|
|
1354
|
+
with _requests(page) as pending:
|
|
1355
|
+
page.locator(RESUME).first.set_input_files(path)
|
|
1356
|
+
name = PurePath(path).stem[:12] # shown, at times shortened, once uploaded
|
|
1357
|
+
deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
|
|
1358
|
+
while not uploaded and not page.evaluate(_SHOWS, name):
|
|
1359
|
+
if time.monotonic() > deadline:
|
|
1360
|
+
raise RuntimeError("the resume upload did not show the file")
|
|
1361
|
+
time.sleep(POLL_S)
|
|
1362
|
+
_quiet(pending, QUIET_S, UPLOAD_TIMEOUT_MS / 1000)
|
|
1363
|
+
finally:
|
|
1364
|
+
page.remove_listener("requestfinished", ended)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
@contextmanager
|
|
1368
|
+
def _requests(page: Page) -> Generator[set[Any]]:
|
|
1369
|
+
"""The page's requests in flight, while in the block."""
|
|
1370
|
+
pending: set[Any] = set()
|
|
1371
|
+
|
|
1324
1372
|
def started(request: Any) -> None:
|
|
1325
1373
|
if request.resource_type in ("xhr", "fetch"):
|
|
1326
1374
|
pending.add(request)
|
|
1327
1375
|
|
|
1328
1376
|
def ended(request: Any) -> None:
|
|
1329
1377
|
pending.discard(request)
|
|
1330
|
-
if "/fileUpload" in request.url and (response := request.response()) and response.ok:
|
|
1331
|
-
uploaded.append(True)
|
|
1332
1378
|
|
|
1333
1379
|
page.on("request", started)
|
|
1334
1380
|
page.on("requestfinished", ended)
|
|
1335
1381
|
page.on("requestfailed", ended)
|
|
1336
1382
|
try:
|
|
1337
|
-
|
|
1338
|
-
name = PurePath(path).stem[:12] # shown, at times shortened, once uploaded
|
|
1339
|
-
deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
|
|
1340
|
-
while not uploaded and not page.evaluate(_SHOWS, name):
|
|
1341
|
-
if time.monotonic() > deadline:
|
|
1342
|
-
raise RuntimeError("the resume upload did not show the file")
|
|
1343
|
-
time.sleep(UPLOAD_POLL_S)
|
|
1344
|
-
deadline, quiet_since = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000, time.monotonic()
|
|
1345
|
-
while time.monotonic() < deadline and time.monotonic() - quiet_since < QUIET_S:
|
|
1346
|
-
time.sleep(UPLOAD_POLL_S)
|
|
1347
|
-
if pending:
|
|
1348
|
-
quiet_since = time.monotonic()
|
|
1383
|
+
yield pending
|
|
1349
1384
|
finally:
|
|
1350
1385
|
page.remove_listener("request", started)
|
|
1351
1386
|
page.remove_listener("requestfinished", ended)
|
|
1352
1387
|
page.remove_listener("requestfailed", ended)
|
|
1353
1388
|
|
|
1354
1389
|
|
|
1390
|
+
def _quiet(pending: set[Any], seconds: float, timeout_s: float) -> None:
|
|
1391
|
+
"""Waits until no request has been in flight for `seconds`, `timeout_s` at most."""
|
|
1392
|
+
deadline, quiet_since = time.monotonic() + timeout_s, time.monotonic()
|
|
1393
|
+
while time.monotonic() < deadline and time.monotonic() - quiet_since < seconds:
|
|
1394
|
+
time.sleep(POLL_S)
|
|
1395
|
+
if pending:
|
|
1396
|
+
quiet_since = time.monotonic()
|
|
1397
|
+
|
|
1398
|
+
|
|
1355
1399
|
def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
|
|
1356
1400
|
form = page.locator(FORM)
|
|
1357
1401
|
kind, dom = raw["kind"], raw["dom"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|