atsfill 0.0.5.dev181__tar.gz → 0.0.5.dev184__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.dev181 → atsfill-0.0.5.dev184}/PKG-INFO +1 -1
  2. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/pyproject.toml +1 -1
  3. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/ashby.py +20 -8
  5. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/breezy.py +9 -2
  6. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/jobstreet.py +35 -17
  7. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/lever.py +9 -2
  8. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/manatal.py +12 -4
  9. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/phenom.py +29 -2
  10. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/recruitee.py +9 -2
  11. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/teamtailor.py +14 -5
  12. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/workable.py +9 -2
  13. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/adapter.py +10 -0
  14. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/commands.py +11 -2
  15. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/LICENSE +0 -0
  16. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/README.md +0 -0
  17. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/__init__.py +0 -0
  18. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/__init__.py +0 -0
  19. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/adpmyjobs.py +0 -0
  20. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/bamboohr.py +0 -0
  21. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/dayforce.py +0 -0
  22. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/eightfold.py +0 -0
  23. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/greenhouse.py +0 -0
  24. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/gupy.py +0 -0
  25. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/hireology.py +0 -0
  26. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/hiringthing.py +0 -0
  27. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/hrmdirect.py +0 -0
  28. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/isolvedhire.py +0 -0
  29. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/jazzhr.py +0 -0
  30. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/jobaffinity.py +0 -0
  31. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/join.py +0 -0
  32. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/oracle.py +0 -0
  33. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/paylocity.py +0 -0
  35. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/pinpoint.py +0 -0
  37. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/recruiterflow.py +0 -0
  38. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/successfactors.py +0 -0
  42. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/taleo.py +0 -0
  43. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/welcomekit.py +0 -0
  45. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/workatastartup.py +0 -0
  46. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/workday.py +0 -0
  47. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/wttj.py +0 -0
  48. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/adapters/zohorecruit.py +0 -0
  49. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/cli.py +0 -0
  50. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/__init__.py +0 -0
  51. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/accounts.py +0 -0
  52. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/attempts.py +0 -0
  53. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/browser.py +0 -0
  54. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/conditions.py +0 -0
  55. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/html.py +0 -0
  56. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/matching.py +0 -0
  57. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/otp.py +0 -0
  58. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/passwords.py +0 -0
  59. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/paths.py +0 -0
  60. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/registry.py +0 -0
  61. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/results.py +0 -0
  62. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/src/atsfill/py.typed +0 -0
  65. {atsfill-0.0.5.dev181 → atsfill-0.0.5.dev184}/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.dev181
3
+ Version: 0.0.5.dev184
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.dev181"
3
+ version = "0.0.5.dev184"
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.dev181"
3
+ version = "0.0.5.dev184"
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"
@@ -78,7 +78,9 @@ class Ashby:
78
78
 
79
79
  def __init__(self) -> None:
80
80
  self._forms: list[dict[str, Any]] = [] # the application form, then each survey
81
- self._pending: set[Any] = set() # GraphQL requests in flight, since `browser_read`
81
+ self._pending: set[Any] = set() # GraphQL requests in flight, since `open_form`
82
+ self._listening = False
83
+ self._loaded: Response | None = None # the ApiJobPosting response `open_form` caught
82
84
  self._last_change = 0.0
83
85
  self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
84
86
 
@@ -125,14 +127,24 @@ class Ashby:
125
127
  except OSError, ValueError, KeyError, TypeError, Unsupported:
126
128
  return None
127
129
 
128
- def browser_read(self, page: Page) -> list[Field]:
129
- page.on("request", self._on_request)
130
- page.on("requestfinished", self._on_finished)
131
- page.on("requestfailed", self._on_finished)
132
- page.on("response", self._on_response)
130
+ def open_form(self, page: Page, url: str) -> None:
131
+ """Loads `/application`, listening first for the page's own ApiJobPosting response."""
132
+ if not self._listening:
133
+ page.on("request", self._on_request)
134
+ page.on("requestfinished", self._on_finished)
135
+ page.on("requestfailed", self._on_finished)
136
+ page.on("response", self._on_response)
137
+ self._listening = True
133
138
  with page.expect_response(lambda r: _op(r) == "ApiJobPosting") as posting:
134
- page.goto(self.posting(page.url) + "/application")
135
- job = posting.value.json()["data"]["jobPosting"]
139
+ page.goto(self.posting(url) + "/application")
140
+ self._loaded = posting.value
141
+
142
+ def browser_read(self, page: Page) -> list[Field]:
143
+ if self._loaded is None: # a page claimed once loaded: an embed
144
+ self.open_form(page, page.url)
145
+ assert self._loaded
146
+ loaded, self._loaded = self._loaded, None
147
+ job = loaded.json()["data"]["jobPosting"]
136
148
  if job is None:
137
149
  raise Unsupported("posting_gone")
138
150
  self._forms = [job["applicationForm"], *job["surveyForms"]]
@@ -211,6 +211,10 @@ _FILE_SHOWN = """e => {
211
211
  }"""
212
212
 
213
213
 
214
+ def _on_form(url: str) -> bool:
215
+ return urlsplit(url).path.rstrip("/").endswith("/apply")
216
+
217
+
214
218
  class Breezy:
215
219
  name = "breezy"
216
220
  known_gaps: dict[str, Gap] = {}
@@ -236,9 +240,12 @@ class Breezy:
236
240
  match = JOB.match(parts.path)
237
241
  return f"https://{parts.hostname}/p/{match[1]}" if match else url
238
242
 
243
+ def open_form(self, page: Page, url: str) -> None:
244
+ page.goto(url if _on_form(url) else f"{self.posting(url)}/apply")
245
+
239
246
  def browser_read(self, page: Page) -> list[Field]:
240
- if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
241
- page.goto(f"{self.posting(page.url)}/apply")
247
+ if not _on_form(page.url):
248
+ self.open_form(page, page.url)
242
249
  form = page.locator(FORM)
243
250
  try:
244
251
  form.locator("[name=cName]").wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
@@ -34,6 +34,8 @@ APPLY = "[data-automation=job-detail-apply]"
34
34
  CONTINUE = "[data-testid=continue-button]"
35
35
  SUBMIT = "[data-testid=review-submit-application]"
36
36
  SAVE_DETAILS = "[data-automation=personal-details-save-button]"
37
+ RESUME_FILE = "input[type=file][id^=resume]"
38
+ LETTER_FILE = "input[type=file][id^=coverLetter]"
37
39
  CHALLENGE = "Just a moment..." # Cloudflare's interstitial: it passes on its own, or on a reload
38
40
  CHALLENGE_TRIES = 2
39
41
  SIGN_IN_TIMEOUT_MS = 90_000 # SEEK's sign-in takes up to ~50 s to show its form
@@ -53,6 +55,7 @@ SUGGEST_TIMEOUT_S = 8
53
55
  POLL_MS = 250
54
56
  KEY_DELAY_MS = 40
55
57
  DETAILS = ("firstName", "lastName", "currentLocation", "phone")
58
+ DOCUMENTS = ("resume", "coverLetter", "coverLetterFile")
56
59
  VISIBILITY = ["Public", "Standard", "Limited", "Hidden"]
57
60
 
58
61
  _STEP_ONE = """() => {
@@ -62,7 +65,7 @@ _STEP_ONE = """() => {
62
65
  .find(e => e.checked);
63
66
  const label = e => e && document.querySelector(`label[for="${e.id}"]`)?.innerText.trim();
64
67
  const resume = picked('document-select');
65
- const letter = document.querySelector('[data-testid=coverLetter-method-change]');
68
+ const method = picked('coverLetter-method');
66
69
  const phone = value('#phoneNumber');
67
70
  return {
68
71
  firstName: value('#firstName'), lastName: value('#lastName'),
@@ -70,7 +73,8 @@ _STEP_ONE = """() => {
70
73
  phone: phone && code?.value
71
74
  ? `${code.options[code.selectedIndex].text.match(/\\+\\d+/)?.[0] ?? ''}${phone}` : null,
72
75
  resume: resume && resume.value !== 'dont-include' ? label(resume) : null,
73
- coverLetter: letter?.checked ? value('[data-testid=coverLetterTextInput]') : null,
76
+ coverLetter: method?.value === 'change' ? value('[data-testid=coverLetterTextInput]') : null,
77
+ coverLetterFile: method?.value === 'upload' ? label(method) : null,
74
78
  };
75
79
  }"""
76
80
 
@@ -198,7 +202,7 @@ class Jobstreet:
198
202
  self._read |= _personal_details(page, self._fields, answers)
199
203
  _documents_fill(page, answers)
200
204
  documents = page.evaluate(_STEP_ONE)
201
- self._read |= {k: documents[k] for k in ("resume", "coverLetter") if documents[k]}
205
+ self._read |= {k: documents[k] for k in DOCUMENTS if documents[k]}
202
206
  _continue(page)
203
207
  if page.url.split("?")[0].endswith("/role-requirements"):
204
208
  for field_id, answer in answers.items():
@@ -316,14 +320,12 @@ def _operations(response: Response) -> dict[str, Any]:
316
320
 
317
321
  def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
318
322
  """The first step: the profile's personal details, then the resume and the cover letter,
319
- optional where the page offers to leave them out."""
323
+ optional where the page offers to leave them out. The cover letter is written or uploaded,
324
+ two alternatives."""
320
325
  details = viewer["personalDetails"]
321
326
  code = (details.get("countryCallingCode") or {}).get("countryCallingCode", "")
322
327
  location = (viewer.get("currentLocation2") or {}).get("description")
323
- resume = page.locator("input[type=file][id^=resume]")
324
- accept: list[str] | None = None
325
- if resume.count():
326
- accept = [str(a) for a in (resume.get_attribute("accept") or "").split(",")]
328
+ letter = not page.locator("[data-testid=coverLetter-method-none]").count()
327
329
  return [
328
330
  Field("firstName", "First name", FieldType.TEXT, True,
329
331
  prefilled=details.get("firstName") or None),
@@ -333,12 +335,21 @@ def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
333
335
  Field("phone", "Phone number", FieldType.PHONE, False,
334
336
  prefilled=f"{code}{details['phoneNumber']}" if details.get("phoneNumber") else None),
335
337
  Field("resume", "Resumé", FieldType.FILE,
336
- not page.locator("input[type=radio][value=dont-include]").count(), accept=accept),
337
- Field("coverLetter", "Cover letter", FieldType.TEXT,
338
- not page.locator("[data-testid=coverLetter-method-none]").count()),
338
+ not page.locator("input[type=radio][value=dont-include]").count(),
339
+ accept=_accept(page, RESUME_FILE)),
340
+ Field("coverLetter", "Cover letter", FieldType.TEXT, letter, one_of="coverLetter"),
341
+ Field("coverLetterFile", "Cover letter", FieldType.FILE, letter,
342
+ accept=_accept(page, LETTER_FILE), one_of="coverLetter"),
339
343
  ] # fmt: skip
340
344
 
341
345
 
346
+ def _accept(page: Page, selector: str) -> list[str] | None:
347
+ upload = page.locator(selector)
348
+ if not upload.count():
349
+ return None
350
+ return [str(a) for a in (upload.get_attribute("accept") or "").split(",")]
351
+
352
+
342
353
  def _question(raw: dict[str, Any]) -> Field | None:
343
354
  field_id, label = f"question.{raw['id']}", raw["text"]
344
355
  options = [o["text"] for o in raw.get("options") or []]
@@ -404,20 +415,27 @@ def _phone(page: Page, answer: str) -> None:
404
415
 
405
416
  def _documents_fill(page: Page, answers: dict[str, Answer]) -> None:
406
417
  """Uploads the resume, which the page then picks, or leaves it out; the same for the cover
407
- letter, written in its box."""
418
+ letter, uploaded or written in its box."""
408
419
  if (resume := answers.get("resume")) is not None:
409
- page.locator("input[type=file][id^=resume]").set_input_files(str(resume))
410
- name = str(resume).rsplit("/", 1)[-1]
411
- page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
420
+ _upload(page, RESUME_FILE, str(resume))
412
421
  elif (none := page.locator("input[type=radio][value=dont-include]")).count():
413
422
  none.check(force=True)
414
- if (letter := answers.get("coverLetter")) is not None:
415
- page.locator("[data-testid=coverLetter-method-change]").check(force=True)
423
+ if (letter := answers.get("coverLetterFile")) is not None:
424
+ _upload(page, LETTER_FILE, str(letter))
425
+ elif (letter := answers.get("coverLetter")) is not None:
426
+ page.locator("input[type=radio][data-testid=coverLetter-method-change]").check(force=True)
416
427
  page.locator("[data-testid=coverLetterTextInput]").fill(str(letter))
417
428
  elif (none := page.locator("[data-testid=coverLetter-method-none]")).count():
418
429
  none.check(force=True)
419
430
 
420
431
 
432
+ def _upload(page: Page, selector: str, path: str) -> None:
433
+ """Sets the file, then waits for the radio the page adds and picks, labelled with its name."""
434
+ page.locator(selector).set_input_files(path)
435
+ name = path.rsplit("/", 1)[-1]
436
+ page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
437
+
438
+
421
439
  def _answer(page: Page, field: Field, answer: Answer) -> None:
422
440
  name = f"questionnaire.{field.id.removeprefix('question.')}"
423
441
  select = page.locator(f'select[name="{name}"]')
@@ -116,6 +116,10 @@ _READ_BACK = """(form, kinds) => {
116
116
  }"""
117
117
 
118
118
 
119
+ def _on_form(url: str) -> bool:
120
+ return urlsplit(url).path.rstrip("/").endswith("/apply")
121
+
122
+
119
123
  class Lever:
120
124
  name = "lever"
121
125
  known_gaps: dict[str, Gap] = {}
@@ -134,9 +138,12 @@ class Lever:
134
138
  parts, job = urlsplit(url), _job(url)
135
139
  return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
136
140
 
141
+ def open_form(self, page: Page, url: str) -> None:
142
+ page.goto(url if _on_form(url) else f"{self.posting(url)}/apply")
143
+
137
144
  def browser_read(self, page: Page) -> list[Field]:
138
- if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
139
- page.goto(f"{self.posting(page.url)}/apply")
145
+ if not _on_form(page.url):
146
+ self.open_form(page, page.url)
140
147
  form = page.locator(FORM)
141
148
  try:
142
149
  form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
@@ -12,7 +12,7 @@ from typing import Any
12
12
  from urllib.parse import urlsplit
13
13
 
14
14
  from atsfill.core.adapter import Blocked, Gap, Unsupported, known_gap
15
- from atsfill.core.browser import HTTP_TIMEOUT_S, Page
15
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Response
16
16
  from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
17
17
 
18
18
  HOST = "www.careers-page.com"
@@ -183,6 +183,7 @@ class Manatal:
183
183
 
184
184
  def __init__(self) -> None:
185
185
  self._entries: list[dict[str, Any]] = []
186
+ self._loaded: Response | None = None # the form's GET `open_form` caught
186
187
 
187
188
  def detect(self, url: str, page: Page | None) -> bool:
188
189
  parts = urlsplit(url).path.strip("/").split("/")
@@ -210,13 +211,20 @@ class Manatal:
210
211
  except OSError, ValueError, KeyError, TypeError:
211
212
  return None
212
213
 
213
- def browser_read(self, page: Page) -> list[Field]:
214
+ def open_form(self, page: Page, url: str) -> None:
215
+ """Loads `/apply`, listening first for the form's GET."""
214
216
  with page.expect_response(
215
217
  lambda r: r.request.method == "GET" and "/application-form/" in r.url,
216
218
  timeout=LOAD_TIMEOUT_MS,
217
219
  ) as loaded:
218
- page.goto(_posting(page.url) + "/apply")
219
- response = loaded.value
220
+ page.goto(_posting(url) + "/apply")
221
+ self._loaded = loaded.value
222
+
223
+ def browser_read(self, page: Page) -> list[Field]:
224
+ if self._loaded is None:
225
+ self.open_form(page, page.url)
226
+ assert self._loaded
227
+ response, self._loaded = self._loaded, None
220
228
  if response.headers.get("x-amzn-waf-action") == "challenge":
221
229
  raise Blocked("captcha: AWS WAF challenge")
222
230
  if not response.ok:
@@ -44,6 +44,7 @@ SEQ = re.compile(r"^/[a-z]{2,8}/[a-z]{2}/job/([A-Za-z0-9]+)")
44
44
  CDN = "https://cdn-prod-static.phenompeople.com/CareerConnectResources"
45
45
  FORM = "form.rjsf"
46
46
  NEXT = "button#next"
47
+ TERMS = "terms" # a notice some sites' wizard opens on, its one button accepting it
47
48
  SUBMIT = "button#submit, button.btn-submit, button[type=submit].btn-navigate:not(#next)"
48
49
  RESUME = ".resume-upload-wrapper input[type=file]"
49
50
  RESUME_ID = "resume"
@@ -264,6 +265,11 @@ class Phenom:
264
265
  SideEffect(
265
266
  "upload", "hiring_system", "the resume is uploaded to Phenom and parsed at once"
266
267
  ),
268
+ SideEffect(
269
+ "fill",
270
+ "hiring_system",
271
+ "an employer notice the wizard opens on (`page=terms`) is accepted to reach the form",
272
+ ),
267
273
  SideEffect(
268
274
  "fill",
269
275
  "hiring_system",
@@ -355,6 +361,7 @@ class Phenom:
355
361
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
356
362
  page.context.clear_cookies()
357
363
  self._open(page, fresh=True)
364
+ self._accept_terms(page)
358
365
  self._kept, self._shown = {}, {}
359
366
  if RESUME_ID in answers and page.locator(RESUME).count():
360
367
  _upload(page, _one(answers[RESUME_ID]))
@@ -414,17 +421,31 @@ class Phenom:
414
421
  if fresh or not page.url.startswith(self._apply):
415
422
  page.goto(self._apply, wait_until="domcontentloaded")
416
423
  _check_open(page)
424
+ self._wait_for_step(page)
425
+
426
+ def _wait_for_step(self, page: Page) -> None:
417
427
  try:
418
428
  page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]").first.wait_for(
419
429
  state="attached", timeout=FORM_TIMEOUT_MS
420
430
  )
421
431
  except Timeout:
422
- query = parse_qs(urlsplit(page.url).query)
423
- if step := (query.get("stepname") or query.get("page") or [None])[0]:
432
+ if step := _step_name(page.url):
424
433
  gap = known_gap(self, "first_step", f"the wizard opens on {step!r}")
425
434
  raise Unsupported(gap) from None
426
435
  raise RuntimeError("no application step on the page") from None
427
436
 
437
+ def _accept_terms(self, page: Page) -> None:
438
+ """Accepts the terms some sites' wizard opens on, a step with no field before the form."""
439
+ if _step_name(page.url) != TERMS:
440
+ return
441
+ _click(page, page.locator(FORM).locator(SUBMIT).first)
442
+ try:
443
+ page.wait_for_url(lambda url: _step_name(url) != TERMS, timeout=STEP_TIMEOUT_MS)
444
+ except Timeout:
445
+ gap = known_gap(self, "first_step", "the wizard stays on its terms once accepted")
446
+ raise Unsupported(gap) from None
447
+ self._wait_for_step(page)
448
+
428
449
  def _step(self, page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
429
450
  """The answered fields the current step shows, in the configuration's order."""
430
451
  shown = {r["id"]: r for r in page.locator(FORM).evaluate(_SCAN)}
@@ -1237,6 +1258,12 @@ def _seq(url: str) -> str | None:
1237
1258
  return parse_qs(parts.query).get("jobSeqNo", [None])[0]
1238
1259
 
1239
1260
 
1261
+ def _step_name(url: str) -> str | None:
1262
+ """The wizard's step as its URL names it (`stepname=`, `page=`), if it does."""
1263
+ query = parse_qs(urlsplit(url).query)
1264
+ return (query.get("stepname") or query.get("page") or [None])[0]
1265
+
1266
+
1240
1267
  def _apply_url(page: Page) -> str:
1241
1268
  """The wizard's URL: the page's, or where the job page's Apply link leads; the wizard's
1242
1269
  own when no link shows, which `_open` then finds or not."""
@@ -62,6 +62,10 @@ _READ_BACK = """(form, kinds) => {
62
62
  }"""
63
63
 
64
64
 
65
+ def _on_form(url: str) -> bool:
66
+ return urlsplit(url).path.rstrip("/").endswith("/c/new")
67
+
68
+
65
69
  class Recruitee:
66
70
  name = "recruitee"
67
71
  known_gaps: dict[str, Gap] = {}
@@ -84,9 +88,12 @@ class Recruitee:
84
88
  match = JOB.match(parts.path)
85
89
  return f"https://{parts.hostname}/o/{match[1]}" if match else url
86
90
 
91
+ def open_form(self, page: Page, url: str) -> None:
92
+ page.goto(url if _on_form(url) else f"{self.posting(url)}/c/new")
93
+
87
94
  def browser_read(self, page: Page) -> list[Field]:
88
- if not urlsplit(page.url).path.rstrip("/").endswith("/c/new"):
89
- page.goto(f"{self.posting(page.url)}/c/new")
95
+ if not _on_form(page.url):
96
+ self.open_form(page, page.url)
90
97
  form = page.locator(FORM)
91
98
  try:
92
99
  form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
@@ -125,6 +125,7 @@ class Teamtailor:
125
125
  self._follow_ups: set[str] = set()
126
126
  self._uploads: dict[str, str] = {} # file field id: the answer's file name
127
127
  self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
128
+ self._form: str | None = None # the form `open_form` loaded
128
129
 
129
130
  def detect(self, url: str, page: Page | None) -> bool:
130
131
  """A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
@@ -141,13 +142,21 @@ class Teamtailor:
141
142
  assert match, url
142
143
  return f"https://{parts.hostname}/jobs/{match[1]}"
143
144
 
144
- def browser_read(self, page: Page) -> list[Field]:
145
+ def open_form(self, page: Page, url: str) -> None:
145
146
  """Opens the form's own page, never the overlay nor "Apply with LinkedIn"."""
146
- parts = urlsplit(page.url)
147
+ parts = urlsplit(url)
147
148
  job = JOB.search(parts.path)
148
- assert job, page.url # detected on a job URL
149
- page.goto(f"{parts.scheme}://{parts.netloc}{parts.path[: job.end()]}/applications/new")
150
- if urlsplit(page.url).hostname != parts.hostname:
149
+ assert job, url # detected on a job URL
150
+ form = f"{parts.scheme}://{parts.netloc}{parts.path[: job.end()]}/applications/new"
151
+ page.goto(form)
152
+ self._form = form
153
+
154
+ def browser_read(self, page: Page) -> list[Field]:
155
+ if self._form is None: # a page claimed once loaded: a custom domain
156
+ self.open_form(page, page.url)
157
+ assert self._form
158
+ opened, self._form = self._form, None
159
+ if urlsplit(page.url).hostname != urlsplit(opened).hostname:
151
160
  raise Unsupported(f"external_step: the job applies on another site ({page.url})")
152
161
  form = page.locator(FORM)
153
162
  try:
@@ -141,6 +141,10 @@ _READ_BACK = """(form, kinds) => {
141
141
  }"""
142
142
 
143
143
 
144
+ def _on_form(url: str) -> bool:
145
+ return "/apply" in urlsplit(url).path
146
+
147
+
144
148
  class Workable:
145
149
  name = "workable"
146
150
  known_gaps = {"entries": Gap.RENDERING}
@@ -174,9 +178,12 @@ class Workable:
174
178
  except OSError, ValueError, KeyError, TypeError:
175
179
  return None
176
180
 
181
+ def open_form(self, page: Page, url: str) -> None:
182
+ page.goto(url if _on_form(url) else f"{self.posting(url)}/apply/")
183
+
177
184
  def browser_read(self, page: Page) -> list[Field]:
178
- if "/apply" not in urlsplit(page.url).path:
179
- page.goto(f"{self.posting(page.url)}/apply/")
185
+ if not _on_form(page.url):
186
+ self.open_form(page, page.url)
180
187
  form = page.locator(FORM)
181
188
  try:
182
189
  form.wait_for(timeout=FORM_TIMEOUT_MS)
@@ -280,6 +280,16 @@ class SendsToOpen(Adapter, Protocol):
280
280
  ...
281
281
 
282
282
 
283
+ @runtime_checkable
284
+ class OpensForm(Adapter, Protocol):
285
+ """An adapter whose form is another page than the posting: the core has it load the form
286
+ in place of the posting, when it claims the posting by its URL (ADR 0003)."""
287
+
288
+ def open_form(self, page: Page, url: str) -> None:
289
+ """Loads the form of the posting at `url`, so that `browser_read` finds it open."""
290
+ ...
291
+
292
+
283
293
  @runtime_checkable
284
294
  class ReadsByRequest(Adapter, Protocol):
285
295
  """An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
@@ -29,6 +29,7 @@ from atsfill.core.adapter import (
29
29
  ListsShownFields,
30
30
  NeedsAccount,
31
31
  NotTaken,
32
+ OpensForm,
32
33
  ProfileIncomplete,
33
34
  ReadsByRequest,
34
35
  Refused,
@@ -173,7 +174,7 @@ def _opened(
173
174
  session = None
174
175
  with open_page(data, session, seconds) as page:
175
176
  try:
176
- page.goto(url)
177
+ _load(page, adapter, url)
177
178
  except browser.Timeout:
178
179
  if adapter is None:
179
180
  raise
@@ -207,6 +208,14 @@ def _opened(
207
208
  browser.save_session(page, session)
208
209
 
209
210
 
211
+ def _load(page: Page, adapter: object, url: str) -> None:
212
+ """Loads the posting, or its form where the adapter that claims it opens it (ADR 0003)."""
213
+ if isinstance(adapter, OpensForm):
214
+ adapter.open_form(page, url)
215
+ else:
216
+ page.goto(url)
217
+
218
+
210
219
  _again: ContextVar[Callable[[], Result | None] | None] = ContextVar("again", default=None)
211
220
 
212
221
 
@@ -281,7 +290,7 @@ def _sign_in_again(
281
290
  return _login_required(url, adapter, user)
282
291
  session = accounts.session_file(mine, adapter.name, scope)
283
292
  accounts.sign_in(page, adapter, scope, *credentials, session)
284
- page.goto(url)
293
+ _load(page, adapter, url)
285
294
  return None
286
295
  email = credentials[0] if credentials else ""
287
296
  scoped = url if adapter.per_employer else None
File without changes
File without changes