atsfill 0.0.4.dev35__tar.gz → 0.0.4.dev38__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 (37) hide show
  1. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/README.md +1 -1
  3. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/pyproject.toml +2 -2
  4. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/pyproject.toml.orig +2 -2
  5. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev38/src/atsfill/adapters/bamboohr.py +445 -0
  7. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/dayforce.py +2 -1
  8. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/paylocity.py +2 -1
  9. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/recruitee.py +1 -1
  10. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/registry.toml +2 -0
  11. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/LICENSE +0 -0
  12. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/jazzhr.py +0 -0
  18. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/lever.py +0 -0
  19. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/manatal.py +0 -0
  20. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/oracle.py +0 -0
  21. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/paycor.py +0 -0
  22. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/smartrecruiters.py +0 -0
  23. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/teamtailor.py +0 -0
  24. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/workable.py +0 -0
  25. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/adapters/wttj.py +0 -0
  26. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/cli.py +0 -0
  27. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/__init__.py +0 -0
  28. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/accounts.py +0 -0
  29. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/adapter.py +0 -0
  30. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/attempts.py +0 -0
  31. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/browser.py +0 -0
  32. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/commands.py +0 -0
  33. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/matching.py +0 -0
  34. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/paths.py +0 -0
  35. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/registry.py +0 -0
  36. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/src/atsfill/core/results.py +0 -0
  37. {atsfill-0.0.4.dev35 → atsfill-0.0.4.dev38}/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.dev35
3
+ Version: 0.0.4.dev38
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) ![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)
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) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![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) ![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)
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) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![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.dev35"
3
+ version = "0.0.4.dev38"
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"
@@ -68,7 +68,7 @@ select = [
68
68
  [tool.pytest.ini_options]
69
69
  testpaths = ["tests"]
70
70
  markers = ["live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live"]
71
- addopts = "-m 'not live' -n 8"
71
+ addopts = "-m 'not live' -n 8 --dist loadscope"
72
72
 
73
73
  [[tool.uv.index]]
74
74
  name = "testpypi"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev35"
3
+ version = "0.0.4.dev38"
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"
@@ -55,7 +55,7 @@ testpaths = ["tests"]
55
55
  markers = [
56
56
  "live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live",
57
57
  ]
58
- addopts = "-m 'not live' -n 8"
58
+ addopts = "-m 'not live' -n 8 --dist loadscope"
59
59
 
60
60
  [[tool.uv.index]]
61
61
  name = "testpypi"
@@ -1,4 +1,5 @@
1
1
  from atsfill.adapters.ashby import Ashby
2
+ from atsfill.adapters.bamboohr import BambooHR
2
3
  from atsfill.adapters.breezy import Breezy
3
4
  from atsfill.adapters.dayforce import Dayforce
4
5
  from atsfill.adapters.greenhouse import Greenhouse
@@ -33,4 +34,5 @@ ADAPTERS: list[Adapter] = [
33
34
  Dayforce(),
34
35
  Recruitee(),
35
36
  Breezy(),
37
+ BambooHR(),
36
38
  ]
@@ -0,0 +1,445 @@
1
+ """BambooHR (`<company>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
2
+
3
+ The job page renders its application form, behind Apply for This Job, from a public JSON
4
+ answer that lists every field with its label, requirement and options. `read_api` fetches it
5
+ over HTTP, `read_form` through the page's own browser context, and both list the fields from
6
+ it. Dropdowns are Fabric menus over a hidden `select`; a file is uploaded once attached.
7
+ """
8
+
9
+ import json
10
+ import re
11
+ import urllib.request
12
+ from contextlib import suppress
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+
16
+ from atsfill.core import browser
17
+ from atsfill.core.adapter import Blocked, Unsupported
18
+ from atsfill.core.browser import Locator, Page, Timeout
19
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
20
+
21
+ HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
22
+ JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
23
+ DETAIL = "https://{host}/careers/{job}/detail"
24
+ STATES = "https://{host}/ajax/get_states?country={country}"
25
+ FORM = "form:has(#firstName)"
26
+ APPLY = "button:has-text('Apply for This Job'), a:has-text('Apply for This Job')"
27
+ SUBMIT = "button:has-text('Submit Application')"
28
+ CAPTCHA = "iframe[title=reCAPTCHA]"
29
+ CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
30
+ FORM_TIMEOUT_MS = 30_000
31
+ UPLOAD_TIMEOUT_MS = 30_000
32
+ SHOWN_TIMEOUT_MS = 2_000
33
+ STATES_TIMEOUT_MS = 15_000 # for the state's list a country change refetches
34
+ SETTLE_MS = 300 # for React to render what a response brought
35
+ CAPTCHA_TIMEOUT_MS = 20_000
36
+ SUBMIT_TIMEOUT_MS = 90_000
37
+ YES_NO = ["Yes", "No"]
38
+ FILE_TYPES = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt", ".rtf"]
39
+ FILE_TYPES += [".jpg", ".jpeg", ".tif", ".tiff", ".gif", ".png", ".bmp"]
40
+ EMPTY = "–Select–"
41
+ DATE_PARTS = r"yyyy|mon|mm|dd"
42
+ MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
43
+ OTHER = "Other"
44
+ # The countries the page has a state list for, by id; any other country's state is typed.
45
+ STATE_LISTS = {"1": "United States", "2": "Canada", "3": "Australia", "222": "United Kingdom"}
46
+ # The veteran question is a radio, then for a veteran a multi-select of the API's kinds.
47
+ VETERAN = {"Decline to Answer": "declineToAnswer", "Not a Veteran": "notVeteran"}
48
+
49
+ # Each standard field: (its kind, the `name` of its control).
50
+ STANDARD = {
51
+ "firstName": ("text", "firstName"),
52
+ "lastName": ("text", "lastName"),
53
+ "email": ("text", "email"),
54
+ "phone": ("phone", "phone"),
55
+ "streetAddress": ("text", "streetAddress.value"),
56
+ "city": ("text", "city.value"),
57
+ "state": ("state", "state.value"),
58
+ "zip": ("text", "zip.value"),
59
+ "countryId": ("select", "countryId.value"),
60
+ "resumeFileId": ("file", "resumeFileId"),
61
+ "coverLetterFileId": ("file", "coverLetterFileId"),
62
+ "dateAvailable": ("date", None),
63
+ "desiredPay": ("text", "desiredPay"),
64
+ "websiteUrl": ("text", "websiteUrl"),
65
+ "linkedinUrl": ("text", "linkedinUrl"),
66
+ "twitterUsername": ("text", "twitterUsername"),
67
+ "referredBy": ("text", "referredBy"),
68
+ "educationLevelId": ("select", "educationLevelId"),
69
+ "educationInstitutionName": ("text", "educationInstitutionName"),
70
+ "references": ("text", "references"),
71
+ "genderId": ("select", "genderId"),
72
+ "ethnicityId": ("select", "ethnicityId"),
73
+ "veteranStatusId": ("veteran", "veteranStatusId"),
74
+ "disabilityId": ("select", "disabilityId"),
75
+ }
76
+ # A custom question's kind by its API type; its control is `customQuestionAnswers.<type>_<id>`.
77
+ CUSTOM = {
78
+ "short": "text",
79
+ "long": "text",
80
+ "yes_no": "radio",
81
+ "multi": "radio",
82
+ "checkbox": "checkbox",
83
+ "file": "file",
84
+ }
85
+ TYPES = {
86
+ "text": FieldType.TEXT,
87
+ "phone": FieldType.PHONE,
88
+ "date": FieldType.DATE,
89
+ "select": FieldType.CHOICE,
90
+ "veteran": FieldType.CHOICE,
91
+ "state": FieldType.CHOICE,
92
+ "radio": FieldType.CHOICE,
93
+ "checkbox": FieldType.CHOICE,
94
+ "file": FieldType.FILE,
95
+ }
96
+
97
+ # The form's JSON answer and the state lists, fetched as the page fetches them.
98
+ _FETCH = """async urls => {
99
+ const out = [];
100
+ for (const url of urls) {
101
+ const response = await fetch(url);
102
+ out.push(response.ok ? {json: await response.json()} : {status: response.status});
103
+ }
104
+ return out;
105
+ }"""
106
+
107
+ # What each present control shows, by the field's kind.
108
+ _READ_BACK = """(form, specs) => {
109
+ const named = name => form.querySelector(`[name="${name}"]`);
110
+ const shown = select => {
111
+ const text = select?.closest("[data-fabric-component=Select]")
112
+ ?.querySelector("button[data-menu-id]")?.textContent.trim();
113
+ return text && text !== "–Select–" ? text : null;
114
+ };
115
+ const out = {};
116
+ for (const [id, [kind, name, extra]] of Object.entries(specs)) {
117
+ const el = name ? named(name) : null;
118
+ if (kind === "radio") {
119
+ const checked = form.querySelector(`input[name="${name}"]:checked`);
120
+ out[id] = checked ? checked.closest("label").textContent.trim() : null;
121
+ } else if (kind === "veteran") {
122
+ const checked = form.querySelector("#declineToAnswer:checked, #notVeteran:checked");
123
+ const kinds = el ? [...el.selectedOptions].map(o => extra[o.value]).filter(Boolean) : [];
124
+ out[id] = checked ? checked.closest("label").textContent.trim() : kinds[0] ?? null;
125
+ } else if (kind === "checkbox") out[id] = el ? (el.checked ? "Yes" : "No") : null;
126
+ else if (kind === "select") out[id] = shown(el);
127
+ else if (kind === "state") out[id] = el?.tagName === "SELECT" ? shown(el) : el?.value;
128
+ else if (kind === "file") {
129
+ const list = el?.parentElement
130
+ .querySelector("[data-fabric-component=FileUploadList]");
131
+ out[id] = el?.value && list ? list.textContent.trim() : null;
132
+ } else if (kind === "date") {
133
+ const input = form.querySelector("input[placeholder*=yyyy]");
134
+ out[id] = input ? [input.value, input.placeholder] : null;
135
+ } else out[id] = el?.value;
136
+ }
137
+ return out;
138
+ }"""
139
+
140
+
141
+ class BambooHR:
142
+ name = "bamboohr"
143
+ side_effects = [
144
+ SideEffect("upload", "hiring_system", "an attached file is stored by BambooHR at once"),
145
+ SideEffect("submit", "employer", "the application reaches the employer"),
146
+ ]
147
+
148
+ def __init__(self) -> None:
149
+ # each field's (kind, control name, extra): a radio's option ids by text, the
150
+ # veteran kinds' texts by id, a date's nothing
151
+ self._specs: dict[str, tuple[str, str | None, Any]] = {}
152
+
153
+ def detect(self, url: str, page: Page | None) -> bool:
154
+ return _job(url) is not None
155
+
156
+ def posting(self, url: str) -> str:
157
+ job = _job(url)
158
+ return f"https://{job[0]}/careers/{job[1]}" if job else url
159
+
160
+ def read_api(self, url: str) -> list[Field] | None:
161
+ if (job := _job(url)) is None:
162
+ return None
163
+ try:
164
+ detail = _get(DETAIL.format(host=job[0], job=job[1]))
165
+ states = {c: _get(STATES.format(host=job[0], country=c)) for c in STATE_LISTS}
166
+ return [f for f, _ in _form(detail, states, None)]
167
+ except OSError, ValueError, KeyError, TypeError, Unsupported:
168
+ return None
169
+
170
+ def read_form(self, page: Page) -> list[Field]:
171
+ host, job = _job(page.url) or ("", "")
172
+ form = page.locator(FORM)
173
+ try:
174
+ page.locator(f"{APPLY}, {FORM}").first.wait_for(timeout=FORM_TIMEOUT_MS)
175
+ if not form.count():
176
+ browser.click(page.locator(APPLY).first)
177
+ form.locator("#firstName").wait_for(timeout=FORM_TIMEOUT_MS)
178
+ except Timeout:
179
+ raise Unsupported("posting_gone: no application form on the page") from None
180
+ urls = [DETAIL.format(host=host, job=job)]
181
+ urls += [STATES.format(host=host, country=c) for c in STATE_LISTS]
182
+ detail, *lists = page.evaluate(_FETCH, urls)
183
+ if "status" in detail:
184
+ raise Unsupported(f"posting_gone: the form's detail answered {detail['status']}")
185
+ states = {c: s.get("json", {}) for c, s in zip(STATE_LISTS, lists, strict=True)}
186
+ country = form.locator("select[name='countryId.value']")
187
+ default = (_shown(country) or "") if country.count() else None
188
+ fields = _form(detail["json"], states, default)
189
+ self._specs = {f.id: spec for f, spec in fields}
190
+ for field, (kind, name, _) in fields:
191
+ if field.id.endswith("_other"):
192
+ continue # shown once its question is answered Other
193
+ present = (
194
+ form.locator("input[placeholder*=yyyy]")
195
+ if kind == "date"
196
+ else form.locator("#veteran")
197
+ if kind == "veteran"
198
+ else form.locator(f"[name='{name}']")
199
+ )
200
+ if not present.count():
201
+ raise Unsupported(f"form_shape: {field.id} is not on the page")
202
+ return [f for f, _ in fields]
203
+
204
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
205
+ form = page.locator(FORM)
206
+ # the country before the state, whose list follows it
207
+ order = sorted(self._specs, key=lambda i: i != "countryId")
208
+ for field_id in order:
209
+ if field_id not in answers:
210
+ continue
211
+ kind, name, extra = self._specs[field_id]
212
+ answer = answers[field_id]
213
+ value = answer if isinstance(answer, str) else answer[0]
214
+ control = form.locator(f"[name='{name}']")
215
+ match kind:
216
+ case "file":
217
+ _attach(form, str(name), answer)
218
+ case "select" if field_id == "countryId":
219
+ if _shown(control) == value:
220
+ continue # the state's list stays
221
+ with (
222
+ suppress(Timeout),
223
+ page.expect_response(
224
+ lambda r: "/ajax/get_states" in r.url, timeout=STATES_TIMEOUT_MS
225
+ ),
226
+ ): # the state's list follows the country
227
+ _pick(page, control, value)
228
+ page.wait_for_timeout(SETTLE_MS)
229
+ case "select":
230
+ _pick(page, control, value)
231
+ case "state":
232
+ if control.evaluate("e => e.tagName") == "SELECT":
233
+ _pick(page, control, value)
234
+ else:
235
+ control.fill(value)
236
+ case "veteran":
237
+ if value in VETERAN:
238
+ browser.click(_label(form, f"#{VETERAN[value]}"))
239
+ else:
240
+ browser.click(_label(form, "#veteran"))
241
+ control.wait_for(state="attached", timeout=SHOWN_TIMEOUT_MS)
242
+ _pick(page, control, value)
243
+ case "radio":
244
+ option = (extra or {}).get(value, value)
245
+ browser.click(_label(form, f"input[name='{name}'][value='{option}']"))
246
+ case "checkbox":
247
+ if control.is_checked() != (value == "Yes"):
248
+ browser.click(_label(form, f"[name='{name}']"))
249
+ case "date":
250
+ date = form.locator("input[placeholder*=yyyy]")
251
+ date.fill(_to_page(value, date.get_attribute("placeholder") or ""))
252
+ case _:
253
+ if field_id.endswith("_other"):
254
+ try:
255
+ control.wait_for(state="attached", timeout=SHOWN_TIMEOUT_MS)
256
+ except Timeout:
257
+ continue # its question was not answered Other
258
+ control.fill(value)
259
+
260
+ def read_back(self, page: Page) -> dict[str, Answer]:
261
+ specs = {
262
+ i: (kind, name, extra if kind == "veteran" else None)
263
+ for i, (kind, name, extra) in self._specs.items()
264
+ }
265
+ values = page.locator(FORM).evaluate(_READ_BACK, specs)
266
+ if isinstance(date := values.get("dateAvailable"), list):
267
+ values["dateAvailable"] = _from_page(*date)
268
+ return {k: v for k, v in values.items() if v not in (None, "")}
269
+
270
+ def submit(self, page: Page) -> str | None:
271
+ """Ticks the reCAPTCHA box, then clicks submit; the evidence is the answer to the
272
+ form's POST. An image challenge instead of the tick is `Blocked`."""
273
+ host = urlsplit(page.url).hostname
274
+ captcha = page.locator(CAPTCHA)
275
+ if captcha.count():
276
+ captcha.content_frame.locator("#recaptcha-anchor").click()
277
+ token = page.locator("#g-recaptcha-response")
278
+ try:
279
+ page.wait_for_function(
280
+ "el => el.value !== ''", arg=token.element_handle(), timeout=CAPTCHA_TIMEOUT_MS
281
+ )
282
+ except Timeout:
283
+ if page.locator(CHALLENGE).is_visible():
284
+ raise Blocked("captcha: reCAPTCHA image challenge") from None
285
+ raise
286
+ with page.expect_response(
287
+ lambda r: (
288
+ r.request.method == "POST"
289
+ and urlsplit(r.url).hostname == host
290
+ and "/ajax/files/" not in r.url
291
+ ),
292
+ timeout=SUBMIT_TIMEOUT_MS,
293
+ ) as applied:
294
+ browser.click(page.locator(SUBMIT))
295
+ response = applied.value
296
+ if not response.ok:
297
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
298
+ return f"{response.url} {response.status}"
299
+
300
+
301
+ def _job(url: str) -> tuple[str, str] | None:
302
+ parts = urlsplit(url)
303
+ if not (parts.hostname and HOST.match(parts.hostname)) or not (m := JOB.match(parts.path)):
304
+ return None
305
+ return parts.hostname, m[1]
306
+
307
+
308
+ def _get(url: str) -> Any:
309
+ request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
310
+ with urllib.request.urlopen(request, timeout=20) as response:
311
+ return json.load(response)
312
+
313
+
314
+ def _shown(select: Locator) -> str | None:
315
+ """The text a Fabric select's button shows, None while nothing is picked."""
316
+ button = select.locator("xpath=..").locator("button[data-menu-id]")
317
+ text = (button.text_content() or "").strip()
318
+ return text if text and text != EMPTY else None
319
+
320
+
321
+ def _pick(page: Page, select: Locator, text: str) -> None:
322
+ """Picks a Fabric select's option by its text from the menu its button opens."""
323
+ button = select.locator("xpath=..").locator("button[data-menu-id]")
324
+ browser.click(button)
325
+ menu = page.locator(f"[id='{button.get_attribute('data-menu-id')}']")
326
+ item = menu.locator("[role=menuitem], [role=option]").filter(has_text=_exact(text))
327
+ multiple = select.evaluate("e => e.multiple")
328
+ browser.click(item.first)
329
+ if multiple: # stays open for more picks
330
+ page.keyboard.press("Escape")
331
+
332
+
333
+ def _attach(form: Locator, name: str, answer: Answer) -> None:
334
+ """Attaches through the file input beside the hidden id, then waits for the upload."""
335
+ hidden = f"[name='{name}']"
336
+ form.locator(f"*:has(> {hidden}) input[type=file]").set_input_files(answer)
337
+ form.page.wait_for_function(
338
+ "el => el.value !== ''",
339
+ arg=form.locator(hidden).element_handle(),
340
+ timeout=UPLOAD_TIMEOUT_MS,
341
+ )
342
+
343
+
344
+ def _label(form: Locator, control: str) -> Locator:
345
+ return form.locator(f"label:has({control})")
346
+
347
+
348
+ def _exact(value: str) -> re.Pattern[str]:
349
+ return re.compile(rf"^\s*{re.escape(value)}\s*$")
350
+
351
+
352
+ def _to_page(iso: str, placeholder: str) -> str:
353
+ """A YYYY-MM-DD date in the page's format, its placeholder (`dd mon yyyy`, `mm/dd/yyyy`)."""
354
+ year, month, day = iso.split("-")
355
+ parts = {"yyyy": year, "mon": MONTHS[int(month) - 1], "mm": month, "dd": day}
356
+ return re.sub(DATE_PARTS, lambda m: parts[m[0]], placeholder)
357
+
358
+
359
+ def _from_page(value: str, placeholder: str) -> str:
360
+ """The page's date as YYYY-MM-DD, by the placeholder's order of its parts."""
361
+ order, words = re.findall(DATE_PARTS, placeholder), re.findall(r"\w+", value)
362
+ parts = dict(zip(order, words, strict=False))
363
+ if parts.get("mon") in MONTHS:
364
+ parts["mm"] = f"{MONTHS.index(parts['mon']) + 1:02}"
365
+ if not {"yyyy", "mm", "dd"} <= parts.keys():
366
+ return value
367
+ return f"{parts['yyyy']}-{parts['mm']}-{parts['dd']}"
368
+
369
+
370
+ def _form(
371
+ detail: dict[str, Any], states: dict[str, Any], default: str | None
372
+ ) -> list[tuple[Field, tuple[str, str | None, Any]]]:
373
+ """Each field of the form's JSON answer with how to find and fill it; `default` is the
374
+ country the page shows before any answer (empty for none), None to take the JSON's."""
375
+ result = detail["result"]
376
+ country = (result["formFields"].get("countryId") or {}).get("value") or ""
377
+ if default is not None:
378
+ by_name = {name: c for c, name in STATE_LISTS.items()}
379
+ country = by_name.get(default, default)
380
+ if result["jobOpening"].get("jobOpeningStatus") not in (None, "Open"):
381
+ raise Unsupported("posting_gone: the job opening is not open")
382
+ out = []
383
+ for key, entry in result["formFields"].items():
384
+ if key == "customQuestions":
385
+ for question in entry:
386
+ out += _custom(question)
387
+ continue
388
+ if not entry: # a demographic question the employer does not ask
389
+ continue
390
+ if key not in STANDARD:
391
+ raise Unsupported(f"form_shape: {key} is a field it does not know")
392
+ kind, name = STANDARD[key]
393
+ label, required = entry["label"].strip(), entry["isRequired"]
394
+ options = [o["text"] for o in entry.get("options") or []] or None
395
+ extra: Any = None
396
+ prefilled = key == "countryId" and bool(country)
397
+ if key == "state" and options:
398
+ options = _states(states, country)
399
+ if key == "state" and not options:
400
+ kind = "text"
401
+ if kind == "veteran":
402
+ extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
403
+ options = list(VETERAN) + list(extra.values())
404
+ accept = FILE_TYPES if kind == "file" else None
405
+ field = Field(key, label, TYPES[kind], required, options, None, accept, prefilled)
406
+ out.append((field, (kind, name, extra)))
407
+ return out
408
+
409
+
410
+ def _states(states: dict[str, Any], default: str | None) -> list[str]:
411
+ """The state lists of every country that has one, the default country's (an id) first:
412
+ the list the page shows follows the country answered."""
413
+ order = sorted(STATE_LISTS, key=lambda c: c != default)
414
+ names = [s["full_name"] for c in order for s in (states.get(c) or {}).get("data") or []]
415
+ return list(dict.fromkeys(names))
416
+
417
+
418
+ def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
419
+ qid, kind_api = question["id"], question["type"]
420
+ if kind_api not in CUSTOM:
421
+ raise Unsupported(f"form_shape: question {qid} is a {kind_api}")
422
+ kind = CUSTOM[kind_api]
423
+ name = f"customQuestionAnswers.{kind_api}_{qid}"
424
+ label, required = question["question"].strip(), question["isRequired"]
425
+ options, extra = None, None
426
+ if kind_api == "yes_no" or kind == "checkbox":
427
+ options = YES_NO
428
+ elif kind_api == "multi":
429
+ extra = {o["option"].strip(): str(o["id"]) for o in question["options"]}
430
+ if question.get("hasOther") == "yes":
431
+ extra[OTHER] = "other"
432
+ options = list(extra)
433
+ accept = FILE_TYPES if kind == "file" else None
434
+ field_id = f"question_{qid}"
435
+ field = Field(field_id, label, TYPES[kind], required, options, None, accept)
436
+ out = [(field, (kind, name, extra))]
437
+ if extra and OTHER in extra and question.get("hasOther") == "yes":
438
+ other = Field(
439
+ f"{field_id}_other",
440
+ f"{label}: please specify (asked when {label!r} is {OTHER})",
441
+ FieldType.TEXT,
442
+ False,
443
+ )
444
+ out.append((other, ("text", f"{name}_other", None)))
445
+ return out
@@ -35,6 +35,7 @@ STEP_TIMEOUT_MS = 20_000
35
35
  SUBMIT_TIMEOUT_MS = 90_000
36
36
  UPDATE_TRIES = 3
37
37
  FOLLOWUP_TIMEOUT_MS = 3_000
38
+ DIAL_TIMEOUT_MS = 2_000 # per dial code tried, longest first
38
39
  NEXT_TRIES = 3
39
40
  STEP_POLLS = 16 # of POLL_MS, before Next is clicked again
40
41
  POLL_MS = 500
@@ -769,7 +770,7 @@ def _fill_phone(page: Page, selector: str, value: str) -> None:
769
770
  dial.press_sequentially(f"+{code}", delay=10)
770
771
  options = page.locator(_option(dial)).filter(has_text=re.compile(rf"\+{code}(\s|$)"))
771
772
  with suppress(Timeout):
772
- options.first.wait_for(timeout=2_000)
773
+ options.first.wait_for(timeout=DIAL_TIMEOUT_MS)
773
774
  if options.count():
774
775
  main = options.filter(has_text=MAIN_COUNTRY.get(code, "\0"))
775
776
  (main if main.count() else options).first.click()
@@ -31,6 +31,7 @@ SUBMIT = "[data-automation-id=btnSubmit]"
31
31
  STEP = "#appDetailDiv .type-bold.float-right" # "Step 2 of 5"
32
32
  FORCED_RESUME = ".modal.in #btn-forceResume"
33
33
  FORM_TIMEOUT_MS = 30_000
34
+ COOKIE_TIMEOUT_MS = 5_000
34
35
  STEP_TIMEOUT_MS = 20_000
35
36
  UPLOAD_TIMEOUT_MS = 30_000
36
37
  SUBMIT_TIMEOUT_MS = 90_000
@@ -473,7 +474,7 @@ def _present(page: Page, control: Control) -> bool:
473
474
  def _dismiss_cookies(page: Page) -> None:
474
475
  close = page.locator("#onetrust-banner-sdk .onetrust-close-btn-handler")
475
476
  with suppress(Timeout):
476
- close.first.wait_for(state="visible", timeout=5_000)
477
+ close.first.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
477
478
  close.first.click()
478
479
 
479
480
 
@@ -63,7 +63,7 @@ _READ = """form => {
63
63
  field = {id: name, required: false, options: [], accept: null,
64
64
  maxLength: el.maxLength > 0 ? el.maxLength : null};
65
65
  if (el.type === "radio") field.kind = "radio";
66
- else if (el.type === "checkbox" && /\.(flag|consent)$/.test(name)) {
66
+ else if (el.type === "checkbox" && /\\.(flag|consent)$/.test(name)) {
67
67
  field.kind = "legal";
68
68
  field.label = text(form.querySelector(`[id="${el.getAttribute("aria-labelledby")}"]`));
69
69
  } else if (el.type === "checkbox") field.kind = "checkboxes";
@@ -173,7 +173,9 @@ kind = "ats"
173
173
  share = 0.0133
174
174
  account = "none"
175
175
  hosting = ["hosted"]
176
+ adapter = "bamboohr"
176
177
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
178
+ dry_run_verified = { date = 2026-10-21, version = "0.0.3", by = "gate", commit = "b756312" }
177
179
 
178
180
  [taleo]
179
181
  kind = "ats"
File without changes