atsfill 0.0.5.dev158__tar.gz → 0.0.5.dev160__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 (63) hide show
  1. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/join.py +182 -6
  5. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/LICENSE +0 -0
  6. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/README.md +0 -0
  7. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/__init__.py +0 -0
  8. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/__init__.py +0 -0
  9. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/ashby.py +0 -0
  10. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/bamboohr.py +0 -0
  11. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/breezy.py +0 -0
  12. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/dayforce.py +0 -0
  13. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/eightfold.py +0 -0
  14. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/gupy.py +0 -0
  16. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hiringthing.py +0 -0
  18. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hrmdirect.py +0 -0
  19. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/isolvedhire.py +0 -0
  20. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jazzhr.py +0 -0
  21. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jobaffinity.py +0 -0
  22. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jobstreet.py +0 -0
  23. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/lever.py +0 -0
  24. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/manatal.py +0 -0
  25. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/oracle.py +0 -0
  26. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/paycor.py +0 -0
  27. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/paylocity.py +0 -0
  28. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/personio.py +0 -0
  29. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/phenom.py +0 -0
  30. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/pinpoint.py +0 -0
  31. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/recruitee.py +0 -0
  32. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/recruiterflow.py +0 -0
  33. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/rippling.py +0 -0
  34. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/smartrecruiters.py +0 -0
  35. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/successfactors.js +0 -0
  36. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/successfactors.py +0 -0
  37. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/taleo.py +0 -0
  38. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/teamtailor.py +0 -0
  39. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/ukg.py +0 -0
  40. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workable.py +0 -0
  41. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workatastartup.py +0 -0
  42. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workday.py +0 -0
  43. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/wttj.py +0 -0
  44. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/adapters/zohorecruit.py +0 -0
  45. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/cli.py +0 -0
  46. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/__init__.py +0 -0
  47. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/accounts.py +0 -0
  48. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/adapter.py +0 -0
  49. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/attempts.py +0 -0
  50. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/browser.py +0 -0
  51. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/commands.py +0 -0
  52. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/conditions.py +0 -0
  53. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/html.py +0 -0
  54. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/matching.py +0 -0
  55. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/otp.py +0 -0
  56. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/passwords.py +0 -0
  57. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/paths.py +0 -0
  58. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/registry.py +0 -0
  59. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/results.py +0 -0
  60. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/snapshot.py +0 -0
  61. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/core/telemetry.py +0 -0
  62. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/src/atsfill/py.typed +0 -0
  63. {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev160}/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.dev158
3
+ Version: 0.0.5.dev160
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.dev158"
3
+ version = "0.0.5.dev160"
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.dev158"
3
+ version = "0.0.5.dev160"
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"
@@ -3,12 +3,16 @@
3
3
  Applying is a wizard of one step per page, ending on a review page whose Confirm & apply is the
4
4
  send. Signed in, the wizard takes the name from the account and asks the CV, a cover letter and
5
5
  the job's screening questions, which the page's own GraphQL query lists. The fill walks the
6
- steps to the review page, and the read-back is what the review page lists.
6
+ steps to the review page, and the read-back is what the review page lists. What the wizard takes
7
+ from the account (name, phone, country, city, links) is the account profile's, a form of one
8
+ profile step that `profile` saves (ADR 0017).
7
9
  """
8
10
 
9
11
  import json
10
12
  import re
11
13
  import time
14
+ from collections.abc import Callable
15
+ from dataclasses import replace
12
16
  from typing import Any
13
17
  from urllib.parse import urlsplit
14
18
 
@@ -17,10 +21,10 @@ from atsfill.core.adapter import (
17
21
  Blocked,
18
22
  Gap,
19
23
  NotTaken,
24
+ ProfileIncomplete,
20
25
  Refused,
21
26
  SignUpDetails,
22
27
  Unsupported,
23
- known_gap,
24
28
  )
25
29
  from atsfill.core.browser import Locator, Page, Request, Response, Timeout
26
30
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
@@ -31,7 +35,13 @@ ME = f"https://{HOST}/api/me"
31
35
  GRAPHQL = f"https://{HOST}/candidate-api/graphql"
32
36
  LOGIN = f"https://{HOST}/auth/login/candidate?redirectUrl=%2Ftalent%2Fhome"
33
37
  UNVERIFIED = "/auth/candidates/unverified-email"
38
+ PROFILE = f"https://{HOST}/talent/me?edit=profile"
39
+ GENERAL = "general" # the account profile's one profile step
34
40
  STEP_TIMEOUT_MS = 20_000
41
+ PROFILE_TIMEOUT_MS = 20_000
42
+ SAVE_TIMEOUT_MS = 20_000
43
+ MENU_TIMEOUT_MS = 5_000
44
+ SUGGEST_TIMEOUT_MS = 10_000 # the city's suggestions come from a request per search
35
45
  UPLOAD_TIMEOUT_MS = 30_000
36
46
  ACCOUNT_TIMEOUT_MS = 20_000
37
47
  SUBMIT_TIMEOUT_MS = 60_000
@@ -57,8 +67,27 @@ CLASS_STEPS = {
57
67
  "availability": "AVAILABILITY",
58
68
  }
59
69
  PROFILE_STEPS = ("personalInformation", "professionalLinks") # the account profile's
70
+ # What a wizard's profile step can ask that the account profile lacks, by input id.
71
+ LACKS = {
72
+ "candidate.firstName": "a first name",
73
+ "candidate.lastName": "a last name",
74
+ "candidate.phoneNumber": "a phone",
75
+ }
60
76
  PDF = [".pdf", "application/pdf"]
61
77
 
78
+ # The profile form's controls, by its field ids.
79
+ TEXTS = {
80
+ "first_name": "candidate.firstName",
81
+ "last_name": "candidate.lastName",
82
+ "linkedin": "professionalLinks.LINKEDIN",
83
+ }
84
+ COUNTRY = '[id="candidate.countryId"]'
85
+ CITY = "[data-testid=CityName]"
86
+ PHONE = '[id="candidate.phoneNumber"]'
87
+ DIAL = "[data-phone-input] button" # the phone's country code, a popover of countries
88
+ POPOVER = "[data-scope=popover][data-part=content]"
89
+ DIAL_CODE = re.compile(r"\(\+(\d+)\)\s*$") # an option of the popover: `France (+33)`
90
+
62
91
  # The page's own GraphQL request, from the page.
63
92
  _FETCH = """async ([url, body]) => (await fetch(url, {method: "POST", body: JSON.stringify(body),
64
93
  headers: {"content-type": "application/json"}})).json()"""
@@ -70,14 +99,26 @@ _REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
70
99
  ? [label.textContent.trim(), value.textContent.trim()] : null;
71
100
  }).filter(Boolean)"""
72
101
 
102
+ # A select's value: the paragraph it shows, not an option of its open menu.
103
+ _SHOWN = """root => {
104
+ const p = [...root.querySelectorAll("p")].find(p => !p.closest("[role=option]"));
105
+ return p ? p.textContent.trim() : "";
106
+ }"""
107
+
108
+ # The ids of the inputs that are empty, of those given.
109
+ _EMPTY = (
110
+ """ids => ids.filter(id => { const e = document.getElementById(id); return e && !e.value; })"""
111
+ )
112
+
73
113
 
74
114
  class Join:
75
115
  name = "join"
76
116
  per_employer = False
77
117
  asks = {"name"} # the account's first and last name
78
- known_gaps = {"profile_incomplete": Gap.FLOW}
118
+ known_gaps: dict[str, Gap] = {}
79
119
  side_effects = [
80
120
  SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
121
+ SideEffect("profile", "hiring_system", "profile --answers saves the account profile"),
81
122
  SideEffect("upload", "hiring_system", "a file is stored as soon as it is chosen"),
82
123
  SideEffect("submit", "employer", "the application reaches the employer"),
83
124
  ]
@@ -196,9 +237,11 @@ class Join:
196
237
  elif step in PROFILE_STEPS:
197
238
  try: # shown when the job requires what the account profile lacks
198
239
  _next(page, step)
199
- except NotTaken, RuntimeError:
200
- detail = f"the {step} step asks what the account profile lacks"
201
- raise Unsupported(known_gap(self, "profile_incomplete", detail)) from None
240
+ except NotTaken, RuntimeError: # Continue disabled, or refused on click
241
+ lacks = [LACKS[i] for i in page.evaluate(_EMPTY, list(LACKS))]
242
+ raise ProfileIncomplete(
243
+ f"the job asks for {' and '.join(lacks) or f'what {step} asks'}"
244
+ ) from None
202
245
  continue
203
246
  else:
204
247
  raise Unsupported(f"unhandled_rendering: a wizard step {step!r}")
@@ -239,6 +282,83 @@ class Join:
239
282
  return f"POST {parts.path}?{parts.query} {response.status}"
240
283
  raise RuntimeError(f"POST {response.url} answered {response.status}: {body.get('errors')}")
241
284
 
285
+ def profile_url(self, scope: str | None) -> str:
286
+ return PROFILE
287
+
288
+ def profile_steps(self, page: Page) -> list[str]:
289
+ return [GENERAL]
290
+
291
+ def open_step(self, page: Page, step: str) -> bool:
292
+ if step != GENERAL:
293
+ raise Unsupported(f"unhandled_rendering: a profile step {step!r}")
294
+ if page.url != PROFILE:
295
+ page.goto(PROFILE)
296
+ page.locator(f'[id="{TEXTS["first_name"]}"]').wait_for(timeout=PROFILE_TIMEOUT_MS)
297
+ return True
298
+
299
+ def profile_read(self, page: Page) -> list[Field]:
300
+ """The general information form: the name, the phone, the country and city of residence
301
+ and the LinkedIn URL. The photo, the bio, the work authorizations and the other links are
302
+ left as they are, as are the profile's other sections (experience, education, …)."""
303
+ saved = self.profile_read_back(page)
304
+ limit = {
305
+ k: int(page.locator(f'[id="{i}"]').get_attribute("maxlength") or 0) or None
306
+ for k, i in TEXTS.items()
307
+ }
308
+ fields = [
309
+ Field("first_name", "First name", FieldType.TEXT, True, max_length=limit["first_name"]),
310
+ Field("last_name", "Last name", FieldType.TEXT, True, max_length=limit["last_name"]),
311
+ Field("phone", "Phone number, with its country code", FieldType.PHONE, False),
312
+ Field("country", "Country of residence", FieldType.CHOICE, True, _menu(page, COUNTRY)),
313
+ Field("city", "City, in the country of residence", FieldType.TYPEAHEAD, False),
314
+ Field("linkedin", "LinkedIn", FieldType.URL, False),
315
+ ] # fmt: skip
316
+ return [replace(f, prefilled=saved.get(f.id)) for f in fields]
317
+
318
+ def profile_fill(self, page: Page, answers: dict[str, Answer]) -> None:
319
+ """In the form's order: the city is searched in the country of residence."""
320
+ for field_id, input_id in TEXTS.items():
321
+ if isinstance(answer := answers.get(field_id), str):
322
+ page.locator(f'[id="{input_id}"]').fill(answer)
323
+ if isinstance(answer := answers.get("phone"), str):
324
+ _phone(page, answer)
325
+ if isinstance(answer := answers.get("country"), str):
326
+ _pick(page, COUNTRY, answer, lambda text: text == answer, MENU_TIMEOUT_MS)
327
+ if isinstance(answer := answers.get("city"), str):
328
+ _pick(page, CITY, answer, lambda text: True, SUGGEST_TIMEOUT_MS)
329
+
330
+ def profile_read_back(self, page: Page) -> dict[str, Answer]:
331
+ values = {k: page.locator(f'[id="{i}"]').input_value() for k, i in TEXTS.items()}
332
+ phone = page.locator(PHONE)
333
+ if phone.input_value(): # `data-value` holds the country code and the number
334
+ values["phone"] = phone.get_attribute("data-value") or ""
335
+ values["country"] = _shown(page, COUNTRY)
336
+ values["city"] = _shown(page, CITY)
337
+ return {k: v for k, v in values.items() if v}
338
+
339
+ def save(self, page: Page) -> None:
340
+ """Save changes posts `UpdateTalentPersonalInformation`; the button stays disabled while
341
+ a required field is empty or nothing changed."""
342
+ button = page.locator("form button[type=submit]").last
343
+ try: # it is enabled a moment after the last change
344
+ page.wait_for_function(
345
+ "e => !e.disabled", arg=button.element_handle(), timeout=BUTTON_TIMEOUT_MS
346
+ )
347
+ except Timeout:
348
+ raise Refused(
349
+ f"step_refused: {GENERAL}: the form keeps its Save button disabled"
350
+ ) from None
351
+ with page.expect_response(
352
+ lambda r: "operationName=UpdateTalentPersonalInformation" in r.url,
353
+ timeout=SAVE_TIMEOUT_MS,
354
+ ) as sent:
355
+ button.click()
356
+ response = sent.value
357
+ if not response.ok:
358
+ raise RuntimeError(f"UpdateTalentPersonalInformation answered {response.status}")
359
+ if errors := response.json().get("errors"):
360
+ raise Refused(f"step_refused: {GENERAL}: {errors[0].get('message')}")
361
+
242
362
  def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
243
363
  if not isinstance(answer, str):
244
364
  return
@@ -348,6 +468,62 @@ def _date(page: Page, answer: str) -> None:
348
468
  (trigger if trigger.count() else day.first).click()
349
469
 
350
470
 
471
+ def _select(page: Page, container: str) -> tuple[Locator, Locator]:
472
+ """A select of the profile form: its combobox, and the options its menu shows, whose ids
473
+ carry the combobox's."""
474
+ box = page.locator(container).locator("input[role=combobox]")
475
+ suffix = (box.get_attribute("id") or "").removeprefix("select-input-")
476
+ return box, page.locator(f'[id^="react-select-{suffix}-option-"]')
477
+
478
+
479
+ def _menu(page: Page, container: str) -> list[str]:
480
+ box, options = _select(page, container)
481
+ box.click()
482
+ options.first.wait_for(timeout=MENU_TIMEOUT_MS)
483
+ texts = [t.strip() for t in options.all_inner_texts()]
484
+ box.press("Escape")
485
+ return texts
486
+
487
+
488
+ def _pick(page: Page, container: str, answer: str, take: Callable[[str], bool], ms: int) -> None:
489
+ """Searches the select for the answer and picks the first option `take` accepts."""
490
+ box, options = _select(page, container)
491
+ box.click()
492
+ box.fill(answer)
493
+ try:
494
+ options.first.wait_for(timeout=ms)
495
+ except Timeout:
496
+ raise NotTaken(f"no option for {answer!r}") from None
497
+ texts = [t.strip() for t in options.all_inner_texts()]
498
+ picked = next((i for i, text in enumerate(texts) if take(text)), None)
499
+ if picked is None:
500
+ raise NotTaken(f"no option {answer!r} in {texts}")
501
+ options.nth(picked).click()
502
+
503
+
504
+ def _shown(page: Page, container: str) -> str:
505
+ """A select's value as it shows, empty under its placeholder."""
506
+ return page.locator(container).first.evaluate(_SHOWN)
507
+
508
+
509
+ def _phone(page: Page, answer: str) -> None:
510
+ """Picks the country whose code starts the answer, the longest of them, then types the rest:
511
+ the number's input takes digits only."""
512
+ digits = re.sub(r"\D", "", answer)
513
+ if not answer.strip().startswith("+"):
514
+ raise NotTaken(f"the phone {answer!r} has no country code, `+<code> <number>`")
515
+ page.locator(DIAL).click()
516
+ options = page.locator(POPOVER).locator("[role=option]")
517
+ options.first.wait_for(timeout=MENU_TIMEOUT_MS)
518
+ codes = [m[1] if (m := DIAL_CODE.search(t)) else "" for t in options.all_inner_texts()]
519
+ starting = [i for i, code in enumerate(codes) if code and digits.startswith(code)]
520
+ if not starting:
521
+ raise NotTaken(f"no country code starts the phone {answer!r}")
522
+ picked = max(starting, key=lambda i: len(codes[i]))
523
+ options.nth(picked).click()
524
+ page.locator(PHONE).fill(digits[len(codes[picked]) :])
525
+
526
+
351
527
  def _email_step(page: Page, email: str) -> bool:
352
528
  """The account page's first step: the email. Whether an account has it, from what the page
353
529
  asks next: a password alone for one, a name and a password for none."""
File without changes
File without changes