atsfill 0.0.4.dev34__tar.gz → 0.0.4.dev35__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 (36) hide show
  1. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/README.md +1 -1
  3. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev35/src/atsfill/adapters/breezy.py +403 -0
  7. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/dayforce.py +0 -0
  12. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/greenhouse.py +0 -0
  13. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/hireology.py +0 -0
  14. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/jazzhr.py +0 -0
  15. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/lever.py +0 -0
  16. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/manatal.py +0 -0
  17. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/oracle.py +0 -0
  18. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/paycor.py +0 -0
  19. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/paylocity.py +0 -0
  20. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/recruitee.py +0 -0
  21. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/smartrecruiters.py +0 -0
  22. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/teamtailor.py +0 -0
  23. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/workable.py +0 -0
  24. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/adapters/wttj.py +0 -0
  25. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/cli.py +0 -0
  26. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/__init__.py +0 -0
  27. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/accounts.py +0 -0
  28. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/adapter.py +0 -0
  29. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/attempts.py +0 -0
  30. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/browser.py +0 -0
  31. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/commands.py +0 -0
  32. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/matching.py +0 -0
  33. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/paths.py +0 -0
  34. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/registry.py +0 -0
  35. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/src/atsfill/core/results.py +0 -0
  36. {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev35}/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.dev34
3
+ Version: 0.0.4.dev35
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) ![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)
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) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-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) ![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)
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) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-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.dev34"
3
+ version = "0.0.4.dev35"
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.dev34"
3
+ version = "0.0.4.dev35"
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,4 +1,5 @@
1
1
  from atsfill.adapters.ashby import Ashby
2
+ from atsfill.adapters.breezy import Breezy
2
3
  from atsfill.adapters.dayforce import Dayforce
3
4
  from atsfill.adapters.greenhouse import Greenhouse
4
5
  from atsfill.adapters.hireology import Hireology
@@ -31,4 +32,5 @@ ADAPTERS: list[Adapter] = [
31
32
  Lever(),
32
33
  Dayforce(),
33
34
  Recruitee(),
35
+ Breezy(),
34
36
  ]
@@ -0,0 +1,403 @@
1
+ """Breezy HR (`<company>.breezy.hr/p/<position>`, or a company's own domain), as
2
+ docs/adapters/breezy.md observes.
3
+
4
+ Two page engines, an AngularJS one and a React one, render the same markup: named controls
5
+ under `h3` headings, the questionnaire's as `section_<section>_question_<n>`. The fields are
6
+ read from the page. Files upload to Breezy when attached; the rest reaches it on submit.
7
+ """
8
+
9
+ import re
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ from atsfill.core import browser
16
+ from atsfill.core.adapter import Unsupported
17
+ from atsfill.core.browser import Locator, Page, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ HOST = re.compile(r"^[a-z0-9-]+\.breezy\.hr$")
21
+ JOB = re.compile(r"^/p/([0-9a-f]{12,})(?:-[^/]*)?(?:/apply)?/?$")
22
+ PORTAL = "script[src*='assets-cdn.breezy.hr/breezy-portal']"
23
+ APPLIED = re.compile(r"/api/apply/[0-9a-f]+$")
24
+ REFERENCE = re.compile(r"^(section_\w+)\.reference(\d+)\.(\w+)$")
25
+ FORM = "form[name=form]"
26
+ NEXT = "button[ng-click='nextSection()']"
27
+ SUBMIT = "button[ng-click='apply()'], button[type=submit]"
28
+ FORM_TIMEOUT_MS = 30_000
29
+ UPLOAD_TIMEOUT_MS = 30_000
30
+ STEP_TIMEOUT_MS = 5_000
31
+ SUBMIT_TIMEOUT_MS = 90_000
32
+ MAX_STEPS = 20
33
+ YES_NO = ["Yes", "No"]
34
+ KINDS = {
35
+ "text": FieldType.TEXT,
36
+ "phone": FieldType.PHONE,
37
+ "date": FieldType.DATE,
38
+ "select": FieldType.CHOICE,
39
+ "radio": FieldType.CHOICE,
40
+ "consent": FieldType.CHOICE,
41
+ "checkboxes": FieldType.MULTI_CHOICE,
42
+ "file": FieldType.FILE,
43
+ }
44
+ # Labels of the controls without a heading of their own.
45
+ LABELS = {
46
+ "cResume": "Resume",
47
+ "salaryCurrency": "Desired Salary: currency",
48
+ "salaryPeriod": "Desired Salary: period",
49
+ }
50
+ REFERENCE_PARTS = {
51
+ "name": "Full Name",
52
+ "email_address": "Email Address",
53
+ "phone_number": "Phone Number",
54
+ "type": "Relationship",
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Entry:
60
+ """A list the page adds entries to on a click; atsfill fills its first entry."""
61
+
62
+ model: str # its controls' `ng-model` object
63
+ add: str # what its "Add" link calls
64
+ heading: str
65
+ checked: tuple[str, ...] # what Breezy checks of the first entry when the list is required
66
+ fields: tuple[tuple[str, str, str], ...] # (key, label, kind)
67
+
68
+
69
+ _DATES = (("date_start", "Start date", "date"), ("date_end", "End date", "date"))
70
+ ENTRIES = {
71
+ "work_history": Entry(
72
+ "candidatePosition",
73
+ "addPosition",
74
+ "Work History",
75
+ ("company_name", "title"),
76
+ (
77
+ ("company_name", "Company", "text"),
78
+ ("title", "Title", "text"),
79
+ *_DATES,
80
+ ("summary", "Summary", "text"),
81
+ ),
82
+ ), # fmt: skip
83
+ "education": Entry(
84
+ "candidateSchool",
85
+ "addEducation",
86
+ "Education",
87
+ ("school_name", "field_of_study"),
88
+ (
89
+ ("school_name", "School", "text"),
90
+ ("field_of_study", "Field of Study", "text"),
91
+ *_DATES,
92
+ ("summary", "Summary", "text"),
93
+ ),
94
+ ), # fmt: skip
95
+ }
96
+
97
+ # The fields in page order, one per control name, with its heading's text and how it renders;
98
+ # a question the adapter cannot fill is kept as its kind, to be refused when required.
99
+ _READ = """form => {
100
+ const text = el => {
101
+ if (!el) return "";
102
+ const copy = el.cloneNode(true);
103
+ copy.querySelectorAll(".required, .question-body, select, ul, .error-container")
104
+ .forEach(e => e.remove());
105
+ return copy.textContent.replace(/\\s+/g, " ").trim();
106
+ };
107
+ const starred = h => !!h && [...h.querySelectorAll(".required")]
108
+ .some(s => !s.classList.contains("ng-hide"));
109
+ const heading = el => {
110
+ for (let n = el; n && n !== form; n = n.parentElement)
111
+ for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
112
+ const h = s.matches("h3") ? s : s.querySelector("h3");
113
+ if (h) return h;
114
+ }
115
+ return null;
116
+ };
117
+ const optionText = el => text(el.closest("li.option, li, label"));
118
+ const found = new Map();
119
+ const add = field => {
120
+ if (!found.has(field.id)) found.set(field.id, field);
121
+ return found.get(field.id);
122
+ };
123
+ // A question whose controls are not one named group: its references, or its video.
124
+ const done = new Set();
125
+ const special = q => {
126
+ if (!q || done.has(q)) return done.has(q);
127
+ const h = q.querySelector("h3");
128
+ if (q.querySelector(".referencecheck")) {
129
+ const qid = q.querySelector("[ng-model='reference.name']")?.name;
130
+ const refs = [...q.querySelectorAll("li.reference")];
131
+ refs.forEach((ref, n) => {
132
+ for (const el of ref.querySelectorAll("[ng-model^='reference.']")) {
133
+ const part = el.getAttribute("ng-model").slice("reference.".length);
134
+ const kind = el.type === "radio" ? "radio" : el.type === "tel" ? "phone" : "text";
135
+ const f = add({id: `${qid}.reference${n + 1}.${part}`, kind, options: [],
136
+ question: text(h), part, n: n + 1, refs: refs.length,
137
+ required: el.required || starred(h)});
138
+ if (el.type === "radio") f.options.push(optionText(el));
139
+ }
140
+ });
141
+ } else if (!q.querySelector("[name^='section_']") &&
142
+ q.querySelector("input[type=file], .ziggeo, [ng-if*='video']")) {
143
+ add({id: `video_${found.size}`, label: text(h), kind: "video", required: starred(h)});
144
+ } else return false;
145
+ done.add(q);
146
+ return true;
147
+ };
148
+ for (const el of form.querySelectorAll("input, select, textarea")) {
149
+ if (["hidden", "submit", "button"].includes(el.type)) continue;
150
+ if (el.closest(".apply-field-extra") || special(el.closest("li.question"))) continue;
151
+ const model = el.getAttribute("ng-model") || "";
152
+ const id = el.name || (model === "candidate.salary.period" ? "salaryPeriod" : "");
153
+ if (!id) continue;
154
+ const h = heading(el);
155
+ let kind;
156
+ if (el.type === "file") kind = "file";
157
+ else if (el.tagName === "SELECT") kind = "select";
158
+ else if (el.type === "radio") kind = "radio";
159
+ else if (el.type === "checkbox") kind = id.startsWith("section_") ? "checkboxes" : "consent";
160
+ else if (el.type === "date") kind = "date";
161
+ else if (id === "cPhoneNumber" || el.type === "tel") kind = "phone";
162
+ else kind = "text";
163
+ const accept = el.accept ? el.accept.split(",").map(a => a.trim()).filter(Boolean) : null;
164
+ const field = add({id, kind, options: [], required: false, accept,
165
+ label: kind === "consent" ? optionText(el) : text(h),
166
+ maxLength: el.maxLength > 0 ? el.maxLength : null});
167
+ field.required ||= el.required || (kind !== "consent" && starred(h));
168
+ if (kind === "select")
169
+ field.options = [...el.options].map(o => o.textContent.trim()).filter(Boolean);
170
+ if (kind === "radio" || kind === "checkboxes") field.options.push(optionText(el));
171
+ }
172
+ return [...found.values()];
173
+ }"""
174
+
175
+ # What one field's controls show: a choice its option's text, a box Yes/No, a file the name
176
+ # Breezy shows once it has the upload.
177
+ _READ_BACK = """(controls, kind) => {
178
+ const el = controls[0];
179
+ const optionText = e => (e.closest("li.option, li, label")?.textContent || "")
180
+ .replace(/\\s+/g, " ").trim();
181
+ if (kind === "file")
182
+ return el.closest(".file-input-container")?.querySelector("a.bzyLinkColor:not(.placeholder)")
183
+ ?.textContent.trim() || null;
184
+ if (kind === "select") {
185
+ const option = el.selectedOptions[0];
186
+ return option && option.value !== "?" && option.textContent.trim() || null;
187
+ }
188
+ if (kind === "radio") {
189
+ const checked = controls.find(e => e.checked);
190
+ return checked ? optionText(checked) : null;
191
+ }
192
+ if (kind === "checkboxes") return controls.filter(e => e.checked).map(optionText);
193
+ if (kind === "consent") return el.checked ? "Yes" : "No";
194
+ return el.value;
195
+ }"""
196
+
197
+ # The step on show: the first section the page displays.
198
+ _STEP = """form => [...form.querySelectorAll("[data-section]")]
199
+ .find(s => s.getClientRects().length > 0)?.dataset.section ?? null"""
200
+
201
+ _OPTION_TEXT = """e => (e.closest("li.option, li, label")?.textContent || "")
202
+ .replace(/\\s+/g, " ").trim()"""
203
+
204
+ # Whether a file input's step is on show: the input itself is always hidden.
205
+ _FILE_SHOWN = """e => {
206
+ const step = e.closest("[data-section]");
207
+ return !step || step.getClientRects().length > 0;
208
+ }"""
209
+
210
+
211
+ class Breezy:
212
+ name = "breezy"
213
+ side_effects = [
214
+ SideEffect("upload", "hiring_system", "an attached file is uploaded to Breezy at once"),
215
+ SideEffect("submit", "employer", "the application reaches the employer"),
216
+ ]
217
+
218
+ def __init__(self) -> None:
219
+ self._kinds: dict[str, str] = {} # how the page renders each field
220
+
221
+ def detect(self, url: str, page: Page | None) -> bool:
222
+ """A `*.breezy.hr` position, or one on a company's domain that serves Breezy's portal."""
223
+ parts = urlsplit(url)
224
+ if not JOB.match(parts.path):
225
+ return False
226
+ if HOST.match(parts.hostname or ""):
227
+ return True
228
+ return page is not None and page.locator(PORTAL).count() > 0
229
+
230
+ def posting(self, url: str) -> str:
231
+ parts = urlsplit(url)
232
+ match = JOB.match(parts.path)
233
+ return f"https://{parts.hostname}/p/{match[1]}" if match else url
234
+
235
+ def read_api(self, url: str) -> list[Field] | None:
236
+ return None # the form's configuration is only in the page
237
+
238
+ def read_form(self, page: Page) -> list[Field]:
239
+ if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
240
+ page.goto(f"{self.posting(page.url)}/apply")
241
+ form = page.locator(FORM)
242
+ try:
243
+ form.locator("[name=cName]").wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
244
+ except Timeout:
245
+ raise Unsupported("posting_gone: no application form on the page") from None
246
+ fields, self._kinds = [], {}
247
+ for raw in form.evaluate(_READ):
248
+ if (field := _field(raw)) is not None:
249
+ fields.append(field)
250
+ self._kinds[field.id] = raw["kind"]
251
+ return fields + self._entries(form)
252
+
253
+ def _entries(self, form: Locator) -> list[Field]:
254
+ """The first work history and education entry, when the page offers to add them."""
255
+ out = []
256
+ for prefix, entry in ENTRIES.items():
257
+ if not form.locator(f"a[ng-click*='{entry.add}()']").count():
258
+ continue
259
+ flag = form.locator(f"#{prefix.split('_')[0]}_required")
260
+ required = flag.count() > 0 and flag.first.get_attribute("value") == "required"
261
+ for key, label, kind in entry.fields:
262
+ self._kinds[f"{prefix}.{key}"] = kind
263
+ out.append(
264
+ Field(
265
+ f"{prefix}.{key}",
266
+ f"{entry.heading}: {label}",
267
+ KINDS[kind],
268
+ required and key in entry.checked,
269
+ )
270
+ )
271
+ return out
272
+
273
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
274
+ """Fills the step on show, then goes on with Continue while answers are left."""
275
+ form = page.locator(FORM)
276
+ pending = {k: v for k, v in answers.items() if k in self._kinds}
277
+ for _ in range(MAX_STEPS):
278
+ for field_id in list(pending):
279
+ if _shown(form, field_id):
280
+ self._fill(form, field_id, pending.pop(field_id))
281
+ step = form.locator(NEXT)
282
+ if not pending or not step.is_visible():
283
+ return
284
+ shown = form.evaluate(_STEP)
285
+ browser.click(step)
286
+ with suppress(Timeout):
287
+ page.wait_for_function(
288
+ f"was => ({_STEP})(document.querySelector({FORM!r})) !== was",
289
+ arg=shown,
290
+ timeout=STEP_TIMEOUT_MS,
291
+ )
292
+ if form.evaluate(_STEP) == shown:
293
+ return # Breezy kept the step: an answer it checks is missing
294
+
295
+ def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
296
+ values = [answer] if isinstance(answer, str) else answer
297
+ entry = ENTRIES.get(field_id.split(".")[0])
298
+ if entry and not _controls(form, field_id).count():
299
+ browser.click(form.locator(f"a[ng-click*='{entry.add}()']"))
300
+ _controls(form, field_id).first.wait_for(timeout=STEP_TIMEOUT_MS)
301
+ controls = _controls(form, field_id)
302
+ match self._kinds[field_id]:
303
+ case "file":
304
+ controls.first.set_input_files(values[0])
305
+ with suppress(Timeout): # the name shows once Breezy has the upload
306
+ controls.first.locator(
307
+ "xpath=ancestor::*[contains(@class,'file-input-container')][1]"
308
+ ).locator("a.bzyLinkColor:not(.placeholder)").first.wait_for(
309
+ timeout=UPLOAD_TIMEOUT_MS
310
+ )
311
+ case "select":
312
+ controls.first.select_option(label=values[0])
313
+ case "radio" | "checkboxes":
314
+ for value in values:
315
+ if (option := _option(controls, value)) is not None:
316
+ option.check()
317
+ case "consent":
318
+ controls.first.set_checked(values[0] == "Yes")
319
+ case _:
320
+ controls.first.fill(values[0])
321
+
322
+ def read_back(self, page: Page) -> dict[str, Answer]:
323
+ form = page.locator(FORM)
324
+ out: dict[str, Answer] = {}
325
+ for field_id, kind in self._kinds.items():
326
+ controls = _controls(form, field_id)
327
+ if not controls.count():
328
+ continue
329
+ value = controls.evaluate_all(_READ_BACK, kind)
330
+ if value not in (None, "", []):
331
+ out[field_id] = value
332
+ return out
333
+
334
+ def submit(self, page: Page) -> str | None:
335
+ """Clicks Submit Application; the evidence is Breezy's answer to the application."""
336
+ with page.expect_response(
337
+ lambda r: r.request.method == "POST" and APPLIED.search(r.url) is not None,
338
+ timeout=SUBMIT_TIMEOUT_MS,
339
+ ) as applied:
340
+ browser.click(page.locator(FORM).locator(SUBMIT).locator("visible=true").first)
341
+ response = applied.value
342
+ if not response.ok:
343
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
344
+ return f"{response.url} {response.status}"
345
+
346
+
347
+ def _field(raw: dict[str, Any]) -> Field | None:
348
+ """The field, or None for one the page asks optionally and atsfill cannot fill."""
349
+ kind = raw["kind"]
350
+ if kind == "video":
351
+ if raw["required"]:
352
+ raise Unsupported(f"form_shape: {raw['label']!r} asks for a recorded video")
353
+ return None
354
+ label = LABELS.get(raw["id"]) or raw.get("label", "")
355
+ if "part" in raw:
356
+ label = (
357
+ f"{raw['question']} (reference {raw['n']} of {raw['refs']}): "
358
+ f"{REFERENCE_PARTS.get(raw['part'], raw['part'])}"
359
+ )
360
+ options = {"consent": YES_NO}.get(kind)
361
+ if kind in ("select", "radio", "checkboxes"):
362
+ options = raw["options"]
363
+ max_length = raw.get("maxLength") if KINDS[kind] is FieldType.TEXT else None
364
+ accept = raw.get("accept") if kind == "file" else None
365
+ return Field(raw["id"], label, KINDS[kind], raw["required"], options, max_length, accept)
366
+
367
+
368
+ def _controls(form: Locator, field_id: str) -> Locator:
369
+ """Every control of a field."""
370
+ prefix, _, key = field_id.partition(".")
371
+ if entry := ENTRIES.get(prefix):
372
+ return form.locator(f"[ng-model='{entry.model}.{key}']")
373
+ if field_id == "salaryPeriod":
374
+ return form.locator("select[ng-model='candidate.salary.period']")
375
+ if reference := REFERENCE.match(field_id):
376
+ question, n, part = reference.groups()
377
+ return (
378
+ form.locator(f"li.question:has([name='{question}'])")
379
+ .locator("li.reference")
380
+ .nth(int(n) - 1)
381
+ .locator(f"[ng-model='reference.{part}']")
382
+ )
383
+ return form.locator(f"[name='{field_id}']")
384
+
385
+
386
+ def _shown(form: Locator, field_id: str) -> bool:
387
+ """Whether the field's step is on show; an entry's by the link that adds it."""
388
+ if entry := ENTRIES.get(field_id.split(".")[0]):
389
+ return form.locator(f"a[ng-click*='{entry.add}()']").is_visible()
390
+ control = _controls(form, field_id).first
391
+ if not control.count():
392
+ return False
393
+ if control.get_attribute("type") == "file":
394
+ return control.evaluate(_FILE_SHOWN)
395
+ return control.is_visible()
396
+
397
+
398
+ def _option(controls: Locator, value: str) -> Locator | None:
399
+ """The radio or checkbox whose text is `value`."""
400
+ for control in controls.all():
401
+ if control.evaluate(_OPTION_TEXT) == value:
402
+ return control
403
+ return None
@@ -264,7 +264,9 @@ kind = "ats"
264
264
  share = 0.0069
265
265
  account = "none"
266
266
  hosting = ["hosted"]
267
+ adapter = "breezy"
267
268
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
269
+ dry_run_verified = { date = 2026-10-21, version = "0.0.3", by = "gate", commit = "aac3924" }
268
270
 
269
271
  [recruitee]
270
272
  kind = "ats"
File without changes