atsfill 0.0.4.dev33__tar.gz → 0.0.4.dev34__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 (35) hide show
  1. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/README.md +1 -1
  3. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev34/src/atsfill/adapters/recruitee.py +218 -0
  7. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/dayforce.py +0 -0
  12. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/greenhouse.py +0 -0
  13. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/hireology.py +0 -0
  14. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/jazzhr.py +0 -0
  15. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/lever.py +0 -0
  16. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/manatal.py +0 -0
  17. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/oracle.py +0 -0
  18. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/paycor.py +0 -0
  19. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/paylocity.py +0 -0
  20. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/smartrecruiters.py +0 -0
  21. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/teamtailor.py +0 -0
  22. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/workable.py +0 -0
  23. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/adapters/wttj.py +0 -0
  24. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/cli.py +0 -0
  25. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/__init__.py +0 -0
  26. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/accounts.py +0 -0
  27. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/adapter.py +0 -0
  28. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/attempts.py +0 -0
  29. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/browser.py +0 -0
  30. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/commands.py +0 -0
  31. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/matching.py +0 -0
  32. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/paths.py +0 -0
  33. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/registry.py +0 -0
  34. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/src/atsfill/core/results.py +0 -0
  35. {atsfill-0.0.4.dev33 → atsfill-0.0.4.dev34}/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.dev33
3
+ Version: 0.0.4.dev34
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
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
53
53
  ## Supported hiring systems
54
54
 
55
55
  <!-- badges -->
56
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
56
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
57
57
  <!-- /badges -->
58
58
 
59
59
  | 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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev33"
3
+ version = "0.0.4.dev34"
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.4.dev33"
3
+ version = "0.0.4.dev34"
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"
@@ -8,6 +8,7 @@ from atsfill.adapters.manatal import Manatal
8
8
  from atsfill.adapters.oracle import Oracle
9
9
  from atsfill.adapters.paycor import Paycor
10
10
  from atsfill.adapters.paylocity import Paylocity
11
+ from atsfill.adapters.recruitee import Recruitee
11
12
  from atsfill.adapters.smartrecruiters import SmartRecruiters
12
13
  from atsfill.adapters.teamtailor import Teamtailor
13
14
  from atsfill.adapters.workable import Workable
@@ -29,4 +30,5 @@ ADAPTERS: list[Adapter] = [
29
30
  JazzHR(),
30
31
  Lever(),
31
32
  Dayforce(),
33
+ Recruitee(),
32
34
  ]
@@ -0,0 +1,218 @@
1
+ """Recruitee (`*.recruitee.com` career sites and custom domains on them), as
2
+ docs/adapters/recruitee.md observes.
3
+
4
+ The form is the career site's own page, `/o/<slug>/c/new`: every field is a named control,
5
+ `candidate.<field>` or `candidate.openQuestionAnswers.<question>.<value>`, and the name is the
6
+ field's id. Nothing reaches Recruitee before submit, so the read-back is the page.
7
+ """
8
+
9
+ import re
10
+ from typing import Any
11
+ from urllib.parse import urlsplit
12
+
13
+ from atsfill.core import browser
14
+ from atsfill.core.adapter import Blocked, Unsupported
15
+ from atsfill.core.browser import Locator, Page, Timeout
16
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
+
18
+ JOB = re.compile(r"^/o/([^/?#]+)")
19
+ CAREER_SITE = "script[src*='/js/PublicApp/'], script[src*='recruiteecdn.com']"
20
+ FORM = "form#offer-application-form"
21
+ FORM_TIMEOUT_MS = 30_000
22
+ SUBMIT_TIMEOUT_MS = 90_000
23
+ YES_NO = ["Yes", "No"]
24
+ EXTENSIONS = {"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "application/pdf": [".pdf"]}
25
+ KINDS = {
26
+ "text": FieldType.TEXT,
27
+ "number": FieldType.TEXT,
28
+ "phone": FieldType.PHONE,
29
+ "date": FieldType.DATE,
30
+ "file": FieldType.FILE,
31
+ "radio": FieldType.CHOICE,
32
+ "legal": FieldType.CHOICE,
33
+ "checkboxes": FieldType.MULTI_CHOICE,
34
+ }
35
+
36
+ # The fields in page order, one per control name: its question's label, and how it renders. A
37
+ # labelled question without a named control (a video recorder, drawn late) comes as kind
38
+ # `video`; one without a label is text only.
39
+ _READ = """form => {
40
+ const text = el => {
41
+ if (!el) return "";
42
+ const copy = el.cloneNode(true);
43
+ copy.querySelectorAll("[aria-hidden=true]").forEach(e => e.remove());
44
+ return copy.textContent.replace(/\\s+/g, " ").trim();
45
+ };
46
+ const found = new Map();
47
+ const controls = form.querySelectorAll(
48
+ "input[name]:not([type=hidden]), textarea[name], input[name$='.openQuestionId']");
49
+ for (const el of controls) {
50
+ if (el.type === "hidden") {
51
+ const question = el.name.replace(/\\.openQuestionId$/, "");
52
+ const label = el.parentElement.querySelector("label");
53
+ if (label && !form.querySelector(
54
+ `[name^="${CSS.escape(question)}."]:not([type=hidden])`))
55
+ found.set(question, {id: question, kind: "video", label: text(label),
56
+ required: /\\*/.test(label.textContent), options: []});
57
+ continue;
58
+ }
59
+ const name = el.name;
60
+ let field = found.get(name);
61
+ if (!field) {
62
+ const group = el.closest("fieldset");
63
+ field = {id: name, required: false, options: [], accept: null,
64
+ maxLength: el.maxLength > 0 ? el.maxLength : null};
65
+ if (el.type === "radio") field.kind = "radio";
66
+ else if (el.type === "checkbox" && /\.(flag|consent)$/.test(name)) {
67
+ field.kind = "legal";
68
+ field.label = text(form.querySelector(`[id="${el.getAttribute("aria-labelledby")}"]`));
69
+ } else if (el.type === "checkbox") field.kind = "checkboxes";
70
+ else if (el.type === "file") {
71
+ field.kind = "file";
72
+ field.accept = el.accept ? el.accept.split(",").map(a => a.trim()) : null;
73
+ } else if (el.type === "tel") field.kind = "phone";
74
+ else if (el.type === "date") field.kind = "date";
75
+ else if (el.type === "number") field.kind = "number";
76
+ else field.kind = "text";
77
+ if (field.label === undefined) field.label = ["radio", "checkboxes"].includes(field.kind)
78
+ ? text(group?.querySelector("legend"))
79
+ : text(form.querySelector(`label[for="${el.id}"]`)) || el.placeholder;
80
+ found.set(name, field);
81
+ }
82
+ field.required ||= el.required;
83
+ if (el.type === "radio" || el.type === "checkbox" && field.kind === "checkboxes")
84
+ field.options.push(text(form.querySelector(`label[for="${el.id}"]`)) || el.value);
85
+ }
86
+ return [...found.values()];
87
+ }"""
88
+
89
+ # What each field shows: a choice its checked option's text, a file its attached name.
90
+ _READ_BACK = """(form, kinds) => {
91
+ const optionText = el => (form.querySelector(`label[for="${el.id}"]`)?.textContent || el.value)
92
+ .replace(/\\s+/g, " ").trim();
93
+ const out = {};
94
+ for (const [name, kind] of Object.entries(kinds)) {
95
+ const all = [...form.querySelectorAll(`[name="${CSS.escape(name)}"]`)];
96
+ if (!all.length) continue;
97
+ const el = all[0];
98
+ if (kind === "file") out[name] = el.files[0]?.name || null;
99
+ else if (kind === "radio") {
100
+ const checked = all.find(e => e.checked);
101
+ out[name] = checked ? optionText(checked) : null;
102
+ } else if (kind === "checkboxes") out[name] = all.filter(e => e.checked).map(optionText);
103
+ else if (kind === "legal") out[name] = el.checked ? "Yes" : "No";
104
+ else out[name] = el.value;
105
+ }
106
+ return out;
107
+ }"""
108
+
109
+
110
+ class Recruitee:
111
+ name = "recruitee"
112
+ side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
113
+
114
+ def __init__(self) -> None:
115
+ self._kinds: dict[str, str] = {} # how the page renders each field
116
+
117
+ def detect(self, url: str, page: Page | None) -> bool:
118
+ """A `*.recruitee.com` posting, or a posting page that loads Recruitee's career site."""
119
+ parts = urlsplit(url)
120
+ if not JOB.match(parts.path):
121
+ return False
122
+ if (parts.hostname or "").endswith(".recruitee.com"):
123
+ return True
124
+ return page is not None and page.locator(CAREER_SITE).count() > 0
125
+
126
+ def posting(self, url: str) -> str:
127
+ parts = urlsplit(url)
128
+ match = JOB.match(parts.path)
129
+ return f"https://{parts.hostname}/o/{match[1]}" if match else url
130
+
131
+ def read_api(self, url: str) -> list[Field] | None:
132
+ return None
133
+
134
+ def read_form(self, page: Page) -> list[Field]:
135
+ if not urlsplit(page.url).path.rstrip("/").endswith("/c/new"):
136
+ page.goto(f"{self.posting(page.url)}/c/new")
137
+ form = page.locator(FORM)
138
+ try:
139
+ form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
140
+ except Timeout:
141
+ raise Unsupported("posting_gone: no application form on the page") from None
142
+ fields, self._kinds = [], {}
143
+ for raw in form.evaluate(_READ):
144
+ if raw["kind"] == "video": # recorded in the page, or uploaded to a video service
145
+ if raw["required"]:
146
+ raise Unsupported(f"form_shape: {raw['label']!r} is a video question")
147
+ continue
148
+ fields.append(_field(raw))
149
+ self._kinds[raw["id"]] = raw["kind"]
150
+ return fields
151
+
152
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
153
+ for name, answer in answers.items():
154
+ values = [answer] if isinstance(answer, str) else answer
155
+ controls = page.locator(f"{FORM} [name='{name}']")
156
+ match self._kinds[name]:
157
+ case "file":
158
+ controls.first.set_input_files(values[0])
159
+ case "radio" | "checkboxes":
160
+ for value in values:
161
+ if (option := _option(page, controls, value)) is not None:
162
+ _set(option, True)
163
+ case "legal":
164
+ _set(controls.first, answer == "Yes")
165
+ case "phone": # the country picker follows the number's own prefix
166
+ controls.first.fill("")
167
+ controls.first.press_sequentially(values[0], delay=20)
168
+ case _:
169
+ controls.first.fill(values[0])
170
+
171
+ def read_back(self, page: Page) -> dict[str, Answer]:
172
+ values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
173
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
174
+
175
+ def submit(self, page: Page) -> str | None:
176
+ """Clicks submit; hCaptcha checks the visitor, then the page shows its thanks panel, the
177
+ evidence, or a panel saying this address already applied."""
178
+ browser.click(page.locator(f"{FORM} [data-testid=submit-application-form-button]"))
179
+ panel = page.locator("[role=tabpanel]:not([hidden]):has([aria-live=assertive] h3)")
180
+ try:
181
+ panel.wait_for(timeout=SUBMIT_TIMEOUT_MS)
182
+ except Timeout:
183
+ if page.locator("iframe[src*='hcaptcha'][title*='challenge']").is_visible():
184
+ raise Blocked("captcha: hCaptcha asked for a challenge") from None
185
+ raise
186
+ shown = " ".join((panel.locator("h3").text_content() or "").split())
187
+ if panel.locator("a[href^='mailto:']").count():
188
+ raise Blocked(f"already_applied: {shown}")
189
+ return f"{page.url} ({shown})"
190
+
191
+
192
+ def _field(raw: dict[str, Any]) -> Field:
193
+ kind = raw["kind"]
194
+ label = raw["label"] + (" (a number)" if kind == "number" else "")
195
+ options = {"legal": YES_NO, "radio": raw["options"], "checkboxes": raw["options"]}.get(kind)
196
+ accept = None
197
+ if raw["accept"]:
198
+ accept = [e for a in raw["accept"] for e in EXTENSIONS.get(a, [])] or None
199
+ return Field(
200
+ raw["id"], label, KINDS[kind], raw["required"], options, raw.get("maxLength"), accept
201
+ )
202
+
203
+
204
+ def _set(control: Locator, checked: bool) -> None:
205
+ """Checks or unchecks a radio or checkbox, drawn hidden under its styled box, by a click
206
+ event on the input itself."""
207
+ if control.is_checked() != checked:
208
+ control.dispatch_event("click")
209
+
210
+
211
+ def _option(page: Page, controls: Locator, value: str) -> Locator | None:
212
+ """The radio or checkbox whose label is `value`."""
213
+ for control in controls.all():
214
+ label = page.locator(f"{FORM} label[for='{control.get_attribute('id')}']")
215
+ text = (label.text_content() if label.count() else None) or control.get_attribute("value")
216
+ if " ".join((text or "").split()) == value:
217
+ return control
218
+ return None
@@ -271,7 +271,9 @@ kind = "ats"
271
271
  share = 0.0066
272
272
  account = "none"
273
273
  hosting = ["hosted"]
274
+ adapter = "recruitee"
274
275
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
276
+ dry_run_verified = { date = 2026-10-21, version = "0.0.3", by = "gate", commit = "4489120" }
275
277
 
276
278
  [hrmdirect]
277
279
  kind = "ats"
File without changes