atsfill 0.0.4.dev110__tar.gz → 0.0.4.dev113__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.dev110 → atsfill-0.0.4.dev113}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/README.md +1 -1
  3. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev113/src/atsfill/adapters/ukg.py +485 -0
  7. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/registry.toml +3 -0
  8. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/hiringthing.py +0 -0
  18. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/jazzhr.py +0 -0
  19. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/lever.py +0 -0
  20. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/manatal.py +0 -0
  21. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/oracle.py +0 -0
  22. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/paycor.py +0 -0
  23. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/paylocity.py +0 -0
  24. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/personio.py +0 -0
  25. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/phenom.py +0 -0
  26. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/pinpoint.py +0 -0
  27. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/recruitee.py +0 -0
  28. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/rippling.py +0 -0
  29. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/smartrecruiters.py +0 -0
  30. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/teamtailor.py +0 -0
  31. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/workable.py +0 -0
  32. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/workday.py +0 -0
  33. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/wttj.py +0 -0
  34. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/adapters/zohorecruit.py +0 -0
  35. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/cli.py +0 -0
  36. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/__init__.py +0 -0
  37. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/accounts.py +0 -0
  38. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/adapter.py +0 -0
  39. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/attempts.py +0 -0
  40. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/browser.py +0 -0
  41. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/commands.py +0 -0
  42. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/conditions.py +0 -0
  43. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/html.py +0 -0
  44. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/matching.py +0 -0
  45. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/passwords.py +0 -0
  46. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/paths.py +0 -0
  47. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/registry.py +0 -0
  48. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/src/atsfill/core/results.py +0 -0
  49. {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev113}/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.dev110
3
+ Version: 0.0.4.dev113
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.dev110"
3
+ version = "0.0.4.dev113"
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.dev110"
3
+ version = "0.0.4.dev113"
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
 
@@ -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"
@@ -117,6 +119,7 @@ kind = "ats"
117
119
  share = 0.0190
118
120
  account = "to_read"
119
121
  hosting = ["hosted"]
122
+ block = { vendor = "hCaptcha", signal = "image challenge on the email step that starts sign-in and sign-up, before the form, 4 employers headless, Next disabled on 2 more", step = "form", date = 2026-10-26, version = "0.0.3", camoufox = "0.5.8", browser = "156.0.1-beta.36" }
120
123
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
121
124
 
122
125
  [manatal]
File without changes