atsfill 0.0.5.dev169__tar.gz → 0.0.5.dev177__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.dev169 → atsfill-0.0.5.dev177}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/jobstreet.py +4 -2
  5. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/join.py +19 -13
  6. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/lever.py +2 -1
  7. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/successfactors.py +4 -0
  8. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/accounts.py +4 -2
  9. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/adapter.py +3 -2
  10. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/commands.py +29 -5
  11. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/results.py +1 -1
  12. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/LICENSE +0 -0
  13. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/README.md +0 -0
  14. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/__init__.py +0 -0
  15. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/__init__.py +0 -0
  16. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/adpmyjobs.py +0 -0
  17. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/ashby.py +0 -0
  18. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/bamboohr.py +0 -0
  19. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/breezy.py +0 -0
  20. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/dayforce.py +0 -0
  21. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/eightfold.py +0 -0
  22. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/greenhouse.py +0 -0
  23. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/gupy.py +0 -0
  24. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/hireology.py +0 -0
  25. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/hiringthing.py +0 -0
  26. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/hrmdirect.py +0 -0
  27. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/isolvedhire.py +0 -0
  28. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/jazzhr.py +0 -0
  29. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/jobaffinity.py +0 -0
  30. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/manatal.py +0 -0
  31. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/oracle.py +0 -0
  32. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/paycor.py +0 -0
  33. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/paylocity.py +0 -0
  34. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/phenom.py +0 -0
  36. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/pinpoint.py +0 -0
  37. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/recruitee.py +0 -0
  38. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/recruiterflow.py +0 -0
  39. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/rippling.py +0 -0
  40. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/smartrecruiters.py +0 -0
  41. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/successfactors.js +0 -0
  42. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/taleo.py +0 -0
  43. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/teamtailor.py +0 -0
  44. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/ukg.py +0 -0
  45. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/welcomekit.py +0 -0
  46. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/workable.py +0 -0
  47. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/workatastartup.py +0 -0
  48. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/workday.py +0 -0
  49. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/wttj.py +0 -0
  50. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/adapters/zohorecruit.py +0 -0
  51. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/cli.py +0 -0
  52. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/__init__.py +0 -0
  53. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/attempts.py +0 -0
  54. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/browser.py +0 -0
  55. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/conditions.py +0 -0
  56. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/html.py +0 -0
  57. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/matching.py +0 -0
  58. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/otp.py +0 -0
  59. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/passwords.py +0 -0
  60. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/paths.py +0 -0
  61. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/registry.py +0 -0
  62. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/src/atsfill/py.typed +0 -0
  65. {atsfill-0.0.5.dev169 → atsfill-0.0.5.dev177}/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.dev169
3
+ Version: 0.0.5.dev177
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.dev169"
3
+ version = "0.0.5.dev177"
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.dev169"
3
+ version = "0.0.5.dev177"
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"
@@ -119,10 +119,12 @@ class Jobstreet:
119
119
  def login_url(self, scope: str | None) -> str:
120
120
  return f"https://{scope}/oauth/login?returnUrl=%2F"
121
121
 
122
- def signed_in(self, request: Request, scope: str | None) -> bool:
122
+ def signed_in(self, request: Request, scope: str | None) -> bool | None:
123
123
  """The site's `/profile` redirects an account to `/profile/me` and shows a visitor its
124
- page. Cloudflare answers 403 outside the browser, so a session there reads signed out."""
124
+ page. Cloudflare can answer 403 outside the browser, which tells neither."""
125
125
  response = request.get(f"https://{scope}/profile", max_redirects=0)
126
+ if response.status == 403 or response.headers.get("cf-mitigated") == "challenge":
127
+ return None
126
128
  return response.status in (302, 307) and response.headers.get("location", "").endswith(
127
129
  "/profile/me"
128
130
  )
@@ -58,8 +58,10 @@ QUESTIONS = """query JobScreeningQuestions($input: JobScreeningQuestionsInput!)
58
58
  }
59
59
  }"""
60
60
  YES_NO = ["Yes", "No"] # a boolean's cards, in this order, in the job's language
61
- FILES = {"cv": "cv"} # a file field, by its step
62
- # A question of a class has a step of its own; the others are `question-<n>`, in order.
61
+ FILES = {"cv": "cv", "coverLetter": "coverLetter"} # a file field, by its step
62
+ # The documents a job asks, by their type in the wizard's `documentsConfig`
63
+ DOCUMENTS = {"CV": ("cv", "CV"), "COVER_LETTER": ("coverLetter", "Cover letter")}
64
+ # A question of a class has a step of its own; the others are `question-<position>`.
63
65
  CLASS_STEPS = {
64
66
  "workAuthorization": "WORK_AUTHORIZATION",
65
67
  "salaryExpectation": "SALARY_EXPECTATION",
@@ -200,11 +202,6 @@ class Join:
200
202
  answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
201
203
  questions = answered["data"]["jobScreeningQuestions"]["questions"]
202
204
  self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
203
- fields = [
204
- Field("cv", "CV", FieldType.FILE, True, accept=PDF),
205
- *(_question(q) for q in self._questions),
206
- ]
207
- self._fields = {f.id: f for f in fields if not f.unfillable}
208
205
  browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
209
206
  page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
210
207
  # the wizard opens on `authentication`, and leaves it once it knows the account
@@ -216,6 +213,16 @@ class Join:
216
213
  page.wait_for_timeout(POLL_MS)
217
214
  if _step(page) == "authentication":
218
215
  raise Blocked("login_required: run `atsfill login join`")
216
+ # the wizard's page names the documents the job asks, each required or optional
217
+ raw = page.locator("script#__NEXT_DATA__").text_content() or "{}"
218
+ state = json.loads(raw)["props"]["pageProps"].get("initialState") or {}
219
+ documents = (state.get("job") or {}).get("documentsConfig") or [{"type": "CV"}]
220
+ fields = [
221
+ *(Field(*DOCUMENTS[d["type"]], FieldType.FILE, d.get("constraint") != "OPTIONAL",
222
+ accept=PDF) for d in documents if d["type"] in DOCUMENTS),
223
+ *(_question(q) for q in self._questions),
224
+ ] # fmt: skip
225
+ self._fields = {f.id: f for f in fields if not f.unfillable}
219
226
  return fields
220
227
 
221
228
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
@@ -232,8 +239,6 @@ class Join:
232
239
  field_id = f"question.{question['id']}"
233
240
  if field_id in self._fields:
234
241
  self._question(page, question, answers.get(field_id))
235
- elif step == "coverLetter": # asked by some jobs only: left without one
236
- pass
237
242
  elif step in PROFILE_STEPS:
238
243
  try: # shown when the job requires what the account profile lacks
239
244
  _next(page, step)
@@ -252,10 +257,11 @@ class Join:
252
257
  if kind := CLASS_STEPS.get(step):
253
258
  return next((q for q in self._questions if q.get("questionClass") == kind), None)
254
259
  if match := re.fullmatch(r"question-(\d+)", step):
255
- plain = [q for q in self._questions if not q.get("questionClass")]
256
- if int(match[1]) > len(plain):
257
- raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
258
- return plain[int(match[1]) - 1]
260
+ position = int(match[1])
261
+ plain = (q for q in self._questions if not q.get("questionClass"))
262
+ if question := next((q for q in plain if q.get("position") == position), None):
263
+ return question
264
+ raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
259
265
  return None
260
266
 
261
267
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -19,6 +19,7 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
19
  HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
20
20
  JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
21
21
  FORM = "form#application-form"
22
+ CAPTCHA = "iframe[src*='hcaptcha.com']" # hidden until hCaptcha asks the visitor a challenge
22
23
  FORM_TIMEOUT_MS = 30_000
23
24
  UPLOAD_TIMEOUT_MS = 30_000
24
25
  SUGGEST_TIMEOUT_MS = 10_000
@@ -188,7 +189,7 @@ class Lever:
188
189
  try:
189
190
  page.wait_for_url(re.compile(r"/thanks\b"), timeout=SUBMIT_TIMEOUT_MS)
190
191
  except Timeout:
191
- if page.locator("iframe[src*='hcaptcha'][title*='challenge']").is_visible():
192
+ if page.locator(CAPTCHA).locator("visible=true").count():
192
193
  raise Blocked("captcha: hCaptcha asked for a challenge") from None
193
194
  raise
194
195
  return page.url
@@ -173,6 +173,10 @@ class SuccessFactors:
173
173
 
174
174
  def sign_in(self, page: Page, email: str, password: str) -> None:
175
175
  self._candidate(page)
176
+ if page.locator(QUICK_APPLY).count(): # its sign-in is a dialog, beside its sign-up
177
+ raise Unsupported(
178
+ known_gap(self, "quick_apply_sign_up", "the application is a Quick Apply page")
179
+ )
176
180
  if not page.locator(SIGN_IN).count():
177
181
  page.locator(SIGN_IN_LINK).first.click()
178
182
  page.locator(SIGN_IN).wait_for(timeout=LANDING_TIMEOUT_MS)
@@ -111,11 +111,13 @@ def redacted(error: Exception, password: str) -> Exception:
111
111
 
112
112
 
113
113
  def state(adapter: NeedsAccount, session: Path, scope: str | None) -> str:
114
- """`signed_in` or `signed_out` from the system itself, `missing` with no session stored."""
114
+ """`signed_in` or `signed_out` from the system itself, `unknown` when its answer cannot
115
+ tell, `missing` with no session stored."""
115
116
  if not session.exists():
116
117
  return "missing"
117
118
  with browser.session_request(session) as request:
118
- return "signed_in" if adapter.signed_in(request, scope) else "signed_out"
119
+ signed_in = adapter.signed_in(request, scope)
120
+ return {True: "signed_in", False: "signed_out", None: "unknown"}[signed_in]
119
121
 
120
122
 
121
123
  def wait_for_person(
@@ -122,8 +122,9 @@ class NeedsAccount(Protocol):
122
122
 
123
123
  def login_url(self, scope: str | None) -> str: ...
124
124
 
125
- def signed_in(self, request: Request, scope: str | None) -> bool:
126
- """Whether the cookies `request` carries are signed in, from one request to the system."""
125
+ def signed_in(self, request: Request, scope: str | None) -> bool | None:
126
+ """Whether the cookies `request` carries are signed in, from one request to the system;
127
+ None when its answer cannot tell, such as a bot protection's challenge."""
127
128
  ...
128
129
 
129
130
 
@@ -98,6 +98,9 @@ def _login_required(url: str, adapter: NeedsAccount, user: str) -> Result:
98
98
  type Credentials = tuple[str, str | None] # email, password: None for an account without one
99
99
  # Refusals the page states before any account exists: the generated password goes.
100
100
  NO_ACCOUNT = {"email_in_use", "email_domain_rejected", "password_rule", "sign_up_details"}
101
+ # A sign-in's refusals that find no account with the password: a stored one may be from a
102
+ # sign-up blocked before the account existed.
103
+ NOT_SIGNED_UP = {"wrong_password", "no_account"}
101
104
 
102
105
 
103
106
  def _next_step(
@@ -750,10 +753,11 @@ def login(
750
753
  if password and by_otp:
751
754
  raise ValueError(f"{adapter} signs in with an emailed OTP, not a password")
752
755
  try:
753
- kept = create and not by_otp and passwords.stored(mine, adapter, scope) is not None
756
+ stored = passwords.stored(mine, adapter, scope) if create and not by_otp else None
754
757
  except passwords.PasswordError as e:
755
758
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
756
759
  return report
760
+ kept = stored is not None
757
761
  if email and not kept:
758
762
  accounts.remember(mine, adapter, scope, email)
759
763
  if create and not kept:
@@ -784,6 +788,23 @@ def login(
784
788
  )
785
789
  else:
786
790
  result = _headless(data, user, account, scope, credentials, session, details)
791
+ if (
792
+ kept
793
+ and isinstance(account, CreatesAccount)
794
+ and (result.reason or "").split(":")[0] in NOT_SIGNED_UP
795
+ and credentials[1] == stored
796
+ ):
797
+ # a sign-up blocked before the account existed: sign up again, same password
798
+ return _create(
799
+ report,
800
+ account,
801
+ url,
802
+ mine,
803
+ credentials[0],
804
+ details or SignUpDetails(),
805
+ data,
806
+ kept=credentials[1],
807
+ )
787
808
  report.status = result.status
788
809
  report.reason = _next_step(result.reason, credentials[0], adapter, user, url)
789
810
  if report.reason is None:
@@ -799,10 +820,12 @@ def _create(
799
820
  email: str,
800
821
  details: SignUpDetails,
801
822
  data: Path,
823
+ kept: str | None = None,
802
824
  ) -> SessionReport:
803
825
  """Creates the account: refused before the browser opens when `details` lack one the
804
826
  sign-up asks; the generated password is stored before the click, and deleted only on a
805
- refusal the page states before any account exists. An account without a password gets
827
+ refusal the page states before any account exists. A `kept` password, stored by an
828
+ earlier sign-up, is used again and never deleted. An account without a password gets
806
829
  none."""
807
830
  if not isinstance(account, (CreatesAccount, CreatesAccountByOtp)):
808
831
  report.status = Status.UNSUPPORTED
@@ -812,9 +835,9 @@ def _create(
812
835
  report.status = Status.REFUSED
813
836
  report.reason = f"sign_up_details: no {', '.join(missing)}"
814
837
  return report
815
- password = None if isinstance(account, CreatesAccountByOtp) else passwords.generate()
838
+ password = kept or (None if isinstance(account, CreatesAccountByOtp) else passwords.generate())
816
839
  try:
817
- if password:
840
+ if password and not kept:
818
841
  passwords.store(mine, account.name, report.scope, password)
819
842
  except passwords.PasswordError as e:
820
843
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
@@ -825,9 +848,10 @@ def _create(
825
848
  )
826
849
  report.status = result.status
827
850
  report.reason = _next_step(result.reason, email, account.name, report.user, url)
828
- report.password = "stored" if password else "none"
851
+ report.password = "kept" if kept else "stored" if password else "none"
829
852
  if (
830
853
  password
854
+ and not kept
831
855
  and result.status is Status.REFUSED
832
856
  and (result.reason or "").split(":")[0] in NO_ACCOUNT
833
857
  ):
@@ -120,7 +120,7 @@ class SessionReport:
120
120
  user: str
121
121
  adapter: str
122
122
  scope: str | None # the account scope, None for an account that covers the whole system
123
- session: str # signed_in | signed_out | missing
123
+ session: str # signed_in | signed_out | unknown | missing
124
124
  path: Path
125
125
  status: str | None = None # a login's outcome: signed_in | refused | blocked | unsupported
126
126
  reason: str | None = None # why a login stored nothing, `code` or `code: detail`
File without changes
File without changes