atsfill 0.0.5.dev177__tar.gz → 0.0.5.dev179__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.
Files changed (65) hide show
  1. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/phenom.py +76 -32
  5. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/rippling.py +7 -3
  6. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/LICENSE +0 -0
  7. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/README.md +0 -0
  8. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/adpmyjobs.py +0 -0
  11. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/eightfold.py +0 -0
  16. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/greenhouse.py +0 -0
  17. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/gupy.py +0 -0
  18. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/hireology.py +0 -0
  19. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/hiringthing.py +0 -0
  20. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/hrmdirect.py +0 -0
  21. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/isolvedhire.py +0 -0
  22. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/jazzhr.py +0 -0
  23. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/jobaffinity.py +0 -0
  24. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/jobstreet.py +0 -0
  25. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/join.py +0 -0
  26. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/lever.py +0 -0
  27. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/manatal.py +0 -0
  28. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/oracle.py +0 -0
  29. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/paycor.py +0 -0
  30. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/paylocity.py +0 -0
  31. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/personio.py +0 -0
  32. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/pinpoint.py +0 -0
  33. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/recruitee.py +0 -0
  34. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/recruiterflow.py +0 -0
  35. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/smartrecruiters.py +0 -0
  36. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/successfactors.js +0 -0
  37. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/successfactors.py +0 -0
  38. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/taleo.py +0 -0
  39. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/teamtailor.py +0 -0
  40. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/ukg.py +0 -0
  41. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/welcomekit.py +0 -0
  42. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/workable.py +0 -0
  43. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/workatastartup.py +0 -0
  44. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/workday.py +0 -0
  45. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/wttj.py +0 -0
  46. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/adapters/zohorecruit.py +0 -0
  47. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/cli.py +0 -0
  48. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/__init__.py +0 -0
  49. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/accounts.py +0 -0
  50. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/adapter.py +0 -0
  51. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/attempts.py +0 -0
  52. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/browser.py +0 -0
  53. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/commands.py +0 -0
  54. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/conditions.py +0 -0
  55. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/html.py +0 -0
  56. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/matching.py +0 -0
  57. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/otp.py +0 -0
  58. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/passwords.py +0 -0
  59. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/paths.py +0 -0
  60. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/registry.py +0 -0
  61. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/results.py +0 -0
  62. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/src/atsfill/py.typed +0 -0
  65. {atsfill-0.0.5.dev177 → atsfill-0.0.5.dev179}/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.dev177
3
+ Version: 0.0.5.dev179
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev177"
3
+ version = "0.0.5.dev179"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev177"
3
+ version = "0.0.5.dev179"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- UPLOAD_POLL_S = 0.25
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
- _fill(page, raw, answers[raw["id"]])
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 raises."""
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
- _click(page, form.locator(NEXT))
1205
- deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
1206
- while time.monotonic() < deadline:
1207
- time.sleep(SETTLE_S)
1208
- if page.url != before and page.locator(FORM).count():
1209
- with_controls = page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]")
1210
- if with_controls.count():
1211
- return
1212
- if errors := page.locator(FORM).evaluate(_ERRORS) if page.locator(FORM).count() else []:
1213
- shown = "; ".join(f"{dom}: {message}" for dom, message in errors)
1214
- raise RuntimeError(f"step {number} did not pass: {shown}")
1215
- raise RuntimeError(f"step {number} did not pass in time")
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
- page.locator(RESUME).first.set_input_files(path)
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"]
@@ -137,9 +137,13 @@ _SHOWN = """(root, [id, nth, testids]) => {
137
137
  if (el.getAttribute("role") === "group")
138
138
  return {kind: "checkbox", value: [...el.querySelectorAll("[role=checkbox]")]
139
139
  .filter(e => e.getAttribute("aria-checked") === "true").map(text)};
140
- if (el.querySelector("input[type=file]"))
141
- return {kind: "file", value: [...el.querySelectorAll("[data-testid=chip] [id$=-content]")]
142
- .map(text)[0] ?? null};
140
+ if (el.querySelector("input[type=file]")) {
141
+ // the chip shortens a long name; the upload's status line keeps it whole
142
+ const status = [...el.querySelectorAll("[role=status]")]
143
+ .map(e => text(e).match(/^File (.+) uploaded successfully$/)).find(m => m);
144
+ const chip = el.querySelector("[data-testid=chip] [id$=-content]");
145
+ return {kind: "file", value: status ? status[1] : chip ? text(chip) : null};
146
+ }
143
147
  if (el.querySelector("[data-input=month]")) {
144
148
  const part = p => el.querySelector(`[data-input=${p}]`).value;
145
149
  const [y, m, d] = [part("year"), part("month"), part("day")];
File without changes
File without changes