atsfill 0.0.5.dev150__tar.gz → 0.0.5.dev152__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/README.md +1 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev152/src/atsfill/adapters/recruiterflow.py +467 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/registry.toml +8 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/LICENSE +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev152
|
|
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
|
|
@@ -81,6 +81,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
81
81
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
82
82
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
83
83
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
85
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
85
86
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
86
87
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -52,6 +52,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
52
52
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
53
53
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
54
54
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
56
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
56
57
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
57
58
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -20,6 +20,7 @@ from atsfill.adapters.personio import Personio
|
|
|
20
20
|
from atsfill.adapters.phenom import Phenom
|
|
21
21
|
from atsfill.adapters.pinpoint import Pinpoint
|
|
22
22
|
from atsfill.adapters.recruitee import Recruitee
|
|
23
|
+
from atsfill.adapters.recruiterflow import Recruiterflow
|
|
23
24
|
from atsfill.adapters.rippling import Rippling
|
|
24
25
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
25
26
|
from atsfill.adapters.successfactors import SuccessFactors
|
|
@@ -52,6 +53,7 @@ def adapters() -> list[Adapter]:
|
|
|
52
53
|
Lever(),
|
|
53
54
|
Dayforce(),
|
|
54
55
|
Recruitee(),
|
|
56
|
+
Recruiterflow(),
|
|
55
57
|
Breezy(),
|
|
56
58
|
BambooHR(),
|
|
57
59
|
ZohoRecruit(),
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Recruiterflow (`recruiterflow.com/<employer>/jobs/<id>`), as docs/adapters/recruiterflow.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
The posting page declares its form in one JSON object (`window.applyJobData`), which a React app
|
|
5
|
+
draws: personal information, a candidate profile and the employer's questions, in that order.
|
|
6
|
+
`request_read` fetches the page over HTTP and `browser_read` takes it from the browser; one parser
|
|
7
|
+
lists the fields from the JSON. The questions are drawn one per child of the questions container,
|
|
8
|
+
in the JSON's order, which is how a question's control is found. Files are uploaded at once, the
|
|
9
|
+
résumé parsed into the empty fields, so it goes first.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from dataclasses import field as dc_field
|
|
18
|
+
from datetime import date
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
from atsfill.core import html
|
|
22
|
+
from atsfill.core.adapter import Gap, Unsupported
|
|
23
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
24
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
25
|
+
|
|
26
|
+
ORIGIN = "https://recruiterflow.com"
|
|
27
|
+
HOST = re.compile(r"^(?:www\.)?recruiterflow\.com$")
|
|
28
|
+
JOB = re.compile(r"^/([\w-]+)/jobs/(\d+)/?$")
|
|
29
|
+
DATA = re.compile(r"var convertedToJSON = (\{.*?\});\s*\n", re.S)
|
|
30
|
+
FORM = ".apply-to-job-form-inputs-container form"
|
|
31
|
+
QUESTIONS = ".additional-info-inputs-container"
|
|
32
|
+
OPTION = "[id^=react-select][id*=-option-]"
|
|
33
|
+
FORM_TIMEOUT_MS = 30_000
|
|
34
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
35
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
36
|
+
YES_NO = ["Yes", "No"]
|
|
37
|
+
OPT_IN = "mandatory-opt-in-checkbox"
|
|
38
|
+
DOCUMENTS = [".doc", ".docx", ".txt", ".rtf", ".pdf"] # the résumé's drop zone's
|
|
39
|
+
MONTH = "%b %Y" # how a profile entry's date picker shows a month: `Jan 2020`
|
|
40
|
+
DAY = "%d %b %Y" # how a question's date picker shows a day: `31 Jan 2020`
|
|
41
|
+
MM_YYYY = re.compile(r"(\d\d)/(\d{4})")
|
|
42
|
+
# A question's view field, how it renders, and its field type; the rest are unhandled.
|
|
43
|
+
QUESTION_KINDS = {
|
|
44
|
+
1: ("text", FieldType.TEXT),
|
|
45
|
+
2: ("textarea", FieldType.TEXT),
|
|
46
|
+
4: ("number", FieldType.TEXT),
|
|
47
|
+
5: ("money", FieldType.TEXT),
|
|
48
|
+
6: ("date", FieldType.DATE),
|
|
49
|
+
9: ("yes_no", FieldType.CHOICE),
|
|
50
|
+
10: ("select", FieldType.CHOICE),
|
|
51
|
+
11: ("multi", FieldType.MULTI_CHOICE),
|
|
52
|
+
14: ("file", FieldType.FILE),
|
|
53
|
+
19: ("tree", FieldType.MULTI_CHOICE),
|
|
54
|
+
}
|
|
55
|
+
RANGES = {12: "money range", 13: "number range"} # a Min and a Max, two fields
|
|
56
|
+
NUMBER = " (a number)" # a label's hint for an input that takes only digits
|
|
57
|
+
ENTRIES = { # a profile list's first entry: (key, label, kind)
|
|
58
|
+
"experience": (
|
|
59
|
+
("company-name", "Company Name", "text"),
|
|
60
|
+
("designation", "Title", "text"),
|
|
61
|
+
("from-experience-year", "Start Date", "month"),
|
|
62
|
+
("to-experience-year", "End Date", "month"),
|
|
63
|
+
),
|
|
64
|
+
"education": (
|
|
65
|
+
("school", "School Name", "text"),
|
|
66
|
+
("degree", "Degree", "text"),
|
|
67
|
+
("from-graduation-year", "Start Date", "month"),
|
|
68
|
+
("to-graduation-year", "End Date", "month"),
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
ENTRY_REQUIRED = 3 # Recruiterflow checks a required list's first entry's first three
|
|
72
|
+
|
|
73
|
+
# What each control shows, by the locator `kind` names it with, from inside the form.
|
|
74
|
+
_READ_BACK = """(form, controls) => {
|
|
75
|
+
const out = {};
|
|
76
|
+
const questions = form.querySelector(".additional-info-inputs-container")?.children ?? [];
|
|
77
|
+
const at = c => c.question != null ? questions[c.question] : form;
|
|
78
|
+
for (const c of controls) {
|
|
79
|
+
const box = at(c);
|
|
80
|
+
if (!box) continue;
|
|
81
|
+
const el = c.selector ? box.querySelector(c.selector) :
|
|
82
|
+
c.question != null ? box : form.querySelector(`[name="${c.id}"]`);
|
|
83
|
+
if (!el) continue;
|
|
84
|
+
if (c.kind === "file") {
|
|
85
|
+
out[c.id] = el.querySelector(".file-list-item")?.textContent.trim() ?? "";
|
|
86
|
+
} else if (c.kind === "select" || c.kind === "country") {
|
|
87
|
+
out[c.id] = el.querySelector("[class*=singleValue]")?.textContent.trim() ?? "";
|
|
88
|
+
} else if (c.kind === "multi") {
|
|
89
|
+
out[c.id] = [...el.querySelectorAll("[role=button][aria-label^='Remove ']")]
|
|
90
|
+
.map(e => e.getAttribute("aria-label").slice("Remove ".length));
|
|
91
|
+
} else if (c.kind === "tree") {
|
|
92
|
+
out[c.id] = [...el.querySelectorAll(".rf-parent-child-field-accordion")].flatMap(a =>
|
|
93
|
+
[...a.querySelectorAll(".rf-parent-child-field-list")]
|
|
94
|
+
.filter(i => i.querySelector("input").checked)
|
|
95
|
+
.map(i => [a.querySelector(".rf-parent-child-field-header-checkbox-label")
|
|
96
|
+
.textContent.trim(), i.querySelector("p").textContent.trim()]));
|
|
97
|
+
} else if (c.kind === "yes_no") {
|
|
98
|
+
out[c.id] = el.querySelector("button.selected")?.textContent.trim() ?? "";
|
|
99
|
+
} else if (c.kind === "checkbox") {
|
|
100
|
+
out[c.id] = el.checked ? "Yes" : "No";
|
|
101
|
+
} else out[c.id] = el.value ?? "";
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class _Control:
|
|
109
|
+
field: Field
|
|
110
|
+
kind: str # text | textarea | phone | country | file | month | money | min | max | date
|
|
111
|
+
# | yes_no | select | multi | tree | checkbox
|
|
112
|
+
selector: str | None = None # inside the form, or inside its question's box
|
|
113
|
+
question: int | None = None # the question's place among the questions container's children
|
|
114
|
+
items: dict[str, tuple[str, str]] = dc_field(default_factory=dict) # a tree's, by option
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Recruiterflow:
|
|
118
|
+
name = "recruiterflow"
|
|
119
|
+
known_gaps: dict[str, Gap] = {}
|
|
120
|
+
filters = ["recruiterflow.com##.rf-careers-cookie-banner-container"] # over the questions
|
|
121
|
+
side_effects = [
|
|
122
|
+
SideEffect("upload", "hiring_system", "a file is uploaded at once, a résumé parsed"),
|
|
123
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
self._controls: list[_Control] = []
|
|
128
|
+
|
|
129
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
130
|
+
return _job(url) is not None
|
|
131
|
+
|
|
132
|
+
def posting(self, url: str) -> str:
|
|
133
|
+
job = _job(url)
|
|
134
|
+
return f"{ORIGIN}/{job[0]}/jobs/{job[1]}" if job else url
|
|
135
|
+
|
|
136
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
137
|
+
if _job(url) is None:
|
|
138
|
+
return None
|
|
139
|
+
request = urllib.request.Request(self.posting(url), headers={"User-Agent": "atsfill"})
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
142
|
+
source = response.read().decode()
|
|
143
|
+
except urllib.error.HTTPError, OSError, ValueError:
|
|
144
|
+
return None
|
|
145
|
+
data = _data(source)
|
|
146
|
+
if data is None or not _open(data):
|
|
147
|
+
return None
|
|
148
|
+
return [c.field for c in _controls(data)]
|
|
149
|
+
|
|
150
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
151
|
+
data = _data(page.content())
|
|
152
|
+
if data is None or not _open(data):
|
|
153
|
+
raise Unsupported("posting_gone: no application form on the page")
|
|
154
|
+
try:
|
|
155
|
+
page.locator(FORM).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
156
|
+
except Timeout:
|
|
157
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
158
|
+
self._controls = _controls(data)
|
|
159
|
+
drawn = page.locator(f"{FORM} {QUESTIONS} > *").count()
|
|
160
|
+
asked = len(data["application_form"].get("questions") or [])
|
|
161
|
+
if drawn != asked:
|
|
162
|
+
raise RuntimeError(f"{asked} questions declared, {drawn} drawn")
|
|
163
|
+
return [c.field for c in self._controls]
|
|
164
|
+
|
|
165
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
166
|
+
shown = [
|
|
167
|
+
c for c in self._controls if c.field.id in answers and not c.field.unfillable
|
|
168
|
+
] # fmt: skip
|
|
169
|
+
# a section may start collapsed: its header opens it
|
|
170
|
+
for section in page.locator(f"{FORM} .input-section-container").all():
|
|
171
|
+
inputs = section.locator("xpath=following-sibling::*[1]")
|
|
172
|
+
if inputs.count() and not inputs.is_visible():
|
|
173
|
+
section.click()
|
|
174
|
+
inputs.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
175
|
+
# files first: the résumé's parse fills the empty fields
|
|
176
|
+
for c in sorted(shown, key=lambda c: c.kind != "file"):
|
|
177
|
+
_set(page, c, answers[c.field.id])
|
|
178
|
+
|
|
179
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
180
|
+
controls = [
|
|
181
|
+
{"id": c.field.id, "kind": c.kind, "selector": c.selector, "question": c.question}
|
|
182
|
+
for c in self._controls
|
|
183
|
+
if not c.field.unfillable
|
|
184
|
+
]
|
|
185
|
+
values = page.locator(FORM).evaluate(_READ_BACK, controls)
|
|
186
|
+
out: dict[str, Answer] = {}
|
|
187
|
+
for c in self._controls:
|
|
188
|
+
value = values.get(c.field.id)
|
|
189
|
+
if isinstance(value, str):
|
|
190
|
+
value = _shown(c.kind, value)
|
|
191
|
+
elif c.kind == "tree" and value is not None:
|
|
192
|
+
text = {item: option for option, item in c.items.items()}
|
|
193
|
+
value = [text.get((group, name), name) for group, name in value]
|
|
194
|
+
if value not in (None, "", []):
|
|
195
|
+
out[c.field.id] = value
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
def submit(self, page: Page) -> str | None:
|
|
199
|
+
"""Clicks Submit Application; the page posts the form to `submit-application`, whose
|
|
200
|
+
answer, unless it says `FAILURE`, is the evidence. The page's own check of the required
|
|
201
|
+
fields posts nothing and shows its message instead."""
|
|
202
|
+
path = f"{urlsplit(self.posting(page.url)).path}/submit-application"
|
|
203
|
+
try:
|
|
204
|
+
with page.expect_response(
|
|
205
|
+
lambda r: r.request.method == "POST" and urlsplit(r.url).path == path,
|
|
206
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
207
|
+
) as posted:
|
|
208
|
+
page.locator("#submit-application-button").click()
|
|
209
|
+
except Timeout:
|
|
210
|
+
raise RuntimeError(f"nothing posted: {_message(page)}") from None
|
|
211
|
+
response = posted.value
|
|
212
|
+
try:
|
|
213
|
+
answer = response.json()
|
|
214
|
+
except ValueError:
|
|
215
|
+
answer = {}
|
|
216
|
+
if not response.ok or answer.get("RESULT") == "FAILURE":
|
|
217
|
+
detail = answer.get("message") or response.status_text
|
|
218
|
+
raise RuntimeError(f"submit-application answered {response.status}: {detail}"[:300])
|
|
219
|
+
return f"{response.url} {response.status}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
223
|
+
"""The employer's path and the job id, from `recruiterflow.com/<employer>/jobs/<id>`."""
|
|
224
|
+
parts = urlsplit(url)
|
|
225
|
+
match = JOB.match(parts.path)
|
|
226
|
+
return (match[1], match[2]) if HOST.match(parts.hostname or "") and match else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _data(source: str) -> dict | None:
|
|
230
|
+
match = DATA.search(source)
|
|
231
|
+
return json.loads(match[1]) if match else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _open(data: dict) -> bool:
|
|
235
|
+
return data.get("job_visibility_id") in data.get("ACTIVE_JOB_VISIBILITY", []) and bool(
|
|
236
|
+
data.get("application_form")
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _controls(data: dict) -> list[_Control]:
|
|
241
|
+
"""The form's fields from the page's JSON, in the page's order: a section's item whose
|
|
242
|
+
requirement is `off` is not drawn."""
|
|
243
|
+
form = data["application_form"]
|
|
244
|
+
out: list[_Control] = []
|
|
245
|
+
for item in form.get("personal_info") or []:
|
|
246
|
+
if item["requirement_type"] != "off":
|
|
247
|
+
out += _personal(item)
|
|
248
|
+
for item in form.get("candidate_profile") or []:
|
|
249
|
+
if item["requirement_type"] != "off":
|
|
250
|
+
out += _profile(item)
|
|
251
|
+
for n, question in enumerate(form.get("questions") or []):
|
|
252
|
+
out += _question(n, question)
|
|
253
|
+
if notes := _text(data.get("footer_notes") or ""):
|
|
254
|
+
box = "input.policy-checkbox:not(.is-current-custom-checkbox-container *)"
|
|
255
|
+
out.append(_Control(Field(OPT_IN, notes, FieldType.CHOICE, True, YES_NO), "checkbox", box))
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _personal(item: dict) -> list[_Control]:
|
|
260
|
+
key, label, required = item["key"], item["display_name"], _required(item)
|
|
261
|
+
match key:
|
|
262
|
+
case "name":
|
|
263
|
+
return [
|
|
264
|
+
_Control(Field(f"personal_info.{n}", t, FieldType.TEXT, required), "text")
|
|
265
|
+
for n, t in (("first_name", "First name"), ("last_name", "Last name"))
|
|
266
|
+
]
|
|
267
|
+
case "address":
|
|
268
|
+
parts = [
|
|
269
|
+
("city", "City", FieldType.TEXT, "text"),
|
|
270
|
+
("state", "State", FieldType.TEXT, "text"),
|
|
271
|
+
("country", "Country", FieldType.TYPEAHEAD, "country"),
|
|
272
|
+
("postal_code", "Zip code", FieldType.TEXT, "text"),
|
|
273
|
+
]
|
|
274
|
+
return [
|
|
275
|
+
_Control(
|
|
276
|
+
Field(f"personal_info.location.{n}", f"{label}: {t}", type_, required),
|
|
277
|
+
kind,
|
|
278
|
+
".location-input-wrapper" if kind == "country" else None,
|
|
279
|
+
)
|
|
280
|
+
for n, t, type_, kind in parts
|
|
281
|
+
]
|
|
282
|
+
case "phone":
|
|
283
|
+
return [
|
|
284
|
+
_Control(Field("personal_info.phone", label, FieldType.PHONE, required), "phone")
|
|
285
|
+
]
|
|
286
|
+
case "email" | "linkedin_profile":
|
|
287
|
+
return [
|
|
288
|
+
_Control(Field(f"personal_info.{key}", label, FieldType.TEXT, required), "text")
|
|
289
|
+
]
|
|
290
|
+
reason = f"unhandled_rendering: personal_info.{key} is a {item['response_type']}"
|
|
291
|
+
field = Field(f"personal_info.{key}", label, FieldType.TEXT, required, unfillable=reason)
|
|
292
|
+
return [_Control(field, "text")]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _profile(item: dict) -> list[_Control]:
|
|
296
|
+
key, label, required = item["key"], item["display_name"], _required(item)
|
|
297
|
+
match key:
|
|
298
|
+
case "resume":
|
|
299
|
+
field = Field(
|
|
300
|
+
"candidate_profile.resume", label, FieldType.FILE, required, accept=DOCUMENTS
|
|
301
|
+
)
|
|
302
|
+
return [_Control(field, "file", ":scope > .form-dnd-container")]
|
|
303
|
+
case "cover_letter":
|
|
304
|
+
field = Field("candidate_profile.cover_letter", label, FieldType.FILE, required)
|
|
305
|
+
box = ".candidate-profile-inputs-container .form-dnd-container"
|
|
306
|
+
return [_Control(field, "file", box)]
|
|
307
|
+
case "summary":
|
|
308
|
+
field = Field("candidate_profile.summary", label, FieldType.TEXT, required)
|
|
309
|
+
box = ".candidate-profile-inputs-container textarea"
|
|
310
|
+
return [_Control(field, "textarea", box)]
|
|
311
|
+
case "experience" | "education":
|
|
312
|
+
box = f".{key}-input-wrapper"
|
|
313
|
+
out = []
|
|
314
|
+
for n, (part, text, kind) in enumerate(ENTRIES[key]):
|
|
315
|
+
month = kind == "month"
|
|
316
|
+
field = Field(
|
|
317
|
+
f"candidate_profile.{part}.0",
|
|
318
|
+
f"{label}: {text} (MM/YYYY)" if month else f"{label}: {text}",
|
|
319
|
+
FieldType.TEXT,
|
|
320
|
+
required and n < ENTRY_REQUIRED,
|
|
321
|
+
)
|
|
322
|
+
picker = ".start-date-picker" if part.startswith("from") else ".end-date-picker"
|
|
323
|
+
out.append(_Control(field, kind, f"{box} {picker}" if month else None))
|
|
324
|
+
return out
|
|
325
|
+
reason = f"unhandled_rendering: candidate_profile.{key} is a {item['response_type']}"
|
|
326
|
+
field = Field(f"candidate_profile.{key}", label, FieldType.TEXT, required, unfillable=reason)
|
|
327
|
+
return [_Control(field, "text")]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _question(n: int, question: dict) -> list[_Control]:
|
|
331
|
+
view = question.get("view_field_id") or question.get("custom_field_view_field_id")
|
|
332
|
+
id_, label = f"question.{question['question_id']}", _text(question["question_text"])
|
|
333
|
+
required = _required(question)
|
|
334
|
+
if view in RANGES:
|
|
335
|
+
return [
|
|
336
|
+
_Control(
|
|
337
|
+
Field(f"{id_}.{end}", f"{label} ({end.title()}){NUMBER}", FieldType.TEXT, required),
|
|
338
|
+
end,
|
|
339
|
+
f"input[placeholder={end.title()}]",
|
|
340
|
+
n,
|
|
341
|
+
)
|
|
342
|
+
for end in ("min", "max")
|
|
343
|
+
]
|
|
344
|
+
if view not in QUESTION_KINDS:
|
|
345
|
+
reason = f"unhandled_rendering: {id_} is a view field {view}"
|
|
346
|
+
return [_Control(Field(id_, label, FieldType.TEXT, required, unfillable=reason), "text")]
|
|
347
|
+
kind, type_ = QUESTION_KINDS[view]
|
|
348
|
+
options = None
|
|
349
|
+
if kind == "yes_no":
|
|
350
|
+
options = YES_NO
|
|
351
|
+
elif kind in ("select", "multi"):
|
|
352
|
+
options = list(dict.fromkeys(c for c in question.get("choices") or [] if c.strip()))
|
|
353
|
+
elif kind == "tree":
|
|
354
|
+
options = list(_tree(question.get("choices") or []))
|
|
355
|
+
selector = {
|
|
356
|
+
"text": "input",
|
|
357
|
+
"number": "input",
|
|
358
|
+
"textarea": "textarea",
|
|
359
|
+
"money": "input:not([role=combobox]):not([type=hidden])",
|
|
360
|
+
"date": "input",
|
|
361
|
+
}.get(kind)
|
|
362
|
+
if kind in ("number", "money"):
|
|
363
|
+
label += NUMBER
|
|
364
|
+
items = _tree(question.get("choices") or []) if kind == "tree" else {}
|
|
365
|
+
return [_Control(Field(id_, label, type_, required, options), kind, selector, n, items)]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _tree(groups: list[dict]) -> dict[str, tuple[str, str]]:
|
|
369
|
+
"""A parent-child question's items by option text, its name, or `<group>: <name>` where two
|
|
370
|
+
groups hold the name: the group and the name it is drawn under."""
|
|
371
|
+
items = [(g["name"], c["name"]) for g in groups for c in g.get("children") or []]
|
|
372
|
+
names = [name for _, name in items]
|
|
373
|
+
return {name if names.count(name) == 1 else f"{group}: {name}": (group, name)
|
|
374
|
+
for group, name in items} # fmt: skip
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _required(item: dict) -> bool:
|
|
378
|
+
return item.get("requirement_type") == "mandatory"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _text(source: str) -> str:
|
|
382
|
+
"""A label's text, the footer's HTML or a question's line breaks collapsed."""
|
|
383
|
+
return " ".join(html.parse(source).text().split())
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _box(page: Page, c: _Control) -> Locator:
|
|
387
|
+
form = page.locator(FORM)
|
|
388
|
+
box = form.locator(f"{QUESTIONS} > *").nth(c.question) if c.question is not None else form
|
|
389
|
+
if c.selector:
|
|
390
|
+
return box.locator(c.selector).first
|
|
391
|
+
return box if c.question is not None else form.locator(f"[name='{c.field.id}']").first
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _set(page: Page, c: _Control, answer: Answer) -> None:
|
|
395
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
396
|
+
box = _box(page, c)
|
|
397
|
+
match c.kind:
|
|
398
|
+
case "file":
|
|
399
|
+
with page.expect_response(lambda r: urlsplit(r.url).path == "/api/file/upload",
|
|
400
|
+
timeout=UPLOAD_TIMEOUT_MS): # fmt: skip
|
|
401
|
+
box.locator("input[type=file]").set_input_files(values[0])
|
|
402
|
+
name = values[0].rsplit("/", 1)[-1]
|
|
403
|
+
box.locator(".file-list-item", has_text=name).wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
404
|
+
case "country" | "select" | "multi":
|
|
405
|
+
for value in values:
|
|
406
|
+
_pick(page, box, value, typed=c.kind == "country")
|
|
407
|
+
page.keyboard.press("Escape")
|
|
408
|
+
case "tree": # an item's group is opened to show it
|
|
409
|
+
for value in values:
|
|
410
|
+
group, name = c.items.get(value, ("", value))
|
|
411
|
+
accordion = box.locator(".rf-parent-child-field-accordion").filter(
|
|
412
|
+
has=page.locator(".rf-parent-child-field-header-checkbox-label", has_text=group)
|
|
413
|
+
)
|
|
414
|
+
item = accordion.first.locator(".rf-parent-child-field-list").filter(
|
|
415
|
+
has=page.locator("p", has_text=re.compile(rf"^\s*{re.escape(name)}\s*$"))
|
|
416
|
+
)
|
|
417
|
+
if not item.first.is_visible():
|
|
418
|
+
accordion.first.locator(".rf-plus-minus-icon-container").click()
|
|
419
|
+
item.first.locator("input[type=checkbox]").click()
|
|
420
|
+
case "yes_no":
|
|
421
|
+
box.locator("button", has_text=re.compile(rf"^\s*{values[0]}\s*$")).click()
|
|
422
|
+
case "checkbox":
|
|
423
|
+
if box.is_checked() != (values[0] == "Yes"):
|
|
424
|
+
box.click()
|
|
425
|
+
case "month" if shown := MM_YYYY.fullmatch(values[0]):
|
|
426
|
+
box.fill(date(int(shown[2]), int(shown[1]), 1).strftime(MONTH))
|
|
427
|
+
box.press("Tab")
|
|
428
|
+
case "date":
|
|
429
|
+
box.fill(date.fromisoformat(values[0]).strftime(DAY))
|
|
430
|
+
box.press("Tab")
|
|
431
|
+
case _:
|
|
432
|
+
box.fill(values[0])
|
|
433
|
+
if c.kind == "phone":
|
|
434
|
+
box.blur()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _pick(page: Page, box: Locator, value: str, typed: bool) -> None:
|
|
438
|
+
"""Opens a react-select and clicks the option `value`; a long list is typed into first."""
|
|
439
|
+
options = page.locator(OPTION)
|
|
440
|
+
if not options.count():
|
|
441
|
+
box.locator("[role=combobox]").click()
|
|
442
|
+
if typed:
|
|
443
|
+
page.keyboard.type(value)
|
|
444
|
+
exact = options.filter(has_text=re.compile(rf"^\s*{re.escape(value)}\s*$"))
|
|
445
|
+
(exact if exact.count() else options.filter(has_text=value)).first.click()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _shown(kind: str, value: str) -> str:
|
|
449
|
+
"""A control's value as the answer gives it: a month `MM/YYYY`, a day `YYYY-MM-DD`, an
|
|
450
|
+
amount without its thousands separators."""
|
|
451
|
+
try:
|
|
452
|
+
match kind:
|
|
453
|
+
case "month" if value:
|
|
454
|
+
parsed = date.strptime(value, MONTH)
|
|
455
|
+
return f"{parsed.month:02}/{parsed.year}"
|
|
456
|
+
case "date" if value:
|
|
457
|
+
return date.strptime(value, DAY).isoformat()
|
|
458
|
+
except ValueError:
|
|
459
|
+
return value
|
|
460
|
+
if kind in ("number", "money", "min", "max"):
|
|
461
|
+
return value.replace(",", "")
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _message(page: Page) -> str:
|
|
466
|
+
shown = page.locator(".rf-status-message, [role=alert]:visible, .toast:visible")
|
|
467
|
+
return " ".join(" ".join(shown.all_inner_texts()).split())[:200] or "no message shown"
|
|
@@ -271,6 +271,14 @@ hosting = ["hosted"]
|
|
|
271
271
|
adapter = "recruitee"
|
|
272
272
|
scope = { is = "supported", date = 2026-10-25, commit = "f47f160" }
|
|
273
273
|
|
|
274
|
+
[recruiterflow]
|
|
275
|
+
kind = "ats"
|
|
276
|
+
share = 0.0011
|
|
277
|
+
account = "none"
|
|
278
|
+
hosting = ["hosted"]
|
|
279
|
+
adapter = "recruiterflow"
|
|
280
|
+
scope = { is = "supported", date = 2026-10-28, commit = "043caeb" }
|
|
281
|
+
|
|
274
282
|
[hrmdirect]
|
|
275
283
|
kind = "ats"
|
|
276
284
|
share = 0.0059
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|