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.
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/README.md +1 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev161/src/atsfill/adapters/welcomekit.py +366 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/registry.toml +7 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/LICENSE +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev160 → atsfill-0.0.5.dev161}/src/atsfill/core/telemetry.py +0 -0
- {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.
|
|
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
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
84
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
85
85
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
86
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
86
87
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
87
88
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
88
89
|
[](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
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
55
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
56
56
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
57
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
57
58
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
58
59
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
59
60
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|