atsfill 0.0.5.dev160__tar.gz → 0.0.5.dev161__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 (64) hide show
  1. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/README.md +1 -0
  3. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.5.dev161/src/atsfill/adapters/welcomekit.py +366 -0
  7. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/registry.toml +7 -0
  8. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/LICENSE +0 -0
  9. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/gupy.py +0 -0
  17. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/isolvedhire.py +0 -0
  21. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jazzhr.py +0 -0
  22. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jobaffinity.py +0 -0
  23. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jobstreet.py +0 -0
  24. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/join.py +0 -0
  25. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/lever.py +0 -0
  26. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/manatal.py +0 -0
  27. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/oracle.py +0 -0
  28. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/paycor.py +0 -0
  29. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/paylocity.py +0 -0
  30. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/personio.py +0 -0
  31. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/phenom.py +0 -0
  32. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/pinpoint.py +0 -0
  33. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/recruitee.py +0 -0
  34. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/recruiterflow.py +0 -0
  35. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/rippling.py +0 -0
  36. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/smartrecruiters.py +0 -0
  37. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/successfactors.js +0 -0
  38. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/successfactors.py +0 -0
  39. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/taleo.py +0 -0
  40. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/teamtailor.py +0 -0
  41. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/ukg.py +0 -0
  42. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workable.py +0 -0
  43. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workatastartup.py +0 -0
  44. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workday.py +0 -0
  45. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/wttj.py +0 -0
  46. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/zohorecruit.py +0 -0
  47. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/cli.py +0 -0
  48. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/__init__.py +0 -0
  49. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/accounts.py +0 -0
  50. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/adapter.py +0 -0
  51. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/attempts.py +0 -0
  52. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/browser.py +0 -0
  53. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/commands.py +0 -0
  54. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/conditions.py +0 -0
  55. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/html.py +0 -0
  56. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/matching.py +0 -0
  57. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/otp.py +0 -0
  58. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/passwords.py +0 -0
  59. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/paths.py +0 -0
  60. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/registry.py +0 -0
  61. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/results.py +0 -0
  62. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/snapshot.py +0 -0
  63. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/telemetry.py +0 -0
  64. {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.5.dev160
3
+ Version: 0.0.5.dev161
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
@@ -83,6 +83,7 @@ It is built for agents with a shell: every command prints one JSON document and
83
83
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
84
84
  [![recruiterflow run history](https://shbernal.github.io/atsfill/run-history/recruiterflow.svg)](https://shbernal.github.io/atsfill/run-history/)
85
85
  [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
86
+ [![welcomekit run history](https://shbernal.github.io/atsfill/run-history/welcomekit.svg)](https://shbernal.github.io/atsfill/run-history/)
86
87
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
87
88
  [![workatastartup run history](https://shbernal.github.io/atsfill/run-history/workatastartup.svg)](https://shbernal.github.io/atsfill/run-history/)
88
89
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -54,6 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
54
54
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
55
55
  [![recruiterflow run history](https://shbernal.github.io/atsfill/run-history/recruiterflow.svg)](https://shbernal.github.io/atsfill/run-history/)
56
56
  [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
57
+ [![welcomekit run history](https://shbernal.github.io/atsfill/run-history/welcomekit.svg)](https://shbernal.github.io/atsfill/run-history/)
57
58
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
58
59
  [![workatastartup run history](https://shbernal.github.io/atsfill/run-history/workatastartup.svg)](https://shbernal.github.io/atsfill/run-history/)
59
60
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev160"
3
+ version = "0.0.5.dev161"
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.dev160"
3
+ version = "0.0.5.dev161"
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"
@@ -27,6 +27,7 @@ from atsfill.adapters.smartrecruiters import SmartRecruiters
27
27
  from atsfill.adapters.successfactors import SuccessFactors
28
28
  from atsfill.adapters.teamtailor import Teamtailor
29
29
  from atsfill.adapters.ukg import UKG
30
+ from atsfill.adapters.welcomekit import WelcomeKit
30
31
  from atsfill.adapters.workable import Workable
31
32
  from atsfill.adapters.workatastartup import WorkAtAStartup
32
33
  from atsfill.adapters.workday import Workday
@@ -45,6 +46,7 @@ def adapters() -> list[Adapter]:
45
46
  WorkAtAStartup(),
46
47
  Join(),
47
48
  JobAffinity(),
49
+ WelcomeKit(),
48
50
  SmartRecruiters(),
49
51
  Oracle(),
50
52
  Manatal(),
@@ -0,0 +1,366 @@
1
+ """WelcomeKit, Welcome to the Jungle's ATS, as docs/adapters/welcomekit.md observes.
2
+
3
+ Two forms, by host. The older one (`welcomekit.co/candidates/new?o=<org>&j=<job>`) is a Rails
4
+ form rendered on the server: `request_read` fetches it over HTTP, `browser_read` takes the page's
5
+ HTML, one parser lists the fields. The newer one (`ats.welcometothejungle.com/board/<org>/job/
6
+ <id>/apply`) is a React app over a JSON API: fixed personal fields, then the job's questions as
7
+ the API declares them; both reads list the fields from that JSON.
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import urllib.error
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+ from urllib.parse import parse_qs, urlsplit
16
+
17
+ from atsfill.core import html
18
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
19
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
20
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
21
+
22
+ OLD_ORIGIN = "https://www.welcomekit.co"
23
+ OLD_HOST = re.compile(r"^(?:www\.)?welcomekit\.co$")
24
+ OLD_PATH = "/candidates/new"
25
+ OLD = "form#new_candidate"
26
+ NEW_ORIGIN = "https://ats.welcometothejungle.com"
27
+ NEW_HOST = "ats.welcometothejungle.com"
28
+ NEW_PATH = re.compile(r"^/board/([\w-]+)/job/([0-9a-f-]{36})(?:/apply)?/?$")
29
+ API = "https://api.ats.welcometothejungle.com/api/v1/board/organizations/{org}/jobs/{job}"
30
+ NEW_SUBMIT = "[data-testid=submit-application-button]"
31
+ CAPTCHA = "iframe[title=reCAPTCHA]"
32
+ CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
33
+ FORM_TIMEOUT_MS = 30_000
34
+ UPLOAD_TIMEOUT_MS = 30_000
35
+ CAPTCHA_TIMEOUT_MS = 20_000
36
+ SUBMIT_TIMEOUT_MS = 90_000
37
+ YES_NO = ["Yes", "No"]
38
+ PHOTO = ["image/png", "image/jpeg", "image/gif"] # the photo's input declares no type
39
+ # The newer form's personal fields, always drawn: (name, label, type, required)
40
+ PERSONAL = [
41
+ ("firstname", "First name", FieldType.TEXT, True),
42
+ ("lastname", "Last name", FieldType.TEXT, True),
43
+ ("email", "Email address", FieldType.TEXT, True),
44
+ ("linkedin_url", "LinkedIn profile URL", FieldType.URL, False),
45
+ ("phone_number", "Phone number", FieldType.PHONE, False),
46
+ ("personal_website_url", "Personal website URL", FieldType.URL, False),
47
+ ]
48
+ QUESTION_KINDS = {"text": FieldType.TEXT, "boolean": FieldType.CHOICE, "document": FieldType.FILE}
49
+
50
+ # What each control holds, by name: a value, a select's option text, a box as Yes/No, a file's
51
+ # name; a newer form's boolean question, by its place among them, as its selected button.
52
+ _READ_BACK = """(form, controls) => {
53
+ const out = {};
54
+ const booleans = [...form.querySelectorAll("[data-testid=board-boolean-field-true]")]
55
+ .map(b => b.parentElement);
56
+ for (const c of controls) {
57
+ if (c.kind === "boolean") {
58
+ const selected = booleans[c.place]?.querySelector("[data-selected=true]");
59
+ out[c.id] = selected?.textContent.trim() ?? "";
60
+ continue;
61
+ }
62
+ const el = form.querySelector(`[name="${c.name}"]:not([type=hidden])`);
63
+ if (!el) continue;
64
+ if (c.kind === "select") out[c.id] = el.selectedOptions[0]?.textContent.trim() ?? "";
65
+ else if (c.kind === "checkbox") out[c.id] = el.checked ? "Yes" : "No";
66
+ else if (c.kind === "file") out[c.id] = el.files[0]?.name ?? "";
67
+ else if (c.kind === "upload") {
68
+ const done = el.closest("[data-testid^=file-input-]")?.querySelector("svg.name-check");
69
+ out[c.id] = done?.parentElement.textContent.trim().replace(/ \\([^()]*\\)$/, "") ?? "";
70
+ }
71
+ else out[c.id] = el.value ?? "";
72
+ }
73
+ return out;
74
+ }"""
75
+
76
+
77
+ @dataclass
78
+ class _Control:
79
+ field: Field
80
+ kind: str # text | select | checkbox | file | boolean | upload
81
+ name: str # the control's `name`
82
+ place: int = 0 # a boolean question's place among the form's boolean questions
83
+
84
+
85
+ class WelcomeKit:
86
+ name = "welcomekit"
87
+ known_gaps: dict[str, Gap] = {}
88
+ side_effects = [
89
+ SideEffect("upload", "hiring_system", "the newer form uploads a file once it is attached"),
90
+ SideEffect("submit", "employer", "the application reaches the employer"),
91
+ ]
92
+
93
+ def __init__(self) -> None:
94
+ self._controls: list[_Control] = []
95
+ self._new = False
96
+
97
+ def detect(self, url: str, page: Page | None) -> bool:
98
+ return _old(url) is not None or _new(url) is not None
99
+
100
+ def posting(self, url: str) -> str:
101
+ if old := _old(url):
102
+ return f"{OLD_ORIGIN}{OLD_PATH}?o={old[0]}&j={old[1]}"
103
+ if new := _new(url):
104
+ return f"{NEW_ORIGIN}/board/{new[0]}/job/{new[1]}/apply"
105
+ return url
106
+
107
+ def request_read(self, url: str) -> list[Field] | None:
108
+ try:
109
+ if _old(url):
110
+ controls = _old_controls(_get(self.posting(url)))
111
+ elif new := _new(url):
112
+ controls = _new_controls(json.loads(_get(API.format(org=new[0], job=new[1]))))
113
+ else:
114
+ return None
115
+ except urllib.error.HTTPError, OSError, ValueError:
116
+ return None
117
+ return [c.field for c in controls] if controls else None
118
+
119
+ def browser_read(self, page: Page) -> list[Field]:
120
+ if new := _new(page.url):
121
+ self._new = True
122
+ if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
123
+ page.goto(self.posting(page.url))
124
+ api = API.format(org=new[0], job=new[1])
125
+ data = page.evaluate(_FETCH, api)
126
+ if data is None or not (controls := _new_controls(data)):
127
+ raise Unsupported("posting_gone: the job is not published")
128
+ try:
129
+ page.locator(NEW_SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
130
+ except Timeout:
131
+ raise Unsupported("posting_gone: no application form on the page") from None
132
+ for c in controls: # each declared question is drawn
133
+ if c.kind == "boolean":
134
+ page.locator("[data-testid=board-boolean-field-true]").nth(c.place).wait_for(
135
+ state="attached", timeout=FORM_TIMEOUT_MS
136
+ )
137
+ elif c.kind in ("text", "upload") and c.name.startswith("custom_questions"):
138
+ page.locator(f"[name='{c.name}']").wait_for(
139
+ state="attached", timeout=FORM_TIMEOUT_MS
140
+ )
141
+ else:
142
+ try:
143
+ page.locator(OLD).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
144
+ except Timeout:
145
+ raise Unsupported("posting_gone: no application form on the page") from None
146
+ controls = _old_controls(page.content())
147
+ self._controls = controls
148
+ return [c.field for c in controls]
149
+
150
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
151
+ form = page.locator("form").first if self._new else page.locator(OLD)
152
+ for c in self._controls:
153
+ if c.field.id not in answers or c.field.unfillable:
154
+ continue
155
+ value = answers[c.field.id]
156
+ value = value if isinstance(value, str) else value[0]
157
+ control = form.locator(f"[name='{c.name}']:not([type=hidden])")
158
+ match c.kind:
159
+ case "select":
160
+ control.select_option(label=value)
161
+ case "checkbox": # drawn over by its iCheck box: its label toggles it
162
+ if control.is_checked() != (value == "Yes"):
163
+ form.locator(f"label[for='{control.get_attribute('id')}']").click()
164
+ case "file":
165
+ control.set_input_files(value)
166
+ case "upload": # the file goes to storage at once; a ticked badge then names it
167
+ control.set_input_files(value)
168
+ name = value.rsplit("/", 1)[-1]
169
+ input_ = page.locator(f"input[name='{c.name}']")
170
+ widget = form.locator("[data-testid^=file-input-]").filter(has=input_)
171
+ badge = widget.locator("*:has(> svg.name-check)", has_text=name)
172
+ badge.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
173
+ case "boolean":
174
+ testid = "true" if value == "Yes" else "false"
175
+ buttons = form.locator(f"[data-testid=board-boolean-field-{testid}]")
176
+ button = buttons.nth(c.place)
177
+ if button.get_attribute("data-selected") != "true":
178
+ button.click()
179
+ case _:
180
+ control.fill(value)
181
+
182
+ def read_back(self, page: Page) -> dict[str, Answer]:
183
+ form = page.locator("form").first if self._new else page.locator(OLD)
184
+ controls = [
185
+ {"id": c.field.id, "name": c.name, "kind": c.kind, "place": c.place}
186
+ for c in self._controls
187
+ if not c.field.unfillable
188
+ ]
189
+ values = form.evaluate(_READ_BACK, controls)
190
+ return {k: v for k, v in values.items() if v not in (None, "")}
191
+
192
+ def submit(self, page: Page) -> str:
193
+ """The older form: ticks the reCAPTCHA box, `Blocked` on an image challenge, posts the
194
+ form and lands on the page after it, the evidence. The newer form: clicks Submit
195
+ application; the evidence is the answer to the apply POST."""
196
+ if self._new:
197
+ return _submit_new(page)
198
+ captcha = page.locator(CAPTCHA)
199
+ if captcha.count():
200
+ captcha.content_frame.locator("#recaptcha-anchor").click()
201
+ token = page.locator("#g-recaptcha-response")
202
+ try:
203
+ page.wait_for_function(
204
+ "el => el.value !== ''", arg=token.element_handle(), timeout=CAPTCHA_TIMEOUT_MS
205
+ )
206
+ except Timeout:
207
+ if page.locator(CHALLENGE).is_visible():
208
+ raise Blocked("captcha: reCAPTCHA image challenge") from None
209
+ raise
210
+ with page.expect_navigation(timeout=SUBMIT_TIMEOUT_MS):
211
+ page.locator(f"{OLD} [type=submit]").click()
212
+ if page.locator(OLD).count():
213
+ raise RuntimeError(f"the form was not taken: {_errors(page)}")
214
+ return page.url
215
+
216
+
217
+ # The newer form's job, fetched from inside the page so it is the browser that asks.
218
+ _FETCH = "url => fetch(url).then(r => r.ok ? r.json() : null)"
219
+
220
+
221
+ def _old(url: str) -> tuple[str, str] | None:
222
+ """The organization and job references, from `welcomekit.co/candidates/new?o=..&j=..`."""
223
+ parts = urlsplit(url)
224
+ query = parse_qs(parts.query)
225
+ o, j = query.get("o", [""])[0], query.get("j", [""])[0]
226
+ ok = OLD_HOST.match(parts.hostname or "") and parts.path.rstrip("/") == OLD_PATH
227
+ return (o, j) if ok and o and j else None
228
+
229
+
230
+ def _new(url: str) -> tuple[str, str] | None:
231
+ """The organization slug and the job id, from `ats.welcometothejungle.com/board/..`."""
232
+ parts = urlsplit(url)
233
+ match = NEW_PATH.match(parts.path)
234
+ return (match[1], match[2]) if parts.hostname == NEW_HOST and match else None
235
+
236
+
237
+ def _get(url: str) -> str:
238
+ request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
239
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
240
+ return response.read().decode()
241
+
242
+
243
+ def _old_controls(source: str) -> list[_Control]:
244
+ """The older form's controls, in the page's order; an unknown job's page has no form."""
245
+ page = html.parse(source)
246
+ form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == "new_candidate")
247
+ if form is None:
248
+ return []
249
+ labels = {n.attrs["for"]: n for n in form.walk() if n.tag == "label" and "for" in n.attrs}
250
+ out: list[_Control] = []
251
+ for node in form.walk():
252
+ name = node.attrs.get("name", "")
253
+ kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
254
+ if node.tag not in ("input", "select", "textarea") or not name.startswith("candidate["):
255
+ continue
256
+ if kind == "hidden" or node.closest(lambda n: "hidden" in n.classes()):
257
+ continue
258
+ field_id = _id(name)
259
+ own = labels.get(node.attrs.get("id", ""))
260
+ label = _text(own) if own is not None else _group_label(node, field_id)
261
+ required = "required" in node.attrs
262
+ match kind:
263
+ case "text" | "email" | "textarea":
264
+ field, kept = Field(field_id, label, FieldType.TEXT, required), "text"
265
+ case "tel":
266
+ field, kept = Field(field_id, label, FieldType.PHONE, required), "text"
267
+ case "select":
268
+ options = [
269
+ (o, " ".join(o.text().split()))
270
+ for o in node.walk()
271
+ if o.tag == "option" and o.attrs.get("value")
272
+ ]
273
+ default = next((t for o, t in options if "selected" in o.attrs), None)
274
+ texts = [t for _, t in options]
275
+ field = Field(field_id, label, FieldType.CHOICE, required, texts, default=default)
276
+ kept = "select"
277
+ case "file":
278
+ accept = node.attrs.get("accept")
279
+ types = accept.split(",") if accept else PHOTO if field_id == "image" else None
280
+ field, kept = Field(field_id, label, FieldType.FILE, required, accept=types), "file"
281
+ case "checkbox":
282
+ field = Field(field_id, label, FieldType.CHOICE, required, YES_NO)
283
+ kept = "checkbox"
284
+ case _:
285
+ reason = f"unhandled_rendering: {field_id} is a {kind}"
286
+ field = Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
287
+ kept = "text"
288
+ out.append(_Control(field, kept, name))
289
+ return out
290
+
291
+
292
+ def _id(name: str) -> str:
293
+ """`candidate[profile_attributes][firstname]` is `firstname`, `candidate[answers[0][answer]]`
294
+ `answers.0`."""
295
+ parts = re.findall(r"\w+", name)[1:]
296
+ parts = [p for p in parts if p != "profile_attributes"]
297
+ return ".".join(parts[:-1] if parts[0] == "answers" else parts)
298
+
299
+
300
+ def _group_label(node: html.Node, field_id: str) -> str:
301
+ """An address part's label: its group's, with the part's placeholder; the country list,
302
+ which has none and lists its countries in English, `Country`."""
303
+ group = node.closest(lambda n: "address" in n.classes() and "form-group" in n.classes())
304
+ label = group.find(lambda n: n.tag == "label") if group else None
305
+ placeholder = node.attrs.get("placeholder", "Country" if node.tag == "select" else "")
306
+ if label is None:
307
+ return placeholder or field_id
308
+ return f"{_text(label)}: {placeholder}" if placeholder else _text(label)
309
+
310
+
311
+ def _text(label: html.Node) -> str:
312
+ return " ".join(label.text().split()).removesuffix("*").strip()
313
+
314
+
315
+ def _new_controls(data: dict) -> list[_Control]:
316
+ """The newer form's controls from its job's JSON: the personal fields, the résumé, then the
317
+ questions by their order; none for a job that is not published."""
318
+ job = data.get("job") or {}
319
+ if job.get("status") != "published":
320
+ return []
321
+ out = [_Control(Field(n, t, k, r), "text", n) for n, t, k, r in PERSONAL]
322
+ cv = Field("cv", "Resume", FieldType.FILE, True, accept=[".pdf"])
323
+ out.append(_Control(cv, "upload", "cv"))
324
+ booleans = 0
325
+ questions = sorted(job.get("questions") or [], key=lambda q: q.get("order") or 0)
326
+ for n, q in enumerate(questions):
327
+ name = f"custom_questions.{n}.answer"
328
+ field_id, label, required = f"custom_questions.{n}", q["label"].strip(), q["required"]
329
+ match q["type"]:
330
+ case "boolean":
331
+ field = Field(field_id, label, FieldType.CHOICE, required, YES_NO)
332
+ out.append(_Control(field, "boolean", name, booleans))
333
+ booleans += 1
334
+ case "document":
335
+ field = Field(field_id, label, FieldType.FILE, required, accept=[".pdf"])
336
+ out.append(_Control(field, "upload", name))
337
+ case "text":
338
+ out.append(_Control(Field(field_id, label, FieldType.TEXT, required), "text", name))
339
+ case other:
340
+ reason = f"unhandled_rendering: {field_id} is a {other} question"
341
+ field = Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
342
+ out.append(_Control(field, "text", name))
343
+ return out
344
+
345
+
346
+ def _submit_new(page: Page) -> str:
347
+ new = _new(page.url)
348
+ assert new is not None
349
+ path = urlsplit(API.format(org=new[0], job=new[1])).path + "/apply"
350
+ try:
351
+ with page.expect_response(
352
+ lambda r: r.request.method == "POST" and urlsplit(r.url).path == path,
353
+ timeout=SUBMIT_TIMEOUT_MS,
354
+ ) as applied:
355
+ page.locator(NEW_SUBMIT).click()
356
+ except Timeout:
357
+ raise RuntimeError(f"nothing posted: {_errors(page)}") from None
358
+ response = applied.value
359
+ if not response.ok:
360
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
361
+ return f"{response.url} {response.status}"
362
+
363
+
364
+ def _errors(page: Page) -> str:
365
+ shown = page.locator(".help-block:visible, .error:visible, [role=alert]:visible")
366
+ return " ".join(" ".join(shown.all_inner_texts()).split())[:200] or "no error shown"
@@ -420,3 +420,10 @@ account = "none"
420
420
  hosting = ["hosted"]
421
421
  adapter = "jobaffinity"
422
422
  scope = { is = "supported", date = 2026-10-28, commit = "02939fd" }
423
+
424
+ [welcomekit]
425
+ kind = "ats"
426
+ account = "none"
427
+ hosting = ["hosted"]
428
+ adapter = "welcomekit"
429
+ scope = { is = "supported", date = 2026-10-29, commit = "238798d" }
File without changes