atsfill 0.0.4.dev78__tar.gz → 0.0.4.dev80__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.dev78 → atsfill-0.0.4.dev80}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/README.md +1 -1
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev80/src/atsfill/adapters/eightfold.py +582 -0
- atsfill-0.0.4.dev80/src/atsfill/adapters/hiringthing.py +335 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/registry.toml +4 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/LICENSE +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev78 → atsfill-0.0.4.dev80}/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.dev80
|
|
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,8 +2,10 @@ 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
|
|
8
|
+
from atsfill.adapters.hiringthing import HiringThing
|
|
7
9
|
from atsfill.adapters.jazzhr import JazzHR
|
|
8
10
|
from atsfill.adapters.lever import Lever
|
|
9
11
|
from atsfill.adapters.manatal import Manatal
|
|
@@ -47,5 +49,7 @@ def adapters() -> list[Adapter]:
|
|
|
47
49
|
Personio(),
|
|
48
50
|
Rippling(),
|
|
49
51
|
Pinpoint(),
|
|
52
|
+
HiringThing(),
|
|
53
|
+
Eightfold(),
|
|
50
54
|
Phenom(), # last: it claims a URL by its path alone
|
|
51
55
|
]
|
|
@@ -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,335 @@
|
|
|
1
|
+
"""HiringThing (`<employer>.<white-label domain>/job/<id>/<slug>`), as docs/adapters/hiringthing.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
The job page carries the posting's configuration in a React component's props, and its Apply
|
|
5
|
+
button renders the form from them in the page: MUI inputs named by field id, radios and boxes,
|
|
6
|
+
react-select comboboxes for the country and state, Dropzone areas that upload a file as soon as
|
|
7
|
+
it is attached. `read_form` lists the rendered controls; there is no `read_api`, since which
|
|
8
|
+
controls render is the page's logic over the props, not the props alone.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
18
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
19
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
|
+
|
|
21
|
+
# The white-label domains HiringThing serves its career sites on, as freehire indexes them.
|
|
22
|
+
DOMAINS = (
|
|
23
|
+
"hiringthing.com",
|
|
24
|
+
"oasisrecruit.com",
|
|
25
|
+
"prismhr-hire.com",
|
|
26
|
+
"gnahiring.com",
|
|
27
|
+
"primepay-recruit.com",
|
|
28
|
+
"checkwritersrecruit.com",
|
|
29
|
+
"exponentats.com",
|
|
30
|
+
"rippling-ats.com",
|
|
31
|
+
"verahr-hiring.com",
|
|
32
|
+
"alphastaff-hiring.com",
|
|
33
|
+
"elevate-ats.com",
|
|
34
|
+
"iconnecthire.com",
|
|
35
|
+
"atsmodule.com",
|
|
36
|
+
"viewpointhr-ats.com",
|
|
37
|
+
"ezhiregov.com",
|
|
38
|
+
"lumberhiring.com",
|
|
39
|
+
"topdoghrrecruiting.com",
|
|
40
|
+
"teammemberhire-recruit.com",
|
|
41
|
+
"edistoats.com",
|
|
42
|
+
"topgradinghire.com",
|
|
43
|
+
)
|
|
44
|
+
JOB = re.compile(r"^/job/(\d+)(?:/|$)")
|
|
45
|
+
PROPS = "[data-react-class='HiringThing.Components.ApplyButtonGroup']"
|
|
46
|
+
APPLY = "button.job-apply-button"
|
|
47
|
+
FORM = "#job-application-form"
|
|
48
|
+
SUBMIT = f"{FORM} button[type=submit]"
|
|
49
|
+
BANNER = ".cookie-banner"
|
|
50
|
+
UPLOAD = "/client_api/add_file"
|
|
51
|
+
FORM_TIMEOUT_MS = 30_000
|
|
52
|
+
PAGE_TIMEOUT_MS = 30_000
|
|
53
|
+
MENU_TIMEOUT_MS = 5_000
|
|
54
|
+
REGIONS_TIMEOUT_MS = 10_000
|
|
55
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
56
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
57
|
+
REQUIRED = re.compile(r"\s*\(required\)\s*$")
|
|
58
|
+
DATE = "%m/%d/%Y"
|
|
59
|
+
YES_NO = ["Yes", "No"]
|
|
60
|
+
TYPES = {
|
|
61
|
+
"text": FieldType.TEXT,
|
|
62
|
+
"date": FieldType.DATE,
|
|
63
|
+
"phone": FieldType.PHONE,
|
|
64
|
+
"radio": FieldType.CHOICE,
|
|
65
|
+
"box": FieldType.CHOICE,
|
|
66
|
+
"boxes": FieldType.MULTI_CHOICE,
|
|
67
|
+
"select": FieldType.CHOICE,
|
|
68
|
+
"region": FieldType.TYPEAHEAD,
|
|
69
|
+
"file": FieldType.FILE,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Each control of the form by its name: its kind, label, options; a Dropzone area by its id.
|
|
73
|
+
_CONTROLS = """form => {
|
|
74
|
+
const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
|
|
75
|
+
const option = e => text(e.closest(".multi-option") ?? e.closest("label"));
|
|
76
|
+
const labelOf = id => document.getElementById(`${id}_label`);
|
|
77
|
+
const TEXTS = {text: "text", email: "text", textarea: "text", tel: "phone"};
|
|
78
|
+
const out = new Map();
|
|
79
|
+
for (const el of form.querySelectorAll("input, textarea, select")) {
|
|
80
|
+
const name = el.getAttribute("name") || "";
|
|
81
|
+
if (el.getAttribute("role") === "combobox") {
|
|
82
|
+
const id = el.id;
|
|
83
|
+
const kind = id.endsWith(".state") ? "region" : "select";
|
|
84
|
+
out.set(id, {id, kind, label: text(labelOf(id))});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!name || el.type === "hidden" || el.type === "file" || name.endsWith("phoneCountry"))
|
|
88
|
+
continue;
|
|
89
|
+
const label = labelOf(name);
|
|
90
|
+
if (el.type === "radio" || el.type === "checkbox") {
|
|
91
|
+
const own = el.type === "radio" ? el.value : option(el);
|
|
92
|
+
const entry = out.get(name) ?? {id: name, kind: el.type, label: text(label), options: []};
|
|
93
|
+
entry.options.push(own);
|
|
94
|
+
if (el.type === "checkbox") {
|
|
95
|
+
entry.kind = label?.classList.contains("multi-field-label") ? "boxes" : "box";
|
|
96
|
+
if (entry.kind === "box") entry.label = own;
|
|
97
|
+
}
|
|
98
|
+
out.set(name, entry);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const typed = el.tagName === "INPUT" ? el.type : el.tagName.toLowerCase();
|
|
102
|
+
let kind = TEXTS[typed] ?? "unhandled";
|
|
103
|
+
if (label?.classList.contains("datePicker-field-label")) kind = "date";
|
|
104
|
+
const maxLength = el.maxLength > 0 ? el.maxLength : null;
|
|
105
|
+
out.set(name, {id: name, kind, label: text(label), maxLength, typed});
|
|
106
|
+
}
|
|
107
|
+
for (const zone of form.querySelectorAll(".file-field-input[id]"))
|
|
108
|
+
out.set(zone.id, {id: zone.id, kind: "file", label: text(labelOf(`${zone.id}.file`))});
|
|
109
|
+
return [...out.values()];
|
|
110
|
+
}"""
|
|
111
|
+
|
|
112
|
+
# What each field holds: a value, the checked option, a combobox's shown value, a file's name.
|
|
113
|
+
_READ_BACK = """(form, fields) => {
|
|
114
|
+
const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
|
|
115
|
+
const option = e => text(e.closest(".multi-option") ?? e.closest("label"));
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [id, kind] of fields) {
|
|
118
|
+
const named = [...form.querySelectorAll(`[name="${id}"]`)];
|
|
119
|
+
if (kind === "radio") out[id] = named.find(e => e.checked)?.value ?? null;
|
|
120
|
+
else if (kind === "boxes")
|
|
121
|
+
out[id] = named.filter(e => e.checked).map(option);
|
|
122
|
+
else if (kind === "box") out[id] = named[0]?.checked ? "Yes" : "No";
|
|
123
|
+
else if (kind === "select" || kind === "region") {
|
|
124
|
+
const select = document.getElementById(id)?.closest(".Select");
|
|
125
|
+
out[id] = text(select?.querySelector(".Select-value-label")) || null;
|
|
126
|
+
} else if (kind === "file") {
|
|
127
|
+
const zone = document.getElementById(id);
|
|
128
|
+
const names = [...(zone?.querySelectorAll(".dz-complete .dz-filename") ?? [])];
|
|
129
|
+
out[id] = names.length ? text(names[names.length - 1]) : null;
|
|
130
|
+
} else out[id] = named[0]?.value ?? null;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HiringThing:
|
|
137
|
+
name = "hiringthing"
|
|
138
|
+
known_gaps: dict[str, Gap] = {}
|
|
139
|
+
side_effects = [
|
|
140
|
+
SideEffect("upload", "hiring_system", "a file is uploaded to HiringThing once attached"),
|
|
141
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self._kinds: dict[str, str] = {}
|
|
146
|
+
|
|
147
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
148
|
+
parts = urlsplit(url)
|
|
149
|
+
if not JOB.match(parts.path):
|
|
150
|
+
return False
|
|
151
|
+
host = parts.hostname or ""
|
|
152
|
+
if any(host.endswith(f".{d}") for d in DOMAINS):
|
|
153
|
+
return True
|
|
154
|
+
return page is not None and page.locator(PROPS).count() > 0
|
|
155
|
+
|
|
156
|
+
def posting(self, url: str) -> str:
|
|
157
|
+
parts = urlsplit(url)
|
|
158
|
+
job = JOB.match(parts.path)
|
|
159
|
+
return f"https://{parts.hostname}/job/{job[1]}" if job else url
|
|
160
|
+
|
|
161
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
162
|
+
props = page.locator(PROPS).first
|
|
163
|
+
try:
|
|
164
|
+
props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
|
|
165
|
+
except Timeout:
|
|
166
|
+
raise Unsupported("posting_gone: no application on the page") from None
|
|
167
|
+
_hide_banner(page)
|
|
168
|
+
form = page.locator(FORM)
|
|
169
|
+
if not form.count():
|
|
170
|
+
page.locator(APPLY).filter(visible=True).first.click()
|
|
171
|
+
try:
|
|
172
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
173
|
+
except Timeout:
|
|
174
|
+
raise Unsupported("posting_gone: Apply showed no form") from None
|
|
175
|
+
if form.locator("[id='user.state']:not([role=combobox])").count():
|
|
176
|
+
with suppress(Timeout): # the state list loads after the form, and replaces its input
|
|
177
|
+
_combobox(page, "user.state").wait_for(state="attached", timeout=REGIONS_TIMEOUT_MS)
|
|
178
|
+
questions = _questions(props.get_attribute("data-react-props") or "{}")
|
|
179
|
+
controls = form.evaluate(_CONTROLS)
|
|
180
|
+
self._kinds = {c["id"]: c["kind"] for c in controls if c["kind"] != "unhandled"}
|
|
181
|
+
return [
|
|
182
|
+
_field(c, questions, _menu(page, c["id"]) if c["kind"] == "select" else None)
|
|
183
|
+
for c in controls
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
187
|
+
_hide_banner(page)
|
|
188
|
+
form = page.locator(FORM)
|
|
189
|
+
# the state last: its options are the chosen country's
|
|
190
|
+
for field_id, kind in sorted(self._kinds.items(), key=lambda k: k[1] == "region"):
|
|
191
|
+
if field_id not in answers:
|
|
192
|
+
continue
|
|
193
|
+
answer = answers[field_id]
|
|
194
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
195
|
+
named = form.locator(f"[name='{field_id}']")
|
|
196
|
+
match kind:
|
|
197
|
+
case "text" | "phone":
|
|
198
|
+
named.fill(value)
|
|
199
|
+
case "date":
|
|
200
|
+
named.fill("")
|
|
201
|
+
named.press_sequentially(datetime.fromisoformat(value).strftime(DATE))
|
|
202
|
+
named.press("Tab")
|
|
203
|
+
case "radio":
|
|
204
|
+
form.locator(
|
|
205
|
+
f"input[type=radio][name='{field_id}'][value={json.dumps(value)}]"
|
|
206
|
+
).check()
|
|
207
|
+
case "box":
|
|
208
|
+
named.set_checked(value == "Yes")
|
|
209
|
+
case "boxes":
|
|
210
|
+
for option in [answer] if isinstance(answer, str) else answer:
|
|
211
|
+
exact = re.compile(f"^\\s*{re.escape(option)}\\s*$")
|
|
212
|
+
box = form.locator(
|
|
213
|
+
".multi-option", has=page.locator(f"[name='{field_id}']")
|
|
214
|
+
)
|
|
215
|
+
box = box.filter(has_text=exact)
|
|
216
|
+
box.locator("input").first.check()
|
|
217
|
+
case "select" | "region":
|
|
218
|
+
_pick(page, field_id, value, exact=kind == "select")
|
|
219
|
+
case "file":
|
|
220
|
+
_attach(page, field_id, value)
|
|
221
|
+
|
|
222
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
223
|
+
values = page.locator(FORM).evaluate(_READ_BACK, list(self._kinds.items()))
|
|
224
|
+
out: dict[str, Answer] = {}
|
|
225
|
+
for field_id, value in values.items():
|
|
226
|
+
if self._kinds[field_id] == "date" and value:
|
|
227
|
+
with suppress(ValueError):
|
|
228
|
+
value = datetime.strptime(value, DATE).date().isoformat()
|
|
229
|
+
if value not in (None, "", []):
|
|
230
|
+
out[field_id] = value
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def submit(self, page: Page) -> str | None:
|
|
234
|
+
"""Clicks Submit Application; the evidence is the answer to the application's POST."""
|
|
235
|
+
host = urlsplit(page.url).hostname
|
|
236
|
+
with page.expect_response(
|
|
237
|
+
lambda r: (
|
|
238
|
+
r.request.method == "POST"
|
|
239
|
+
and urlsplit(r.url).hostname == host
|
|
240
|
+
and not r.url.endswith(UPLOAD)
|
|
241
|
+
),
|
|
242
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
243
|
+
) as applied:
|
|
244
|
+
page.locator(SUBMIT).click()
|
|
245
|
+
response = applied.value
|
|
246
|
+
if page.locator("iframe[src*='recaptcha'][title*='challenge']").count():
|
|
247
|
+
raise Blocked("captcha: a reCAPTCHA challenge showed on submit")
|
|
248
|
+
if not response.ok:
|
|
249
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
250
|
+
return f"{response.url} {response.status}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _questions(props: str) -> dict[str, str]:
|
|
254
|
+
"""Each question's text by its id, from the job's props."""
|
|
255
|
+
with suppress(ValueError, KeyError, TypeError):
|
|
256
|
+
table = json.loads(props)["jobObj"]["table"]
|
|
257
|
+
return {str(q["table"]["id"]): q["table"]["question"] for q in table.get("questions") or []}
|
|
258
|
+
return {}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _field(control: dict, questions: dict[str, str], options: list[str] | None) -> Field:
|
|
262
|
+
"""A field from a rendered control; a history entry's field labelled with its question."""
|
|
263
|
+
field_id, kind = control["id"], control["kind"]
|
|
264
|
+
label = control["label"] or field_id
|
|
265
|
+
required = bool(REQUIRED.search(label))
|
|
266
|
+
label = REQUIRED.sub("", label)
|
|
267
|
+
if entry := re.match(r"job_assessment\.question_(\d+)\.response\.\d+\.", field_id):
|
|
268
|
+
label = f"{questions.get(entry[1], 'History')} {label}"
|
|
269
|
+
if kind == "unhandled":
|
|
270
|
+
reason = f"unhandled_rendering: {field_id} is a {control['typed']}"
|
|
271
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
272
|
+
if kind == "box":
|
|
273
|
+
options = YES_NO
|
|
274
|
+
required = "disclosure_agree" in field_id # an agreement, not a preference
|
|
275
|
+
elif kind in ("radio", "boxes"):
|
|
276
|
+
options = control["options"]
|
|
277
|
+
max_length = control.get("maxLength") if kind == "text" else None
|
|
278
|
+
return Field(field_id, label, TYPES[kind], required, options, max_length)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _hide_banner(page: Page) -> None:
|
|
282
|
+
"""Hides the cookie banner, which covers the form's bottom, without answering it."""
|
|
283
|
+
with suppress(Exception):
|
|
284
|
+
page.locator(BANNER).evaluate_all("es => es.forEach(e => e.style.display = 'none')")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _combobox(page: Page, field_id: str) -> Locator:
|
|
288
|
+
return page.locator(f"{FORM} input[role=combobox][id='{field_id}']")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _menu(page: Page, field_id: str) -> list[str]:
|
|
292
|
+
"""A react-select's options, read by opening its menu and closing it again."""
|
|
293
|
+
box = _combobox(page, field_id)
|
|
294
|
+
box.focus()
|
|
295
|
+
box.press("ArrowDown")
|
|
296
|
+
options = page.locator(".Select-menu-outer .Select-option")
|
|
297
|
+
with suppress(Timeout):
|
|
298
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
299
|
+
texts = [" ".join(t.split()) for t in options.all_inner_texts()]
|
|
300
|
+
box.press("Escape")
|
|
301
|
+
return texts
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _pick(page: Page, field_id: str, value: str, exact: bool) -> None:
|
|
305
|
+
"""Types into a react-select and clicks the option that is the answer, or for a typeahead
|
|
306
|
+
the first that starts with it."""
|
|
307
|
+
box = _combobox(page, field_id)
|
|
308
|
+
box.focus()
|
|
309
|
+
box.press_sequentially(value)
|
|
310
|
+
options = page.locator(".Select-menu-outer .Select-option")
|
|
311
|
+
try:
|
|
312
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
313
|
+
except Timeout:
|
|
314
|
+
box.press("Escape")
|
|
315
|
+
raise NotTaken(f"no option {value!r} in {field_id}") from None
|
|
316
|
+
texts = [" ".join(t.split()) for t in options.all_inner_texts()]
|
|
317
|
+
hit = next((i for i, t in enumerate(texts) if t == value), None)
|
|
318
|
+
if hit is None and not exact:
|
|
319
|
+
hit = next((i for i, t in enumerate(texts) if t.startswith(value)), None)
|
|
320
|
+
if hit is None:
|
|
321
|
+
box.press("Escape")
|
|
322
|
+
raise NotTaken(f"no option {value!r} in {field_id}")
|
|
323
|
+
options.nth(hit).click()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _attach(page: Page, field_id: str, path: str) -> None:
|
|
327
|
+
"""Attaches a file through its Dropzone area's file chooser, and waits for its upload."""
|
|
328
|
+
zone = page.locator(f"{FORM} [id='{field_id}'] .dropzone")
|
|
329
|
+
with page.expect_file_chooser() as chooser:
|
|
330
|
+
zone.click()
|
|
331
|
+
chooser.value.set_files(path)
|
|
332
|
+
name = path.rsplit("/", 1)[-1]
|
|
333
|
+
done = zone.locator(".dz-complete .dz-filename", has_text=name)
|
|
334
|
+
with suppress(Timeout):
|
|
335
|
+
done.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
@@ -352,7 +352,9 @@ kind = "ats"
|
|
|
352
352
|
share = 0.0040
|
|
353
353
|
account = "none"
|
|
354
354
|
hosting = ["hosted"]
|
|
355
|
+
adapter = "eightfold"
|
|
355
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" }
|
|
356
358
|
|
|
357
359
|
[startupjobs]
|
|
358
360
|
kind = "aggregator"
|
|
@@ -396,7 +398,9 @@ kind = "ats"
|
|
|
396
398
|
share = 0.0028
|
|
397
399
|
account = "none"
|
|
398
400
|
hosting = ["hosted"]
|
|
401
|
+
adapter = "hiringthing"
|
|
399
402
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
403
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "cce148b" }
|
|
400
404
|
|
|
401
405
|
[jibe]
|
|
402
406
|
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
|