atsfill 0.0.4.dev76__tar.gz → 0.0.4.dev78__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.dev76 → atsfill-0.0.4.dev78}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/README.md +1 -1
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev78/src/atsfill/adapters/rippling.py +457 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/LICENSE +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/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.dev78
|
|
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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                    
|
|
57
|
+
                     
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| 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 |
|
|
@@ -14,6 +14,7 @@ from atsfill.adapters.personio import Personio
|
|
|
14
14
|
from atsfill.adapters.phenom import Phenom
|
|
15
15
|
from atsfill.adapters.pinpoint import Pinpoint
|
|
16
16
|
from atsfill.adapters.recruitee import Recruitee
|
|
17
|
+
from atsfill.adapters.rippling import Rippling
|
|
17
18
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
18
19
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
19
20
|
from atsfill.adapters.workable import Workable
|
|
@@ -44,6 +45,7 @@ def adapters() -> list[Adapter]:
|
|
|
44
45
|
BambooHR(),
|
|
45
46
|
ZohoRecruit(),
|
|
46
47
|
Personio(),
|
|
48
|
+
Rippling(),
|
|
47
49
|
Pinpoint(),
|
|
48
50
|
Phenom(), # last: it claims a URL by its path alone
|
|
49
51
|
]
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Rippling Recruiting (`ats.rippling.com/<board>/jobs/<job>`), as docs/adapters/rippling.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
The job's apply page (`…/apply`) carries its form in the page data: the standard questions by
|
|
5
|
+
id and type, the employer's question groups, and whether the EEO questionnaire is asked.
|
|
6
|
+
`read_api` fetches the page over HTTP, `read_form` reads the data of the page it opened, and
|
|
7
|
+
both list the fields from it. Each field renders in an element whose `data-testid` is the
|
|
8
|
+
field's id; the résumé is uploaded and parsed as soon as it is attached.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import urllib.request
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from atsfill.core import browser
|
|
20
|
+
from atsfill.core.adapter import Gap, NotTaken, Unsupported, out_of_scope
|
|
21
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
22
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
23
|
+
|
|
24
|
+
HOST = "ats.rippling.com"
|
|
25
|
+
JOB = re.compile(r"^(?:/[a-z]{2}-[A-Z]{2})?/([^/]+)/jobs/([0-9a-f-]{36})(?:/|$)")
|
|
26
|
+
POSTING = "https://ats.rippling.com/{board}/jobs/{job}"
|
|
27
|
+
APPLY = POSTING + "/apply"
|
|
28
|
+
APPLIED = re.compile(r"^https://ats\.rippling\.com/api/.*/apply")
|
|
29
|
+
PARSED = re.compile(r"/parse_resume$")
|
|
30
|
+
PARSED_NOTICE = re.compile(r"^Résumé parsing was (successful|unsuccessful)")
|
|
31
|
+
DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
|
|
32
|
+
FORM_TIMEOUT_MS = 30_000
|
|
33
|
+
PARSE_TIMEOUT_MS = 30_000
|
|
34
|
+
UPLOAD_TIMEOUT_MS = 60_000
|
|
35
|
+
NOTICE_TIMEOUT_MS = 15_000
|
|
36
|
+
OPTIONS_TIMEOUT_MS = 10_000
|
|
37
|
+
PLACES_QUIET_MS = 500 # how long the place suggestions stay unchanged before one is picked
|
|
38
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
39
|
+
DIAL_CODES = (3, 2, 1) # the lengths a dial code can have, longest tried first
|
|
40
|
+
FILE_TYPES = [".doc", ".docx", ".pdf"]
|
|
41
|
+
OTHER = "Other"
|
|
42
|
+
COOKIES = "#transcend-consent-manager"
|
|
43
|
+
TESTIDS = {"pronouns": "pronouns_strategy"} # a field whose element is not named by its id
|
|
44
|
+
|
|
45
|
+
# A standard question's type, by its `fieldType`; the location is a place typeahead.
|
|
46
|
+
BASIC = {
|
|
47
|
+
"SHORT_ANSWER": FieldType.TEXT,
|
|
48
|
+
"PHONE_NUMBER": FieldType.PHONE,
|
|
49
|
+
"FILE": FieldType.FILE,
|
|
50
|
+
"PRONOUN": FieldType.CHOICE,
|
|
51
|
+
}
|
|
52
|
+
# An employer's question's type, by its `questionType`.
|
|
53
|
+
CUSTOM = {
|
|
54
|
+
"SHORT_ANSWER": FieldType.TEXT,
|
|
55
|
+
"LONG_ANSWER": FieldType.TEXT,
|
|
56
|
+
"DATE": FieldType.DATE,
|
|
57
|
+
"DOCUMENT_UPLOAD": FieldType.FILE,
|
|
58
|
+
"SINGLE_SELECT_RADIO": FieldType.CHOICE,
|
|
59
|
+
"SINGLE_SELECT_DROPDOWN": FieldType.CHOICE,
|
|
60
|
+
"KNOCKOUT": FieldType.CHOICE,
|
|
61
|
+
"YES_NO_SCALE_4": FieldType.CHOICE,
|
|
62
|
+
"MULTI_SELECT_CHECKBOX": FieldType.MULTI_CHOICE,
|
|
63
|
+
}
|
|
64
|
+
SCALE = ["Strong No", "No", "Yes", "Strong Yes"]
|
|
65
|
+
# "Enter my pronouns" opens a text box instead of picking.
|
|
66
|
+
PRONOUNS = [
|
|
67
|
+
"She/her/hers",
|
|
68
|
+
"He/him/his",
|
|
69
|
+
"They/them/theirs",
|
|
70
|
+
"She/her/hers, they/them/theirs",
|
|
71
|
+
"He/him/his, they/them/theirs",
|
|
72
|
+
"Just use my name",
|
|
73
|
+
]
|
|
74
|
+
# The EEO questionnaire, the same on every form that asks it; every answer optional.
|
|
75
|
+
EEOC = [
|
|
76
|
+
("eeoc.gender", "Gender", ["Male", "Female", "Non-binary", "Choose not to disclose"]),
|
|
77
|
+
(
|
|
78
|
+
"eeoc.race",
|
|
79
|
+
"Please identify your race",
|
|
80
|
+
[
|
|
81
|
+
"American Indian or Alaskan Native",
|
|
82
|
+
"Asian",
|
|
83
|
+
"Black or African American",
|
|
84
|
+
"Middle Eastern or North African",
|
|
85
|
+
"White",
|
|
86
|
+
"Native Hawaiian or other Pacific Islander",
|
|
87
|
+
"Two or more races",
|
|
88
|
+
"Choose not to disclose",
|
|
89
|
+
],
|
|
90
|
+
),
|
|
91
|
+
("eeoc.hispanicOrLatino", "Are you Hispanic/Latino?", ["Yes", "No", "Choose not to disclose"]),
|
|
92
|
+
(
|
|
93
|
+
"eeoc.veteranStatus",
|
|
94
|
+
"Veteran Status",
|
|
95
|
+
[
|
|
96
|
+
"I am not a protected veteran",
|
|
97
|
+
"I identify as one or more of the classifications of a protected veteran",
|
|
98
|
+
"Choose not to disclose",
|
|
99
|
+
],
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
SMS = (
|
|
103
|
+
"sms_opt_in",
|
|
104
|
+
"Text message updates about this application",
|
|
105
|
+
[
|
|
106
|
+
"Yes - I consent to receiving text messages",
|
|
107
|
+
"No - I do not consent to receiving text messages",
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Resolves true once no suggestion list has changed for `quiet` ms, false after `limit` ms.
|
|
112
|
+
_QUIET = """([quiet, limit]) => new Promise(done => {
|
|
113
|
+
let timer;
|
|
114
|
+
const end = (settled) => { observer.disconnect(); clearTimeout(timer); clearTimeout(cap);
|
|
115
|
+
done(settled); };
|
|
116
|
+
const wait = () => { clearTimeout(timer); timer = setTimeout(() => end(true), quiet); };
|
|
117
|
+
const list = n => n.nodeType === 1
|
|
118
|
+
&& (n.matches("[role=listbox]") || n.querySelector("[role=listbox]"));
|
|
119
|
+
const listed = m => (m.target.nodeType === 1 ? m.target : m.target.parentElement)
|
|
120
|
+
?.closest("[role=listbox]") || [...m.addedNodes, ...m.removedNodes].some(list);
|
|
121
|
+
const observer = new MutationObserver(ms => { if (ms.some(listed)) wait(); });
|
|
122
|
+
observer.observe(document.body, {childList: true, subtree: true, characterData: true});
|
|
123
|
+
const cap = setTimeout(() => end(false), limit);
|
|
124
|
+
wait();
|
|
125
|
+
})"""
|
|
126
|
+
|
|
127
|
+
# The field's control and what it shows, by its rendering: the first element carrying the
|
|
128
|
+
# field's `data-testid`, or the second for an "Other" answer's text box.
|
|
129
|
+
_SHOWN = """(root, [id, nth, testids]) => {
|
|
130
|
+
const el = [...root.querySelectorAll("[data-testid]")]
|
|
131
|
+
.filter(e => e.dataset.testid === (testids[id] ?? id))[nth];
|
|
132
|
+
if (!el) return {kind: "missing"};
|
|
133
|
+
const text = e => e.innerText.trim();
|
|
134
|
+
if (el.getAttribute("role") === "radiogroup") {
|
|
135
|
+
const on = el.querySelector("[role=radio][aria-checked=true]");
|
|
136
|
+
return {kind: "radio", value: on ? (on.getAttribute("aria-label") || text(on)) : null};
|
|
137
|
+
}
|
|
138
|
+
if (el.getAttribute("role") === "group")
|
|
139
|
+
return {kind: "checkbox", value: [...el.querySelectorAll("[role=checkbox]")]
|
|
140
|
+
.filter(e => e.getAttribute("aria-checked") === "true").map(text)};
|
|
141
|
+
if (el.querySelector("input[type=file]"))
|
|
142
|
+
return {kind: "file", value: [...el.querySelectorAll("[data-testid=chip] [id$=-content]")]
|
|
143
|
+
.map(text)[0] ?? null};
|
|
144
|
+
if (el.querySelector("[data-input=month]")) {
|
|
145
|
+
const part = p => el.querySelector(`[data-input=${p}]`).value;
|
|
146
|
+
const [y, m, d] = [part("year"), part("month"), part("day")];
|
|
147
|
+
return {kind: "date",
|
|
148
|
+
value: y && m && d ? `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}` : null};
|
|
149
|
+
}
|
|
150
|
+
const box = el.querySelector("[role=combobox]");
|
|
151
|
+
if (box && box.tagName !== "INPUT")
|
|
152
|
+
return {kind: "dropdown", value: box.querySelector("p") ? null : text(box)};
|
|
153
|
+
if (box) return {kind: "search", value: box.value || null};
|
|
154
|
+
const input = el.matches("input, textarea") ? el : el.querySelector("input, textarea");
|
|
155
|
+
if (input) return {kind: "text", value: input.value};
|
|
156
|
+
return {kind: "unknown"};
|
|
157
|
+
}"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Rippling:
|
|
161
|
+
name = "rippling"
|
|
162
|
+
known_gaps: dict[str, Gap] = {}
|
|
163
|
+
side_effects = [
|
|
164
|
+
SideEffect("upload", "hiring_system", "an attached file is uploaded, a résumé parsed"),
|
|
165
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def __init__(self) -> None:
|
|
169
|
+
self._fields: dict[str, Field] = {}
|
|
170
|
+
|
|
171
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
172
|
+
return _job(url) is not None
|
|
173
|
+
|
|
174
|
+
def posting(self, url: str) -> str:
|
|
175
|
+
job = _job(url)
|
|
176
|
+
return POSTING.format(board=job[0], job=job[1]) if job else url
|
|
177
|
+
|
|
178
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
179
|
+
if (job := _job(url)) is None:
|
|
180
|
+
return None
|
|
181
|
+
request = urllib.request.Request(
|
|
182
|
+
APPLY.format(board=job[0], job=job[1]), headers={"User-Agent": "Mozilla/5.0"}
|
|
183
|
+
)
|
|
184
|
+
try:
|
|
185
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
186
|
+
html = response.read().decode()
|
|
187
|
+
match = DATA.search(html)
|
|
188
|
+
return _form(_api(json.loads(match[1]))) if match else None
|
|
189
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
193
|
+
job = _job(page.url)
|
|
194
|
+
assert job is not None
|
|
195
|
+
apply = APPLY.format(board=job[0], job=job[1])
|
|
196
|
+
if urlsplit(page.url).path != urlsplit(apply).path:
|
|
197
|
+
page.goto(apply)
|
|
198
|
+
try:
|
|
199
|
+
page.locator("[data-testid=Apply]").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
200
|
+
except Timeout:
|
|
201
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
202
|
+
_hide_cookies(page)
|
|
203
|
+
data = page.locator("#__NEXT_DATA__").text_content() or "{}"
|
|
204
|
+
out = []
|
|
205
|
+
for field in _form(_api(json.loads(data))):
|
|
206
|
+
if not field.unfillable and not field.id.endswith(("_other", "_comment")):
|
|
207
|
+
kind = page.locator("body").evaluate(_SHOWN, [field.id, 0, TESTIDS])["kind"]
|
|
208
|
+
if kind in ("missing", "unknown"):
|
|
209
|
+
reason = f"unhandled_rendering: {field.id} is {kind} on the page"
|
|
210
|
+
field = replace(field, unfillable=reason)
|
|
211
|
+
out.append(field)
|
|
212
|
+
self._fields = {f.id: f for f in out if not f.unfillable}
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
216
|
+
if "resume" in answers: # its parse fills the fields it finds: first, so they are ours
|
|
217
|
+
with page.expect_response(
|
|
218
|
+
lambda r: PARSED.search(urlsplit(r.url).path) is not None,
|
|
219
|
+
timeout=PARSE_TIMEOUT_MS,
|
|
220
|
+
):
|
|
221
|
+
_attach(page, "resume", answers["resume"])
|
|
222
|
+
with suppress(Timeout): # its notice covers the fields below it while it shows
|
|
223
|
+
page.get_by_text(PARSED_NOTICE).wait_for(state="hidden", timeout=NOTICE_TIMEOUT_MS)
|
|
224
|
+
for field_id, field in self._fields.items():
|
|
225
|
+
if field_id == "resume" or field_id not in answers:
|
|
226
|
+
continue
|
|
227
|
+
answer = answers[field_id]
|
|
228
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
229
|
+
if field_id.endswith("_comment"):
|
|
230
|
+
_comment(page, field_id.removesuffix("_comment"), value)
|
|
231
|
+
elif field_id.endswith("_other"):
|
|
232
|
+
page.locator(f"[data-testid='{field_id.removesuffix('_other')}']").nth(1).locator(
|
|
233
|
+
"input"
|
|
234
|
+
).fill(value)
|
|
235
|
+
elif field_id == "phone_number":
|
|
236
|
+
_phone(page, value)
|
|
237
|
+
elif field_id == "location":
|
|
238
|
+
_place(page, value)
|
|
239
|
+
elif field.type is FieldType.FILE:
|
|
240
|
+
_attach(page, field_id, value)
|
|
241
|
+
else:
|
|
242
|
+
_fill(page, field_id, answer)
|
|
243
|
+
|
|
244
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
245
|
+
body, out = page.locator("body"), {}
|
|
246
|
+
for field_id in self._fields:
|
|
247
|
+
if field_id.endswith("_other"):
|
|
248
|
+
shown = body.evaluate(_SHOWN, [field_id.removesuffix("_other"), 1, TESTIDS])
|
|
249
|
+
elif field_id == "phone_number":
|
|
250
|
+
code = page.locator("[data-testid='phone_number-code'] input").input_value()
|
|
251
|
+
number = page.locator("[data-testid='input-phone_number']").input_value()
|
|
252
|
+
shown = {"value": f"{code.split()[0]} {number}" if number else None}
|
|
253
|
+
elif field_id == "location":
|
|
254
|
+
shown = {"value": page.locator("[data-testid=location] input").input_value()}
|
|
255
|
+
else:
|
|
256
|
+
shown = body.evaluate(_SHOWN, [field_id, 0, TESTIDS])
|
|
257
|
+
if (value := shown.get("value")) not in (None, "", []):
|
|
258
|
+
out[field_id] = value
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
def submit(self, page: Page) -> str | None:
|
|
262
|
+
"""Clicks Apply; the evidence is the answer to the application's POST."""
|
|
263
|
+
with page.expect_response(
|
|
264
|
+
lambda r: r.request.method == "POST" and APPLIED.match(r.url) is not None,
|
|
265
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
266
|
+
) as applied:
|
|
267
|
+
_click(page.locator("[data-testid=Apply]"))
|
|
268
|
+
response = applied.value
|
|
269
|
+
if not response.ok:
|
|
270
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
271
|
+
return f"{response.url} {response.status}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
275
|
+
parts = urlsplit(url)
|
|
276
|
+
if parts.hostname != HOST or not (match := JOB.match(parts.path)):
|
|
277
|
+
return None
|
|
278
|
+
return match[1], match[2]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _api(data: dict[str, Any]) -> dict[str, Any]:
|
|
282
|
+
api = data["props"]["pageProps"].get("apiData") or {}
|
|
283
|
+
if not (api.get("jobPost") or {}).get("activeJobApplication"):
|
|
284
|
+
raise Unsupported("posting_gone: the page has no application form")
|
|
285
|
+
return api
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _form(api: dict[str, Any]) -> list[Field]:
|
|
289
|
+
"""Every field of the apply page's data, in the page's order."""
|
|
290
|
+
post = api["jobPost"]
|
|
291
|
+
application = post["activeJobApplication"]
|
|
292
|
+
out = [_basic(q) for q in application["basicQuestions"]]
|
|
293
|
+
for group in application.get("additionalQuestions") or []:
|
|
294
|
+
for q in group["form"]["questions"]:
|
|
295
|
+
out += _custom(f"customQuestions.{group['id']}.{q['uniqueKey']}", q)
|
|
296
|
+
if post.get("eeocQuestionnaireEnabledForJobPost"):
|
|
297
|
+
out += [Field(i, label, FieldType.CHOICE, False, options) for i, label, options in EEOC]
|
|
298
|
+
if api.get("showCc305Form"):
|
|
299
|
+
reason = "unhandled_rendering: cc305 is the voluntary disability form"
|
|
300
|
+
out.append(Field("cc305", "Voluntary self-identification of disability", FieldType.CHOICE,
|
|
301
|
+
False, unfillable=reason)) # fmt: skip
|
|
302
|
+
for n, question in enumerate(api.get("videoScreeningQuestions") or []):
|
|
303
|
+
field_id = f"video.{question.get('uniqueKey') or question.get('id') or n}"
|
|
304
|
+
label = question.get("title") or question.get("question") or "Video question"
|
|
305
|
+
out.append(Field(field_id, label, FieldType.FILE, True,
|
|
306
|
+
unfillable=out_of_scope(field_id, "a video recorded live"))) # fmt: skip
|
|
307
|
+
out.append(Field(SMS[0], SMS[1], FieldType.CHOICE, False, SMS[2]))
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _basic(q: dict[str, Any]) -> Field:
|
|
312
|
+
field_id, label, required = q["oid"], q["title"], bool(q.get("required"))
|
|
313
|
+
if (kind := BASIC.get(q["fieldType"])) is None:
|
|
314
|
+
reason = f"unhandled_rendering: {field_id} is a {q['fieldType']}"
|
|
315
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
316
|
+
if field_id == "location":
|
|
317
|
+
kind = FieldType.TYPEAHEAD
|
|
318
|
+
options = PRONOUNS if kind is FieldType.CHOICE else None
|
|
319
|
+
accept = FILE_TYPES if kind is FieldType.FILE else None
|
|
320
|
+
return Field(field_id, label, kind, required, options, accept=accept)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _custom(field_id: str, q: dict[str, Any]) -> list[Field]:
|
|
324
|
+
label, required = q["title"].strip(), bool(q.get("isRequired"))
|
|
325
|
+
if (kind := CUSTOM.get(q["questionType"])) is None:
|
|
326
|
+
reason = f"unhandled_rendering: {field_id} is a {q['questionType']}"
|
|
327
|
+
return [Field(field_id, label, FieldType.TEXT, required, unfillable=reason)]
|
|
328
|
+
options = None
|
|
329
|
+
if kind in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
|
|
330
|
+
options = SCALE if q["questionType"] == "YES_NO_SCALE_4" else list(q["strChoices"])
|
|
331
|
+
if q.get("isOtherEnabled"):
|
|
332
|
+
options.append(OTHER)
|
|
333
|
+
max_length = q.get("maxChars") if kind is FieldType.TEXT else None
|
|
334
|
+
out = [Field(field_id, label, kind, required, options, max_length)]
|
|
335
|
+
if q.get("isOtherEnabled"):
|
|
336
|
+
asked = f"{label}: {OTHER} (asked when {label!r} is {OTHER})"
|
|
337
|
+
out.append(Field(f"{field_id}_other", asked, FieldType.TEXT, False))
|
|
338
|
+
if q.get("allowComments"):
|
|
339
|
+
out.append(Field(f"{field_id}_comment", f"{label}: comment", FieldType.TEXT, False))
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _hide_cookies(page: Page) -> None:
|
|
344
|
+
"""Hides the cookie banner, whose layer takes the clicks on the lower part of the page: it
|
|
345
|
+
renders in a closed shadow root, out of reach of a click, and shows a moment after the
|
|
346
|
+
form. Nothing is answered to it."""
|
|
347
|
+
page.add_style_tag(content=f"{COOKIES} {{display: none !important}}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _click(target: Locator) -> None:
|
|
351
|
+
"""Clicks the target scrolled to the middle of the window, clear of the Apply bar fixed
|
|
352
|
+
at its bottom."""
|
|
353
|
+
target.evaluate("e => e.scrollIntoView({block: 'center'})")
|
|
354
|
+
browser.click(target)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _box(page: Page, field_id: str) -> Locator:
|
|
358
|
+
return page.locator(f"[data-testid='{TESTIDS.get(field_id, field_id)}']").first
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _option(page: Page, field_id: str, value: str) -> None:
|
|
362
|
+
"""Picks the open list's option whose text is `value`."""
|
|
363
|
+
option = page.locator("[role=listbox] [role=option]").filter(has_text=_exact(value))
|
|
364
|
+
try:
|
|
365
|
+
option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
366
|
+
except Timeout:
|
|
367
|
+
page.keyboard.press("Escape")
|
|
368
|
+
raise NotTaken(f"{field_id}: no option {value!r}") from None
|
|
369
|
+
_click(option.first)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _fill(page: Page, field_id: str, answer: Answer) -> None:
|
|
373
|
+
box = _box(page, field_id)
|
|
374
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
375
|
+
match page.locator("body").evaluate(_SHOWN, [field_id, 0, TESTIDS])["kind"]:
|
|
376
|
+
case "radio":
|
|
377
|
+
radio = box.locator("[role=radio]").filter(has_text=_exact(values[0]))
|
|
378
|
+
if not radio.count():
|
|
379
|
+
radio = box.locator(f"[role=radio][aria-label='{values[0]}']")
|
|
380
|
+
_click(radio.first)
|
|
381
|
+
case "checkbox":
|
|
382
|
+
for item in box.locator("[role=checkbox]").all():
|
|
383
|
+
if (item.get_attribute("aria-checked") == "true") != (
|
|
384
|
+
item.inner_text().strip() in values
|
|
385
|
+
):
|
|
386
|
+
_click(item)
|
|
387
|
+
case "date":
|
|
388
|
+
year, month, day = values[0].split("-")
|
|
389
|
+
for part, text in (("month", month), ("day", day), ("year", year)):
|
|
390
|
+
box.locator(f"[data-input={part}]").fill(text)
|
|
391
|
+
case "dropdown" | "search":
|
|
392
|
+
_click(box.locator("[role=combobox]"))
|
|
393
|
+
_option(page, field_id, values[0])
|
|
394
|
+
case _:
|
|
395
|
+
own = box.evaluate("e => e.matches('input, textarea')")
|
|
396
|
+
(box if own else box.locator("input, textarea").first).fill(values[0])
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _attach(page: Page, field_id: str, path: Answer) -> None:
|
|
400
|
+
box = _box(page, field_id)
|
|
401
|
+
box.locator("input[type=file]").set_input_files(path if isinstance(path, str) else path[0])
|
|
402
|
+
box.locator("[data-testid=chip]").first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _comment(page: Page, field_id: str, value: str) -> None:
|
|
406
|
+
comment = page.locator(f"[data-testid='{field_id}_comment'] textarea")
|
|
407
|
+
if not comment.count():
|
|
408
|
+
block = _box(page, field_id).locator("xpath=ancestor::*[@data-testid='field'][1]/..")
|
|
409
|
+
_click(block.locator("button[data-testid='Add comment']").first)
|
|
410
|
+
comment.fill(value)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _phone(page: Page, value: str) -> None:
|
|
414
|
+
"""Picks the dial code the number starts with, unless the shown one fits, then types the
|
|
415
|
+
rest."""
|
|
416
|
+
digits = re.sub(r"\D", "", value)
|
|
417
|
+
code = page.locator("[data-testid='phone_number-code'] input")
|
|
418
|
+
shown = re.sub(r"\D", "", code.input_value().split()[0] if code.input_value() else "")
|
|
419
|
+
if value.startswith("+") and not (shown and digits.startswith(shown)):
|
|
420
|
+
for length in DIAL_CODES:
|
|
421
|
+
_click(code)
|
|
422
|
+
code.fill(f"+{digits[:length]}")
|
|
423
|
+
option = page.locator("[role=listbox] [role=option]").filter(
|
|
424
|
+
has_text=re.compile(rf"^\s*\+{digits[:length]} ")
|
|
425
|
+
)
|
|
426
|
+
try:
|
|
427
|
+
option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS // len(DIAL_CODES))
|
|
428
|
+
except Timeout:
|
|
429
|
+
continue
|
|
430
|
+
_click(option.first)
|
|
431
|
+
shown = digits[:length]
|
|
432
|
+
break
|
|
433
|
+
else:
|
|
434
|
+
page.keyboard.press("Escape")
|
|
435
|
+
raise NotTaken(f"phone_number: no dial code for {value!r}")
|
|
436
|
+
national = digits[len(shown) :] if value.startswith("+") else digits
|
|
437
|
+
page.locator("[data-testid='input-phone_number']").fill(national)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _place(page: Page, value: str) -> None:
|
|
441
|
+
"""Types the place and picks the first suggestion once the list has settled: each key
|
|
442
|
+
typed renders its own suggestions a moment later, replacing the ones shown."""
|
|
443
|
+
box = page.locator("[data-testid=location] input")
|
|
444
|
+
box.fill("")
|
|
445
|
+
box.press_sequentially(value)
|
|
446
|
+
option = page.locator("[role=listbox] [role=option]")
|
|
447
|
+
try:
|
|
448
|
+
option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
449
|
+
except Timeout:
|
|
450
|
+
raise NotTaken(f"location: no place suggested for {value!r}") from None
|
|
451
|
+
if not page.evaluate(_QUIET, [PLACES_QUIET_MS, OPTIONS_TIMEOUT_MS]):
|
|
452
|
+
raise NotTaken(f"location: the places suggested for {value!r} kept changing")
|
|
453
|
+
_click(option.first)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
457
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
@@ -307,7 +307,9 @@ kind = "ats"
|
|
|
307
307
|
share = 0.0050
|
|
308
308
|
account = "none"
|
|
309
309
|
hosting = ["hosted"]
|
|
310
|
+
adapter = "rippling"
|
|
310
311
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
312
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "e8b7a0a" }
|
|
311
313
|
|
|
312
314
|
[isolvedhire]
|
|
313
315
|
kind = "ats"
|
|
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
|