atsfill 0.0.4.dev104__tar.gz → 0.0.4.dev106__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 (48) hide show
  1. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/README.md +1 -1
  3. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/recruitee.py +129 -68
  6. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/teamtailor.py +5 -1
  7. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/registry.toml +1 -1
  8. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/__init__.py +0 -0
  11. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/eightfold.py +0 -0
  16. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/greenhouse.py +0 -0
  17. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/jazzhr.py +0 -0
  20. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/lever.py +0 -0
  21. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/manatal.py +0 -0
  22. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/oracle.py +0 -0
  23. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paycor.py +0 -0
  24. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paylocity.py +0 -0
  25. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/personio.py +0 -0
  26. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/phenom.py +0 -0
  27. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/pinpoint.py +0 -0
  28. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/rippling.py +0 -0
  29. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/smartrecruiters.py +0 -0
  30. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workable.py +0 -0
  31. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workday.py +0 -0
  32. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/wttj.py +0 -0
  33. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/zohorecruit.py +0 -0
  34. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/cli.py +0 -0
  35. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/__init__.py +0 -0
  36. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/accounts.py +0 -0
  37. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/adapter.py +0 -0
  38. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/attempts.py +0 -0
  39. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/browser.py +0 -0
  40. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/commands.py +0 -0
  41. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/conditions.py +0 -0
  42. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/html.py +0 -0
  43. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/matching.py +0 -0
  44. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/passwords.py +0 -0
  45. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/paths.py +0 -0
  46. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/registry.py +0 -0
  47. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/results.py +0 -0
  48. {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/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.dev104
3
+ Version: 0.0.4.dev106
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
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
57
57
  ## Supported hiring systems
58
58
 
59
59
  <!-- badges -->
60
- ![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) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-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) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7)
60
+ ![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) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-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-25](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--25-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7)
61
61
  <!-- /badges -->
62
62
 
63
63
  | 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) ![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) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-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) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7)
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) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-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-25](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--25-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7)
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.dev104"
3
+ version = "0.0.4.dev106"
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.dev104"
3
+ version = "0.0.4.dev106"
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"
@@ -7,23 +7,27 @@ field's id. Nothing reaches Recruitee before submit, so the read-back is the pag
7
7
  """
8
8
 
9
9
  import re
10
+ import urllib.request
10
11
  from typing import Any
11
12
  from urllib.parse import urlsplit
12
13
 
13
- from atsfill.core import browser
14
+ from atsfill.core import browser, html
14
15
  from atsfill.core.adapter import Blocked, Gap, Unsupported, out_of_scope
15
- from atsfill.core.browser import Locator, Page, Timeout
16
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
17
+ from atsfill.core.html import Node
16
18
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
19
 
18
20
  JOB = re.compile(r"^/o/([^/?#]+)")
19
21
  CAREER_SITE = "script[src*='/js/PublicApp/'], script[src*='recruiteecdn.com']"
20
- FORM = "form#offer-application-form"
22
+ FORM_ID = "offer-application-form"
23
+ FORM = f"form#{FORM_ID}"
21
24
  FORM_TIMEOUT_MS = 30_000
22
25
  SUBMIT_TIMEOUT_MS = 90_000
23
26
  KEY_DELAY_MS = 20
24
27
  YES_NO = ["Yes", "No"]
25
28
  LETTER_FILE, LETTER = "candidate.coverLetterFile", "candidate.coverLetter"
26
- LETTER_SWITCH = "[data-cy=cover-letter-switch-button]"
29
+ LETTER_SWITCH_CY = "cover-letter-switch-button"
30
+ LETTER_SWITCH = f"[data-cy={LETTER_SWITCH_CY}]"
27
31
  EXTENSIONS = {"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "application/pdf": [".pdf"]}
28
32
  KINDS = {
29
33
  "text": FieldType.TEXT,
@@ -37,69 +41,6 @@ KINDS = {
37
41
  "checkboxes": FieldType.MULTI_CHOICE,
38
42
  }
39
43
 
40
- # The fields in page order, one per control name: its question's label, and how it renders. A
41
- # labelled question without a named control (a video recorder, drawn late) comes as kind
42
- # `video`; one without a label is text only. A cover letter upload the switch turns into a
43
- # textarea is followed by that textarea, its alternative.
44
- _READ = """(form, [letterFile, letter, letterSwitch]) => {
45
- const text = el => {
46
- if (!el) return "";
47
- const copy = el.cloneNode(true);
48
- copy.querySelectorAll("[aria-hidden=true]").forEach(e => e.remove());
49
- return copy.textContent.replace(/\\s+/g, " ").trim();
50
- };
51
- const found = new Map();
52
- const controls = form.querySelectorAll(
53
- "input[name]:not([type=hidden]), textarea[name], input[name$='.openQuestionId']");
54
- for (const el of controls) {
55
- if (el.type === "hidden") {
56
- const question = el.name.replace(/\\.openQuestionId$/, "");
57
- const label = el.parentElement.querySelector("label");
58
- if (label && !form.querySelector(
59
- `[name^="${CSS.escape(question)}."]:not([type=hidden])`))
60
- found.set(question, {id: question, kind: "video", label: text(label),
61
- required: /\\*/.test(label.textContent), options: []});
62
- continue;
63
- }
64
- const name = el.name;
65
- let field = found.get(name);
66
- if (!field) {
67
- const group = el.closest("fieldset");
68
- field = {id: name, required: false, options: [], accept: null,
69
- maxLength: el.maxLength > 0 ? el.maxLength : null};
70
- if (el.type === "radio") field.kind = "radio";
71
- else if (el.type === "checkbox" && /\\.(flag|consent)$/.test(name)) {
72
- field.kind = "legal";
73
- field.label = text(form.querySelector(`[id="${el.getAttribute("aria-labelledby")}"]`));
74
- } else if (el.type === "checkbox") field.kind = "checkboxes";
75
- else if (el.type === "file") {
76
- field.kind = "file";
77
- field.accept = el.accept ? el.accept.split(",").map(a => a.trim()) : null;
78
- } else if (el.type === "tel") field.kind = "phone";
79
- else if (el.type === "date") field.kind = "date";
80
- else if (el.type === "number") field.kind = "number";
81
- else field.kind = "text";
82
- if (field.label === undefined) field.label = ["radio", "checkboxes"].includes(field.kind)
83
- ? text(group?.querySelector("legend"))
84
- : text(form.querySelector(`label[for="${el.id}"]`)) || el.placeholder;
85
- found.set(name, field);
86
- }
87
- field.required ||= el.required;
88
- if (el.type === "radio" || el.type === "checkbox" && field.kind === "checkboxes")
89
- field.options.push(text(form.querySelector(`label[for="${el.id}"]`)) || el.value);
90
- }
91
- const out = [];
92
- for (const field of found.values()) {
93
- out.push(field);
94
- if (field.id === letterFile && form.querySelector(letterSwitch)) {
95
- field.oneOf = letterFile;
96
- out.push({id: letter, kind: "letter", label: field.label, required: field.required,
97
- options: [], accept: null, maxLength: null, oneOf: letterFile});
98
- }
99
- }
100
- return out;
101
- }"""
102
-
103
44
  # What each field shows: a choice its checked option's text, a file its attached name.
104
45
  _READ_BACK = """(form, kinds) => {
105
46
  const optionText = el => (form.querySelector(`label[for="${el.id}"]`)?.textContent || el.value)
@@ -151,8 +92,28 @@ class Recruitee:
151
92
  form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
152
93
  except Timeout:
153
94
  raise Unsupported("posting_gone: no application form on the page") from None
95
+ return self._listed(html.parse(form.evaluate("f => f.outerHTML")))
96
+
97
+ def request_read(self, url: str) -> list[Field] | None:
98
+ """The form's own page, server-rendered, on a `*.recruitee.com` host: a custom domain is
99
+ known only by its page."""
100
+ parts = urlsplit(url)
101
+ if not JOB.match(parts.path) or not (parts.hostname or "").endswith(".recruitee.com"):
102
+ return None
103
+ request = urllib.request.Request(
104
+ f"{self.posting(url)}/c/new", headers={"User-Agent": "atsfill"}
105
+ )
106
+ try:
107
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
108
+ page = html.parse(response.read().decode())
109
+ except OSError, ValueError:
110
+ return None
111
+ form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == FORM_ID)
112
+ return self._listed(form) if form else None
113
+
114
+ def _listed(self, form: Node) -> list[Field]:
154
115
  fields, self._kinds = [], {}
155
- for raw in form.evaluate(_READ, [LETTER_FILE, LETTER, LETTER_SWITCH]):
116
+ for raw in _read(form):
156
117
  fields.append(_field(raw))
157
118
  if raw["kind"] != "video":
158
119
  self._kinds[raw["id"]] = raw["kind"]
@@ -239,3 +200,103 @@ def _option(page: Page, controls: Locator, value: str) -> Locator | None:
239
200
  if " ".join((text or "").split()) == value:
240
201
  return control
241
202
  return None
203
+
204
+
205
+ def _text(node: Node | None) -> str:
206
+ """A node's text without what it hides from screen readers, its runs of whitespace one
207
+ space."""
208
+ if node is None:
209
+ return ""
210
+
211
+ def shown(n: Node) -> str:
212
+ return "".join(
213
+ c if isinstance(c, str) else shown(c)
214
+ for c in n.children
215
+ if isinstance(c, str) or c.attrs.get("aria-hidden") != "true"
216
+ )
217
+
218
+ return re.sub(r"[\s\ufeff]+", " ", shown(node)).strip() # as JavaScript's `\s`
219
+
220
+
221
+ def _read(form: Node) -> list[dict[str, Any]]:
222
+ """The fields in page order, one per control name: its question's label, and how it
223
+ renders. A labelled question without a named control (a video recorder, drawn late) comes as
224
+ kind `video`; one without a label is text only. A cover letter upload the switch turns into
225
+ a textarea is followed by that textarea, its alternative."""
226
+ nodes = form.walk()
227
+ by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
228
+ labels: dict[str, Node] = {}
229
+ for n in nodes:
230
+ if n.tag == "label" and "for" in n.attrs:
231
+ labels.setdefault(n.attrs["for"], n)
232
+ found: dict[str, dict[str, Any]] = {}
233
+ for el in nodes:
234
+ name, kind = el.attrs.get("name"), el.attrs.get("type", "text").lower()
235
+ if el.tag not in ("input", "textarea") or not name:
236
+ continue
237
+ if el.tag == "textarea":
238
+ kind = "textarea"
239
+ if kind == "hidden":
240
+ if not name.endswith(".openQuestionId") or el.parent is None:
241
+ continue
242
+ question = name.removesuffix(".openQuestionId")
243
+ label = el.parent.find(lambda n: n.tag == "label")
244
+ answered = any(
245
+ n.attrs.get("name", "").startswith(f"{question}.")
246
+ and n.attrs.get("type") != "hidden"
247
+ for n in nodes
248
+ )
249
+ if label is not None and not answered:
250
+ found[question] = {"id": question, "kind": "video", "label": _text(label),
251
+ "required": "*" in label.text(), "options": []} # fmt: skip
252
+ continue
253
+ field = found.get(name)
254
+ if field is None:
255
+ max_length = int(el.attrs.get("maxlength") or 0)
256
+ field: dict[str, Any] = {
257
+ "id": name,
258
+ "required": False,
259
+ "options": [],
260
+ "accept": None,
261
+ "maxLength": max_length if max_length > 0 else None,
262
+ }
263
+ if kind == "radio":
264
+ field["kind"] = "radio"
265
+ elif kind == "checkbox" and re.search(r"\.(flag|consent)$", name):
266
+ field["kind"] = "legal"
267
+ field["label"] = _text(by_id.get(el.attrs.get("aria-labelledby", "")))
268
+ elif kind == "checkbox":
269
+ field["kind"] = "checkboxes"
270
+ elif kind == "file":
271
+ field["kind"] = "file"
272
+ accept = el.attrs.get("accept")
273
+ field["accept"] = [a.strip() for a in accept.split(",")] if accept else None
274
+ else:
275
+ field["kind"] = {"tel": "phone", "date": "date", "number": "number"}.get(
276
+ kind, "text"
277
+ )
278
+ if "label" not in field:
279
+ if field["kind"] in ("radio", "checkboxes"):
280
+ group = el.closest(lambda n: n.tag == "fieldset")
281
+ field["label"] = _text(
282
+ group.find(lambda n: n.tag == "legend") if group else None
283
+ )
284
+ else:
285
+ own = labels.get(el.attrs.get("id", ""))
286
+ field["label"] = _text(own) or el.attrs.get("placeholder", "")
287
+ found[name] = field
288
+ field["required"] = field["required"] or "required" in el.attrs
289
+ if kind == "radio" or (kind == "checkbox" and field["kind"] == "checkboxes"):
290
+ field["options"].append(
291
+ _text(labels.get(el.attrs.get("id", ""))) or el.attrs.get("value", "")
292
+ )
293
+ switch = any(n.attrs.get("data-cy") == LETTER_SWITCH_CY for n in nodes)
294
+ out = []
295
+ for field in found.values():
296
+ out.append(field)
297
+ if field["id"] == LETTER_FILE and switch:
298
+ field["oneOf"] = LETTER_FILE
299
+ out.append({"id": LETTER, "kind": "letter", "label": field["label"],
300
+ "required": field["required"], "options": [], "accept": None,
301
+ "maxLength": None, "oneOf": LETTER_FILE}) # fmt: skip
302
+ return out
@@ -373,7 +373,11 @@ def _pick(question: Locator, value: str) -> None:
373
373
  trigger = question.locator("button[aria-haspopup]").first
374
374
  if trigger.get_attribute("aria-expanded") != "true":
375
375
  browser.click(trigger)
376
- browser.click(question.locator(f"[role^=menuitem][data-search-text='{value}']").first)
376
+ items = question.locator("[role^=menuitem]")
377
+ for item in items.all(): # the text may hold a quote, which a selector would have to escape
378
+ if item.get_attribute("data-search-text") == value:
379
+ browser.click(item)
380
+ return
377
381
 
378
382
 
379
383
  def _is_question(node: Node) -> bool:
@@ -288,7 +288,7 @@ account = "none"
288
288
  hosting = ["hosted"]
289
289
  adapter = "recruitee"
290
290
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
291
- dry_run_verified = { date = 2026-10-21, version = "0.0.3", by = "adapter_gate", commit = "4489120" }
291
+ dry_run_verified = { date = 2026-10-25, version = "0.0.3", by = "adapter_gate", commit = "f47f160" }
292
292
 
293
293
  [hrmdirect]
294
294
  kind = "ats"
File without changes