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.
Files changed (61) hide show
  1. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/README.md +1 -0
  3. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.5.dev152/src/atsfill/adapters/recruiterflow.py +467 -0
  7. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/registry.toml +8 -0
  8. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/LICENSE +0 -0
  9. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/gupy.py +0 -0
  17. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/isolvedhire.py +0 -0
  21. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/jazzhr.py +0 -0
  22. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/jobaffinity.py +0 -0
  23. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/join.py +0 -0
  24. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/lever.py +0 -0
  25. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/manatal.py +0 -0
  26. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/oracle.py +0 -0
  27. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/paycor.py +0 -0
  28. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/paylocity.py +0 -0
  29. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/personio.py +0 -0
  30. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/phenom.py +0 -0
  31. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/pinpoint.py +0 -0
  32. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/recruitee.py +0 -0
  33. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/rippling.py +0 -0
  34. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/smartrecruiters.py +0 -0
  35. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/successfactors.js +0 -0
  36. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/successfactors.py +0 -0
  37. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/taleo.py +0 -0
  38. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/teamtailor.py +0 -0
  39. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/ukg.py +0 -0
  40. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/workable.py +0 -0
  41. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/workday.py +0 -0
  42. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/wttj.py +0 -0
  43. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/adapters/zohorecruit.py +0 -0
  44. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/cli.py +0 -0
  45. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/__init__.py +0 -0
  46. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/accounts.py +0 -0
  47. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/adapter.py +0 -0
  48. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/attempts.py +0 -0
  49. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/browser.py +0 -0
  50. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/commands.py +0 -0
  51. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/conditions.py +0 -0
  52. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/html.py +0 -0
  53. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/matching.py +0 -0
  54. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/otp.py +0 -0
  55. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/passwords.py +0 -0
  56. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/paths.py +0 -0
  57. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/registry.py +0 -0
  58. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/results.py +0 -0
  59. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/snapshot.py +0 -0
  60. {atsfill-0.0.5.dev150 → atsfill-0.0.5.dev152}/src/atsfill/core/telemetry.py +0 -0
  61. {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.dev150
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
  [![eightfold run history](https://shbernal.github.io/atsfill/run-history/eightfold.svg)](https://shbernal.github.io/atsfill/run-history/)
82
82
  [![pinpoint run history](https://shbernal.github.io/atsfill/run-history/pinpoint.svg)](https://shbernal.github.io/atsfill/run-history/)
83
83
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
84
+ [![recruiterflow run history](https://shbernal.github.io/atsfill/run-history/recruiterflow.svg)](https://shbernal.github.io/atsfill/run-history/)
84
85
  [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
85
86
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
86
87
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](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
  [![eightfold run history](https://shbernal.github.io/atsfill/run-history/eightfold.svg)](https://shbernal.github.io/atsfill/run-history/)
53
53
  [![pinpoint run history](https://shbernal.github.io/atsfill/run-history/pinpoint.svg)](https://shbernal.github.io/atsfill/run-history/)
54
54
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
55
+ [![recruiterflow run history](https://shbernal.github.io/atsfill/run-history/recruiterflow.svg)](https://shbernal.github.io/atsfill/run-history/)
55
56
  [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
56
57
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
57
58
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev150"
3
+ version = "0.0.5.dev152"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev150"
3
+ version = "0.0.5.dev152"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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