atsfill 0.0.4.dev102__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.dev102 → atsfill-0.0.4.dev106}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/README.md +1 -1
  3. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paylocity.py +4 -2
  6. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/recruitee.py +129 -68
  7. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/teamtailor.py +248 -73
  8. atsfill-0.0.4.dev106/src/atsfill/core/html.py +71 -0
  9. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/registry.toml +2 -2
  10. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/LICENSE +0 -0
  11. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/__init__.py +0 -0
  13. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/greenhouse.py +0 -0
  19. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/jazzhr.py +0 -0
  22. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/lever.py +0 -0
  23. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/manatal.py +0 -0
  24. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/oracle.py +0 -0
  25. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paycor.py +0 -0
  26. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/personio.py +0 -0
  27. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/phenom.py +0 -0
  28. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/pinpoint.py +0 -0
  29. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/rippling.py +0 -0
  30. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/smartrecruiters.py +0 -0
  31. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workable.py +0 -0
  32. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workday.py +0 -0
  33. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/wttj.py +0 -0
  34. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/zohorecruit.py +0 -0
  35. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/cli.py +0 -0
  36. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/__init__.py +0 -0
  37. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/accounts.py +0 -0
  38. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/adapter.py +0 -0
  39. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/attempts.py +0 -0
  40. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/browser.py +0 -0
  41. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/commands.py +0 -0
  42. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/conditions.py +0 -0
  43. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/matching.py +0 -0
  44. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/passwords.py +0 -0
  45. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/paths.py +0 -0
  46. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/registry.py +0 -0
  47. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/results.py +0 -0
  48. {atsfill-0.0.4.dev102 → 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.dev102
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.dev102"
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.dev102"
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"
@@ -264,6 +264,8 @@ SHOWN_WITH = {
264
264
  "priorFelony": "priorConviction",
265
265
  "priorMisdemeanor": "priorConviction",
266
266
  **{f"contactSupervisor{k}": "mayWeContact" for k in ("Name", "Position", "Phone", "Email")},
267
+ # the address block renders only with its line 1
268
+ **dict.fromkeys(("country", "address2", "city", "state", "postalCode"), "address"),
267
269
  }
268
270
  SECTIONS = {
269
271
  "info": INFO,
@@ -442,6 +444,8 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
442
444
  for field in section["fields"]:
443
445
  if not field["isIncluded"]:
444
446
  continue
447
+ if SHOWN_WITH.get(field["name"], field["name"]) not in included:
448
+ continue
445
449
  specs = (vocabulary or {}).get(field["name"])
446
450
  if field["name"] == "desiredSalary":
447
451
  specs = SALARY.get(custom.get("desiredSalaryInputType"))
@@ -449,8 +453,6 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
449
453
  if entries and field["name"] not in NOT_ANSWERS:
450
454
  out.append(_unfillable(name, field, prefix, n, vocabulary is None))
451
455
  continue
452
- if SHOWN_WITH.get(field["name"], field["name"]) not in included:
453
- continue
454
456
  for spec in specs:
455
457
  if entries or not ENTRY.search(spec.dom): # the section's own fields show
456
458
  add(spec, field, prefix, n)
@@ -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
@@ -9,14 +9,16 @@ video Teamtailor holds under the id the form keeps.
9
9
  import re
10
10
  import time
11
11
  import unicodedata
12
+ import urllib.request
12
13
  from dataclasses import replace
13
14
  from pathlib import PurePath
14
15
  from typing import Any
15
16
  from urllib.parse import urlsplit
16
17
 
17
- from atsfill.core import browser, matching
18
+ from atsfill.core import browser, html, matching
18
19
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
19
- from atsfill.core.browser import Locator, Page, Response
20
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Response
21
+ from atsfill.core.html import Node
20
22
  from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
21
23
 
22
24
  FORM = "#job-application-form"
@@ -26,6 +28,7 @@ QUESTION = ".question, [data-careersite--form-target=qualifyingAnswers]"
26
28
  LOCKED = "[data-careersite--form-target=formContentWrapper].cursor-not-allowed"
27
29
  LOCATION = "candidate[location][query]"
28
30
  VIDEO = "[data-controller~='questions--tt-video-answer']"
31
+ UPLOAD = "forms--inputs--upload" # a drop zone's controller
29
32
  CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
30
33
  JOB = re.compile(r"/jobs/(\d+)[^/]*")
31
34
  SUBMIT_TIMEOUT_MS = 90_000
@@ -55,73 +58,13 @@ STANDARD_TYPES = {
55
58
  }
56
59
  CHECKBOX = ["Yes", "No"]
57
60
 
58
- # Every field of the form, described from the page. A custom question's id is
59
- # `question:<Teamtailor question id>`; the others are the input's name.
60
- _READ = """(form, QUESTION) => {
61
- const label = el => {
62
- if (!el) return "";
63
- const copy = el.cloneNode(true);
64
- copy.querySelectorAll("sup, .sr-only").forEach(e => e.remove());
65
- return copy.textContent.replace(/\\s+/g, " ").trim();
66
- };
67
- const required = el => !!el?.querySelector("sup[data-asterisk]");
68
- const PICKS = "input[type=radio], input[type=checkbox]";
69
- const options = (box, inputs) => {
70
- const item = value => box.querySelector(`[role^=menuitem][data-value="${value}"]`);
71
- return [...inputs]
72
- .map(e => label(e.labels?.[0]) || item(e.value)?.dataset.searchText || e.value);
73
- };
74
- const fields = [];
75
- for (const el of form.querySelectorAll("input, textarea")) {
76
- if (el.closest(QUESTION) || el.disabled || fields.some(f => f.id === el.name)) continue;
77
- if (el.type !== "file" && /_remote_url\\]$/.test(el.name)) continue; // an upload's address
78
- const kind = el.tagName === "TEXTAREA" ? "textarea"
79
- : el.name === "candidate[location][query]" ? "location"
80
- : el.type === "checkbox" && el.name.endsWith("[]") ? "choices" : el.type;
81
- let id = el.name;
82
- if (kind === "file") id = `candidate[${el.id.replace(/^candidate_/, "")}]`;
83
- if (!["text", "email", "tel", "textarea", "checkbox", "choices", "file", "location"]
84
- .includes(kind) || !id) continue;
85
- if (/linkedin|referrer|social_image/.test(id)) continue;
86
- const box = kind === "choices" ? el.closest("fieldset") : null;
87
- const host = box ? box.querySelector("legend")
88
- : el.labels?.[0] ?? el.closest("[data-controller]")?.querySelector("label");
89
- fields.push({id, kind, label: label(host), required: required(host),
90
- options: box ? options(box, form.querySelectorAll(`[name="${id}"]`)) : null,
91
- maxLength: el.maxLength > 0 ? el.maxLength : null,
92
- accept: el.accept ? el.accept.split(",") : null});
93
- }
94
- for (const q of form.querySelectorAll(QUESTION)) {
95
- const qid = q.querySelector("input[name$='[question_id]']")?.value;
96
- const qualifying = !q.matches(".question");
97
- const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
98
- const kind = /picked_video/.test(named?.name) ? "video"
99
- : q.querySelector("input[type=file]") && !named ? "file"
100
- : (named?.name.match(/\\]\\[(\\w+)\\](\\[\\])?$/)?.[1] ?? "unknown");
101
- const picks = options(q, q.querySelectorAll(PICKS));
102
- if (named?.type === "range") {
103
- const step = Number(named.step) || 1;
104
- for (let v = Number(named.min); v <= Number(named.max); v += step) picks.push(String(v));
105
- }
106
- const menu = [...q.querySelectorAll("[role^=menuitem]")].map(e => e.dataset.searchText);
107
- const parent = q.dataset.questionShowIfUuid
108
- && form.querySelector(`[data-question-uuid="${q.dataset.questionShowIfUuid}"]`);
109
- const shownBy = parent && [...parent.querySelectorAll(PICKS)]
110
- .find(e => e.value === q.dataset.questionShowIfValue);
111
- const parentId = parent?.querySelector("input[name$='[question_id]']")?.value;
112
- fields.push({id: `question:${qid}`, kind, question: true, qualifying,
113
- shownBy: parent && shownBy
114
- ? {id: `question:${parentId}`, option: options(parent, [shownBy])[0]} : null,
115
- visible: q.checkVisibility(),
116
- label: label(q.querySelector("legend, label")),
117
- required: qualifying || q.dataset.questionMandatory === "true",
118
- accept: kind === "video"
119
- ? q.querySelector("input[type=file]")?.accept.split(",").filter(Boolean) : null,
120
- options: picks.length ? picks : menu.length ? menu : null});
121
- }
122
- return fields;
123
- }"""
61
+ # A standard field the form shows but no answer is asked for.
62
+ NOT_ASKED = re.compile(r"linkedin|referrer|social_image")
63
+ STANDARD_KINDS = ("text", "email", "tel", "textarea", "checkbox", "choices", "file", "location")
64
+ ANSWER_KIND = re.compile(r"\]\[(\w+)\](\[\])?$")
124
65
 
66
+ # Whether each custom or qualifying question shows, in the order `_read` lists them.
67
+ _VISIBLE = "(form, QUESTION) => [...form.querySelectorAll(QUESTION)].map(q => q.checkVisibility())"
125
68
  # What the form will send: each field's value, a choice as its option's text, a file as the
126
69
  # uploaded file's name, an address once a suggestion is picked, a video question as the id of
127
70
  # the video it holds.
@@ -211,8 +154,32 @@ class Teamtailor:
211
154
  form.wait_for()
212
155
  except browser.Timeout:
213
156
  raise Unsupported("posting_gone: no application form on the page") from None
157
+ return self._listed(form.evaluate("f => f.outerHTML"))
158
+
159
+ def request_read(self, url: str) -> list[Field] | None:
160
+ """The form's own page, on a `*.teamtailor.com` host: a custom domain is known only by
161
+ its page."""
162
+ parts = urlsplit(url)
163
+ job = JOB.search(parts.path)
164
+ if not job or not (parts.hostname or "").endswith(".teamtailor.com"):
165
+ return None
166
+ new = f"https://{parts.hostname}{parts.path[: job.end()]}/applications/new"
167
+ request = urllib.request.Request(new, headers={"User-Agent": "atsfill"})
168
+ try:
169
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
170
+ if urlsplit(response.url).hostname != parts.hostname:
171
+ return None # applies on another site
172
+ page = html.parse(response.read().decode())
173
+ except OSError, ValueError:
174
+ return None
175
+ form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == FORM[1:])
176
+ return self._listed(form) if form else None
177
+
178
+ def _listed(self, form: str | html.Node) -> list[Field]:
179
+ if isinstance(form, str):
180
+ form = html.parse(form)
214
181
  fields, self._qualifying, self._follow_ups = [], set(), set()
215
- for raw in form.evaluate(_READ, QUESTION):
182
+ for raw in _read(form):
216
183
  if raw.get("qualifying"):
217
184
  self._qualifying.add(raw["id"])
218
185
  field = self._field(raw)
@@ -242,8 +209,10 @@ class Teamtailor:
242
209
  self._wait_for_uploads(form)
243
210
 
244
211
  def shown_fields(self, page: Page) -> list[str]:
245
- raws = page.locator(FORM).evaluate(_READ, QUESTION)
246
- return [raw["id"] for raw in raws if raw.get("visible", True)]
212
+ form = page.locator(FORM)
213
+ raws = _read(html.parse(form.evaluate("f => f.outerHTML")))
214
+ visible = iter(form.evaluate(_VISIBLE, QUESTION))
215
+ return [raw["id"] for raw in raws if not raw.get("question") or next(visible)]
247
216
 
248
217
  def read_back(self, page: Page) -> dict[str, Answer]:
249
218
  values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
@@ -330,7 +299,7 @@ class Teamtailor:
330
299
  return form.locator("[data-controller~='forms--inputs--location']")
331
300
  if field_id.endswith("_remote_url]"):
332
301
  dom_id = "candidate_" + field_id[len("candidate[") : -1]
333
- return form.locator("[data-controller~='forms--inputs--upload']").filter(
302
+ return form.locator(f"[data-controller~='{UPLOAD}']").filter(
334
303
  has=form.page.locator(f"#{dom_id}")
335
304
  )
336
305
  return form.locator(f"[name='{field_id}']").first.locator("xpath=..")
@@ -404,4 +373,210 @@ def _pick(question: Locator, value: str) -> None:
404
373
  trigger = question.locator("button[aria-haspopup]").first
405
374
  if trigger.get_attribute("aria-expanded") != "true":
406
375
  browser.click(trigger)
407
- 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
381
+
382
+
383
+ def _is_question(node: Node) -> bool:
384
+ """A custom question, or a qualifying one (QUESTION)."""
385
+ return (
386
+ "question" in node.classes()
387
+ or node.attrs.get("data-careersite--form-target") == "qualifyingAnswers"
388
+ )
389
+
390
+
391
+ def _label_text(node: Node | None) -> str:
392
+ """A label's text, without its required mark and its screen-reader text."""
393
+ if node is None:
394
+ return ""
395
+ parts = [
396
+ c if isinstance(c, str) else _label_text(c)
397
+ for c in node.children
398
+ if isinstance(c, str) or (c.tag != "sup" and "sr-only" not in c.classes())
399
+ ]
400
+ return "".join(parts)
401
+
402
+
403
+ def _plain(node: Node | None) -> str:
404
+ return " ".join(_label_text(node).split())
405
+
406
+
407
+ def _required(node: Node | None) -> bool:
408
+ return (
409
+ node is not None
410
+ and node.find(lambda n: n.tag == "sup" and "data-asterisk" in n.attrs) is not None
411
+ )
412
+
413
+
414
+ def _is_upload(node: Node) -> bool:
415
+ return UPLOAD in node.attrs.get("data-controller", "").split()
416
+
417
+
418
+ def _upload(box: Node) -> list[dict[str, Any]]:
419
+ """A drop zone: its label names its field, `candidate_<name>`. Its `input[type=file]` is
420
+ added by the page's script, so the server's HTML holds none."""
421
+ label = box.find(lambda n: n.tag == "label" and n.attrs.get("for", "").startswith("candidate_"))
422
+ if label is None:
423
+ return []
424
+ field_id = f"candidate[{label.attrs['for'].removeprefix('candidate_')}]"
425
+ if NOT_ASKED.search(field_id):
426
+ return []
427
+ accept = box.attrs.get(f"data-{UPLOAD}-accepted-files-value")
428
+ return [{"id": field_id, "kind": "file", "label": _plain(label), "required": _required(label),
429
+ "options": None, "maxLength": None,
430
+ "accept": accept.split(",") if accept else None}] # fmt: skip
431
+
432
+
433
+ def _read(form: Node) -> list[dict[str, Any]]:
434
+ """Every field of the form, described from its HTML. A custom question's id is
435
+ `question:<Teamtailor question id>`; the others are the input's name."""
436
+ nodes = form.walk()
437
+ labels: dict[str, Node] = {}
438
+ for n in nodes:
439
+ if n.tag == "label" and n.attrs.get("for"):
440
+ labels.setdefault(n.attrs["for"], n)
441
+
442
+ def label_of(el: Node) -> Node | None:
443
+ wrapping = el.parent.closest(lambda n: n.tag == "label") if el.parent else None
444
+ if wrapping is not None and wrapping.attrs.get("for", el.attrs.get("id")) == el.attrs.get(
445
+ "id"
446
+ ):
447
+ return wrapping
448
+ return labels.get(el.attrs["id"]) if el.attrs.get("id") else None
449
+
450
+ def menu_item(box: Node, value: str) -> Node | None:
451
+ return box.find(
452
+ lambda n: (
453
+ n.attrs.get("role", "").startswith("menuitem")
454
+ and n.attrs.get("data-value") == value
455
+ )
456
+ )
457
+
458
+ def options(box: Node, inputs: list[Node]) -> list[str]:
459
+ out = []
460
+ for e in inputs:
461
+ value = e.attrs.get("value", "")
462
+ item = menu_item(box, value)
463
+ out.append(
464
+ _plain(label_of(e)) or (item.attrs.get("data-search-text") if item else "") or value
465
+ )
466
+ return out
467
+
468
+ def kind_of(el: Node) -> str:
469
+ return (
470
+ "textarea" if el.tag == "textarea" else el.attrs.get("type", "text").lower() or "text"
471
+ )
472
+
473
+ def picks(box: Node) -> list[Node]:
474
+ return [n for n in box.walk() if n.tag == "input" and kind_of(n) in ("radio", "checkbox")]
475
+
476
+ def disabled(el: Node) -> bool:
477
+ return (
478
+ "disabled" in el.attrs
479
+ or el.closest(lambda n: n.tag == "fieldset" and "disabled" in n.attrs) is not None
480
+ )
481
+
482
+ def question_id(q: Node) -> str | None:
483
+ hidden = q.find(
484
+ lambda n: n.tag == "input" and n.attrs.get("name", "").endswith("[question_id]")
485
+ )
486
+ return hidden.attrs.get("value") if hidden else None
487
+
488
+ fields: list[dict[str, Any]] = []
489
+ for el in nodes:
490
+ if _is_upload(el) and not el.closest(_is_question):
491
+ fields += _upload(el)
492
+ continue
493
+ if el.tag not in ("input", "textarea") or (
494
+ kind_of(el) == "file" and el.closest(_is_upload)
495
+ ):
496
+ continue
497
+ name = el.attrs.get("name", "")
498
+ if el.closest(_is_question) or disabled(el) or any(f["id"] == name for f in fields):
499
+ continue
500
+ kind = kind_of(el)
501
+ if kind != "file" and re.search(r"_remote_url\]$", name): # an upload's address
502
+ continue
503
+ if name == LOCATION:
504
+ kind = "location"
505
+ elif kind == "checkbox" and name.endswith("[]"):
506
+ kind = "choices"
507
+ field_id = name
508
+ if kind == "file":
509
+ field_id = f"candidate[{el.attrs.get('id', '').removeprefix('candidate_')}]"
510
+ if kind not in STANDARD_KINDS or not field_id or NOT_ASKED.search(field_id):
511
+ continue
512
+ box = el.closest(lambda n: n.tag == "fieldset") if kind == "choices" else None
513
+ if box is not None:
514
+ host = box.find(lambda n: n.tag == "legend")
515
+ else:
516
+ controller = el.closest(lambda n: "data-controller" in n.attrs)
517
+ host = label_of(el) or (
518
+ controller.find(lambda n: n.tag == "label") if controller else None
519
+ )
520
+ same = [n for n in nodes if n.attrs.get("name") == field_id]
521
+ max_length = int(el.attrs.get("maxlength") or 0)
522
+ accept = el.attrs.get("accept")
523
+ fields.append({
524
+ "id": field_id, "kind": kind, "label": _plain(host), "required": _required(host),
525
+ "options": options(box, same) if box is not None else None,
526
+ "maxLength": max_length if max_length > 0 else None,
527
+ "accept": accept.split(",") if accept else None,
528
+ }) # fmt: skip
529
+ questions = [n for n in nodes if _is_question(n)]
530
+ for q in questions:
531
+ qualifying = "question" not in q.classes()
532
+ named = q.find(
533
+ lambda n: (
534
+ n.attrs.get("name", "").startswith("candidate[answers_attributes]")
535
+ and n.attrs.get("type") != "hidden"
536
+ )
537
+ )
538
+ file = q.find(lambda n: n.tag == "input" and kind_of(n) == "file")
539
+ name = named.attrs["name"] if named else ""
540
+ if "picked_video" in name:
541
+ kind = "video"
542
+ elif (file is not None or q.find(_is_upload)) and named is None:
543
+ kind = "file"
544
+ else:
545
+ kind = match[1] if (match := ANSWER_KIND.search(name)) else "unknown"
546
+ listed = options(q, picks(q))
547
+ if named is not None and kind_of(named) == "range":
548
+ step = float(named.attrs.get("step") or 1) or 1
549
+ value, top = float(named.attrs.get("min") or 0), float(named.attrs.get("max") or 0)
550
+ while value <= top:
551
+ listed.append(f"{value:g}")
552
+ value += step
553
+ menu = [
554
+ n.attrs.get("data-search-text", "")
555
+ for n in q.walk()
556
+ if n.attrs.get("role", "").startswith("menuitem")
557
+ ]
558
+ shown_by = None
559
+ if uuid := q.attrs.get("data-question-show-if-uuid"):
560
+ parent = next((n for n in nodes if n.attrs.get("data-question-uuid") == uuid), None)
561
+ value = q.attrs.get("data-question-show-if-value")
562
+ pick = (
563
+ next((e for e in picks(parent) if e.attrs.get("value") == value), None)
564
+ if parent
565
+ else None
566
+ )
567
+ if parent is not None and pick is not None:
568
+ shown_by = {
569
+ "id": f"question:{question_id(parent)}",
570
+ "option": options(parent, [pick])[0],
571
+ }
572
+ # a video's accepted types are set by the page's script: none in the server's HTML
573
+ accept = [a for a in (file.attrs.get("accept") or "").split(",") if a] if file else None
574
+ fields.append({
575
+ "id": f"question:{question_id(q)}", "kind": kind, "question": True,
576
+ "qualifying": qualifying, "shownBy": shown_by,
577
+ "label": _plain(q.find(lambda n: n.tag in ("legend", "label"))),
578
+ "required": qualifying or q.attrs.get("data-question-mandatory") == "true",
579
+ "accept": (accept or None) if kind == "video" else None,
580
+ "options": listed or menu or None,
581
+ }) # fmt: skip
582
+ return fields
@@ -0,0 +1,71 @@
1
+ """A page's HTML as a tree, for an adapter that reads a server-rendered form by one parser in
2
+ both its request read and its browser read (ADR 0011)."""
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from html.parser import HTMLParser
7
+
8
+ VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
9
+
10
+
11
+ @dataclass(eq=False)
12
+ class Node:
13
+ tag: str
14
+ attrs: dict[str, str]
15
+ parent: Node | None = None
16
+ children: list[Node | str] = field(default_factory=list)
17
+
18
+ def walk(self) -> list[Node]:
19
+ """Every element under this one, in document order."""
20
+ out = []
21
+ for child in self.children:
22
+ if isinstance(child, Node):
23
+ out += [child, *child.walk()]
24
+ return out
25
+
26
+ def find(self, match: Callable[[Node], bool]) -> Node | None:
27
+ return next((n for n in self.walk() if match(n)), None)
28
+
29
+ def closest(self, match: Callable[[Node], bool]) -> Node | None:
30
+ """This element or its nearest ancestor that matches."""
31
+ node: Node | None = self
32
+ while node is not None and not match(node):
33
+ node = node.parent
34
+ return node
35
+
36
+ def text(self) -> str:
37
+ return "".join(c if isinstance(c, str) else c.text() for c in self.children)
38
+
39
+ def classes(self) -> list[str]:
40
+ return self.attrs.get("class", "").split()
41
+
42
+
43
+ class _Tree(HTMLParser):
44
+ def __init__(self) -> None:
45
+ super().__init__(convert_charrefs=True)
46
+ self.root = self.open = Node("", {})
47
+
48
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
49
+ node = Node(tag, {k: v or "" for k, v in attrs}, self.open)
50
+ self.open.children.append(node)
51
+ if tag not in VOID:
52
+ self.open = node
53
+
54
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
55
+ self.open.children.append(Node(tag, {k: v or "" for k, v in attrs}, self.open))
56
+
57
+ def handle_endtag(self, tag: str) -> None:
58
+ node: Node | None = self.open
59
+ while node is not None and node.tag != tag:
60
+ node = node.parent
61
+ if node is not None and node.parent is not None:
62
+ self.open = node.parent
63
+
64
+ def handle_data(self, data: str) -> None:
65
+ self.open.children.append(data)
66
+
67
+
68
+ def parse(html: str) -> Node:
69
+ tree = _Tree()
70
+ tree.feed(html)
71
+ return tree.root
@@ -36,7 +36,7 @@ account = "none"
36
36
  hosting = ["hosted"]
37
37
  adapter = "teamtailor"
38
38
  send_verified = { date = 2026-10-20, version = "0.0.3", by = "shbernal", commit = "9bc6d84" }
39
- dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "adapter_gate", commit = "91a0bba" }
39
+ dry_run_verified = { date = 2026-10-25, version = "0.0.3", by = "adapter_gate", commit = "6852a03" }
40
40
 
41
41
  [greenhouse]
42
42
  kind = "ats"
@@ -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