atsfill 0.0.4.dev76__tar.gz → 0.0.4.dev79__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.dev79}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/README.md +1 -1
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev79/src/atsfill/adapters/eightfold.py +582 -0
- atsfill-0.0.4.dev79/src/atsfill/adapters/rippling.py +457 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/registry.toml +4 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/LICENSE +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/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.dev79
|
|
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 |
|
|
@@ -2,6 +2,7 @@ from atsfill.adapters.ashby import Ashby
|
|
|
2
2
|
from atsfill.adapters.bamboohr import BambooHR
|
|
3
3
|
from atsfill.adapters.breezy import Breezy
|
|
4
4
|
from atsfill.adapters.dayforce import Dayforce
|
|
5
|
+
from atsfill.adapters.eightfold import Eightfold
|
|
5
6
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
6
7
|
from atsfill.adapters.hireology import Hireology
|
|
7
8
|
from atsfill.adapters.jazzhr import JazzHR
|
|
@@ -14,6 +15,7 @@ from atsfill.adapters.personio import Personio
|
|
|
14
15
|
from atsfill.adapters.phenom import Phenom
|
|
15
16
|
from atsfill.adapters.pinpoint import Pinpoint
|
|
16
17
|
from atsfill.adapters.recruitee import Recruitee
|
|
18
|
+
from atsfill.adapters.rippling import Rippling
|
|
17
19
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
18
20
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
19
21
|
from atsfill.adapters.workable import Workable
|
|
@@ -44,6 +46,8 @@ def adapters() -> list[Adapter]:
|
|
|
44
46
|
BambooHR(),
|
|
45
47
|
ZohoRecruit(),
|
|
46
48
|
Personio(),
|
|
49
|
+
Rippling(),
|
|
47
50
|
Pinpoint(),
|
|
51
|
+
Eightfold(),
|
|
48
52
|
Phenom(), # last: it claims a URL by its path alone
|
|
49
53
|
]
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Eightfold (`<employer>.eightfold.ai/careers/job/<pid>` career sites, and the same pages on
|
|
2
|
+
the employer's own domain), as docs/adapters/eightfold.md observes.
|
|
3
|
+
|
|
4
|
+
A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
|
|
5
|
+
sections of questions by id, type, requirement and options, and the questions another one's
|
|
6
|
+
answer shows. `read_api` fetches it over HTTP, `read_form` through the page's browser context,
|
|
7
|
+
and both list the fields from it. Each question renders as a control whose element id is its
|
|
8
|
+
section's title and its id, with every other character an underscore.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import http.cookiejar
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import urllib.request
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
20
|
+
|
|
21
|
+
from atsfill.core import browser
|
|
22
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
23
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
24
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
25
|
+
|
|
26
|
+
HOST = re.compile(r"^[\w-]+\.eightfold\.ai$")
|
|
27
|
+
JOB = re.compile(r"^/careers/job/(\d+)")
|
|
28
|
+
APPLY_PATH = "/careers/apply"
|
|
29
|
+
KEPT = ("domain", "microsite") # the query parameters that pick the employer's site
|
|
30
|
+
POSITION = "/api/pcsx/position_details?position_id={pid}&hl=en"
|
|
31
|
+
QUESTIONS = "/api/application/v2/questions?pids={pid}&user_mode=logged_out_candidate"
|
|
32
|
+
CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
|
|
33
|
+
SUBMIT = "[data-test-id=submitApplicationButton]"
|
|
34
|
+
AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
|
|
35
|
+
COOKIES = [
|
|
36
|
+
"#onetrust-reject-all-handler",
|
|
37
|
+
"#truste-consent-required",
|
|
38
|
+
"#didomi-notice-disagree-button",
|
|
39
|
+
]
|
|
40
|
+
# An employer's own banner, declined by its button's name.
|
|
41
|
+
BANNERS = ", ".join(
|
|
42
|
+
f"[{attribute}*={word} i]"
|
|
43
|
+
for word in ("cookie", "consent", "truste")
|
|
44
|
+
for attribute in ("id", "class")
|
|
45
|
+
)
|
|
46
|
+
DECLINE = re.compile(
|
|
47
|
+
r"^\s*((reject|decline|refuse|deny)( all)?( cookies)?|(required|necessary|essential) only)\s*$",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
PICKER = "[data-testid=picker-dialog]"
|
|
51
|
+
MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
|
|
52
|
+
FORM_TIMEOUT_MS = 30_000
|
|
53
|
+
COOKIE_TIMEOUT_MS = 5_000
|
|
54
|
+
SHOWN_TIMEOUT_MS = 2_000
|
|
55
|
+
OPTIONS_TIMEOUT_MS = 5_000
|
|
56
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
57
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
58
|
+
PARSE_SETTLE_MS = 2_000
|
|
59
|
+
RESUME_UPLOAD = re.compile(r"/api/application/v2/resume_upload")
|
|
60
|
+
PICKER_MONTHS = 1200 # the furthest a date is paged to, in months
|
|
61
|
+
DATE_FORMATS = ["%d %b %Y", "%m/%d/%Y", "%d/%m/%Y", "%Y-%m-%d", "%b %d, %Y"]
|
|
62
|
+
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"
|
|
63
|
+
DIAL = re.compile(r"\(\+([\d ]+)\)")
|
|
64
|
+
FLAG = 0x1F1E6 # regional indicator A: a flag emoji is the country's code in these letters
|
|
65
|
+
PRIMARY = {"1": "US", "7": "RU", "44": "GB", "47": "NO", "61": "AU", "358": "FI"}
|
|
66
|
+
|
|
67
|
+
# A question's kind, by its type; a dropdown whose options load from another answer is typed.
|
|
68
|
+
KINDS = {
|
|
69
|
+
"text": "text",
|
|
70
|
+
"select": "select",
|
|
71
|
+
"radio": "radio",
|
|
72
|
+
"checkbox": "checkbox",
|
|
73
|
+
"date": "date",
|
|
74
|
+
"phone": "phone",
|
|
75
|
+
"resumeUpload": "file",
|
|
76
|
+
"fileUpload": "file",
|
|
77
|
+
"positionLocation": "pills",
|
|
78
|
+
}
|
|
79
|
+
TYPES = {
|
|
80
|
+
"text": FieldType.TEXT,
|
|
81
|
+
"select": FieldType.CHOICE,
|
|
82
|
+
"lookup": FieldType.TYPEAHEAD,
|
|
83
|
+
"radio": FieldType.CHOICE,
|
|
84
|
+
"checkbox": FieldType.MULTI_CHOICE,
|
|
85
|
+
"date": FieldType.DATE,
|
|
86
|
+
"phone": FieldType.PHONE,
|
|
87
|
+
"file": FieldType.FILE,
|
|
88
|
+
"pills": FieldType.MULTI_CHOICE,
|
|
89
|
+
}
|
|
90
|
+
NOT_FIELDS = {"instruction"}
|
|
91
|
+
|
|
92
|
+
_FETCH = """async ([url, body]) => {
|
|
93
|
+
const csrf = document.querySelector('meta[name=_csrf]')?.content;
|
|
94
|
+
const init = body === null ? {} : {method: 'POST', body, headers: {
|
|
95
|
+
'Content-Type': 'application/json', 'X-CSRF-Token': csrf}};
|
|
96
|
+
const r = await fetch(url, init);
|
|
97
|
+
return r.ok ? {text: await r.text()} : {status: r.status};
|
|
98
|
+
}"""
|
|
99
|
+
|
|
100
|
+
# The element id of each question's control, by its id: the shortest id on the page that ends
|
|
101
|
+
# with it, among the ids each field's error line carries.
|
|
102
|
+
_DOM_IDS = """keys => {
|
|
103
|
+
const ids = [...document.querySelectorAll('[id$="_error"]')].map(e => e.id.slice(0, -6));
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [qid, ends] of keys) {
|
|
106
|
+
const hits = ids.filter(id => ends.some(end => id === end || id.endsWith('_' + end)));
|
|
107
|
+
if (hits.length) out[qid] = hits.sort((a, b) => a.length - b.length)[0];
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}"""
|
|
111
|
+
|
|
112
|
+
_READ_BACK = """controls => {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const [qid, dom, kind] of controls) {
|
|
115
|
+
const err = document.getElementById(dom + '_error');
|
|
116
|
+
const box = err?.parentElement;
|
|
117
|
+
if (!box) continue;
|
|
118
|
+
const text = el => (el?.textContent || '').trim();
|
|
119
|
+
if (kind === 'text') {
|
|
120
|
+
out[qid] = box.querySelector('input:not([type=file]), textarea')?.value ?? null;
|
|
121
|
+
} else if (kind === 'select' || kind === 'lookup' || kind === 'date') {
|
|
122
|
+
out[qid] = box.querySelector('input[role=combobox]')?.value ?? null;
|
|
123
|
+
} else if (kind === 'radio') {
|
|
124
|
+
const on = box.querySelector('input[type=radio]:checked');
|
|
125
|
+
out[qid] = on ? text(box.querySelector(`label[for="${CSS.escape(on.id)}"]`)) : null;
|
|
126
|
+
} else if (kind === 'checkbox') {
|
|
127
|
+
out[qid] = [...box.querySelectorAll('input[type=checkbox]:checked')]
|
|
128
|
+
.map(on => text(box.querySelector(`label[for="${CSS.escape(on.id)}"]`)));
|
|
129
|
+
} else if (kind === 'pills') {
|
|
130
|
+
out[qid] = [...box.querySelectorAll('[id^=selectPilllist] > span')].map(text);
|
|
131
|
+
} else if (kind === 'phone') {
|
|
132
|
+
const code = box.querySelector('input[role=combobox]')?.value || '';
|
|
133
|
+
const dial = (code.match(/\\(\\+([\\d ]+)\\)/) || [])[1];
|
|
134
|
+
const number = box.querySelector('input:not([role=combobox])')?.value || '';
|
|
135
|
+
out[qid] = number ? (dial ? `+${dial.replace(/ /g, '')} ${number}` : number) : null;
|
|
136
|
+
} else if (kind === 'file') {
|
|
137
|
+
const names = [...box.querySelectorAll('li span[title]')].map(s => s.title);
|
|
138
|
+
out[qid] = names[0] ?? null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}"""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class _Control:
|
|
147
|
+
field: Field
|
|
148
|
+
kind: str
|
|
149
|
+
ends: list[str] # what the control's element id ends with
|
|
150
|
+
# the questions whose answer renders it, each with the answers that do (any when empty)
|
|
151
|
+
parents: list[tuple[str, list[str]]] = field(default_factory=list)
|
|
152
|
+
resume: bool = False
|
|
153
|
+
dom: str | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Eightfold:
|
|
157
|
+
name = "eightfold"
|
|
158
|
+
known_gaps: dict[str, Gap] = {}
|
|
159
|
+
side_effects = [
|
|
160
|
+
SideEffect(
|
|
161
|
+
"upload",
|
|
162
|
+
"hiring_system",
|
|
163
|
+
"the resume is uploaded to Eightfold and parsed at once, after agreeing to the "
|
|
164
|
+
"dialog that says job recommendations follow by email",
|
|
165
|
+
),
|
|
166
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
self._controls: list[_Control] = [] # in the form's order
|
|
171
|
+
|
|
172
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
173
|
+
if _pid(url) is None:
|
|
174
|
+
return False
|
|
175
|
+
if page is None or HOST.match(urlsplit(url).hostname or ""):
|
|
176
|
+
return True
|
|
177
|
+
return page.locator("meta[name=_csrf]").count() > 0 and "_EF_GROUP_ID" in page.content()
|
|
178
|
+
|
|
179
|
+
def posting(self, url: str) -> str:
|
|
180
|
+
parts, pid = urlsplit(url), _pid(url)
|
|
181
|
+
return f"https://{parts.hostname}/careers/job/{pid}" if pid else url
|
|
182
|
+
|
|
183
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
184
|
+
if (pid := _pid(url)) is None:
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
jar = urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
|
|
188
|
+
opener = urllib.request.build_opener(jar)
|
|
189
|
+
opener.addheaders = [("User-Agent", UA)]
|
|
190
|
+
base = _base(url)
|
|
191
|
+
position = json.loads(_get(opener, base + _query(POSITION.format(pid=pid), url)))
|
|
192
|
+
_applies(position)
|
|
193
|
+
html = _get(opener, _apply_url(url, pid))
|
|
194
|
+
if (csrf := CSRF.search(html)) is None:
|
|
195
|
+
return None
|
|
196
|
+
request = urllib.request.Request(
|
|
197
|
+
base + _query(QUESTIONS.format(pid=pid), url),
|
|
198
|
+
data=b'{"answers":{}}',
|
|
199
|
+
headers={"Content-Type": "application/json", "X-CSRF-Token": csrf[1]},
|
|
200
|
+
)
|
|
201
|
+
api = json.loads(_get(opener, request))
|
|
202
|
+
return [c.field for c in _form(api["data"])]
|
|
203
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported, Blocked:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
207
|
+
pid = _pid(page.url)
|
|
208
|
+
if pid is None:
|
|
209
|
+
raise Unsupported(f"posting_gone: {page.url} is not a job page")
|
|
210
|
+
position = page.evaluate(_FETCH, [_query(POSITION.format(pid=pid), page.url), None])
|
|
211
|
+
if "status" in position:
|
|
212
|
+
raise Unsupported(f"posting_gone: the position API answered {position['status']}")
|
|
213
|
+
_applies(json.loads(position["text"]))
|
|
214
|
+
if urlsplit(page.url).path != APPLY_PATH:
|
|
215
|
+
page.goto(_apply_url(page.url, pid))
|
|
216
|
+
try:
|
|
217
|
+
page.locator(SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
|
|
218
|
+
except Timeout:
|
|
219
|
+
raise Unsupported("posting_gone: no application form on the apply page") from None
|
|
220
|
+
api = page.evaluate(_FETCH, [_query(QUESTIONS.format(pid=pid), page.url), '{"answers":{}}'])
|
|
221
|
+
if "status" in api:
|
|
222
|
+
raise Unsupported(f"posting_gone: the questions API answered {api['status']}")
|
|
223
|
+
self._controls = _form(json.loads(api["text"])["data"])
|
|
224
|
+
_decline_cookies(page)
|
|
225
|
+
doms = page.evaluate(_DOM_IDS, [[c.field.id, c.ends] for c in self._controls])
|
|
226
|
+
for control in self._controls:
|
|
227
|
+
control.dom = doms.get(control.field.id)
|
|
228
|
+
if control.field.unfillable:
|
|
229
|
+
continue
|
|
230
|
+
if control.dom is None and not control.parents:
|
|
231
|
+
reason = f"unhandled_rendering: {control.field.id} is not on the page"
|
|
232
|
+
control.field = replace(control.field, unfillable=reason)
|
|
233
|
+
elif control.dom is not None and _prefilled(page, control):
|
|
234
|
+
control.field = replace(control.field, prefilled=True)
|
|
235
|
+
fields = [c.field for c in self._controls]
|
|
236
|
+
self._controls = [c for c in self._controls if not c.field.unfillable]
|
|
237
|
+
return fields
|
|
238
|
+
|
|
239
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
240
|
+
# the resume first: what its parse finds is written over the fields filled before it
|
|
241
|
+
order = sorted(self._controls, key=lambda c: not c.resume)
|
|
242
|
+
_decline_cookies(page, 0) # a banner shown since the read
|
|
243
|
+
given: dict[str, Answer] = {}
|
|
244
|
+
for control in order:
|
|
245
|
+
if (answer := answers.get(control.field.id)) is None:
|
|
246
|
+
continue
|
|
247
|
+
if control.dom is None: # rendered once a question that shows it is answered
|
|
248
|
+
if not any(_shows(given.get(p), values) for p, values in control.parents):
|
|
249
|
+
continue
|
|
250
|
+
with suppress(Timeout):
|
|
251
|
+
_box_of(page, control).wait_for(timeout=SHOWN_TIMEOUT_MS)
|
|
252
|
+
control.dom = _find(page, control)
|
|
253
|
+
if control.dom is None:
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
_fill(page, control, answer)
|
|
257
|
+
except browser.Covered: # a banner that showed late
|
|
258
|
+
page.keyboard.press("Escape")
|
|
259
|
+
_decline_cookies(page)
|
|
260
|
+
_fill(page, control, answer)
|
|
261
|
+
given[control.field.id] = answer
|
|
262
|
+
|
|
263
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
264
|
+
unplaced = [[c.field.id, c.ends] for c in self._controls if c.dom is None]
|
|
265
|
+
doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
|
|
266
|
+
for control in self._controls:
|
|
267
|
+
control.dom = control.dom or doms.get(control.field.id)
|
|
268
|
+
shown = [c for c in self._controls if c.dom]
|
|
269
|
+
values = page.evaluate(_READ_BACK, [[c.field.id, c.dom, c.kind] for c in shown])
|
|
270
|
+
out: dict[str, Answer] = {}
|
|
271
|
+
for control in shown:
|
|
272
|
+
value = values.get(control.field.id)
|
|
273
|
+
if control.kind == "date" and value:
|
|
274
|
+
value = _iso(value)
|
|
275
|
+
if value not in (None, "", []):
|
|
276
|
+
out[control.field.id] = value
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
def submit(self, page: Page) -> str | None:
|
|
280
|
+
"""Clicks submit; the evidence is the application API's answer."""
|
|
281
|
+
with page.expect_response(
|
|
282
|
+
lambda r: (
|
|
283
|
+
r.request.method == "POST" and "/api/application/" in r.url and "apply" in r.url
|
|
284
|
+
),
|
|
285
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
286
|
+
) as applied:
|
|
287
|
+
_click(page.locator(SUBMIT))
|
|
288
|
+
response = applied.value
|
|
289
|
+
if not response.ok:
|
|
290
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
291
|
+
return f"{response.url.split('?')[0]} {response.status}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _pid(url: str) -> str | None:
|
|
295
|
+
parts = urlsplit(url)
|
|
296
|
+
if job := JOB.match(parts.path):
|
|
297
|
+
return job[1]
|
|
298
|
+
if parts.path == APPLY_PATH and (pid := parse_qs(parts.query).get("pid")):
|
|
299
|
+
return pid[0] if pid[0].isdigit() else None
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _base(url: str) -> str:
|
|
304
|
+
return f"https://{urlsplit(url).hostname}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _query(path: str, url: str) -> str:
|
|
308
|
+
"""The path with the parameters of `url` that pick the employer's site."""
|
|
309
|
+
kept = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items() if k in KEPT}
|
|
310
|
+
return f"{path}&{urlencode(kept)}" if kept else path
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _apply_url(url: str, pid: str) -> str:
|
|
314
|
+
return _base(url) + _query(f"{APPLY_PATH}?pid={pid}", url)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _get(opener, request) -> str:
|
|
318
|
+
with opener.open(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
319
|
+
return response.read().decode()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _applies(position: dict[str, Any]) -> None:
|
|
323
|
+
"""Raises when the posting is not applied to on this page."""
|
|
324
|
+
data = position.get("data") or {}
|
|
325
|
+
action = ((data.get("positionUserActions") or {}).get("applyAction")) or {}
|
|
326
|
+
match action.get("status"):
|
|
327
|
+
case "link_off":
|
|
328
|
+
host = urlsplit(action.get("applyUrl") or "").hostname
|
|
329
|
+
raise Unsupported(f"external_step: the application is on {host}")
|
|
330
|
+
case "log_in":
|
|
331
|
+
raise Blocked("login_required: the employer's career site asks to sign in to apply")
|
|
332
|
+
case "allowed" | None:
|
|
333
|
+
return
|
|
334
|
+
case other:
|
|
335
|
+
raise Unsupported(f"posting_gone: the apply action is {other}")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _norm(qid: str) -> str:
|
|
339
|
+
return re.sub(r"[^A-Za-z0-9_]", "_", qid)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _plain(label: str) -> str:
|
|
343
|
+
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", label)).strip()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _label(q: dict[str, Any], by_id: dict[str, dict[str, Any]], depth: int = 0) -> str:
|
|
347
|
+
"""The question's text; one some of a parent's answers render says so, naming the first
|
|
348
|
+
parent by its own label, so that a parent asked for other answers names it apart."""
|
|
349
|
+
label = _plain(q.get("label") or "")
|
|
350
|
+
if (q.get("textOptions") or {}).get("type") == "number": # a number input takes no text
|
|
351
|
+
label += " (a number)"
|
|
352
|
+
first = (q.get("parentDependencies") or [{}])[0]
|
|
353
|
+
if not first.get("values") or depth > MAX_DEPTH:
|
|
354
|
+
return label
|
|
355
|
+
parent = by_id.get(str(first["questionId"]), {})
|
|
356
|
+
labels = {str(c["value"]): c["label"] for c in _choices(parent)}
|
|
357
|
+
shown = " or ".join(labels.get(str(v), str(v)) for v in first["values"])
|
|
358
|
+
named = _label(parent, by_id, depth + 1)
|
|
359
|
+
quote = '"' if "'" in named else "'" # never escaped: the outermost is the last
|
|
360
|
+
return f"{label} (asked when {quote}{named}{quote} is {shown})"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _choices(q: dict[str, Any]) -> list[dict[str, Any]]:
|
|
364
|
+
for key in ("selectOptions", "radioOptions", "checkboxOptions"):
|
|
365
|
+
if (options := q.get(key)) and options.get("choices"):
|
|
366
|
+
return options["choices"]
|
|
367
|
+
return []
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _form(data: dict[str, Any]) -> list[_Control]:
|
|
371
|
+
questions = [q for s in data["fields"] for q in s["questions"]]
|
|
372
|
+
by_id = {str(q["questionId"]): q for q in questions}
|
|
373
|
+
controls = []
|
|
374
|
+
for q in questions:
|
|
375
|
+
if q.get("type") in NOT_FIELDS:
|
|
376
|
+
continue
|
|
377
|
+
controls.append(_control(q, by_id))
|
|
378
|
+
return controls
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
|
|
382
|
+
qid, label = str(q["questionId"]), _label(q, by_id)
|
|
383
|
+
kind = KINDS.get(q.get("type", ""))
|
|
384
|
+
choices = _choices(q)
|
|
385
|
+
if kind == "select" and not choices and (q.get("selectOptions") or {}).get("apiEndpoint"):
|
|
386
|
+
kind = "lookup" # options load once the question it depends on is answered
|
|
387
|
+
required = bool(q.get("required"))
|
|
388
|
+
parents = []
|
|
389
|
+
for dependency in q.get("parentDependencies") or []:
|
|
390
|
+
parent = by_id.get(str(dependency["questionId"]), {})
|
|
391
|
+
labels = {str(c["value"]): c["label"] for c in _choices(parent)}
|
|
392
|
+
values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
|
|
393
|
+
parents.append((str(dependency["questionId"]), values))
|
|
394
|
+
if parents and parents[0][1]: # asked for some of its parent's answers only
|
|
395
|
+
required = False
|
|
396
|
+
ends = list(dict.fromkeys([_norm(qid), qid]))
|
|
397
|
+
if kind is None:
|
|
398
|
+
reason = f"unhandled_rendering: {qid} is a question of type {q.get('type')}"
|
|
399
|
+
return _Control(Field(qid, label, FieldType.TEXT, required, unfillable=reason), "", ends)
|
|
400
|
+
options = [c["label"] for c in choices] or None
|
|
401
|
+
accept = None
|
|
402
|
+
if kind == "file":
|
|
403
|
+
files = q.get("resumeOptions") or q.get("fileUploadOptions") or {}
|
|
404
|
+
types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
|
|
405
|
+
accept = [t.strip() for t in types if t.strip()] or None
|
|
406
|
+
today = bool((q.get("dateOptions") or {}).get("today")) # today's date, read-only
|
|
407
|
+
question = Field(qid, label, TYPES[kind], required, options, accept=accept, prefilled=today)
|
|
408
|
+
return _Control(question, kind, ends, parents, q.get("type") == "resumeUpload")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _decline_cookies(page: Page, wait_ms: int = COOKIE_TIMEOUT_MS) -> None:
|
|
412
|
+
"""Declines the employer's cookie banner, which covers the form's lower half."""
|
|
413
|
+
decline = page.locator(", ".join(COOKIES)).or_(
|
|
414
|
+
page.locator(BANNERS).get_by_role("button", name=DECLINE)
|
|
415
|
+
)
|
|
416
|
+
if wait_ms: # a timeout of 0 waits for ever
|
|
417
|
+
with suppress(Timeout):
|
|
418
|
+
decline.first.wait_for(timeout=wait_ms)
|
|
419
|
+
for button in decline.all():
|
|
420
|
+
if button.is_visible():
|
|
421
|
+
button.click()
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _find(page: Page, control: _Control) -> str | None:
|
|
426
|
+
return page.evaluate(_DOM_IDS, [[control.field.id, control.ends]]).get(control.field.id)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _box_of(page: Page, control: _Control):
|
|
430
|
+
ends = " , ".join(f'[id$="_{end}_error"]' for end in control.ends if '"' not in end)
|
|
431
|
+
return page.locator(ends or "#none").first
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _box(page: Page, control: _Control):
|
|
435
|
+
return page.locator(f"[id={json.dumps(f'{control.dom}_error')}]").locator("xpath=..")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _prefilled(page: Page, control: _Control) -> bool:
|
|
439
|
+
if control.kind in ("text", "select", "date"):
|
|
440
|
+
el = _box(page, control).locator("input:not([type=file]), textarea").first
|
|
441
|
+
return bool(el.count() and el.input_value())
|
|
442
|
+
if control.kind == "pills":
|
|
443
|
+
return _box(page, control).locator("[id^=selectPilllist]").count() > 0
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _fill(page: Page, control: _Control, answer: Answer) -> None:
|
|
448
|
+
box = _box(page, control)
|
|
449
|
+
value = answer if isinstance(answer, str) else (answer[0] if answer else "")
|
|
450
|
+
match control.kind:
|
|
451
|
+
case "text":
|
|
452
|
+
box.locator("input:not([type=file]), textarea").first.fill(value)
|
|
453
|
+
case "select":
|
|
454
|
+
_pick(page, box.locator("input[role=combobox]").first, value, exact=True)
|
|
455
|
+
case "lookup":
|
|
456
|
+
_pick(page, box.locator("input[role=combobox]").first, value, exact=False)
|
|
457
|
+
case "radio" | "checkbox": # the input lies over its label
|
|
458
|
+
picks = [answer] if isinstance(answer, str) else answer
|
|
459
|
+
labels = box.locator("label[for]").all()
|
|
460
|
+
if missing := set(picks) - {label.inner_text().strip() for label in labels}:
|
|
461
|
+
raise NotTaken(f"no option {sorted(missing)[0]!r}")
|
|
462
|
+
for label in labels:
|
|
463
|
+
choice = box.locator(f"[id={json.dumps(label.get_attribute('for'))}]")
|
|
464
|
+
if choice.is_checked() != (label.inner_text().strip() in picks):
|
|
465
|
+
_click(choice)
|
|
466
|
+
case "pills":
|
|
467
|
+
picks = [answer] if isinstance(answer, str) else answer
|
|
468
|
+
pills = box.locator("[id^=selectPilllist]")
|
|
469
|
+
for pill in pills.all():
|
|
470
|
+
if pill.locator("span").first.inner_text().strip() not in picks:
|
|
471
|
+
_click(pill.locator("button"))
|
|
472
|
+
shown = [p.locator("span").first.inner_text().strip() for p in pills.all()]
|
|
473
|
+
for pick in picks:
|
|
474
|
+
if pick not in shown:
|
|
475
|
+
_pick(page, box.locator("input[role=combobox]").first, pick, exact=True)
|
|
476
|
+
case "date":
|
|
477
|
+
_date(page, box.locator("input[role=combobox]").first, date.fromisoformat(value))
|
|
478
|
+
case "phone":
|
|
479
|
+
_phone(page, box, value)
|
|
480
|
+
case "file":
|
|
481
|
+
_upload(page, box, value, control.resume)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _pick(page: Page, combo, value: str, exact: bool) -> None:
|
|
485
|
+
"""Opens a dropdown and clicks the option with the value; a lookup's options are those its
|
|
486
|
+
API gives for the answers so far."""
|
|
487
|
+
_click(combo)
|
|
488
|
+
options = page.locator(f"[id={json.dumps(combo.get_attribute('aria-controls'))}] [role=option]")
|
|
489
|
+
with suppress(Timeout):
|
|
490
|
+
options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
491
|
+
pattern = _exact(value) if exact else re.compile(rf"^\s*{re.escape(value)}", re.IGNORECASE)
|
|
492
|
+
option = options.filter(has_text=pattern).first
|
|
493
|
+
if not option.count():
|
|
494
|
+
page.keyboard.press("Escape")
|
|
495
|
+
raise NotTaken(f"no option {value!r}")
|
|
496
|
+
option.click()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _phone(page: Page, box, value: str) -> None:
|
|
500
|
+
"""Picks the country code the number starts with, the longest that fits, then types the
|
|
501
|
+
rest of the number. Where several countries share it, its main country."""
|
|
502
|
+
digits = re.sub(r"\D", "", value)
|
|
503
|
+
combo = box.locator("input[role=combobox]").first
|
|
504
|
+
number = box.locator("input:not([role=combobox])").first
|
|
505
|
+
if not value.startswith("+"):
|
|
506
|
+
number.fill(value)
|
|
507
|
+
return
|
|
508
|
+
_click(combo)
|
|
509
|
+
options = page.locator(f"[id={json.dumps(combo.get_attribute('aria-controls'))}] [role=option]")
|
|
510
|
+
options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
511
|
+
texts = options.all_inner_texts()
|
|
512
|
+
fits = []
|
|
513
|
+
for i, text in enumerate(texts):
|
|
514
|
+
if (dial := DIAL.search(text)) and digits.startswith(code := dial[1].replace(" ", "")):
|
|
515
|
+
fits.append((len(code), _country(text) == PRIMARY.get(code), -i, code))
|
|
516
|
+
if not fits:
|
|
517
|
+
page.keyboard.press("Escape")
|
|
518
|
+
number.fill(digits)
|
|
519
|
+
return
|
|
520
|
+
_, _, i, code = max(fits)
|
|
521
|
+
options.nth(-i).click()
|
|
522
|
+
number.fill(digits[len(code) :])
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _country(text: str) -> str:
|
|
526
|
+
letters = [chr(ord(c) - FLAG + ord("A")) for c in text[:2] if FLAG <= ord(c) < FLAG + 26]
|
|
527
|
+
return "".join(letters)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _date(page: Page, combo, day: date) -> None:
|
|
531
|
+
"""Opens the date picker and pages to the day's month, then clicks it."""
|
|
532
|
+
_click(combo)
|
|
533
|
+
picker = page.locator(PICKER).last
|
|
534
|
+
picker.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
535
|
+
cell = picker.locator(f"td[title='{day.isoformat()}'] [role=button]")
|
|
536
|
+
for _ in range(PICKER_MONTHS):
|
|
537
|
+
if cell.count():
|
|
538
|
+
break
|
|
539
|
+
shown = picker.locator("td[title]").first.get_attribute("title") or ""
|
|
540
|
+
step = "next" if shown < day.isoformat() else "prev"
|
|
541
|
+
picker.locator(f"[data-testid=picker-header-{step}-btn]").click()
|
|
542
|
+
cell.click()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _upload(page: Page, box, path: str, resume: bool) -> None:
|
|
546
|
+
"""Attaches a file; a resume first asks to agree to the site's privacy terms, and is then
|
|
547
|
+
uploaded and parsed at once."""
|
|
548
|
+
box.locator("input[type=file]").first.set_input_files(path)
|
|
549
|
+
if resume:
|
|
550
|
+
with suppress(Timeout):
|
|
551
|
+
page.locator(AGREE).wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
552
|
+
if page.locator(AGREE).is_visible():
|
|
553
|
+
with page.expect_response(RESUME_UPLOAD, timeout=UPLOAD_TIMEOUT_MS):
|
|
554
|
+
_click(page.locator(AGREE))
|
|
555
|
+
page.wait_for_timeout(PARSE_SETTLE_MS) # what the parse finds lands after
|
|
556
|
+
box.locator("li span[title]").first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _click(target) -> None:
|
|
560
|
+
"""Clicks the target scrolled to the middle of the window, clear of the sticky footer."""
|
|
561
|
+
target.evaluate("el => el.scrollIntoView({block: 'center'})")
|
|
562
|
+
browser.click(target)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _iso(shown: str) -> str:
|
|
566
|
+
"""A date as the picker shows it, in the site's locale, back to ISO."""
|
|
567
|
+
for pattern in DATE_FORMATS:
|
|
568
|
+
with suppress(ValueError):
|
|
569
|
+
return datetime.strptime(shown, pattern).date().isoformat()
|
|
570
|
+
return shown
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _shows(answer: Answer | None, values: list[str]) -> bool:
|
|
574
|
+
"""Whether a parent's answer renders its dependent: any answer when `values` is empty."""
|
|
575
|
+
if answer in (None, "", []):
|
|
576
|
+
return False
|
|
577
|
+
picks = [answer] if isinstance(answer, str) else answer
|
|
578
|
+
return not values or any(p in values for p in picks)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
582
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
@@ -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"
|
|
@@ -350,7 +352,9 @@ kind = "ats"
|
|
|
350
352
|
share = 0.0040
|
|
351
353
|
account = "none"
|
|
352
354
|
hosting = ["hosted"]
|
|
355
|
+
adapter = "eightfold"
|
|
353
356
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
357
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "24e163f" }
|
|
354
358
|
|
|
355
359
|
[startupjobs]
|
|
356
360
|
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
|