atsfill 0.0.4.dev123__tar.gz → 0.0.4.dev124__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.4.dev123 → atsfill-0.0.4.dev124}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/README.md +1 -1
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev124/src/atsfill/adapters/join.py +367 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/registry.toml +3 -1
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/LICENSE +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev124
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                           
|
|
60
|
+
                            
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                           
|
|
32
|
+
                            
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -8,6 +8,7 @@ from atsfill.adapters.gupy import Gupy
|
|
|
8
8
|
from atsfill.adapters.hireology import Hireology
|
|
9
9
|
from atsfill.adapters.hiringthing import HiringThing
|
|
10
10
|
from atsfill.adapters.jazzhr import JazzHR
|
|
11
|
+
from atsfill.adapters.join import Join
|
|
11
12
|
from atsfill.adapters.lever import Lever
|
|
12
13
|
from atsfill.adapters.manatal import Manatal
|
|
13
14
|
from atsfill.adapters.oracle import Oracle
|
|
@@ -36,6 +37,7 @@ def adapters() -> list[Adapter]:
|
|
|
36
37
|
Teamtailor(),
|
|
37
38
|
Greenhouse(),
|
|
38
39
|
WelcomeToTheJungle(),
|
|
40
|
+
Join(),
|
|
39
41
|
SmartRecruiters(),
|
|
40
42
|
Oracle(),
|
|
41
43
|
Manatal(),
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""JOIN (`join.com/companies/<company>/<job id>-<slug>`), as docs/adapters/join.md observes.
|
|
2
|
+
|
|
3
|
+
Applying is a wizard of one step per page, ending on a review page whose Confirm & apply is the
|
|
4
|
+
send. Signed in, the wizard takes the name from the account and asks the CV, a cover letter and
|
|
5
|
+
the job's screening questions, which the page's own GraphQL query lists. The fill walks the
|
|
6
|
+
steps to the review page, and the read-back is what the review page lists.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import browser
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = "join.com"
|
|
21
|
+
JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/(\d+)(?:-[^/]*)?(?:/apply(?:/.*)?)?/?$")
|
|
22
|
+
ME = f"https://{HOST}/api/me"
|
|
23
|
+
GRAPHQL = f"https://{HOST}/candidate-api/graphql"
|
|
24
|
+
LOGIN = f"https://{HOST}/auth/login/candidate?redirectUrl=%2Ftalent%2Fhome"
|
|
25
|
+
UNVERIFIED = "/auth/candidates/unverified-email"
|
|
26
|
+
STEP_TIMEOUT_MS = 20_000
|
|
27
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
28
|
+
ACCOUNT_TIMEOUT_MS = 20_000
|
|
29
|
+
SUBMIT_TIMEOUT_MS = 60_000
|
|
30
|
+
BUTTON_TIMEOUT_MS = 2_000
|
|
31
|
+
OPENING_TIMEOUT_S = 20
|
|
32
|
+
CONTINUE_TIMEOUT_S = 15
|
|
33
|
+
POLL_MS = 250
|
|
34
|
+
MAX_STEPS = 60
|
|
35
|
+
MAX_MONTHS = 36 # how far the calendar is paged for a date
|
|
36
|
+
|
|
37
|
+
QUESTIONS = """query JobScreeningQuestions($input: JobScreeningQuestionsInput!) {
|
|
38
|
+
jobScreeningQuestions(input: $input) {
|
|
39
|
+
questions { id question options type isRequired position questionClass }
|
|
40
|
+
}
|
|
41
|
+
}"""
|
|
42
|
+
YES_NO = ["Yes", "No"] # a boolean's cards, in this order, in the job's language
|
|
43
|
+
FILES = {"cv": "cv"} # a file field, by its step
|
|
44
|
+
# A question of a class has a step of its own; the others are `question-<n>`, in order.
|
|
45
|
+
CLASS_STEPS = {
|
|
46
|
+
"workAuthorization": "WORK_AUTHORIZATION",
|
|
47
|
+
"salaryExpectation": "SALARY_EXPECTATION",
|
|
48
|
+
"startAvailability": "AVAILABILITY",
|
|
49
|
+
"availability": "AVAILABILITY",
|
|
50
|
+
}
|
|
51
|
+
PROFILE_STEPS = ("personalInformation", "professionalLinks") # the account profile's
|
|
52
|
+
PDF = [".pdf", "application/pdf"]
|
|
53
|
+
|
|
54
|
+
# The page's own GraphQL request, from the page.
|
|
55
|
+
_FETCH = """async ([url, body]) => (await fetch(url, {method: "POST", body: JSON.stringify(body),
|
|
56
|
+
headers: {"content-type": "application/json"}})).json()"""
|
|
57
|
+
|
|
58
|
+
# What the review page lists: each entry's label, then its value.
|
|
59
|
+
_REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
|
|
60
|
+
const [label, value] = div.children;
|
|
61
|
+
return div.children.length === 2 && label.tagName === "P"
|
|
62
|
+
? [label.textContent.trim(), value.textContent.trim()] : null;
|
|
63
|
+
}).filter(Boolean)"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Join:
|
|
67
|
+
name = "join"
|
|
68
|
+
per_employer = False
|
|
69
|
+
asks_name = True # the account's first and last name
|
|
70
|
+
known_gaps = {"profile_incomplete": Gap.FLOW}
|
|
71
|
+
side_effects = [
|
|
72
|
+
SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
|
|
73
|
+
SideEffect("upload", "hiring_system", "a file is stored as soon as it is chosen"),
|
|
74
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self._fields: dict[str, Field] = {}
|
|
79
|
+
self._questions: list[dict[str, Any]] = []
|
|
80
|
+
self._seen: dict[str, Answer] = {} # each question as its step was left
|
|
81
|
+
self._files: list[str] = [] # the file fields answered, in the steps' order
|
|
82
|
+
|
|
83
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
84
|
+
parts = urlsplit(url)
|
|
85
|
+
return (parts.hostname or "").removeprefix("www.") == HOST and bool(JOB.match(parts.path))
|
|
86
|
+
|
|
87
|
+
def posting(self, url: str) -> str:
|
|
88
|
+
match = JOB.match(urlsplit(url).path)
|
|
89
|
+
return f"https://{HOST}/companies/{match[1]}/{match[2]}" if match else url
|
|
90
|
+
|
|
91
|
+
def account_scope(self, url: str) -> None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def login_url(self, scope: str | None) -> str:
|
|
95
|
+
return LOGIN
|
|
96
|
+
|
|
97
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
98
|
+
"""`api/me` answers the candidate for an account."""
|
|
99
|
+
response = request.get(ME)
|
|
100
|
+
return response.ok and response.json().get("type") == "candidate"
|
|
101
|
+
|
|
102
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
103
|
+
if not _email_step(page, email):
|
|
104
|
+
raise Refused("wrong_password: no account has this email")
|
|
105
|
+
page.locator("#password").fill(password)
|
|
106
|
+
login = _account_post(page, "LoginWithCredentials")
|
|
107
|
+
if login.json().get("errors"):
|
|
108
|
+
raise Refused("wrong_password")
|
|
109
|
+
page.wait_for_url(lambda url: "/auth/login" not in url, timeout=ACCOUNT_TIMEOUT_MS)
|
|
110
|
+
if urlsplit(page.url).path == UNVERIFIED:
|
|
111
|
+
raise Blocked("email_link: the account is not confirmed")
|
|
112
|
+
|
|
113
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
114
|
+
"""Creates the account, signed in, then waits for its confirmation link: until it is
|
|
115
|
+
clicked the wizard does not open."""
|
|
116
|
+
first, _, last = (name or "").strip().rpartition(" ")
|
|
117
|
+
if not first or not last:
|
|
118
|
+
raise Unsupported("unhandled_rendering: the sign-up asks a first and a last name")
|
|
119
|
+
if _email_step(page, email):
|
|
120
|
+
raise Refused("email_in_use")
|
|
121
|
+
page.locator("#firstName").fill(first)
|
|
122
|
+
page.locator("#lastName").fill(last)
|
|
123
|
+
page.locator("#password").fill(password)
|
|
124
|
+
submit = page.locator("[data-testid=SubmitButton]")
|
|
125
|
+
try:
|
|
126
|
+
page.wait_for_function(
|
|
127
|
+
"e => !e.disabled", arg=submit.element_handle(), timeout=BUTTON_TIMEOUT_MS
|
|
128
|
+
)
|
|
129
|
+
except Timeout:
|
|
130
|
+
raise Refused("password_rule: the sign-up keeps its button disabled") from None
|
|
131
|
+
registered = _account_post(page, "RegisterCandidate")
|
|
132
|
+
if errors := registered.json().get("errors"):
|
|
133
|
+
raise Refused(f"password_rule: {errors[0].get('message')}")
|
|
134
|
+
raise Blocked("email_link: the sign-up sent a confirmation link")
|
|
135
|
+
|
|
136
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
137
|
+
"""Lists the fields, then opens the wizard on its first step."""
|
|
138
|
+
if not JOB.match(urlsplit(page.url).path):
|
|
139
|
+
raise RuntimeError(f"{page.url} is not a JOIN job")
|
|
140
|
+
raw = page.locator("script#__NEXT_DATA__").text_content() or "{}"
|
|
141
|
+
job = json.loads(raw)["props"]["pageProps"].get("pageData", {}).get("job")
|
|
142
|
+
if not job or job.get("status") != "ONLINE":
|
|
143
|
+
raise Unsupported(f"posting_gone: the job is {job and job.get('status')}")
|
|
144
|
+
variables = {"input": {"companyId": job["companyId"], "jobId": job["id"],
|
|
145
|
+
"paginationPage": {"page": 1, "pageSize": 100}}} # fmt: skip
|
|
146
|
+
body = {
|
|
147
|
+
"operationName": "JobScreeningQuestions",
|
|
148
|
+
"variables": variables,
|
|
149
|
+
"query": QUESTIONS,
|
|
150
|
+
}
|
|
151
|
+
answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
|
|
152
|
+
questions = answered["data"]["jobScreeningQuestions"]["questions"]
|
|
153
|
+
self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
|
|
154
|
+
fields = [
|
|
155
|
+
Field("cv", "CV", FieldType.FILE, True, accept=PDF),
|
|
156
|
+
*(_question(q) for q in self._questions),
|
|
157
|
+
]
|
|
158
|
+
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
159
|
+
browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
|
|
160
|
+
page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
|
|
161
|
+
# the wizard opens on `authentication`, and leaves it once it knows the account
|
|
162
|
+
signed_out = page.locator("#email:visible")
|
|
163
|
+
deadline = time.monotonic() + OPENING_TIMEOUT_S
|
|
164
|
+
while _step(page) == "authentication" and time.monotonic() < deadline:
|
|
165
|
+
if signed_out.count():
|
|
166
|
+
break
|
|
167
|
+
page.wait_for_timeout(POLL_MS)
|
|
168
|
+
if _step(page) == "authentication":
|
|
169
|
+
raise Blocked("login_required: run `atsfill login join`")
|
|
170
|
+
return fields
|
|
171
|
+
|
|
172
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
173
|
+
"""Walks the wizard from the step it is on to the review page, answering each step; a
|
|
174
|
+
step without a listed field (the account's name, its links) is left as it is."""
|
|
175
|
+
for _ in range(MAX_STEPS):
|
|
176
|
+
step = _step(page)
|
|
177
|
+
if step == "review":
|
|
178
|
+
page.locator("a > div > p + p").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
179
|
+
return
|
|
180
|
+
if step in FILES:
|
|
181
|
+
self._file(page, FILES[step], answers.get(FILES[step]))
|
|
182
|
+
elif question := self._step_question(step):
|
|
183
|
+
field_id = f"question.{question['id']}"
|
|
184
|
+
if field_id in self._fields:
|
|
185
|
+
self._question(page, question, answers.get(field_id))
|
|
186
|
+
elif step == "coverLetter": # asked by some jobs only: left without one
|
|
187
|
+
pass
|
|
188
|
+
elif step in PROFILE_STEPS:
|
|
189
|
+
try: # shown when the job requires what the account profile lacks
|
|
190
|
+
_next(page, step)
|
|
191
|
+
except NotTaken, RuntimeError:
|
|
192
|
+
detail = f"the {step} step asks what the account profile lacks"
|
|
193
|
+
raise Unsupported(known_gap(self, "profile_incomplete", detail)) from None
|
|
194
|
+
continue
|
|
195
|
+
else:
|
|
196
|
+
raise Unsupported(f"unhandled_rendering: a wizard step {step!r}")
|
|
197
|
+
_next(page, step)
|
|
198
|
+
raise RuntimeError(f"the wizard did not reach its review page in {MAX_STEPS} steps")
|
|
199
|
+
|
|
200
|
+
def _step_question(self, step: str) -> dict[str, Any] | None:
|
|
201
|
+
if kind := CLASS_STEPS.get(step):
|
|
202
|
+
return next((q for q in self._questions if q.get("questionClass") == kind), None)
|
|
203
|
+
if match := re.fullmatch(r"question-(\d+)", step):
|
|
204
|
+
plain = [q for q in self._questions if not q.get("questionClass")]
|
|
205
|
+
if int(match[1]) > len(plain):
|
|
206
|
+
raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
|
|
207
|
+
return plain[int(match[1]) - 1]
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
211
|
+
"""The files as the review page lists them; each question as its step was left, since
|
|
212
|
+
the review shows a classed question's answer reworded (`€50,000`)."""
|
|
213
|
+
values: dict[str, Answer] = dict(self._seen)
|
|
214
|
+
files = iter(self._files) # the review lists the files by name, in the steps' order
|
|
215
|
+
for _, value in page.evaluate(_REVIEW):
|
|
216
|
+
if value.lower().endswith(".pdf") and (field_id := next(files, None)):
|
|
217
|
+
values[field_id] = value
|
|
218
|
+
return {k: v for k, v in values.items() if v}
|
|
219
|
+
|
|
220
|
+
def submit(self, page: Page) -> str | None:
|
|
221
|
+
with page.expect_response(
|
|
222
|
+
lambda r: r.request.method == "POST" and r.url.startswith(GRAPHQL)
|
|
223
|
+
and "apply" in r.url.lower(),
|
|
224
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
225
|
+
) as sent: # fmt: skip
|
|
226
|
+
browser.click(page.locator("button[type=submit]:visible").last)
|
|
227
|
+
response = sent.value
|
|
228
|
+
body = response.json()
|
|
229
|
+
if response.ok and not body.get("errors"):
|
|
230
|
+
parts = urlsplit(response.url)
|
|
231
|
+
return f"POST {parts.path}?{parts.query} {response.status}"
|
|
232
|
+
raise RuntimeError(f"POST {response.url} answered {response.status}: {body.get('errors')}")
|
|
233
|
+
|
|
234
|
+
def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
|
|
235
|
+
if not isinstance(answer, str):
|
|
236
|
+
return
|
|
237
|
+
page.locator("input[type=file]").first.set_input_files(answer)
|
|
238
|
+
# once stored, the step shows the file in place of its input
|
|
239
|
+
page.wait_for_function(
|
|
240
|
+
"() => !document.querySelector('input[type=file]')", timeout=UPLOAD_TIMEOUT_MS
|
|
241
|
+
)
|
|
242
|
+
self._files.append(field_id)
|
|
243
|
+
|
|
244
|
+
def _question(self, page: Page, question: dict[str, Any], answer: Answer | None) -> None:
|
|
245
|
+
field_id = f"question.{question['id']}"
|
|
246
|
+
cards = page.locator("[role=radio]")
|
|
247
|
+
match question["type"]:
|
|
248
|
+
case "BOOLEAN" | "SINGLE_CHOICE":
|
|
249
|
+
cards.first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
250
|
+
# the cards are the options in order: a letter, the option, a description
|
|
251
|
+
texts = [_card(t) for t in cards.all_inner_texts()]
|
|
252
|
+
options = YES_NO if question["type"] == "BOOLEAN" else question["options"]
|
|
253
|
+
if isinstance(answer, str):
|
|
254
|
+
if answer not in options or options.index(answer) >= len(texts):
|
|
255
|
+
raise NotTaken(f"no option {answer!r} in {field_id}: {texts}")
|
|
256
|
+
card = cards.nth(options.index(answer))
|
|
257
|
+
if card.get_attribute("aria-checked") != "true":
|
|
258
|
+
card.click()
|
|
259
|
+
checked = [c.get_attribute("aria-checked") == "true" for c in cards.all()]
|
|
260
|
+
if True in checked and checked.index(True) < len(options):
|
|
261
|
+
self._seen[field_id] = options[checked.index(True)]
|
|
262
|
+
case "DATE":
|
|
263
|
+
if isinstance(answer, str):
|
|
264
|
+
_date(page, answer)
|
|
265
|
+
picked = page.locator("td[data-part=table-cell][aria-selected=true]")
|
|
266
|
+
if picked.count():
|
|
267
|
+
self._seen[field_id] = picked.first.get_attribute("data-value") or ""
|
|
268
|
+
case _ if isinstance(answer, str):
|
|
269
|
+
name = f"screeningQuestions.answer-{question['id']}"
|
|
270
|
+
target = page.locator(f'[name="{name}"], [id="{name}"], #salaryExpectationAmount')
|
|
271
|
+
if not target.count():
|
|
272
|
+
target = page.locator(
|
|
273
|
+
"textarea:visible, input:visible:not([type=file]):not([role=combobox])"
|
|
274
|
+
)
|
|
275
|
+
target.first.fill(answer)
|
|
276
|
+
self._seen[field_id] = target.first.input_value()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _step(page: Page) -> str:
|
|
280
|
+
return urlsplit(page.url).path.rstrip("/").rsplit("/", 1)[-1]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _continue(page: Page) -> Locator:
|
|
284
|
+
"""The footer's last button, after Back: its label is in the job's language."""
|
|
285
|
+
return page.locator("button:visible:has(> [data-button-label])").last
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _next(page: Page, step: str) -> None:
|
|
289
|
+
button = _continue(page)
|
|
290
|
+
deadline = time.monotonic() + CONTINUE_TIMEOUT_S # an upload enables it once stored
|
|
291
|
+
while button.is_disabled() and time.monotonic() < deadline:
|
|
292
|
+
page.wait_for_timeout(POLL_MS)
|
|
293
|
+
if button.is_disabled():
|
|
294
|
+
raise NotTaken(f"the {step} step keeps Continue disabled: a required answer is missing")
|
|
295
|
+
on = page.url
|
|
296
|
+
try:
|
|
297
|
+
button.click(timeout=STEP_TIMEOUT_MS)
|
|
298
|
+
page.wait_for_url(lambda url: url != on, timeout=STEP_TIMEOUT_MS)
|
|
299
|
+
except Timeout:
|
|
300
|
+
raise RuntimeError(f"Continue on the {step} step went nowhere") from None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _question(q: dict[str, Any]) -> Field:
|
|
304
|
+
field_id, label, required = f"question.{q['id']}", q["question"], bool(q["isRequired"])
|
|
305
|
+
match q["type"]:
|
|
306
|
+
case "SINGLE_CHOICE":
|
|
307
|
+
return Field(field_id, label, FieldType.CHOICE, required, list(q["options"] or []))
|
|
308
|
+
case "BOOLEAN":
|
|
309
|
+
return Field(field_id, label, FieldType.CHOICE, required, YES_NO)
|
|
310
|
+
case "DATE":
|
|
311
|
+
return Field(field_id, label, FieldType.DATE, required)
|
|
312
|
+
case "INTEGER":
|
|
313
|
+
return Field(field_id, f"{label} (a number)", FieldType.TEXT, required)
|
|
314
|
+
case "TEXT":
|
|
315
|
+
return Field(field_id, label, FieldType.TEXT, required)
|
|
316
|
+
reason = f"unhandled_rendering: {label!r} is a {q['type']} question"
|
|
317
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _card(text: str) -> str:
|
|
321
|
+
"""A choice card's option: the line after its letter."""
|
|
322
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
323
|
+
return lines[1] if len(lines) > 1 else (lines[0] if lines else "")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _date(page: Page, answer: str) -> None:
|
|
327
|
+
"""Pages the calendar to the answer's month, then picks its day."""
|
|
328
|
+
day = page.locator(f'td[data-part=table-cell][data-value="{answer}"]')
|
|
329
|
+
for _ in range(MAX_MONTHS):
|
|
330
|
+
if day.count() and day.first.is_visible():
|
|
331
|
+
break
|
|
332
|
+
shown = page.locator("td[data-part=table-cell][data-value]").evaluate_all(
|
|
333
|
+
"es => es.map(e => e.dataset.value)"
|
|
334
|
+
)
|
|
335
|
+
side = "next" if shown and answer > max(shown) else "prev"
|
|
336
|
+
page.locator(f"button[data-part={side}-trigger]").first.click()
|
|
337
|
+
else:
|
|
338
|
+
raise NotTaken(f"the calendar shows no {answer}")
|
|
339
|
+
trigger = day.first.locator("[data-part=table-cell-trigger]")
|
|
340
|
+
(trigger if trigger.count() else day.first).click()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _email_step(page: Page, email: str) -> bool:
|
|
344
|
+
"""The account page's first step: the email. Whether an account has it, from what the page
|
|
345
|
+
asks next: a password alone for one, a name and a password for none."""
|
|
346
|
+
try:
|
|
347
|
+
page.locator("#email").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
|
|
348
|
+
except Timeout:
|
|
349
|
+
raise Unsupported(f"unhandled_rendering: no email step on {page.url}") from None
|
|
350
|
+
page.locator("#email").fill(email)
|
|
351
|
+
with page.expect_response(
|
|
352
|
+
lambda r: "operationName=CheckCandidate" in r.url, timeout=ACCOUNT_TIMEOUT_MS
|
|
353
|
+
) as checked:
|
|
354
|
+
page.locator("button[type=submit]").first.click()
|
|
355
|
+
page.locator("#password").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
|
|
356
|
+
return bool(checked.value.json()["data"]["checkCandidate"]["exists"])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _account_post(page: Page, operation: str) -> Response:
|
|
360
|
+
with page.expect_response(
|
|
361
|
+
lambda r: f"operationName={operation}" in r.url, timeout=ACCOUNT_TIMEOUT_MS
|
|
362
|
+
) as answered:
|
|
363
|
+
page.locator("[data-testid=SubmitButton]").click()
|
|
364
|
+
response = answered.value
|
|
365
|
+
if response.status == 429:
|
|
366
|
+
raise Blocked(f"rate_limited: {operation} answered 429")
|
|
367
|
+
return response
|
|
@@ -399,9 +399,11 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
|
|
|
399
399
|
[join]
|
|
400
400
|
kind = "ats"
|
|
401
401
|
share = 0.0032
|
|
402
|
-
account = "
|
|
402
|
+
account = "to_send" # an unknown email reads and fills the form; a known one signs in first
|
|
403
|
+
adapter = "join"
|
|
403
404
|
hosting = ["hosted"]
|
|
404
405
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
406
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "bfa5b31" }
|
|
405
407
|
|
|
406
408
|
[4dayweek]
|
|
407
409
|
kind = "aggregator"
|
|
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
|