atsfill 0.0.5.dev179__tar.gz → 0.0.5.dev181__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.dev179 → atsfill-0.0.5.dev181}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/phenom.py +115 -32
  5. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/LICENSE +0 -0
  6. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/README.md +0 -0
  7. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/__init__.py +0 -0
  8. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/__init__.py +0 -0
  9. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/adpmyjobs.py +0 -0
  10. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/gupy.py +0 -0
  17. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/isolvedhire.py +0 -0
  21. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/jazzhr.py +0 -0
  22. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/jobaffinity.py +0 -0
  23. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/jobstreet.py +0 -0
  24. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/join.py +0 -0
  25. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/lever.py +0 -0
  26. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/manatal.py +0 -0
  27. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/oracle.py +0 -0
  28. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/paycor.py +0 -0
  29. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/paylocity.py +0 -0
  30. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/personio.py +0 -0
  31. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/pinpoint.py +0 -0
  32. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/recruitee.py +0 -0
  33. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/recruiterflow.py +0 -0
  34. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/rippling.py +0 -0
  35. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/smartrecruiters.py +0 -0
  36. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/successfactors.js +0 -0
  37. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/successfactors.py +0 -0
  38. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/taleo.py +0 -0
  39. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/teamtailor.py +0 -0
  40. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/ukg.py +0 -0
  41. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/welcomekit.py +0 -0
  42. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/workable.py +0 -0
  43. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/workatastartup.py +0 -0
  44. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/workday.py +0 -0
  45. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/wttj.py +0 -0
  46. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/adapters/zohorecruit.py +0 -0
  47. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/cli.py +0 -0
  48. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/__init__.py +0 -0
  49. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/accounts.py +0 -0
  50. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/adapter.py +0 -0
  51. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/attempts.py +0 -0
  52. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/browser.py +0 -0
  53. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/commands.py +0 -0
  54. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/conditions.py +0 -0
  55. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/html.py +0 -0
  56. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/matching.py +0 -0
  57. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/otp.py +0 -0
  58. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/passwords.py +0 -0
  59. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/paths.py +0 -0
  60. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/registry.py +0 -0
  61. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/results.py +0 -0
  62. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/src/atsfill/py.typed +0 -0
  65. {atsfill-0.0.5.dev179 → atsfill-0.0.5.dev181}/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.dev179
3
+ Version: 0.0.5.dev181
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.dev179"
3
+ version = "0.0.5.dev181"
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.dev179"
3
+ version = "0.0.5.dev181"
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"
@@ -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 ([url, body, token]) => {
230
- const init = body ? {method: 'POST', body: JSON.stringify(body),
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, [url, None, token]),
324
- lambda body: page.evaluate(_FETCH, ["/applySubmit", body, token]),
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(p, u, path, nav)
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
- """Posts a loader's payload to `/applySubmit`, the page's values put in; None when it
874
- names an answer, or the request fails."""
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
- body = json.loads(filled)
880
- body.setdefault("formData", {}).update(extra)
881
- body["formData"].setdefault("applyAtsSource", self.ddo.get("applyATS"))
882
- return self.post(body)
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, p: dict, u: dict, path: str, nav: Any) -> list[dict[str, Any]]:
949
- """The fields the country's answer before any adds under `cntryFields` (names,
950
- address)."""
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
- code, _ = self.prefilled(path)
953
- if "getCountrySpecificFields" not in payload or code is None:
982
+ if "getCountrySpecificFields" not in payload:
954
983
  return []
955
- part = get(self.call(payload, countryCode=code), "applyGetReferences.data")
956
- if not isinstance(get(part, "schema"), dict):
957
- return []
958
- return self.object(
959
- part["schema"], part.get("uiSchema") or {}, "cntryFields.", nav, False, None
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:
File without changes
File without changes