atsfill 0.0.4.dev107__tar.gz → 0.0.4.dev111__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 (49) hide show
  1. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/README.md +1 -1
  3. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/__init__.py +2 -0
  6. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/personio.py +21 -76
  7. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/pinpoint.py +24 -78
  8. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/teamtailor.py +3 -2
  9. atsfill-0.0.4.dev111/src/atsfill/adapters/ukg.py +485 -0
  10. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/registry.toml +2 -0
  11. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/LICENSE +0 -0
  12. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/greenhouse.py +0 -0
  19. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/jazzhr.py +0 -0
  22. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/lever.py +0 -0
  23. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/manatal.py +0 -0
  24. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/oracle.py +0 -0
  25. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/paycor.py +0 -0
  26. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/paylocity.py +0 -0
  27. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/phenom.py +0 -0
  28. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/recruitee.py +0 -0
  29. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/rippling.py +0 -0
  30. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/smartrecruiters.py +0 -0
  31. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/workable.py +0 -0
  32. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/workday.py +0 -0
  33. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/wttj.py +0 -0
  34. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/zohorecruit.py +0 -0
  35. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/cli.py +0 -0
  36. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/__init__.py +0 -0
  37. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/accounts.py +0 -0
  38. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/adapter.py +0 -0
  39. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/attempts.py +0 -0
  40. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/browser.py +0 -0
  41. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/commands.py +0 -0
  42. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/conditions.py +0 -0
  43. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/html.py +0 -0
  44. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/matching.py +0 -0
  45. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/passwords.py +0 -0
  46. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/paths.py +0 -0
  47. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/registry.py +0 -0
  48. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/results.py +0 -0
  49. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/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.dev107
3
+ Version: 0.0.4.dev111
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-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)
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) ![ukg: unsupported 2026-10-26](https://img.shields.io/badge/ukg-unsupported%202026--10--26-e0af68)
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-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)
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) ![ukg: unsupported 2026-10-26](https://img.shields.io/badge/ukg-unsupported%202026--10--26-e0af68)
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.dev107"
3
+ version = "0.0.4.dev111"
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.dev107"
3
+ version = "0.0.4.dev111"
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"
@@ -19,6 +19,7 @@ from atsfill.adapters.recruitee import Recruitee
19
19
  from atsfill.adapters.rippling import Rippling
20
20
  from atsfill.adapters.smartrecruiters import SmartRecruiters
21
21
  from atsfill.adapters.teamtailor import Teamtailor
22
+ from atsfill.adapters.ukg import UKG
22
23
  from atsfill.adapters.workable import Workable
23
24
  from atsfill.adapters.workday import Workday
24
25
  from atsfill.adapters.wttj import WelcomeToTheJungle
@@ -53,6 +54,7 @@ def adapters() -> list[Adapter]:
53
54
  HiringThing(),
54
55
  Eightfold(),
55
56
  Workday(),
57
+ UKG(),
56
58
  Phenom(), # last: it claims a URL by its path alone
57
59
  ]
58
60
 
@@ -9,10 +9,9 @@ list the fields from it with one parser. A document is uploaded to Personio once
9
9
  import re
10
10
  import urllib.request
11
11
  from contextlib import suppress
12
- from dataclasses import dataclass, field
13
- from html.parser import HTMLParser
14
12
  from urllib.parse import urlsplit
15
13
 
14
+ from atsfill.core import html
16
15
  from atsfill.core.adapter import Blocked, Gap, Unsupported
17
16
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
18
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
@@ -35,7 +34,6 @@ KINDS = {
35
34
  "select": FieldType.CHOICE,
36
35
  "file": FieldType.FILE,
37
36
  }
38
- VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
39
37
 
40
38
  # What each control holds: a value, a select's option value, a document's uploaded names.
41
39
  _READ_BACK = """(form, ids) => {
@@ -78,10 +76,10 @@ class Personio:
78
76
  request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
79
77
  try:
80
78
  with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
81
- html = response.read().decode()
79
+ page = html.parse(response.read().decode())
82
80
  except OSError, ValueError:
83
81
  return None
84
- form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
82
+ form = next((n for n in page.walk() if n.tag == "form"), None)
85
83
  return [f for f, _ in _form(form)] if form else None
86
84
 
87
85
  def browser_read(self, page: Page) -> list[Field]:
@@ -91,7 +89,7 @@ class Personio:
91
89
  page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
92
90
  except Timeout:
93
91
  raise Unsupported("posting_gone: no application form on the page") from None
94
- listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")))
92
+ listed = _form(html.parse(page.locator(FORM).evaluate("f => f.outerHTML")))
95
93
  self._options = {f.id: o for f, o in listed if o and not f.unfillable}
96
94
  self._fields = [f for f, _ in listed if not f.unfillable]
97
95
  return [f for f, _ in listed]
@@ -165,73 +163,7 @@ def _apply(url: str) -> str:
165
163
  return f"https://{parts.hostname}/job/{job[1]}/apply{query}"
166
164
 
167
165
 
168
- @dataclass
169
- class _Node:
170
- tag: str
171
- attrs: dict[str, str]
172
- parent: _Node | None = None
173
- children: list[_Node | str] = field(default_factory=list)
174
-
175
- def walk(self) -> list[_Node]:
176
- out = []
177
- for child in self.children:
178
- if isinstance(child, _Node):
179
- out += [child, *child.walk()]
180
- return out
181
-
182
- def text(self) -> str:
183
- return "".join(c if isinstance(c, str) else c.text() for c in self.children)
184
-
185
- def label(self) -> str:
186
- """The text, without the spans that mark it required or optional."""
187
- own = (c for c in self.children if isinstance(c, str) or c.tag != "span")
188
- return " ".join("".join(c if isinstance(c, str) else c.text() for c in own).split())
189
-
190
- def starred(self) -> bool:
191
- return any(
192
- isinstance(c, _Node) and c.tag == "span" and c.text().strip() == "*"
193
- for c in self.children
194
- )
195
-
196
- def ancestor(self, role: str) -> _Node | None:
197
- node = self.parent
198
- while node is not None and node.attrs.get("role") != role:
199
- node = node.parent
200
- return node
201
-
202
-
203
- class _Tree(HTMLParser):
204
- def __init__(self) -> None:
205
- super().__init__()
206
- self.root = self.open = _Node("", {})
207
-
208
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
209
- node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
210
- self.open.children.append(node)
211
- if tag not in VOID:
212
- self.open = node
213
-
214
- def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
215
- self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
216
-
217
- def handle_endtag(self, tag: str) -> None:
218
- node: _Node | None = self.open
219
- while node is not None and node.tag != tag:
220
- node = node.parent
221
- if node is not None and node.parent is not None:
222
- self.open = node.parent
223
-
224
- def handle_data(self, data: str) -> None:
225
- self.open.children.append(data)
226
-
227
-
228
- def _parse(html: str) -> _Node:
229
- tree = _Tree()
230
- tree.feed(html)
231
- return tree.root
232
-
233
-
234
- def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
166
+ def _form(form: html.Node) -> list[tuple[Field, dict[str, str] | None]]:
235
167
  """Each named control of the form, with a choice's option texts by value."""
236
168
  nodes = form.walk()
237
169
  by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
@@ -244,11 +176,11 @@ def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
244
176
  kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
245
177
  if kind == "hidden":
246
178
  continue
247
- group = node.ancestor("group")
179
+ group = node.closest(lambda n: n.attrs.get("role") == "group")
248
180
  group_label = by_id.get(group.attrs.get("aria-labelledby", "")) if group else None
249
181
  own = labels.get(node.attrs.get("id", ""))
250
- label = (own.label() if own else "") or (group_label.label() if group_label else "") or name
251
- required = any(n is not None and n.starred() for n in (own, group_label))
182
+ label = (_label(own) if own else "") or (_label(group_label) if group_label else "") or name
183
+ required = any(n is not None and _starred(n) for n in (own, group_label))
252
184
  if kind not in KINDS or "multiple" in node.attrs and kind == "select":
253
185
  reason = f"unhandled_rendering: {name} is a {kind}"
254
186
  out.append((Field(name, label, FieldType.TEXT, required, unfillable=reason), None))
@@ -282,6 +214,19 @@ def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
282
214
  return out
283
215
 
284
216
 
217
+ def _label(node: html.Node) -> str:
218
+ """A label's text, without the spans that mark it required or optional."""
219
+ own = (c for c in node.children if isinstance(c, str) or c.tag != "span")
220
+ return " ".join("".join(c if isinstance(c, str) else c.text() for c in own).split())
221
+
222
+
223
+ def _starred(node: html.Node) -> bool:
224
+ return any(
225
+ isinstance(c, html.Node) and c.tag == "span" and c.text().strip() == "*"
226
+ for c in node.children
227
+ )
228
+
229
+
285
230
  def _attach(control: Locator, path: str) -> None:
286
231
  """Attaches a document and waits for Personio's upload to show its name in the category."""
287
232
  name = path.rsplit("/", 1)[-1]
@@ -11,12 +11,12 @@ import json
11
11
  import re
12
12
  import urllib.request
13
13
  from contextlib import suppress
14
- from dataclasses import dataclass, field, replace
14
+ from dataclasses import dataclass, replace
15
15
  from datetime import date
16
- from html.parser import HTMLParser
17
16
  from typing import Any
18
17
  from urllib.parse import urlsplit
19
18
 
19
+ from atsfill.core import html
20
20
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
21
21
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
22
22
  from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
@@ -43,7 +43,6 @@ JOB_PREFERENCES = "job_preferences"
43
43
  TEXT_QUESTIONS = {"Shorttext", "Longtext", "Url", "Numberinput"} # Url is a `url` field
44
44
  FILE_QUESTIONS = {"Document", "Multipledocuments"}
45
45
  NOT_FIELDS = {"New", "Chatbot", "Stagescarousel"} # components without a field, by last name
46
- VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
47
46
 
48
47
  # What each field holds, by how it renders: a control's value, a react-select's shown value(s),
49
48
  # a radio's label, a date's three selects, a document's uploaded names.
@@ -131,12 +130,10 @@ class Pinpoint:
131
130
  request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
132
131
  try:
133
132
  with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
134
- html = response.read().decode()
133
+ page = html.parse(response.read().decode())
135
134
  except OSError, ValueError:
136
135
  return None
137
- form = next(
138
- (n for n in _parse(html).walk() if n.attrs.get("id") == "application-form"), None
139
- )
136
+ form = next((n for n in page.walk() if n.attrs.get("id") == "application-form"), None)
140
137
  return [f for f, _ in _form(form, self)] if form else None
141
138
 
142
139
  def browser_read(self, page: Page) -> list[Field]:
@@ -147,7 +144,7 @@ class Pinpoint:
147
144
  except Timeout:
148
145
  raise Unsupported("posting_gone: no application form on the page") from None
149
146
  _hydrated(page)
150
- listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
147
+ listed = _form(html.parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
151
148
  self._specs = {f.id: s for f, s in listed if not f.unfillable}
152
149
  if "country" in self._specs: # the visitor's, guessed by the page
153
150
  held = self.read_back(page).get("country")
@@ -200,7 +197,7 @@ class Pinpoint:
200
197
  def shown_fields(self, page: Page) -> list[str]:
201
198
  """The fields of the filled page's form whose control shows: a question component
202
199
  renders none while its parent's answer keeps it hidden, an address line one layout's."""
203
- listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
200
+ listed = _form(html.parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
204
201
  specs = [{"id": f.id, "kind": s.kind, "root": s.root} for f, s in listed]
205
202
  return page.locator(FORM).evaluate(_SHOWN, specs)
206
203
 
@@ -297,66 +294,6 @@ def _attach(root: Locator, path: str) -> None:
297
294
  root.locator(".bp3-tag a.link", has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
298
295
 
299
296
 
300
- @dataclass
301
- class _Node:
302
- tag: str
303
- attrs: dict[str, str]
304
- parent: _Node | None = None
305
- children: list[_Node | str] = field(default_factory=list)
306
-
307
- def walk(self) -> list[_Node]:
308
- out = []
309
- for child in self.children:
310
- if isinstance(child, _Node):
311
- out += [child, *child.walk()]
312
- return out
313
-
314
- def text(self) -> str:
315
- return " ".join(
316
- "".join(c if isinstance(c, str) else c.text() for c in self.children).split()
317
- )
318
-
319
- def classes(self) -> list[str]:
320
- return self.attrs.get("class", "").split()
321
-
322
- def in_component(self) -> bool:
323
- node = self.parent
324
- while node is not None and "-react-component-" not in node.attrs.get("id", ""):
325
- node = node.parent
326
- return node is not None
327
-
328
-
329
- class _Tree(HTMLParser):
330
- def __init__(self) -> None:
331
- super().__init__(convert_charrefs=True)
332
- self.root = self.open = _Node("", {})
333
-
334
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
335
- node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
336
- self.open.children.append(node)
337
- if tag not in VOID:
338
- self.open = node
339
-
340
- def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
341
- self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
342
-
343
- def handle_endtag(self, tag: str) -> None:
344
- node: _Node | None = self.open
345
- while node is not None and node.tag != tag:
346
- node = node.parent
347
- if node is not None and node.parent is not None:
348
- self.open = node.parent
349
-
350
- def handle_data(self, data: str) -> None:
351
- self.open.children.append(data)
352
-
353
-
354
- def _parse(html: str) -> _Node:
355
- tree = _Tree()
356
- tree.feed(html)
357
- return tree.root
358
-
359
-
360
297
  def _key(name: str) -> str:
361
298
  """A control's attribute: `first_name` for `application_form[application][first_name]`."""
362
299
  return re.findall(r"\w+", re.sub(r"\(\di\)", "", name))[-1]
@@ -367,27 +304,36 @@ def _text(label: object) -> str:
367
304
  return " ".join(str(label).split())
368
305
 
369
306
 
307
+ def _in_component(node: html.Node) -> bool:
308
+ """Whether a control is one a React component renders, read from its props instead."""
309
+ return node.parent is not None and bool(
310
+ node.parent.closest(lambda n: "-react-component-" in n.attrs.get("id", ""))
311
+ )
312
+
313
+
370
314
  def _accept(extensions: list[str] | None) -> list[str] | None:
371
315
  return [f".{e}" for e in extensions] if extensions else None
372
316
 
373
317
 
374
- def _form(form: _Node, adapter: Pinpoint) -> list[tuple[Field, _Spec]]:
318
+ def _form(form: html.Node, adapter: Pinpoint) -> list[tuple[Field, _Spec]]:
375
319
  """Each field of the form, in its order, with where its control is."""
376
320
  nodes = form.walk()
377
- labels = {n.attrs["for"]: n.text() for n in nodes if n.tag == "label" and "for" in n.attrs}
321
+ labels = {
322
+ n.attrs["for"]: _text(n.text()) for n in nodes if n.tag == "label" and "for" in n.attrs
323
+ }
378
324
  out: list[tuple[Field, _Spec]] = []
379
325
  titles: dict[int, tuple[str, dict[str, str]]] = {} # a question's id, its values' labels
380
326
  for node in nodes:
381
327
  if node.tag == "script" and COMPONENT in node.classes():
382
- props = json.loads(node.text() or "{}")
328
+ props = json.loads(_text(node.text()) or "{}")
383
329
  name = node.attrs.get("data-component-name", "")
384
330
  out += _component(name, props, node, labels, titles, adapter)
385
- elif node.tag in ("input", "select", "textarea") and not node.in_component():
331
+ elif node.tag in ("input", "select", "textarea") and not _in_component(node):
386
332
  out += _plain(node, labels, out)
387
333
  return out
388
334
 
389
335
 
390
- def _plain(node: _Node, labels: dict[str, str], listed: list) -> list[tuple[Field, _Spec]]:
336
+ def _plain(node: html.Node, labels: dict[str, str], listed: list) -> list[tuple[Field, _Spec]]:
391
337
  """A server-rendered control: a name, the email, the summary, a date of birth, the consent."""
392
338
  name, kind = node.attrs.get("name", ""), node.attrs.get("type", node.tag)
393
339
  if not name or kind in ("hidden", "submit", "button") or "g-recaptcha" in name:
@@ -417,7 +363,7 @@ def _plain(node: _Node, labels: dict[str, str], listed: list) -> list[tuple[Fiel
417
363
  def _component(
418
364
  name: str,
419
365
  props: dict[str, Any],
420
- script: _Node,
366
+ script: html.Node,
421
367
  labels: dict[str, str],
422
368
  titles: dict[int, tuple[str, dict[str, str]]],
423
369
  adapter: Pinpoint,
@@ -536,17 +482,17 @@ def _shown_when(parent: tuple[str, dict[str, str]], conditions: list[dict]) -> C
536
482
  return None if not held else held[0] if len(held) == 1 else AnyOf(held)
537
483
 
538
484
 
539
- def _file_label(script: _Node, props: dict) -> tuple[str, bool]:
485
+ def _file_label(script: html.Node, props: dict) -> tuple[str, bool]:
540
486
  """A document's label, from the `external-form__label` block before its component."""
541
487
  parent = script.parent
542
- siblings = [c for c in parent.children if isinstance(c, _Node)] if parent else []
488
+ siblings = [c for c in parent.children if isinstance(c, html.Node)] if parent else []
543
489
  before = siblings[: siblings.index(script)] if script in siblings else []
544
490
  block = next((n for n in reversed(before) if "external-form__label" in n.classes()), None)
545
491
  if block is None:
546
492
  return props.get("labelText") or _key(props["name"]), False
547
493
  title = next((n for n in block.walk() if n.tag == "div"), block)
548
494
  required = any("external-form__label--required" in n.classes() for n in [block, *block.walk()])
549
- return title.text(), required
495
+ return _text(title.text()), required
550
496
 
551
497
 
552
498
  def _address(props: dict, root: str | None) -> list[tuple[Field, _Spec]]:
@@ -238,8 +238,9 @@ class Teamtailor:
238
238
  host.locator("input[type=file]").set_input_files(answer)
239
239
  case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
240
240
  box = host.locator("input[type=checkbox]")
241
- if box.is_checked() != (answer == "Yes"):
242
- browser.click(page.locator(f"label[for='{box.get_attribute('id')}']"))
241
+ label = page.locator(f"label[for='{box.get_attribute('id')}']")
242
+ if box.is_checked() != (answer == "Yes"): # an empty label has no box to click
243
+ browser.click(label if label.is_visible() else box)
243
244
  case FieldType.CHOICE, str() if host.locator("input[type=range]").count():
244
245
  host.locator("input[type=range]").fill(answer)
245
246
  case FieldType.TYPEAHEAD, str():
@@ -0,0 +1,485 @@
1
+ """UKG Pro Recruiting (`recruiting[2].ultipro.com`, `recruiting.ultipro.ca`,
2
+ `<employer>.rec.pro.ukg.net`: `/<tenant>/JobBoard/<board>/OpportunityDetail?opportunityId=…`),
3
+ as docs/adapters/ukg.md observes.
4
+
5
+ Applying needs an account, one per tenant: the account scope. The form is one page, a
6
+ Knockout view of the posting, read from the page; the read-back is the page too.
7
+ """
8
+
9
+ import re
10
+ import time
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, quote, urlsplit
15
+
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
17
+ from atsfill.core.browser import Locator, Page, Request, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
19
+
20
+ HOST = re.compile(r"^(recruiting\d*\.ultipro\.(com|ca)|[a-z0-9-]+\.rec\.pro\.ukg\.net)$")
21
+ PATH = re.compile(
22
+ r"^/(?P<tenant>[A-Za-z0-9]+)/JobBoard/(?P<board>[0-9a-fA-F-]{36})/Opportunity(Detail|Apply)$"
23
+ )
24
+ SIGN_IN_HOST = re.compile(r"^signin-[a-z0-9]+\.ultipro\.com$")
25
+ SESSION_COOKIE = ".AspNetCore.Cookies"
26
+ FORM = "#OpportunityApply"
27
+ READY = f"{FORM} [data-automation=btn-submit]"
28
+ FORM_TIMEOUT_MS = 30_000
29
+ SIGN_IN_TIMEOUT_S = 30
30
+ SIGN_IN_POLL_MS = 500
31
+ STATES_TIMEOUT_MS = 10_000
32
+ UPLOAD_TIMEOUT_MS = 30_000
33
+ SUBMIT_TIMEOUT_MS = 90_000
34
+ SETTLE_MS = 500
35
+ YES_NO = ["Yes", "No"]
36
+ DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
37
+ COUNTRY, STATE = "Country", "State"
38
+ DAYS, DOCUMENTS = "availability_days", "documents"
39
+ PHONE_GAP = "sign_up_phone"
40
+
41
+ # Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
42
+ ERRORS = [
43
+ (re.compile(r"wrong-(credentials|email-credentials)|invalid-(password|email-password)"),
44
+ Refused, "wrong_password"),
45
+ (re.compile(r"user-exists|email-in-use|already"), Refused, "email_in_use"),
46
+ (re.compile(r"password"), Refused, "password_rule"),
47
+ (re.compile(r"captcha"), Blocked, "captcha"),
48
+ (re.compile(r"too-many|blocked|rate"), Blocked, "rate_limited"),
49
+ ] # fmt: skip
50
+
51
+ # Each visible field of the form, in page order: a control's id (a radio group's name), its
52
+ # label, how it renders, whether it is required, its options and value.
53
+ _READ = """root => {
54
+ const clean = s => (s || "").replace(/\\s+/g, " ").trim();
55
+ const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) &&
56
+ !el.closest(".modal");
57
+ const labelOf = el => {
58
+ if (el.id) {
59
+ const l = root.querySelector(`label[for="${CSS.escape(el.id)}"]`);
60
+ if (l) return l;
61
+ }
62
+ const group = el.closest("[aria-labelledby]");
63
+ if (group) {
64
+ const l = document.getElementById(group.getAttribute("aria-labelledby").split(" ")[0]);
65
+ if (l) return l;
66
+ }
67
+ return el.closest(".form-group")?.querySelector("label");
68
+ };
69
+ const required = (el, label) => el.required || !!label?.classList.contains("required");
70
+ const out = [], seen = new Set();
71
+ for (const el of root.querySelectorAll("input, select, textarea")) {
72
+ const type = (el.type || "").toLowerCase();
73
+ if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
74
+ if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
75
+ const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
76
+ if (!id || seen.has(id)) continue;
77
+ seen.add(id);
78
+ const label = labelOf(el);
79
+ const field = {id, label: clean(label?.textContent), required: required(el, label),
80
+ kind: type, options: [], value: el.value,
81
+ readonly: el.readOnly || el.disabled, maxLength: el.maxLength > 0 ?
82
+ el.maxLength : null};
83
+ if (el.tagName === "SELECT") {
84
+ field.kind = "select";
85
+ field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
86
+ field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
87
+ } else if (el.tagName === "TEXTAREA") field.kind = "text";
88
+ else if (type === "radio") {
89
+ const all = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)];
90
+ field.options = all.map(r => clean(r.closest("label")?.textContent));
91
+ field.required = all.some(r => r.required) || field.required;
92
+ field.value = clean(all.find(r => r.checked)?.closest("label")?.textContent);
93
+ } else if (type === "checkbox") {
94
+ field.label = clean(el.closest("label")?.textContent) || field.label;
95
+ field.value = el.checked ? "Yes" : "No";
96
+ } else if (type === "tel") field.kind = "phone";
97
+ else if (["text", "email", "number", ""].includes(type)) field.kind = "text";
98
+ out.push(field);
99
+ }
100
+ for (const box of root.querySelectorAll("ukg-input[type=date]")) {
101
+ if (!shown(box)) continue;
102
+ const bridge = box.closest("react-ko-es6-bridge") || box.parentElement;
103
+ const named = bridge.querySelector("[id]");
104
+ const id = named?.id;
105
+ if (!id || seen.has(id)) continue;
106
+ seen.add(id);
107
+ const label = bridge.closest(".form-group")?.querySelector("label") || named;
108
+ out.push({id, label: clean(label.textContent), kind: "date",
109
+ required: box.getAttribute("required") === "true" ||
110
+ !!label.classList?.contains("required"),
111
+ options: [], value: box.getAttribute("value") || ""});
112
+ }
113
+ const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
114
+ if (days.length) {
115
+ const label = document.getElementById("AvailabilityDaysQuestion");
116
+ out.push({id: "availability_days", kind: "days", required: false,
117
+ label: clean(label?.textContent) || "Which days are you available to work?",
118
+ options: days.map(d => clean(d.textContent)),
119
+ value: days.filter(d => d.classList.contains("selected"))
120
+ .map(d => clean(d.textContent))});
121
+ }
122
+ const upload = [...root.querySelectorAll("form[action*='/Documents/UploadFile']")]
123
+ .find(f => shown(f.closest(".panel, section, div") || f));
124
+ if (upload) {
125
+ const input = upload.querySelector("input[type=file]");
126
+ out.push({id: "documents", kind: "file", label: "Documents", required: false,
127
+ options: [], accept: (input?.accept || "").split(",").filter(Boolean),
128
+ value: ""});
129
+ }
130
+ return out;
131
+ }"""
132
+
133
+ # What each listed field shows now, by its kind.
134
+ _READ_BACK = """(root, kinds) => {
135
+ const clean = s => (s || "").replace(/\\s+/g, " ").trim();
136
+ const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
137
+ const out = {};
138
+ for (const [id, kind] of Object.entries(kinds)) {
139
+ if (kind === "days") {
140
+ out[id] = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown)
141
+ .filter(d => d.classList.contains("selected")).map(d => clean(d.textContent));
142
+ } else if (kind === "file") {
143
+ out[id] = [...root.querySelectorAll("[data-automation=file-name]")].filter(shown)
144
+ .map(f => clean([...f.querySelectorAll("[id^=FileName], [data-automation=file-link]")]
145
+ .filter(shown).map(e => e.textContent).join(""))).filter(Boolean).pop() || null;
146
+ } else if (kind === "date") {
147
+ const named = root.querySelector(`[id="${CSS.escape(id)}"]`);
148
+ const box = named?.closest("react-ko-es6-bridge, div")?.querySelector("ukg-input");
149
+ out[id] = box?.getAttribute("value") || null;
150
+ } else if (kind === "radio") {
151
+ const checked = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)]
152
+ .find(r => r.checked);
153
+ out[id] = checked ? clean(checked.closest("label")?.textContent) : null;
154
+ } else {
155
+ const el = [...root.querySelectorAll(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`)]
156
+ .find(shown);
157
+ if (!el) continue;
158
+ if (kind === "select") out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
159
+ else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
160
+ else out[id] = el.value;
161
+ }
162
+ }
163
+ return out;
164
+ }"""
165
+
166
+ # Each country's states from the board's states URL, by the country's name.
167
+ _STATES = """async (root, url) => {
168
+ const select = root.querySelector("select#Country");
169
+ const countries = [...(select?.options || [])].filter(o => o.value);
170
+ const lists = await Promise.all(countries.map(async o => {
171
+ const response = await fetch(`${url}?countryId=${o.value}`, {credentials: "include"});
172
+ return response.ok ? await response.json() : [];
173
+ }));
174
+ return Object.fromEntries(countries.map((o, i) => [
175
+ o.textContent.replace(/\\s+/g, " ").trim(), (lists[i] || []).map(s => s.Name)]));
176
+ }"""
177
+
178
+ KINDS = {
179
+ "text": FieldType.TEXT,
180
+ "phone": FieldType.PHONE,
181
+ "select": FieldType.CHOICE,
182
+ "radio": FieldType.CHOICE,
183
+ "checkbox": FieldType.CHOICE,
184
+ "date": FieldType.DATE,
185
+ "days": FieldType.MULTI_CHOICE,
186
+ "file": FieldType.FILE,
187
+ }
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class Board:
192
+ """A tenant's job board, where its postings and its sign-in are served."""
193
+
194
+ host: str
195
+ tenant: str # lowercase
196
+ board: str
197
+ opportunity: str
198
+
199
+ @property
200
+ def base(self) -> str:
201
+ return f"https://{self.host}/{self.tenant}/JobBoard/{self.board}"
202
+
203
+ @property
204
+ def apply(self) -> str:
205
+ return f"{self.base}/OpportunityApply?opportunityId={self.opportunity}"
206
+
207
+
208
+ def board(url: str) -> Board | None:
209
+ parts = urlsplit(url)
210
+ host = (parts.hostname or "").lower()
211
+ match = PATH.match(parts.path)
212
+ opportunity = parse_qs(parts.query).get("opportunityId", [""])[0].lower()
213
+ if not HOST.match(host) or not match or not opportunity:
214
+ return None
215
+ return Board(host, match["tenant"].lower(), match["board"].lower(), opportunity)
216
+
217
+
218
+ class UKG:
219
+ name = "ukg"
220
+ per_employer = True
221
+ asks_name = True
222
+ known_gaps = {PHONE_GAP: Gap.FLOW}
223
+ side_effects = [
224
+ SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
225
+ SideEffect("upload", "hiring_system", "a document is stored in the account's documents"),
226
+ SideEffect("submit", "employer", "the application reaches the employer"),
227
+ ]
228
+
229
+ def __init__(self) -> None:
230
+ self._boards: dict[str, Board] = {} # by tenant, the last posting's
231
+ self._kinds: dict[str, str] = {}
232
+
233
+ def detect(self, url: str, page: Page | None) -> bool:
234
+ return board(url) is not None
235
+
236
+ def posting(self, url: str) -> str:
237
+ found = board(url)
238
+ return f"{found.base}/OpportunityDetail?opportunityId={found.opportunity}" if found else url
239
+
240
+ def account_scope(self, url: str) -> str | None:
241
+ found = board(url)
242
+ if found is None:
243
+ return None
244
+ self._boards[found.tenant] = found
245
+ return found.tenant
246
+
247
+ def login_url(self, scope: str | None) -> str:
248
+ """The board's sign-in, back to the last posting's form once signed in."""
249
+ if scope not in self._boards:
250
+ raise ValueError(f"no UKG posting seen for the tenant {scope!r}")
251
+ found = self._boards[scope]
252
+ back = urlsplit(found.apply)
253
+ return f"{found.base}/Account/Login?redirectUrl={quote(f'{back.path}?{back.query}')}"
254
+
255
+ def signed_in(self, request: Request, scope: str | None) -> bool:
256
+ """The posting's form loads, instead of redirecting to the sign-in or to the tenant's
257
+ name step; without a posting
258
+ seen, the tenant's session cookie is there and unexpired."""
259
+ if scope in self._boards:
260
+ response = request.get(self._boards[scope].apply)
261
+ return response.ok and urlsplit(response.url).path.endswith("/OpportunityApply")
262
+ cookies = request.storage_state()["cookies"]
263
+ return any(
264
+ c["name"] == SESSION_COOKIE and HOST.match(c["domain"].lstrip("."))
265
+ and (c["expires"] < 0 or c["expires"] > time.time())
266
+ for c in cookies
267
+ ) # fmt: skip
268
+
269
+ def sign_in(self, page: Page, email: str, password: str) -> None:
270
+ page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
271
+ page.locator("input#username").fill(email)
272
+ page.locator("input#password").fill(password)
273
+ page.locator("button[type=submit][name=action][data-action-button-primary]").click()
274
+ _left_sign_in(page)
275
+ if "/AuthCode/Register" in page.url:
276
+ raise Unsupported("unhandled_rendering: the tenant asks the account's name")
277
+
278
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
279
+ page.locator("a[href*='/u/signup']").first.click()
280
+ page.locator("input#email").wait_for(timeout=FORM_TIMEOUT_MS)
281
+ page.locator("input#email").fill(email)
282
+ page.locator("input#password").fill(password)
283
+ page.locator("button[type=submit][name=action][data-action-button-primary]").click()
284
+ _left_sign_in(page)
285
+ if "/AuthCode/Register" not in page.url:
286
+ return
287
+ _register(page, name or "")
288
+
289
+ def browser_read(self, page: Page) -> list[Field]:
290
+ found = board(page.url)
291
+ if found is None or not urlsplit(page.url).path.endswith("/OpportunityApply"):
292
+ found = found or next(iter(self._boards.values()), None)
293
+ if found is None:
294
+ raise Unsupported("unhandled_rendering: not a UKG posting page")
295
+ page.goto(found.apply)
296
+ if SIGN_IN_HOST.match(urlsplit(page.url).hostname or ""):
297
+ raise Blocked("login_wall: the form redirects to the sign-in")
298
+ try:
299
+ page.locator(READY).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
300
+ except Timeout:
301
+ raise Unsupported("posting_gone: no application form on the page") from None
302
+ page.wait_for_timeout(SETTLE_MS)
303
+ raws = page.locator(FORM).evaluate(_READ)
304
+ required_resume = '"IsResumeRequired":true' in page.content()
305
+ states = None
306
+ if page.locator(f"{FORM} select#{STATE}").count():
307
+ states = page.locator(FORM).evaluate(_STATES, f"{found.base}/Candidate/GetStates")
308
+ if not any(r["id"] == STATE for r in raws): # shown once a country is picked
309
+ label = page.locator(f"{FORM} label[for={STATE}]").first
310
+ at = next((i + 1 for i, r in enumerate(raws) if r["id"] == "City"), len(raws))
311
+ raws.insert(at, {
312
+ "id": STATE, "label": " ".join((label.text_content() or "").split()),
313
+ "kind": "select", "options": [], "value": "",
314
+ "required": "required" in (label.get_attribute("class") or ""),
315
+ }) # fmt: skip
316
+ self._kinds = {raw["id"]: raw["kind"] for raw in raws}
317
+ fields = []
318
+ for raw in raws:
319
+ if raw["id"] == DOCUMENTS:
320
+ raw["required"] = required_resume
321
+ fields.append(_field(raw, states))
322
+ return fields
323
+
324
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
325
+ for field_id, kind in self._kinds.items():
326
+ if field_id not in answers:
327
+ continue
328
+ answer = answers[field_id]
329
+ values = [answer] if isinstance(answer, str) else answer
330
+ root = page.locator(FORM)
331
+ match kind:
332
+ case "select":
333
+ control = _control(root, field_id)
334
+ control.select_option(label=values[0])
335
+ if field_id == COUNTRY:
336
+ _wait_for_states(page)
337
+ case "radio":
338
+ _option(root, field_id, values[0]).check()
339
+ case "checkbox":
340
+ _control(root, field_id).set_checked(values[0] == "Yes")
341
+ case "date":
342
+ _date(root, field_id, values[0])
343
+ case "days":
344
+ for day in root.locator("[data-automation=day-btn]:visible").all():
345
+ text = (day.text_content() or "").strip()
346
+ if ("selected" in (day.get_attribute("class") or "")) != (text in values):
347
+ day.click()
348
+ case "file":
349
+ _upload(page, values[0])
350
+ case _:
351
+ _control(root, field_id).fill(values[0])
352
+
353
+ def read_back(self, page: Page) -> dict[str, Answer]:
354
+ values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
355
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
356
+
357
+ def submit(self, page: Page) -> str | None:
358
+ """Clicks Submit; the application posts and the page moves to ApplicationSubmitted,
359
+ the evidence. A tenant may have Cloudflare Turnstile check the visitor first."""
360
+ page.locator(READY).click()
361
+ try:
362
+ page.wait_for_url(re.compile(r"/ApplicationSubmitted"), timeout=SUBMIT_TIMEOUT_MS)
363
+ except Timeout:
364
+ if page.locator("iframe[src*='challenges.cloudflare.com']").is_visible():
365
+ raise Blocked("captcha: Cloudflare Turnstile asked for a challenge") from None
366
+ raise
367
+ return page.url
368
+
369
+
370
+ def _left_sign_in(page: Page) -> None:
371
+ """Waits to leave the sign-in host, or for the error the page shows."""
372
+ deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
373
+ said = page.locator(".ulp-input-error-message:visible, #prompt-alert:visible, "
374
+ "[id^=error-element-]:visible") # fmt: skip
375
+ while time.monotonic() < deadline:
376
+ host = urlsplit(page.url).hostname or ""
377
+ path = urlsplit(page.url).path
378
+ if not SIGN_IN_HOST.match(host):
379
+ page.wait_for_load_state()
380
+ return
381
+ if re.search(r"/u/(mfa|email-verification|login-email-verification)", path):
382
+ raise Blocked("email_otp: the sign-in asks for a code sent by email")
383
+ if page.locator("[data-captcha-provider]:visible, .ulp-captcha:visible").count():
384
+ raise Blocked("captcha: the sign-in page shows a captcha")
385
+ if said.count():
386
+ text = " ".join(said.all_inner_texts()).strip()
387
+ codes = (
388
+ " ".join(filter(None, [e.get_attribute("data-error-code") for e in said.all()]))
389
+ + " "
390
+ + " ".join(filter(None, [e.get_attribute("id") for e in said.all()]))
391
+ )
392
+ for pattern, error, code in ERRORS:
393
+ if pattern.search(codes.lower()) or pattern.search(text.lower()):
394
+ raise error(f"{code}: {text}")
395
+ raise Unsupported(f"unhandled_rendering: the sign-in page says {text!r}")
396
+ page.wait_for_timeout(SIGN_IN_POLL_MS)
397
+ raise Unsupported("unhandled_rendering: the sign-in page answered nothing")
398
+
399
+
400
+ def _register(page: Page, name: str) -> None:
401
+ """The tenant's own step after the sign-up: the name, and the privacy policy's box on some
402
+ tenants; the box for text messages is left. Its inputs take typed keys only: a filled value
403
+ leaves Create account disabled."""
404
+ page.locator("input[name=firstName]").wait_for(timeout=FORM_TIMEOUT_MS)
405
+ page.wait_for_load_state("networkidle")
406
+ if page.locator("ukg-input[name=phoneNumber][required=true]").count():
407
+ raise Unsupported(f"{Gap.FLOW}: {PHONE_GAP}: the tenant's sign-up asks a phone number")
408
+ first, _, last = name.strip().partition(" ")
409
+ for field, value in (("firstName", first), ("lastName", last or first)):
410
+ page.locator(f"input[name={field}]").click()
411
+ page.locator(f"input[name={field}]").press_sequentially(value)
412
+ privacy = page.locator("ukg-checkbox[data-automation*=privacy]:visible")
413
+ for box in privacy.all():
414
+ if box.get_attribute("aria-checked") != "true":
415
+ box.click()
416
+ page.get_by_role("button", name="Create account").click()
417
+ page.wait_for_url(lambda u: "/AuthCode/Register" not in u, timeout=FORM_TIMEOUT_MS)
418
+
419
+
420
+ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
421
+ kind, field_id = raw["kind"], raw["id"]
422
+ label = raw["label"] or field_id
423
+ if kind not in KINDS:
424
+ reason = f"unhandled_rendering: {field_id} is a {kind} input"
425
+ return Field(field_id, label, FieldType.TEXT, raw["required"], unfillable=reason)
426
+ value = raw.get("value") or None
427
+ if kind == "checkbox":
428
+ return Field(field_id, label, FieldType.CHOICE, raw["required"], YES_NO, default="No")
429
+ if kind == "days":
430
+ return Field(field_id, label, FieldType.MULTI_CHOICE, False, raw["options"])
431
+ if kind == "file":
432
+ return Field(field_id, label, FieldType.FILE, raw["required"], accept=raw["accept"])
433
+ if raw.get("readonly"): # the account's name, changed on its profile only
434
+ reason = f"unsupported_rendering: {field_id} is read-only" if not value else None
435
+ return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
436
+ unfillable=reason) # fmt: skip
437
+ options = raw["options"] if kind in ("select", "radio") else None
438
+ if field_id == STATE and states is not None:
439
+ sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
440
+ return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
441
+ return Field(
442
+ field_id, label, KINDS[kind], raw["required"], options, raw.get("maxLength"),
443
+ default=value if kind in ("date", "select") else None,
444
+ ) # fmt: skip
445
+
446
+
447
+ def _wait_for_states(page: Page) -> None:
448
+ with suppress(Timeout): # a country without states
449
+ page.wait_for_function(
450
+ "() => document.querySelectorAll('#OpportunityApply select#State option').length > 1",
451
+ timeout=STATES_TIMEOUT_MS,
452
+ )
453
+
454
+
455
+ def _control(root: Locator, field_id: str) -> Locator:
456
+ return root.locator(f'[id="{field_id}"]:visible, [name="{field_id}"]:visible').first
457
+
458
+
459
+ def _option(root: Locator, name: str, text: str) -> Locator:
460
+ for radio in root.locator(f'input[type=radio][name="{name}"]').all():
461
+ label = radio.locator("xpath=ancestor::label[1]").text_content() or ""
462
+ if " ".join(label.split()) == text:
463
+ return radio
464
+ raise NotTaken(f"no option {text!r} for {name}")
465
+
466
+
467
+ def _date(root: Locator, field_id: str, value: str) -> None:
468
+ """Types the date into the picker's month, day and year inputs."""
469
+ year, month, day = value.split("-")
470
+ bridge = root.locator(f'[id="{field_id}"]').locator("xpath=ancestor::react-ko-es6-bridge[1]")
471
+ for part, typed in (("month", month), ("day", day), ("year", year)):
472
+ segment = bridge.locator(f"[data-automation=ukg-date-input-text-{part}]").first
473
+ segment.click()
474
+ segment.press("Control+a")
475
+ segment.press_sequentially(typed)
476
+ bridge.locator("[data-automation=ukg-date-input-text-year]").first.press("Tab")
477
+
478
+
479
+ def _upload(page: Page, path: str) -> None:
480
+ form = page.locator(f"{FORM} form[action*='/Documents/UploadFile']").first
481
+ name = path.rsplit("/", 1)[-1]
482
+ form.locator("input[type=file]").set_input_files(path)
483
+ page.locator(f"{FORM} [data-automation=file-name]:visible", has_text=name).first.wait_for(
484
+ timeout=UPLOAD_TIMEOUT_MS
485
+ )
@@ -102,7 +102,9 @@ kind = "ats"
102
102
  share = 0.0349
103
103
  account = "to_read"
104
104
  hosting = ["hosted"]
105
+ adapter = "ukg"
105
106
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
107
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "a60a17d", by = "agent", reason = "unsupported_flow: sign_up_phone: the tenant's details step after the sign-up requires a phone number, which login does not take (10 of 17 tenants)" }
106
108
 
107
109
  [paycom]
108
110
  kind = "ats"
File without changes