atsfill 0.0.4.dev64__tar.gz → 0.0.4.dev66__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.dev64 → atsfill-0.0.4.dev66}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/breezy.py +6 -3
  5. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/teamtailor.py +51 -12
  6. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/LICENSE +0 -0
  7. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/README.md +0 -0
  8. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/dayforce.py +0 -0
  13. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/greenhouse.py +0 -0
  14. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/hireology.py +0 -0
  15. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/jazzhr.py +0 -0
  16. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/lever.py +0 -0
  17. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/manatal.py +0 -0
  18. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/oracle.py +0 -0
  19. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/paycor.py +0 -0
  20. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/paylocity.py +0 -0
  21. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/phenom.py +0 -0
  22. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/recruitee.py +0 -0
  23. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/smartrecruiters.py +0 -0
  24. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/workable.py +0 -0
  25. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/adapters/wttj.py +0 -0
  26. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/cli.py +0 -0
  27. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/__init__.py +0 -0
  28. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/accounts.py +0 -0
  29. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/adapter.py +0 -0
  30. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/attempts.py +0 -0
  31. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/browser.py +0 -0
  32. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/commands.py +0 -0
  33. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/matching.py +0 -0
  34. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/paths.py +0 -0
  35. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/registry.py +0 -0
  36. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/core/results.py +0 -0
  37. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/src/atsfill/py.typed +0 -0
  38. {atsfill-0.0.4.dev64 → atsfill-0.0.4.dev66}/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.dev64
3
+ Version: 0.0.4.dev66
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.dev64"
3
+ version = "0.0.4.dev66"
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.dev64"
3
+ version = "0.0.4.dev66"
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"
@@ -95,7 +95,8 @@ ENTRIES = {
95
95
  }
96
96
 
97
97
  # The fields in page order, one per control name, with its heading's text and how it renders;
98
- # a live video question is kept as the kind `video`.
98
+ # a live video question is kept as the kind `video`, its id the question's as Breezy names it
99
+ # for its widget (`ziggeo-id`, `<section>:<n>`).
99
100
  _READ = """form => {
100
101
  const text = el => {
101
102
  if (!el) return "";
@@ -139,8 +140,10 @@ _READ = """form => {
139
140
  }
140
141
  });
141
142
  } else if (!q.querySelector("[name^='section_']") &&
142
- q.querySelector("input[type=file], .ziggeo, [ng-if*='video']")) {
143
- add({id: `video_${found.size}`, label: text(h), kind: "video", required: starred(h)});
143
+ q.querySelector("input[type=file], ziggeo, [ng-if*='video']")) {
144
+ const [s, n] = (q.querySelector("ziggeo")?.getAttribute("ziggeo-id") ?? "").split(":");
145
+ const id = n === undefined ? `video_${text(h)}` : `section_${s}_question_${n}`;
146
+ add({id, label: text(h), kind: "video", required: starred(h)});
144
147
  } else return false;
145
148
  done.add(q);
146
149
  return true;
@@ -1,8 +1,9 @@
1
1
  """Teamtailor (`<employer>.teamtailor.com` and career sites on the employer's domain), as
2
2
  docs/adapters/teamtailor.md observes.
3
3
 
4
- The form posts nothing before submit except the resume, which goes to Teamtailor's storage as
5
- soon as it is picked. The read-back is the form's `FormData`.
4
+ The form posts nothing before submit except the files, which go to Teamtailor's storage as
5
+ soon as they are picked. The read-back is the form's `FormData`, and for a video question the
6
+ video Teamtailor holds under the id the form keeps.
6
7
  """
7
8
 
8
9
  import re
@@ -14,7 +15,7 @@ from urllib.parse import urlsplit
14
15
 
15
16
  from atsfill.core import browser, matching
16
17
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
17
- from atsfill.core.browser import Locator, Page
18
+ from atsfill.core.browser import Locator, Page, Response
18
19
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
20
 
20
21
  FORM = "#job-application-form"
@@ -23,11 +24,13 @@ FORM = "#job-application-form"
23
24
  QUESTION = ".question, [data-careersite--form-target=qualifyingAnswers]"
24
25
  LOCKED = "[data-careersite--form-target=formContentWrapper].cursor-not-allowed"
25
26
  LOCATION = "candidate[location][query]"
27
+ VIDEO = "[data-controller~='questions--tt-video-answer']"
26
28
  CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
27
29
  JOB = re.compile(r"/jobs/(\d+)[^/]*")
28
30
  SUBMIT_TIMEOUT_MS = 90_000
29
31
  FOLLOW_UP_TIMEOUT_MS = 1_000
30
32
  UPLOAD_TIMEOUT_S = 60
33
+ UPLOAD_TIMEOUT_MS = UPLOAD_TIMEOUT_S * 1000
31
34
  UPLOAD_POLL_MS = 500
32
35
  KEY_DELAY_MS = 30
33
36
  QUESTION_TYPES = {
@@ -109,13 +112,16 @@ _READ = """(form, QUESTION) => {
109
112
  label: label(q.querySelector("legend, label")) + (when || ""),
110
113
  required: qualifying || (!parent && q.dataset.questionMandatory === "true"),
111
114
  asked: q.dataset.questionMandatory === "true",
115
+ accept: kind === "video"
116
+ ? q.querySelector("input[type=file]")?.accept.split(",").filter(Boolean) : null,
112
117
  options: picks.length ? picks : menu.length ? menu : null});
113
118
  }
114
119
  return fields;
115
120
  }"""
116
121
 
117
122
  # What the form will send: each field's value, a choice as its option's text, a file as the
118
- # uploaded file's name, an address once a suggestion is picked.
123
+ # uploaded file's name, an address once a suggestion is picked, a video question as the id of
124
+ # the video it holds.
119
125
  _READ_BACK = """(form, QUESTION) => {
120
126
  const data = new FormData(form);
121
127
  const text = e => {
@@ -149,7 +155,9 @@ _READ_BACK = """(form, QUESTION) => {
149
155
  const id = `question:${q.querySelector("input[name$='[question_id]']")?.value}`;
150
156
  const checked = [...q.querySelectorAll("input:checked")];
151
157
  const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
152
- if (q.querySelector("input[type=checkbox]")) out[id] = checked.map(text);
158
+ const video = q.querySelector("[name$='[picked_video_attributes][video_id]']");
159
+ if (video) out[id] = data.get(video.name);
160
+ else if (q.querySelector("input[type=checkbox]")) out[id] = checked.map(text);
153
161
  else if (q.querySelector("input[type=radio]")) out[id] = checked[0] ? text(checked[0]) : null;
154
162
  else if (named) out[id] = named.value;
155
163
  }
@@ -159,7 +167,7 @@ _READ_BACK = """(form, QUESTION) => {
159
167
 
160
168
  class Teamtailor:
161
169
  name = "teamtailor"
162
- known_gaps = {"video_upload": Gap.RENDERING, "file_question": Gap.RENDERING}
170
+ known_gaps = {"file_question": Gap.RENDERING}
163
171
  side_effects = [
164
172
  SideEffect("upload", "hiring_system", "a picked file is stored by Teamtailor at once"),
165
173
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -170,6 +178,7 @@ class Teamtailor:
170
178
  self._qualifying: set[str] = set()
171
179
  self._follow_ups: dict[str, bool] = {} # shown by an earlier answer: required once shown
172
180
  self._uploads: dict[str, str] = {} # file field id: the answer's file name
181
+ self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
173
182
 
174
183
  def detect(self, url: str, page: Page | None) -> bool:
175
184
  """A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
@@ -211,6 +220,7 @@ class Teamtailor:
211
220
 
212
221
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
213
222
  form = page.locator(FORM)
223
+ self._videos = {}
214
224
  _decline_cookies(page)
215
225
  for field_id in [i for i in answers if i in self._qualifying]:
216
226
  self._fill(form, field_id, answers[field_id])
@@ -233,6 +243,9 @@ class Teamtailor:
233
243
 
234
244
  def read_back(self, page: Page) -> dict[str, Answer]:
235
245
  values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
246
+ for field_id, (video_id, url) in self._videos.items():
247
+ if values.get(field_id) == video_id: # the name Teamtailor holds the video under
248
+ values[field_id] = page.evaluate(_VIDEO, url)
236
249
  for field_id, name in self._uploads.items():
237
250
  if values.get(field_id) == _stored_name(name):
238
251
  values[field_id] = name
@@ -246,6 +259,8 @@ class Teamtailor:
246
259
  def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
247
260
  page, field, host = form.page, self._fields[field_id], self._host(form, field_id)
248
261
  match field.type, answer:
262
+ case FieldType.FILE, str() if host.locator(VIDEO).count():
263
+ self._videos[field_id] = _upload_video(page, host, answer)
249
264
  case FieldType.FILE, str():
250
265
  host.locator("input[type=file]").set_input_files(answer)
251
266
  case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
@@ -276,11 +291,8 @@ class Teamtailor:
276
291
  def _field(self, raw: dict[str, Any]) -> Field:
277
292
  field_id, label, required = raw["id"], raw["label"], raw["required"]
278
293
  match raw["kind"]:
279
- case "video" if raw.get("question"):
280
- gap = known_gap(
281
- self, "video_upload", f"{field_id} is a video, recorded or uploaded"
282
- )
283
- return Field(field_id, label, FieldType.FILE, required, unfillable=gap)
294
+ case "video" if raw.get("question"): # a clip is uploaded, never recorded
295
+ return Field(field_id, label, FieldType.FILE, required, accept=raw.get("accept"))
284
296
  case "file" if raw.get("question"):
285
297
  gap = known_gap(self, "file_question", f"{field_id} is a question's file drop zone")
286
298
  return Field(field_id, label, FieldType.FILE, required, unfillable=gap)
@@ -323,7 +335,11 @@ class Teamtailor:
323
335
  deadline = time.monotonic() + UPLOAD_TIMEOUT_S
324
336
  while self._uploads and time.monotonic() < deadline:
325
337
  held = form.evaluate(_READ_BACK, QUESTION)
326
- if all(held.get(i) == _stored_name(n) for i, n in self._uploads.items()):
338
+ stored = {
339
+ i: self._videos[i][0] if i in self._videos else _stored_name(n)
340
+ for i, n in self._uploads.items()
341
+ }
342
+ if all(held.get(i) == s for i, s in stored.items()):
327
343
  return
328
344
  form.page.wait_for_timeout(UPLOAD_POLL_MS)
329
345
 
@@ -335,6 +351,29 @@ def _stored_name(name: str) -> str:
335
351
  return re.sub(r"[^a-z0-9.\-]+", "_", ascii_name.lower())
336
352
 
337
353
 
354
+ def _upload_video(page: Page, host: Locator, clip: str) -> tuple[str, str]:
355
+ """Uploads a clip through a video question's "Upload video" input, never its recorder.
356
+ Teamtailor stores it as a video and the form keeps the video's id; returns that id and the
357
+ URL the video is read back from."""
358
+
359
+ def created(response: Response) -> bool:
360
+ path = urlsplit(response.url).path
361
+ return response.request.method == "POST" and path.endswith("/videos")
362
+
363
+ try:
364
+ with page.expect_response(created, timeout=UPLOAD_TIMEOUT_MS) as response:
365
+ host.locator("input[type=file]").set_input_files(clip)
366
+ except browser.Timeout:
367
+ raise NotTaken(f"the video question took no video from {PurePath(clip).name!r}") from None
368
+ video = response.value.json()
369
+ return str(video["id"]), f"{response.value.url}/{video['sgid']}"
370
+
371
+
372
+ # The file name Teamtailor holds a video under.
373
+ _VIDEO = """url => fetch(url, {headers: {Accept: "application/json"}})
374
+ .then(r => r.ok ? r.json() : {}).then(v => v.file_file_name ?? null)"""
375
+
376
+
338
377
  def _decline_cookies(page: Page) -> None:
339
378
  """Closes the cookie banner, a modal dialog that leaves the form inert while it is open."""
340
379
  decline = page.locator("[data-action*='cookies--alert#disableAll']").first
File without changes
File without changes