atsfill 0.0.4.dev62__tar.gz → 0.0.4.dev64__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 (38) hide show
  1. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/manatal.py +51 -11
  5. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/phenom.py +19 -9
  6. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/LICENSE +0 -0
  7. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/README.md +0 -0
  8. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/hireology.py +0 -0
  16. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/jazzhr.py +0 -0
  17. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/lever.py +0 -0
  18. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/oracle.py +0 -0
  19. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/paycor.py +0 -0
  20. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/paylocity.py +0 -0
  21. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/recruitee.py +0 -0
  22. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/smartrecruiters.py +0 -0
  23. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/teamtailor.py +0 -0
  24. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/workable.py +0 -0
  25. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/wttj.py +0 -0
  26. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/cli.py +0 -0
  27. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/__init__.py +0 -0
  28. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/accounts.py +0 -0
  29. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/adapter.py +0 -0
  30. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/attempts.py +0 -0
  31. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/browser.py +0 -0
  32. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/commands.py +0 -0
  33. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/matching.py +0 -0
  34. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/paths.py +0 -0
  35. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/registry.py +0 -0
  36. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/results.py +0 -0
  37. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/py.typed +0 -0
  38. {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev62
3
+ Version: 0.0.4.dev64
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.4.dev62"
3
+ version = "0.0.4.dev64"
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.4.dev62"
3
+ version = "0.0.4.dev64"
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"
@@ -34,6 +34,9 @@ LISTS: dict[str, tuple[str, Callable[[dict[str, Any]], str]]] = {
34
34
  "industries": ("clients/{client}/industries/", lambda n: n["name"]),
35
35
  }
36
36
  ENTRIES = ("educations", "experiences") # a sub-form of entries
37
+ # A salary's amount comes with a currency and a frequency select, each with an id of its own.
38
+ SALARIES = {"expected_salary": "expected", "current_salary": "current"}
39
+ FREQUENCIES = ["Hourly", "Daily", "Weekly", "Monthly", "Yearly"]
37
40
  OTHER = "Other" # the radio or checkbox list option that opens a text box once picked
38
41
  SHORT = {"full_name", "email", "phone_number", "city", "address", "latest_degree",
39
42
  "latest_university", "current_company", "current_department", "current_position",
@@ -43,8 +46,8 @@ LOAD_TIMEOUT_MS = 30_000
43
46
  SUBMIT_TIMEOUT_MS = 120_000
44
47
 
45
48
  # The answer each input holds, by field id, read as the page reads them on Apply.
46
- _READ = """fields => Object.fromEntries(fields.map(([id, kind]) => {
47
- const el = document.getElementById('field' + id);
49
+ _READ = """fields => Object.fromEntries(fields.map(([id, kind, dom]) => {
50
+ const el = document.getElementById(dom);
48
51
  const named = [...document.querySelectorAll(`input[name="${id}"]`)];
49
52
  const texts = s => [...s.selectedOptions].filter(o => o.value).map(o => o.text.trim());
50
53
  switch (kind) {
@@ -107,6 +110,24 @@ def _kind(entry: dict[str, Any]) -> str:
107
110
  return "text"
108
111
 
109
112
 
113
+ def _salary(entry: dict[str, Any]) -> list[tuple[str, str]]:
114
+ """A salary's currency and frequency selects: the field id each is listed as, and its own id."""
115
+ prefix = SALARIES.get(entry["client_field_slug"])
116
+ if prefix is None or entry["field_type"] != "candidate_field":
117
+ return []
118
+ return [(f"{entry['id']}.{part}", f"{prefix}_{part}") for part in ("currency", "frequency")]
119
+
120
+
121
+ def _inputs(entries: list[dict[str, Any]]) -> list[tuple[str, str, str]]:
122
+ """Each input the page reads on Apply: its field id, its kind and its element id."""
123
+ inputs = []
124
+ for entry in entries:
125
+ if (kind := _kind(entry)) != "entries":
126
+ inputs.append((str(entry["id"]), kind, f"field{entry['id']}"))
127
+ inputs += [(fid, "select", dom) for fid, dom in _salary(entry)]
128
+ return inputs
129
+
130
+
110
131
  def _field(entry: dict[str, Any], options: list[str] | None) -> Field:
111
132
  """The field an entry of the endpoint lists, a sub-form of entries aside."""
112
133
  kind, slug = _kind(entry), entry["client_field_slug"]
@@ -182,7 +203,10 @@ class Manatal:
182
203
  return None
183
204
  entries = _get(f"jobs/{job.group(1)}/application-form/")
184
205
  terms = bool(TERMS_REQUIRED.search(html))
185
- return self._fields(entries, terms, lambda e: _options(e, client))
206
+ salary = any(_salary(e) for e in entries)
207
+ currencies = [c["name"] for c in _get("currencies/")] if salary else []
208
+ salaries = lambda dom: currencies if dom.endswith("currency") else FREQUENCIES # noqa: E731
209
+ return self._fields(entries, terms, lambda e: _options(e, client), salaries)
186
210
  except OSError, ValueError, KeyError, TypeError:
187
211
  return None
188
212
 
@@ -199,19 +223,31 @@ class Manatal:
199
223
  raise Unsupported(f"posting_gone: the form answered {response.status}")
200
224
  self._entries = response.json()
201
225
  page.wait_for_selector("#submit-id-submit", timeout=LOAD_TIMEOUT_MS)
202
- # The lists of a multiple select come in their own requests, once the form is drawn.
226
+ # The lists of a multiple select and the currencies come in their own requests, once the
227
+ # form is drawn.
203
228
  page.wait_for_function(
204
229
  "() => [...document.querySelectorAll('select.multiselect')]"
205
- ".every(s => s.options.length && s.classList.contains('select2-hidden-accessible'))",
230
+ ".every(s => s.options.length && s.classList.contains('select2-hidden-accessible'))"
231
+ " && [...document.querySelectorAll('#expected_currency, #current_currency')]"
232
+ ".every(s => s.options.length)",
206
233
  timeout=LOAD_TIMEOUT_MS,
207
234
  )
208
235
  terms = page.locator(f'input[name="{TERMS}"]').count() > 0
209
- return self._fields(self._entries, terms, lambda e: self._shown(page, e))
236
+ return self._fields(
237
+ self._entries,
238
+ terms,
239
+ lambda e: self._shown(page, e),
240
+ lambda dom: page.locator(f"#{dom} option").evaluate_all(
241
+ "os => os.map(o => o.text.trim())"
242
+ ),
243
+ )
210
244
 
211
245
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
212
- kinds = {str(e["id"]): _kind(e) for e in self._entries} | {TERMS: "terms"}
246
+ inputs = _inputs(self._entries)
247
+ kinds = {fid: kind for fid, kind, _ in inputs} | {TERMS: "terms"}
248
+ doms = {fid: dom for fid, _, dom in inputs}
213
249
  for field_id, answer in answers.items():
214
- box = page.locator(f"#field{field_id}")
250
+ box = page.locator(f"#{doms.get(field_id, f'field{field_id}')}")
215
251
  named = page.locator(f'input[name="{field_id}"]')
216
252
  match kinds.get(field_id), answer:
217
253
  case "text", str():
@@ -247,10 +283,10 @@ class Manatal:
247
283
  )
248
284
 
249
285
  def read_back(self, page: Page) -> dict[str, Answer]:
250
- kinds = [[str(e["id"]), _kind(e)] for e in self._entries if _kind(e) != "entries"]
286
+ inputs = [list(i) for i in _inputs(self._entries)]
251
287
  if page.locator(f'input[name="{TERMS}"]').count():
252
- kinds.append([TERMS, "terms"])
253
- held = page.evaluate(_READ, kinds)
288
+ inputs.append([TERMS, "terms", ""])
289
+ held = page.evaluate(_READ, inputs)
254
290
  return {k: v for k, v in held.items() if v not in (None, [])}
255
291
 
256
292
  def submit(self, page: Page) -> str | None:
@@ -271,6 +307,7 @@ class Manatal:
271
307
  entries: list[dict[str, Any]],
272
308
  terms: bool,
273
309
  options: Callable[[dict[str, Any]], list[str] | None],
310
+ salaries: Callable[[str], list[str]],
274
311
  ) -> list[Field]:
275
312
  fields = []
276
313
  for entry in entries:
@@ -278,6 +315,9 @@ class Manatal:
278
315
  fields.append(self._entries_field(entry))
279
316
  continue
280
317
  fields.append(field := _field(entry, options(entry)))
318
+ for fid, dom in _salary(entry):
319
+ label = f"{entry['label']}: {fid.rsplit('.', 1)[1]}"
320
+ fields.append(Field(fid, label, FieldType.CHOICE, field.required, salaries(dom)))
281
321
  if _kind(entry) in ("radio", "checkbox") and OTHER in (field.options or []):
282
322
  fields.append(self._other_field(field))
283
323
  if terms:
@@ -311,6 +311,7 @@ class Phenom:
311
311
  return None
312
312
 
313
313
  def read_form(self, page: Page) -> list[Field]:
314
+ _check_open(page)
314
315
  self._apply = _apply_url(page)
315
316
  self._open(page)
316
317
  try:
@@ -379,6 +380,7 @@ class Phenom:
379
380
  page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
380
381
  if fresh or not page.url.startswith(self._apply):
381
382
  page.goto(self._apply, wait_until="domcontentloaded")
383
+ _check_open(page)
382
384
  try:
383
385
  page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]").first.wait_for(
384
386
  state="attached", timeout=FORM_TIMEOUT_MS
@@ -421,17 +423,25 @@ def get(obj: Any, path: str) -> Any:
421
423
 
422
424
  def _phapp_of(html: str) -> dict[str, Any]:
423
425
  """The apply page's `phApp`, from the object literals its scripts assign."""
426
+ ph = _literal(html, "var phApp = phApp || ")
427
+ ph["ddo"] = _literal(html, "phApp.ddo = ")
428
+ ph["sessionParams"] = _literal(html, "phApp.sessionParams = ")
429
+ return ph
424
430
 
425
- def literal(marker: str) -> Any:
426
- at = html.find(marker)
427
- if at < 0:
428
- raise ValueError(f"no {marker.strip()} on the page")
429
- return json.JSONDecoder().raw_decode(html, at + len(marker))[0]
430
431
 
431
- ph = literal("var phApp = phApp || ")
432
- ph["ddo"] = literal("phApp.ddo = ")
433
- ph["sessionParams"] = literal("phApp.sessionParams = ")
434
- return ph
432
+ def _literal(html: str, marker: str) -> Any:
433
+ at = html.find(marker)
434
+ if at < 0:
435
+ raise ValueError(f"no {marker.strip()} on the page")
436
+ return json.JSONDecoder().raw_decode(html, at + len(marker))[0]
437
+
438
+
439
+ def _check_open(page: Page) -> None:
440
+ """Raises `posting_gone` on Phenom's page for a closed posting, which a closed job's URL
441
+ and its wizard's both serve."""
442
+ with suppress(ValueError):
443
+ if _literal(page.content(), "var phApp = phApp || ").get("pageName") == "404":
444
+ raise Unsupported("posting_gone: the careers site answers its 404 page")
435
445
 
436
446
 
437
447
  def _read(
File without changes
File without changes