atsfill 0.0.4.dev88__tar.gz → 0.0.4.dev89__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 (46) hide show
  1. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/README.md +1 -1
  3. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev89/src/atsfill/adapters/workday.py +995 -0
  7. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/browser.py +2 -0
  8. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/registry.toml +1 -0
  9. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/LICENSE +0 -0
  10. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/eightfold.py +0 -0
  16. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/greenhouse.py +0 -0
  17. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/jazzhr.py +0 -0
  20. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/lever.py +0 -0
  21. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/manatal.py +0 -0
  22. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/oracle.py +0 -0
  23. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/paycor.py +0 -0
  24. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/paylocity.py +0 -0
  25. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/personio.py +0 -0
  26. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/phenom.py +0 -0
  27. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/pinpoint.py +0 -0
  28. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/recruitee.py +0 -0
  29. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/rippling.py +0 -0
  30. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/smartrecruiters.py +0 -0
  31. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/teamtailor.py +0 -0
  32. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/workable.py +0 -0
  33. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/wttj.py +0 -0
  34. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/adapters/zohorecruit.py +0 -0
  35. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/cli.py +0 -0
  36. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/__init__.py +0 -0
  37. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/accounts.py +0 -0
  38. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/adapter.py +0 -0
  39. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/attempts.py +0 -0
  40. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/commands.py +0 -0
  41. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/matching.py +0 -0
  42. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/passwords.py +0 -0
  43. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/paths.py +0 -0
  44. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/registry.py +0 -0
  45. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/src/atsfill/core/results.py +0 -0
  46. {atsfill-0.0.4.dev88 → atsfill-0.0.4.dev89}/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.dev88
3
+ Version: 0.0.4.dev89
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
@@ -55,7 +55,7 @@ It is built for agents with a shell: every command prints one JSON document and
55
55
  ## Supported hiring systems
56
56
 
57
57
  <!-- badges -->
58
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
58
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: mapped 2026-10-20](https://img.shields.io/badge/workday-mapped%202026--10--20-a9b1d6)
59
59
  <!-- /badges -->
60
60
 
61
61
  | System | Account needed | `read` without a browser |
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: mapped 2026-10-20](https://img.shields.io/badge/workday-mapped%202026--10--20-a9b1d6)
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.dev88"
3
+ version = "0.0.4.dev89"
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.dev88"
3
+ version = "0.0.4.dev89"
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"
@@ -20,6 +20,7 @@ from atsfill.adapters.rippling import Rippling
20
20
  from atsfill.adapters.smartrecruiters import SmartRecruiters
21
21
  from atsfill.adapters.teamtailor import Teamtailor
22
22
  from atsfill.adapters.workable import Workable
23
+ from atsfill.adapters.workday import Workday
23
24
  from atsfill.adapters.wttj import WelcomeToTheJungle
24
25
  from atsfill.adapters.zohorecruit import ZohoRecruit
25
26
  from atsfill.core.adapter import Adapter, Filtered
@@ -51,6 +52,7 @@ def adapters() -> list[Adapter]:
51
52
  Pinpoint(),
52
53
  HiringThing(),
53
54
  Eightfold(),
55
+ Workday(),
54
56
  Phenom(), # last: it claims a URL by its path alone
55
57
  ]
56
58
 
@@ -0,0 +1,995 @@
1
+ """Workday (`<tenant>.wd<n>.myworkdayjobs.com/<site>/job/…` and
2
+ `wd<n>.myworkdaysite.com/recruiting/<tenant>/<site>/job/…`), as docs/adapters/workday.md
3
+ observes.
4
+
5
+ Applying needs an account, one per tenant: the account scope. The form is a wizard of pages,
6
+ each saved to a draft application as it is left; only the current page is in the DOM. The
7
+ read walks the pages that save with what they hold, the posting's questionnaires and apply
8
+ flow standing for the pages it does not reach; the read-back is each page's values, read
9
+ before it is saved.
10
+ """
11
+
12
+ import re
13
+ import time
14
+ from dataclasses import dataclass
15
+ from html import unescape
16
+ from typing import Any
17
+ from urllib.parse import urlsplit
18
+
19
+ from atsfill.core import matching
20
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
21
+ from atsfill.core.browser import Locator, Page, PageRequest, Request, Response, Timeout
22
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
23
+
24
+ JOBS_HOST = re.compile(r"^(?P<tenant>[a-z0-9-]+)\.(?P<dc>wd\d+)\.myworkdayjobs\.com$")
25
+ SITE_HOST = re.compile(r"^(?P<dc>wd\d+)\.myworkdaysite\.com$")
26
+ LOCALE = r"(?:/[a-z]{2}-[A-Z]{2})?"
27
+ JOBS_PATH = re.compile(LOCALE + r"/(?P<site>[^/]+)(?P<rest>/.*)?$")
28
+ SITE_PATH = re.compile(LOCALE + r"/recruiting/(?P<tenant>[^/]+)/(?P<site>[^/]+)(?P<rest>/.*)?$")
29
+ JOB = re.compile(r"^/(?:job/[^/]+|details)/(?P<slug>[^/?#]+)")
30
+ SIGN_IN_TIMEOUT_S = 30
31
+ SIGN_IN_POLL_MS = 500
32
+ PRESS_TIMEOUT_MS = 5_000
33
+ PRESSES = 3
34
+ FORM_TIMEOUT_MS = 30_000
35
+ STEP_TIMEOUT_MS = 30_000
36
+ MENU_TIMEOUT_MS = 5_000
37
+ UPLOAD_TIMEOUT_MS = 30_000
38
+ SUBMIT_TIMEOUT_MS = 90_000
39
+ STEP_POLL_MS = 300
40
+ SETTLE_MS = 1_000
41
+ SETTLED = 3 # counts in a row the same
42
+ FOLLOW_UP_ROUNDS = 4 # follow-ups of follow-ups
43
+ RENDER_TRIES = 5
44
+ MAX_PAGES = 10
45
+ PROMPT_LEVELS = 3 # Escapes to close a list
46
+ MAX_PROMPT_OPTIONS = 40 # a longer list (the country phone codes) is read from the API
47
+ NEXT = "[data-automation-id=pageFooterNextButton]"
48
+ APPLIED = "[data-automation-id=alreadyApplied]"
49
+ FIELD = '[data-automation-id^="formField-"]'
50
+ STEP = "[data-automation-id=progressBarActiveStep]"
51
+ ERRORS = "[data-automation-id=errorHeading], [data-automation-id=inputAlert]"
52
+ UPLOADED = "[data-automation-id=file-upload-item-name]"
53
+ DELETE_FILE = "[data-automation-id=delete-file]"
54
+ PROMPT_OPTION = "[data-automation-id=activeListContainer] [data-automation-id=promptOption]:visible"
55
+ PICKED = "[data-automation-id=selectedItem]"
56
+ DELETE_PICK = "[data-automation-id=DELETE_charm]"
57
+ SEARCH = "input[data-uxi-widget-type=selectinput]"
58
+ BACK = "[data-automation-id=backButton]:visible"
59
+ PHONE_CODE_ID = "phoneNumber--countryPhoneCode"
60
+ PHONE_CODE = f'[data-fkit-id="{PHONE_CODE_ID}"]'
61
+ PHONE_CODES = "/values/phone/countryCodes"
62
+ # An answer to a question posts the questionnaire's definition; the page renders its reply
63
+ # over the answers given meanwhile
64
+ DEFINITION = "/definition?"
65
+ # A field whose value is the site's default before any answer, not the account's: the
66
+ # country's default is the browser's locale's, the user's, and decides the address fields read
67
+ SITE_DEFAULTS = {PHONE_CODE_ID}
68
+ PHONE_NUMBER = "phoneNumber--phoneNumber"
69
+ RESUME_ID = "resumeAttachments--attachments"
70
+ REVIEW = "Review"
71
+ MY_INFORMATION = "My Information"
72
+ EXPERIENCE = "My Experience"
73
+ # Each questionnaire's page: `Application Questions`, or `… 1 of 2` and `… 2 of 2`
74
+ QUESTIONNAIRES = [
75
+ ("primary", "questionnaireId", "Application Questions"),
76
+ ("secondary", "secondaryQuestionnaireId", "Application Questions 2 of 2"),
77
+ ]
78
+ SELECT_ONE = "Select One"
79
+ YES_NO = ["Yes", "No"]
80
+ TAG = re.compile(r"<[^>]+>")
81
+ RESUME = re.compile(r"resume|\bcv\b", re.I)
82
+ SKILLS = re.compile(r"skills\b", re.I)
83
+ SKILLS_ID = "skills--skills"
84
+ # The lists atsfill fills the first entry of: each part's id, label, rendering and the apply
85
+ # flow's section that shows it (None: always shown, required with the list)
86
+ type EntryList = tuple[str, re.Pattern[str], list[tuple[str, str, str, str | None]]]
87
+ ENTRY_LISTS: list[EntryList] = [
88
+ ("workExperience", re.compile(r"work (experience|history)", re.I), [
89
+ ("jobTitle", "Job Title", "text", None),
90
+ ("companyName", "Company", "text", None),
91
+ ("location", "Location", "text", "Location"),
92
+ ("currentlyWorkHere", "I currently work here", "box", None),
93
+ ("startDate", "From", "month", None),
94
+ ("endDate", "To", "month", None),
95
+ ("roleDescription", "Role Description", "text", "Role Description"),
96
+ ]),
97
+ ("education", re.compile(r"education", re.I), [
98
+ ("school", "School or University", "school", None),
99
+ ("degree", "Degree", "degrees", None),
100
+ ("fieldOfStudy", "Field of Study", "fieldsOfStudy", "Field of Study"),
101
+ ("gradeAverage", "Overall Result (GPA)", "text", "Overall Result (GPA)"),
102
+ ("firstYearAttended", "From", "year", "From"),
103
+ ("lastYearAttended", "To (Actual or Expected)", "year", "To (Actual or Expected)"),
104
+ ]),
105
+ ] # fmt: skip
106
+ SCHOOL_SEARCH = "university" # finds schools where the tenant lists them
107
+ # The school: a search prompt where the tenant lists schools, else a text input; one field
108
+ SCHOOL_IDS = {"education--schoolName": "education--school"}
109
+ BRACKETED = re.compile(r"\(([^()]+)\)")
110
+ ENTRY_ID = re.compile(r"^(workExperience|education)-(\d+)--")
111
+ ADD = "[data-automation-id=add-button]"
112
+ ENTRIES = re.compile(r"(work|education|skill|website|language|certification|portfolio)", re.I)
113
+ ENTRY_PART = re.compile(r"number|date|attachments", re.I) # a field of an entry, not a list
114
+ KINDS = {
115
+ "text": FieldType.TEXT,
116
+ "list": FieldType.CHOICE,
117
+ "radio": FieldType.CHOICE,
118
+ "box": FieldType.CHOICE,
119
+ "boxes": FieldType.MULTI_CHOICE,
120
+ "prompt": FieldType.TYPEAHEAD,
121
+ "date": FieldType.DATE,
122
+ "month": FieldType.TEXT,
123
+ "year": FieldType.TEXT,
124
+ "file": FieldType.FILE,
125
+ }
126
+ EMAIL = "[data-automation-id=email]"
127
+ WITH_EMAIL = "[data-automation-id=SignInWithEmailButton]" # beside Google, LinkedIn…
128
+ # What the sign-in and sign-up pages say, by the reason it is
129
+ MESSAGES = [
130
+ (re.compile(r"verify your account", re.I), Blocked, "email_link"),
131
+ (re.compile(r"password must|password requirements", re.I), Refused, "password_rule"),
132
+ (re.compile(r"wrong email address or password|account might be locked", re.I), Refused,
133
+ "wrong_password"),
134
+ ] # fmt: skip
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Site:
139
+ """Where a tenant's career site is served: its host, the path before the site, the site."""
140
+
141
+ tenant: str
142
+ host: str
143
+ prefix: str # "" on myworkdayjobs.com, `/recruiting/<tenant>` on myworkdaysite.com
144
+ site: str
145
+ rest: str # the path after the site: `/job/<location>/<slug>`, `/details/<slug>`…
146
+
147
+ @property
148
+ def dc(self) -> str:
149
+ """The data centre, `wd<n>`."""
150
+ return next(label for label in self.host.split(".") if re.fullmatch(r"wd\d+", label))
151
+
152
+ @property
153
+ def base(self) -> str:
154
+ return f"https://{self.host}{self.prefix}/{self.site}"
155
+
156
+
157
+ def site(url: str) -> Site | None:
158
+ parts = urlsplit(url)
159
+ host = parts.hostname or ""
160
+ if (jobs := JOBS_HOST.match(host)) and (path := JOBS_PATH.match(parts.path)):
161
+ return Site(jobs["tenant"], host, "", path["site"], path["rest"] or "")
162
+ if SITE_HOST.match(host) and (path := SITE_PATH.match(parts.path)):
163
+ prefix = f"/recruiting/{path['tenant']}"
164
+ return Site(path["tenant"], host, prefix, path["site"], path["rest"] or "")
165
+ return None
166
+
167
+
168
+ def _profile_hosts(request: Request, scope: str | None) -> list[str]:
169
+ """The Workday hosts a request's cookies are for: the tenant's own, or a shared
170
+ myworkdaysite.com host."""
171
+ hosts = {c["domain"].lstrip(".") for c in request.storage_state()["cookies"]}
172
+ return sorted(
173
+ h for h in hosts
174
+ if SITE_HOST.match(h) or ((m := JOBS_HOST.match(h)) and m["tenant"] == scope)
175
+ ) # fmt: skip
176
+
177
+
178
+ class Workday:
179
+ name = "workday"
180
+ per_employer = True
181
+ known_gaps = {
182
+ "experience_entries": Gap.FLOW,
183
+ "long_wizard": Gap.FLOW,
184
+ "other_country": Gap.FLOW,
185
+ }
186
+ side_effects = [
187
+ SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
188
+ SideEffect("load", "hiring_system", "opening the wizard creates a draft application"),
189
+ SideEffect(
190
+ "fill", "hiring_system", "each page, the resume too, is saved to the draft when left"
191
+ ),
192
+ SideEffect("submit", "employer", "the application reaches the employer"),
193
+ ]
194
+
195
+ def __init__(self) -> None:
196
+ self._sites: dict[str, Site] = {} # by tenant, the last posting's
197
+ self._fields: dict[str, Field] = {}
198
+ self._values: dict[str, Answer] = {}
199
+ self._apply = ""
200
+
201
+ def detect(self, url: str, page: Page | None) -> bool:
202
+ found = site(url)
203
+ return found is not None and bool(JOB.match(found.rest))
204
+
205
+ def posting(self, url: str) -> str:
206
+ """The job's slug on its tenant's site, whatever the host, locale or location."""
207
+ found = site(url)
208
+ if found is None or not (job := JOB.match(found.rest)):
209
+ return url
210
+ return f"https://{found.tenant}.{found.dc}.myworkdayjobs.com/{found.site}/details/{job['slug']}"
211
+
212
+ def account_scope(self, url: str) -> str | None:
213
+ found = site(url)
214
+ if found is None:
215
+ return None
216
+ self._sites[found.tenant] = found
217
+ return found.tenant
218
+
219
+ def login_url(self, scope: str | None) -> str:
220
+ """The site's login page; the site is the last posting's for the tenant."""
221
+ if scope not in self._sites:
222
+ raise ValueError(f"no Workday posting seen for the tenant {scope!r}")
223
+ return f"{self._sites[scope].base}/login"
224
+
225
+ def signed_in(self, request: Request, scope: str | None) -> bool:
226
+ """The tenant's `userprofile` holds the account's email; `{}` for a visitor."""
227
+ for host in _profile_hosts(request, scope):
228
+ response = request.get(f"https://{host}/wday/cxs/{scope}/x/userprofile")
229
+ if response.ok and response.json().get("emailAddress"):
230
+ return True
231
+ return False
232
+
233
+ def sign_in(self, page: Page, email: str, password: str) -> None:
234
+ _email_form(page)
235
+ _submit(page, "signInSubmitButton", email, password)
236
+
237
+ def sign_up(self, page: Page, email: str, password: str) -> None:
238
+ _email_form(page)
239
+ page.locator("[data-automation-id=createAccountLink]").click()
240
+ page.locator("[data-automation-id=verifyPassword]").fill(password)
241
+ consent = page.locator("[data-automation-id=createAccountCheckbox]")
242
+ if consent.count():
243
+ consent.check()
244
+ _submit(page, "createAccountSubmitButton", email, password)
245
+
246
+ def read_form(self, page: Page) -> list[Field]:
247
+ """Walks the wizard as far as its pages save with what they hold, reading each page
248
+ it reaches: from the page, else My Experience from the apply flow's sections and the
249
+ questions from the questionnaires, which also stand for those pages when they are not
250
+ reached. A page after the first that does not save is read once it does."""
251
+ found = site(page.url)
252
+ if found is None or not (job := JOB.match(found.rest)):
253
+ raise RuntimeError(f"{page.url} is not a Workday posting")
254
+ api = f"https://{found.host}/wday/cxs/{found.tenant}/{found.site}"
255
+ calypso = f"https://{found.host}/wday/calypso/cxs"
256
+ posting = _json(page, f"{api}/job/{job['slug']}").get("jobPostingInfo") or {}
257
+ if not posting.get("posted"): # `canApply` is false too with a draft in progress
258
+ raise Unsupported("posting_gone: the posting takes no applications")
259
+ flow = _json(
260
+ page,
261
+ f"{calypso}/jobapplication/{found.tenant}/jobpostings/{job['slug']}"
262
+ "/applyflowpages?applyDetail=applyManually",
263
+ )
264
+ values = f"{calypso}/jobapplication/{found.tenant}/values"
265
+ from_api = {EXPERIENCE: _experience(self, page, flow, values)}
266
+ for kind, key, step in QUESTIONNAIRES:
267
+ if questionnaire := posting.get(key):
268
+ url = f"{calypso}/common/{found.tenant}/questionnaire/{questionnaire}"
269
+ from_api[step] = _questions(kind, _json(page, url).get("questions") or [])
270
+ self._apply = f"{page.url.split('?')[0].split('/apply')[0]}/apply/applyManually"
271
+ page.goto(self._apply)
272
+ _wait_for_page(page)
273
+ seen: dict[str, list[Field]] = {}
274
+ for _ in range(MAX_PAGES):
275
+ step = _step(page)
276
+ if step == REVIEW: # repeats the answers, an upload without its input
277
+ break
278
+ if not (_questions_page(step) or step == EXPERIENCE):
279
+ seen[step] = _read(page, f"{calypso}/jobapplication/{found.tenant}{PHONE_CODES}")
280
+ try:
281
+ _save_and_continue(page)
282
+ except NotTaken:
283
+ break
284
+ steps = [p["descriptor"] for p in flow.get("data") or []]
285
+ steps += [s for s in seen if s not in steps]
286
+ fields = [
287
+ f for s in steps for f in seen.get(s, []) + from_api.get(_questionnaire_step(s), [])
288
+ ]
289
+ self._fields = {f.id: f for f in fields if not f.unfillable}
290
+ return fields
291
+
292
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
293
+ """Fills each page of the wizard from the first and saves it, up to Review."""
294
+ page.goto(self._apply)
295
+ _wait_for_page(page)
296
+ self._values = {}
297
+ pending: set[PageRequest] = set()
298
+ page.on("request", lambda r: pending.add(r) if DEFINITION in r.url else None)
299
+ for done in ("requestfinished", "requestfailed"):
300
+ page.on(done, lambda r: pending.discard(r))
301
+ for _ in range(MAX_PAGES):
302
+ if _step(page) == REVIEW:
303
+ return
304
+ _add_entries(page, answers)
305
+ filled: set[str] = set()
306
+ for _ in range(FOLLOW_UP_ROUNDS): # an answer shows its follow-up questions
307
+ todo = [r for r in _rendered(page, answers) if r["id"] not in filled]
308
+ todo = [r for r in todo if r["id"] in answers and r["id"] in self._fields]
309
+ if not todo:
310
+ break
311
+ for raw in todo:
312
+ _fill(page, self._fields[raw["id"]], raw, answers[raw["id"]])
313
+ filled.add(raw["id"])
314
+ if raw["kind"] != "text":
315
+ _until_rendered(page, pending)
316
+ page.wait_for_timeout(SETTLE_MS)
317
+ self._unread(page)
318
+ self._values |= {r["id"]: r["value"] for r in _page_fields(page)}
319
+ if self._values.get(PHONE_NUMBER) and (code := _phone_code(page)):
320
+ self._values[PHONE_NUMBER] = f"+{code} {self._values[PHONE_NUMBER]}"
321
+ _save_and_continue(page)
322
+ raise Unsupported(known_gap(self, "long_wizard", f"more than {MAX_PAGES} pages"))
323
+
324
+ def _unread(self, page: Page) -> None:
325
+ """Stops on required fields the read did not list: on My Information, the address of a
326
+ country other than the form's; on a later page, one the read could not reach."""
327
+ unread = [
328
+ r["label"] for r in _page_fields(page) if r["required"] and r["id"] not in self._fields
329
+ ]
330
+ if not unread:
331
+ return
332
+ step, labels = _step(page), ", ".join(unread)
333
+ if step == MY_INFORMATION:
334
+ detail = f"the country answered shows fields the read did not: {labels}"
335
+ raise Unsupported(known_gap(self, "other_country", detail))
336
+ raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
337
+
338
+ def read_back(self, page: Page) -> dict[str, Answer]:
339
+ """Each page's values, read before it was saved; one file as its name."""
340
+ values: dict[str, Answer] = {}
341
+ for k, v in self._values.items():
342
+ if k not in self._fields or v in (None, "", []):
343
+ continue
344
+ one_file = self._fields[k].type is FieldType.FILE and len(v) == 1
345
+ values[k] = v[0] if one_file else v
346
+ return values
347
+
348
+ def submit(self, page: Page) -> str | None:
349
+ with page.expect_response(
350
+ lambda r: r.request.method in ("POST", "PUT") and "/submit" in r.url,
351
+ timeout=SUBMIT_TIMEOUT_MS,
352
+ ) as sent:
353
+ page.locator(NEXT).click()
354
+ response = sent.value
355
+ if response.ok:
356
+ return f"{response.request.method} {urlsplit(response.url).path} {response.status}"
357
+ raise RuntimeError(f"{response.url} answered {response.status}")
358
+
359
+
360
+ def _email_form(page: Page) -> None:
361
+ """Opens the email form where the page offers social accounts first."""
362
+ form = page.locator(f"{EMAIL}:visible, {WITH_EMAIL}:visible").first
363
+ form.wait_for(timeout=FORM_TIMEOUT_MS)
364
+ if page.locator(WITH_EMAIL).is_visible():
365
+ page.locator(WITH_EMAIL).click()
366
+ page.locator(EMAIL).wait_for(timeout=FORM_TIMEOUT_MS)
367
+
368
+
369
+ def _submit(page: Page, button: str, email: str, password: str) -> None:
370
+ """Fills the email and password, presses the button through the overlay that takes its
371
+ clicks, and waits to leave the login page or for what the page says."""
372
+ page.locator(EMAIL).fill(email)
373
+ page.locator("[data-automation-id=password]").fill(password)
374
+ press = page.locator(
375
+ f"[data-automation-id=noCaptchaWrapper]:has([data-automation-id={button}])"
376
+ " [data-automation-id=click_filter]"
377
+ )
378
+ said = page.locator("[data-automation-id=errorMessage], [data-automation-id=inputAlert]")
379
+ requested: list[PageRequest] = []
380
+ page.on("request", lambda r: requested.append(r))
381
+ for _ in range(PRESSES): # the first press after typing can post nothing
382
+ if not _on_login(page) or any(_redirected(r) for r in requested):
383
+ break
384
+ try:
385
+ with page.expect_response(_posts_account, timeout=PRESS_TIMEOUT_MS):
386
+ press.click()
387
+ break
388
+ except Timeout:
389
+ if said.count(): # a field the page refused without posting
390
+ break
391
+ deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
392
+ while time.monotonic() < deadline:
393
+ redirects = [_redirected(r) for r in requested]
394
+ if "/login" in redirects:
395
+ raise Refused("email_in_use: the sign-up went back to the login page")
396
+ if "/login/warning" in redirects:
397
+ raise Blocked("email_link: the sign-up sent a verification email")
398
+ if not _on_login(page):
399
+ return
400
+ if said.count():
401
+ text = " ".join(said.all_inner_texts()).strip()
402
+ for pattern, error, code in MESSAGES:
403
+ if pattern.search(text):
404
+ raise error(f"{code}: {text}")
405
+ raise Unsupported(f"unhandled_rendering: the login page says {text!r}")
406
+ page.wait_for_timeout(SIGN_IN_POLL_MS)
407
+ raise Unsupported("unhandled_rendering: the login page answered nothing")
408
+
409
+
410
+ def _on_login(page: Page) -> bool:
411
+ return urlsplit(page.url).path.endswith("/login")
412
+
413
+
414
+ def _redirected(request: PageRequest) -> str | None:
415
+ """Where a sign-up redirected to, after the site: `/login` for an email that has an
416
+ account, `/login/warning` once a verification email is sent; None for another request."""
417
+ before = request.redirected_from
418
+ if before is None or not urlsplit(before.url).path.endswith("/register"):
419
+ return None
420
+ found = site(request.url)
421
+ return found.rest if found else None
422
+
423
+
424
+ def _posts_account(response: Response) -> bool:
425
+ return response.request.method == "POST" and bool(
426
+ re.search(r"/(login|register)$", urlsplit(response.url).path)
427
+ )
428
+
429
+
430
+ def _json(page: Page, url: str) -> Any:
431
+ """A Workday API's answer, fetched by the page: same origin, the session's cookies."""
432
+ return page.evaluate(
433
+ """async url => {
434
+ const response = await fetch(url, {headers: {Accept: "application/json"}});
435
+ return response.ok ? response.json() : {};
436
+ }""",
437
+ url,
438
+ )
439
+
440
+
441
+ def _text(html: str) -> str:
442
+ return re.sub(r"\s+", " ", unescape(TAG.sub(" ", html))).strip()
443
+
444
+
445
+ def _questions_page(step: str) -> bool:
446
+ return step.startswith("Application Questions")
447
+
448
+
449
+ def _questionnaire_step(step: str) -> str:
450
+ """The key a page's API fields are under: `… 1 of 2` is the primary questionnaire's."""
451
+ return re.sub(r" 1 of \d+$", "", step)
452
+
453
+
454
+ def _read(page: Page, phone_codes: str) -> list[Field]:
455
+ """The fields the page shows, a dropdown's or a short prompt's options read by opening it,
456
+ the country phone codes from `phone_codes`, the API's list."""
457
+ raws = _page_fields(page)
458
+ for raw in raws:
459
+ box = page.locator(f'[data-fkit-id="{raw["dom"]}"]')
460
+ if raw["kind"] == "list":
461
+ raw["options"] = _options(page, raw)
462
+ elif raw["id"] == PHONE_CODE_ID:
463
+ raw["options"] = [c["descriptor"] for c in _json(page, phone_codes)]
464
+ elif raw["kind"] == "prompt":
465
+ raw["options"] = _prompt_options(page, box)
466
+ return [f for raw in raws if (f := _field(raw)) is not None]
467
+
468
+
469
+ def _wait_for_page(page: Page) -> None:
470
+ """Waits for the wizard's page, or for what stands before it."""
471
+ page.locator(f"{NEXT}, {APPLIED}").first.wait_for(timeout=FORM_TIMEOUT_MS)
472
+ if page.locator(APPLIED).count():
473
+ raise Unsupported("posting_gone: the account already applied to this posting")
474
+ if _step(page) == REVIEW:
475
+ return
476
+ page.locator(FIELD).first.wait_for(timeout=FORM_TIMEOUT_MS)
477
+ counts, deadline = [-1], time.monotonic() + FORM_TIMEOUT_MS / 1000
478
+ while time.monotonic() < deadline: # sections render one after another, some seconds apart
479
+ page.wait_for_timeout(SETTLE_MS)
480
+ counts.append(page.locator(FIELD).count())
481
+ if len(set(counts[-SETTLED:])) == 1 and len(counts) > SETTLED:
482
+ return
483
+
484
+
485
+ def _step(page: Page) -> str:
486
+ """The wizard's current page, `My Information`…"""
487
+ return page.locator(STEP).inner_text().strip().splitlines()[-1].strip()
488
+
489
+
490
+ def _page_fields(page: Page) -> list[dict[str, Any]]:
491
+ """The page's fields, a list's first entry's parts as `<list>--<part>`: the page numbers
492
+ entries its own way (`workExperience-8--jobTitle`)."""
493
+ raws, first = page.evaluate(_FIELDS), {}
494
+ for raw in raws:
495
+ raw["dom"] = raw["id"] # the page's, to find the field by
496
+ entry = ENTRY_ID.match(raw["id"])
497
+ if entry and first.setdefault(entry[1], entry[2]) == entry[2]:
498
+ raw["id"] = ENTRY_ID.sub(r"\1--", raw["id"])
499
+ raw["id"] = SCHOOL_IDS.get(raw["id"], raw["id"])
500
+ return raws
501
+
502
+
503
+ def _add_entries(page: Page, answers: dict[str, Answer]) -> None:
504
+ """Adds a list's first entry where answers are given for it and the page shows none."""
505
+ shown = {r["id"] for r in _page_fields(page)}
506
+ groups = page.locator("[role=group][aria-labelledby]").all()
507
+ for prefix, pattern, _ in ENTRY_LISTS:
508
+ answered = any(a.startswith(f"{prefix}--") for a in answers)
509
+ if not answered or any(i.startswith(f"{prefix}--") for i in shown):
510
+ continue
511
+ # `Work-Experience-section`, `Work-History-(Optional)-section`
512
+ for group in (g for g in groups if pattern.match(_group_name(g))):
513
+ if group.locator(ADD).count():
514
+ group.locator(ADD).first.click()
515
+ page.locator(f'[data-fkit-id^="{prefix}-"]').first.wait_for(timeout=FORM_TIMEOUT_MS)
516
+ break
517
+
518
+
519
+ def _group_name(group: Locator) -> str:
520
+ return (group.get_attribute("aria-labelledby") or "").replace("-", " ")
521
+
522
+
523
+ def _rendered(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
524
+ """The page's fields once each answered one shows its control: an upload's comes last."""
525
+ for _ in range(RENDER_TRIES):
526
+ raws = _page_fields(page)
527
+ if all(r["kind"] for r in raws if r["id"] in answers):
528
+ break
529
+ page.wait_for_timeout(SETTLE_MS)
530
+ return raws
531
+
532
+
533
+ def _field(raw: dict[str, Any]) -> Field | None:
534
+ kind, options = KINDS.get(raw["kind"]), raw["options"]
535
+ if raw["kind"] == "box":
536
+ options = YES_NO
537
+ if raw["id"] == PHONE_NUMBER:
538
+ kind = FieldType.PHONE
539
+ if raw["kind"] == "prompt" and options: # a list of one value to search
540
+ kind = FieldType.CHOICE
541
+ if kind is None:
542
+ reason = f"unhandled_rendering: {raw['label'] or raw['id']!r} is a {raw['kind']}"
543
+ return Field(raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason)
544
+ prefilled = raw["value"] not in (None, "", [], "No") and raw["id"] not in SITE_DEFAULTS
545
+ return Field(raw["id"], raw["label"], kind, raw["required"], options, prefilled=prefilled)
546
+
547
+
548
+ def _experience(adapter: Workday, page: Page, flow: dict[str, Any], values: str) -> list[Field]:
549
+ """My Experience from the apply flow's sections: the resume, skills, the first entry of the
550
+ work experience and the education lists, their degrees and fields of study from `values`,
551
+ the API's lists. A section's own fields are sections too, hidden or required each; the other
552
+ lists (websites, languages, certifications) atsfill does not fill, a known gap where one is
553
+ required."""
554
+ sections = [
555
+ s
556
+ for p in flow.get("data") or []
557
+ if p.get("descriptor") == EXPERIENCE
558
+ for s in p.get("sections") or []
559
+ ]
560
+ shown = {s["label"]: bool(s["required"]) for s in sections if not s.get("hidden")}
561
+ fields = []
562
+ for label, required in shown.items():
563
+ if RESUME.search(label):
564
+ fields.append(Field(RESUME_ID, label, FieldType.FILE, required))
565
+ elif SKILLS.match(label):
566
+ fields.append(Field(SKILLS_ID, label, FieldType.TYPEAHEAD, required))
567
+ elif entry := next((e for e in ENTRY_LISTS if e[1].match(label)), None):
568
+ fields += _entry(page, entry, required, shown, values)
569
+ elif ENTRIES.match(label) and not ENTRY_PART.search(label):
570
+ reason = known_gap(adapter, "experience_entries", f"{label} is a list of entries")
571
+ fields.append(
572
+ Field(f"myExperience--{label}", label, FieldType.TEXT, required, unfillable=reason)
573
+ )
574
+ return fields
575
+
576
+
577
+ def _entry(
578
+ page: Page,
579
+ entry: EntryList,
580
+ required: bool,
581
+ shown: dict[str, bool],
582
+ values: str,
583
+ ) -> list[Field]:
584
+ """The first entry of a list: each of its parts the posting shows, required where the list
585
+ is and the part is, `<list>--<part>` as its id."""
586
+ prefix, _, parts = entry
587
+ name = {"workExperience": "Work Experience", "education": "Education"}[prefix]
588
+ fields = []
589
+ for part, label, kind, section in parts:
590
+ if section is not None and section not in shown:
591
+ continue
592
+ needed = required and (section is None or shown[section])
593
+ fid, label = f"{prefix}--{part}", f"{name}: {label}"
594
+ match kind:
595
+ case "month" | "year":
596
+ suffix = " (MM/YYYY)" if kind == "month" else " (year)"
597
+ if part == "endDate":
598
+ asked = f"{name}: I currently work here"
599
+ suffix, needed = f'{suffix} (asked when "{asked}" is "No")', False
600
+ fields.append(Field(fid, label + suffix, FieldType.TEXT, needed))
601
+ case "box":
602
+ fields.append(Field(fid, label, FieldType.CHOICE, False, YES_NO))
603
+ case "school":
604
+ fields.append(Field(fid, label, FieldType.TYPEAHEAD, needed))
605
+ case "degrees" | "fieldsOfStudy": # a dropdown, a prompt: the API's list either way
606
+ listed = _json(page, f"{values}/educations/{kind}")
607
+ if listed and all("href" in v for v in listed): # a long list, in groups
608
+ first = urlsplit(listed[0]["href"])
609
+ url = f"{urlsplit(values).scheme}://{urlsplit(values).netloc}{first.path}"
610
+ options = [v["descriptor"] for v in _json(page, f"{url}?{first.query}")]
611
+ fields.append(Field(fid, label, FieldType.TYPEAHEAD, needed, options or None))
612
+ else:
613
+ options = [v["descriptor"] for v in listed]
614
+ fields.append(Field(fid, label, FieldType.CHOICE, needed, options or None))
615
+ case _:
616
+ fields.append(Field(fid, label, FieldType.TEXT, needed))
617
+ return fields
618
+
619
+
620
+ def _questions(kind: str, items: list[dict[str, Any]]) -> list[Field]:
621
+ """A questionnaire's questions. One an answer branches to is asked only after it: optional,
622
+ its label naming the question as listed, itself a follow-up at times, and the answers."""
623
+ by_id = {item["id"]: item for item in items}
624
+ asked_when: dict[str, tuple[str, list[str]]] = {}
625
+ for item in items:
626
+ for answer in item.get("possibleAnswers") or []:
627
+ if follow_up := (answer.get("branchingQuestion") or {}).get("id"):
628
+ parent, values = asked_when.setdefault(follow_up, (item["id"], []))
629
+ if parent == item["id"]:
630
+ values.append(answer["answerText"])
631
+ labels: dict[str, str] = {}
632
+
633
+ def label(qid: str) -> str:
634
+ if qid not in labels:
635
+ item = by_id[qid]
636
+ text = _text(item.get("body") or "")
637
+ if item.get("type", {}).get("descriptor") == "Number":
638
+ text += " (a number)"
639
+ if qid in asked_when and asked_when[qid][0] in by_id:
640
+ parent, values = asked_when[qid]
641
+ text += f' (asked when "{label(parent)}" is "{" or ".join(values)}")'
642
+ labels[qid] = text
643
+ return labels[qid]
644
+
645
+ fields = []
646
+ for item in items:
647
+ fid, text = f"{kind}Questionnaire--{item['id']}", label(item["id"])
648
+ options = [a["answerText"] for a in item.get("possibleAnswers") or []] or None
649
+ required = bool(item.get("required")) and item["id"] not in asked_when
650
+ match item.get("type", {}).get("descriptor"):
651
+ case "Multiple Choice - Single Select":
652
+ fields.append(Field(fid, text, FieldType.CHOICE, required, options))
653
+ case "Multiple Choice - Multi Select":
654
+ fields.append(Field(fid, text, FieldType.MULTI_CHOICE, required, options))
655
+ case "Text" | "Number":
656
+ fields.append(Field(fid, text, FieldType.TEXT, required))
657
+ case "Date":
658
+ fields.append(Field(fid, text, FieldType.DATE, required))
659
+ case "Upload Attachments":
660
+ fields.append(Field(fid, text, FieldType.FILE, required))
661
+ case other:
662
+ reason = f"unhandled_rendering: {fid} is a question of type {other}"
663
+ fields.append(Field(fid, text, FieldType.TEXT, required, unfillable=reason))
664
+ return fields
665
+
666
+
667
+ def _fill(page: Page, field: Field, raw: dict[str, Any], answer: Answer) -> None:
668
+ box = page.locator(f'[data-fkit-id="{raw["dom"]}"]')
669
+ match raw["kind"], answer:
670
+ case "text", str():
671
+ control = box.locator("textarea, input[type=text]").first
672
+ if field.type is FieldType.PHONE:
673
+ answer = _national(page, answer)
674
+ control.fill("")
675
+ control.press_sequentially(answer) # some pages miss a value filled at once
676
+ control.blur() # the page takes the value on leaving the field
677
+ case "list", str():
678
+ _pick(page, field, box, answer)
679
+ case "radio" | "boxes" | "box", _:
680
+ wanted = {answer} if isinstance(answer, str) else set(answer)
681
+ for option in box.locator("input[type=radio], input[type=checkbox]").all():
682
+ name = "Yes" if raw["kind"] == "box" else _option_label(option)
683
+ checked = any(matching.matches(field, w, name) for w in wanted)
684
+ if raw["kind"] == "box":
685
+ checked = answer == "Yes"
686
+ if option.is_checked() != checked and (checked or raw["kind"] != "radio"):
687
+ option.click()
688
+ case "prompt", str():
689
+ _prompt(page, field, box, answer)
690
+ case "date" | "month" | "year", str():
691
+ if raw["kind"] == "date":
692
+ year, month, day = answer.split("-")
693
+ elif raw["kind"] == "month":
694
+ (month, year), day = answer.split("/"), ""
695
+ else:
696
+ year, month, day = answer, "", ""
697
+ for part, value in (("Month", month), ("Day", day), ("Year", year)):
698
+ section = box.locator(f"[data-automation-id=dateSection{part}-input]")
699
+ if section.count():
700
+ section.focus() # a display over it takes clicks
701
+ section.press("Control+a") # the draft's value
702
+ section.press_sequentially(value)
703
+ case "file", _:
704
+ files = [answer] if isinstance(answer, str) else answer
705
+ delete = box.locator(DELETE_FILE)
706
+ while (count := delete.count()) > 0: # the draft's or the profile's files
707
+ delete.first.click()
708
+ deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
709
+ while delete.count() == count and time.monotonic() < deadline:
710
+ page.wait_for_timeout(STEP_POLL_MS)
711
+ box.locator("input[type=file]").set_input_files(files)
712
+ for path in files:
713
+ name = path.rsplit("/", 1)[-1]
714
+ box.locator(UPLOADED, has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
715
+ case kind, _:
716
+ reason = f"unhandled_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
717
+ raise Unsupported(reason)
718
+
719
+
720
+ def _option_label(option: Locator) -> str:
721
+ return option.evaluate(
722
+ """e => (document.querySelector(`label[for="${CSS.escape(e.id)}"]`)
723
+ ?? e.closest("label") ?? e.parentElement).innerText.trim()"""
724
+ )
725
+
726
+
727
+ def _phone_code(page: Page) -> str | None:
728
+ """The country code the form shows beside the phone number, `44` for `(+44)`."""
729
+ box = page.locator(PHONE_CODE)
730
+ code = re.search(r"\(\+(\d+)\)", box.inner_text()) if box.count() else None
731
+ return code[1] if code else None
732
+
733
+
734
+ def _national(page: Page, number: str) -> str:
735
+ """The number without the country code the form shows beside it, which must be the
736
+ number's own."""
737
+ code, digits = _phone_code(page), re.sub(r"[^\d+]", "", number)
738
+ if code and digits.startswith(f"+{code}"):
739
+ return digits[len(code) + 1 :]
740
+ if code and digits.startswith("+"):
741
+ raise NotTaken(f"{number!r} is not a +{code} number: answer {PHONE_CODE_ID} for it")
742
+ return number
743
+
744
+
745
+ def _until_rendered(page: Page, pending: set[PageRequest]) -> None:
746
+ page.wait_for_timeout(STEP_POLL_MS) # the request goes out a moment after the answer
747
+ deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
748
+ while pending and time.monotonic() < deadline:
749
+ page.wait_for_timeout(STEP_POLL_MS)
750
+
751
+
752
+ def _pick(page: Page, field: Field, box: Locator, answer: str) -> None:
753
+ """A dropdown: its options are in the listbox the button controls."""
754
+ listbox = _open(page, box.locator("button[aria-haspopup=listbox]").first)
755
+ for option in listbox.locator("[role=option]:not([aria-disabled=true])").all():
756
+ if matching.matches(field, answer, option.inner_text().strip()):
757
+ option.click()
758
+ return
759
+ page.keyboard.press("Escape")
760
+ raise NotTaken(f"no option {answer!r} in {field.id}")
761
+
762
+
763
+ def _open(page: Page, button: Locator) -> Locator:
764
+ """Opens a dropdown and waits for its options, which load a moment later."""
765
+ for press in range(PRESSES): # a click right after typing elsewhere can go unheard
766
+ button.click()
767
+ try: # some tenants name the listbox only once it opens
768
+ page.wait_for_function(
769
+ "b => b.getAttribute('aria-controls')",
770
+ arg=button.element_handle(),
771
+ timeout=MENU_TIMEOUT_MS / PRESSES,
772
+ )
773
+ break
774
+ except Timeout:
775
+ if press == PRESSES - 1:
776
+ raise
777
+ listbox = page.locator(f"[id='{button.get_attribute('aria-controls')}']")
778
+ listbox.locator(f"[role=option]:not(:text-is('{SELECT_ONE}'))").first.wait_for(
779
+ timeout=MENU_TIMEOUT_MS
780
+ )
781
+ return listbox
782
+
783
+
784
+ def _options(page: Page, raw: dict[str, Any]) -> list[str]:
785
+ listbox = _open(
786
+ page, page.locator(f'[data-fkit-id="{raw["dom"]}"] button[aria-haspopup=listbox]').first
787
+ )
788
+ names = [
789
+ o.inner_text().strip()
790
+ for o in listbox.locator("[role=option]:not([aria-disabled=true])").all()
791
+ ]
792
+ page.keyboard.press("Escape")
793
+ return [n for n in names if n and n != SELECT_ONE]
794
+
795
+
796
+ def _prompt(page: Page, field: Field, box: Locator, answer: str) -> None:
797
+ """A search prompt: typing and Enter list the items that match, at any depth. A prompt of
798
+ one item has it replaced; skills, of many, get one more."""
799
+ if _picked(field, box, answer):
800
+ return
801
+ if field.id != SKILLS_ID: # every other prompt holds one item
802
+ for picked in box.locator(PICKED).all():
803
+ picked.locator(DELETE_PICK).click()
804
+ # The search can miss a name with brackets that their content finds: `Citizen (Ireland)`
805
+ for term in [answer, *BRACKETED.findall(answer)]:
806
+ if _search(page, field, box, answer, term):
807
+ return
808
+ if _pick_in_list(page, field, box, answer): # a search can miss an item the list holds
809
+ return
810
+ raise NotTaken(f"no item {answer!r} in {field.id}")
811
+
812
+
813
+ def _search(page: Page, field: Field, box: Locator, answer: str, term: str) -> bool:
814
+ """Types the term and Enter, which picks the only item that matches, else lists them."""
815
+ search = _close_prompts(page, box)
816
+ search.fill(term) # a click would land on the picked item over it
817
+ search.press("Enter")
818
+ options = page.locator(PROMPT_OPTION)
819
+ deadline = time.monotonic() + MENU_TIMEOUT_MS / 1000
820
+ while time.monotonic() < deadline:
821
+ if _picked(field, box, answer):
822
+ _close_prompts(page, box)
823
+ return True
824
+ for option in options.all():
825
+ if matching.matches(field, answer, option.inner_text().strip()):
826
+ option.click()
827
+ _close_prompts(page, box)
828
+ return True
829
+ page.wait_for_timeout(STEP_POLL_MS)
830
+ return False
831
+
832
+
833
+ def _pick_in_list(page: Page, field: Field, box: Locator, answer: str) -> bool:
834
+ """Picks the item from the prompt's whole list, a category's items behind it."""
835
+ search = _close_prompts(page, box)
836
+ search.fill("")
837
+ search.press("Enter")
838
+ try:
839
+ page.locator(PROMPT_OPTION).first.wait_for(timeout=MENU_TIMEOUT_MS)
840
+ except Timeout:
841
+ _close_prompts(page, box)
842
+ return False
843
+ for n, row in enumerate(page.evaluate(_PROMPT_ROWS)):
844
+ if not row["nested"] and matching.matches(field, answer, row["name"]):
845
+ page.locator(PROMPT_OPTION).nth(n).click()
846
+ break
847
+ if row["nested"]:
848
+ page.locator(PROMPT_OPTION).nth(n).click()
849
+ page.locator(BACK).wait_for(timeout=MENU_TIMEOUT_MS)
850
+ names = [r["name"] for r in page.evaluate(_PROMPT_ROWS)]
851
+ if found := [m for m, n in enumerate(names) if matching.matches(field, answer, n)]:
852
+ page.locator(PROMPT_OPTION).nth(found[0]).click()
853
+ break
854
+ page.locator(BACK).click()
855
+ page.locator(BACK).wait_for(state="hidden", timeout=MENU_TIMEOUT_MS)
856
+ _close_prompts(page, box)
857
+ return _picked(field, box, answer)
858
+
859
+
860
+ def _picked(field: Field, box: Locator, answer: str) -> bool:
861
+ picked = box.locator(PICKED).all_inner_texts()
862
+ return any(matching.matches(field, answer, p.strip()) for p in picked)
863
+
864
+
865
+ def _close_prompts(page: Page, box: Locator) -> Locator:
866
+ """Closes any open prompt list; the box's search input."""
867
+ search = box.locator(SEARCH)
868
+ for _ in range(PROMPT_LEVELS):
869
+ if not page.locator(PROMPT_OPTION).count():
870
+ break
871
+ search.press("Escape")
872
+ page.mouse.click(1, 1) # outside any list, which closes it
873
+ return search
874
+
875
+
876
+ def _prompt_options(page: Page, box: Locator) -> list[str] | None:
877
+ """The items of a prompt's short list, a category's items in its place; None for a long
878
+ list or a search."""
879
+ search = _close_prompts(page, box)
880
+ search.focus()
881
+ search.press("Enter") # on an empty search: the whole list
882
+ options = page.locator(PROMPT_OPTION)
883
+ try:
884
+ options.first.wait_for(timeout=MENU_TIMEOUT_MS)
885
+ except Timeout:
886
+ return None
887
+ rows = page.evaluate(_PROMPT_ROWS)
888
+ if not rows or rows[0]["size"] > MAX_PROMPT_OPTIONS:
889
+ _close_prompts(page, box)
890
+ return None
891
+ items = []
892
+ for n, row in enumerate(rows):
893
+ if not row["nested"]:
894
+ items.append(row["name"])
895
+ continue
896
+ page.locator(PROMPT_OPTION).nth(n).click() # by place: a name can hold a no-break space
897
+ page.locator(BACK).wait_for(timeout=MENU_TIMEOUT_MS)
898
+ items += [r["name"] for r in page.evaluate(_PROMPT_ROWS)]
899
+ page.locator(BACK).click()
900
+ page.locator(BACK).wait_for(state="hidden", timeout=MENU_TIMEOUT_MS)
901
+ _close_prompts(page, box)
902
+ return items
903
+
904
+
905
+ def _save_and_continue(page: Page) -> None:
906
+ """Saves the page; raises with what the page says when it stays. A press can go unheard,
907
+ like the login page's: one that changes nothing is made again."""
908
+ before, shown = _step(page), _errors(page)
909
+ for _ in range(PRESSES):
910
+ page.locator(NEXT).click()
911
+ deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000 / PRESSES
912
+ while time.monotonic() < deadline:
913
+ if _step(page) != before:
914
+ _wait_for_page(page)
915
+ return
916
+ if (errors := _errors(page)) and errors != shown: # a former save's stay a while
917
+ raise NotTaken(f"{before} was not saved: {errors}")
918
+ page.wait_for_timeout(STEP_POLL_MS)
919
+ if errors := _errors(page):
920
+ raise NotTaken(f"{before} was not saved: {errors}")
921
+ raise RuntimeError(f"{before} was not saved in {STEP_TIMEOUT_MS / 1000:.0f}s")
922
+
923
+
924
+ def _errors(page: Page) -> str:
925
+ return re.sub(r"\s+", " ", " ".join(page.locator(ERRORS).all_inner_texts())).strip()
926
+
927
+
928
+ # Each field the page shows: its id, label, whether required (`*`), rendering and value.
929
+ _FIELDS = """() => {
930
+ const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
931
+ const labelOf = (f, e) => text(f.querySelector(`label[for="${CSS.escape(e.id)}"]`)
932
+ ?? e.closest("label") ?? e.parentElement);
933
+ return [...document.querySelectorAll('[data-automation-id^="formField-"][data-fkit-id]')]
934
+ .filter(f => f.offsetParent !== null)
935
+ .map(f => {
936
+ const label = text(f.querySelector("label, legend"));
937
+ const list = f.querySelector("button[aria-haspopup=listbox]");
938
+ const prompt = f.querySelector("input[data-uxi-widget-type=selectinput]");
939
+ const radios = [...f.querySelectorAll("input[type=radio]")];
940
+ const boxes = [...f.querySelectorAll("input[type=checkbox]")];
941
+ const file = f.querySelector("input[type=file]");
942
+ const date = f.querySelector("[data-automation-id^=dateSection]");
943
+ const typed = f.querySelector("textarea, input[type=text]");
944
+ let kind = null, value = null, options = null;
945
+ if (file) {
946
+ kind = "file";
947
+ value = [...f.querySelectorAll("[data-automation-id=file-upload-item-name]")].map(text);
948
+ } else if (date) {
949
+ // a date, a month (MM/YYYY) or a year, by the sections it shows
950
+ const part = p => f.querySelector(`[data-automation-id=dateSection${p}-input]`)?.value;
951
+ const [y, m, d] = [part("Year"), part("Month"), part("Day")];
952
+ kind = d !== undefined ? "date" : m !== undefined ? "month" : "year";
953
+ const pad = v => v.padStart(2, "0");
954
+ value = kind === "date" ? (y && m && d ? `${y}-${pad(m)}-${pad(d)}` : null)
955
+ : kind === "month" ? (y && m ? `${pad(m)}/${y}` : null) : (y || null);
956
+ } else if (list) {
957
+ kind = "list";
958
+ value = text(list) === "Select One" ? null : text(list);
959
+ } else if (prompt) {
960
+ kind = "prompt";
961
+ const picked = [...f.querySelectorAll("[data-automation-id=selectedItem]")].map(text);
962
+ value = picked.length ? picked.join(", ") : null;
963
+ } else if (radios.length) {
964
+ kind = "radio";
965
+ options = radios.map(e => labelOf(f, e));
966
+ const checked = radios.find(e => e.checked);
967
+ value = checked ? labelOf(f, checked) : null;
968
+ } else if (boxes.length > 1) {
969
+ kind = "boxes";
970
+ options = boxes.map(e => labelOf(f, e));
971
+ value = boxes.filter(e => e.checked).map(e => labelOf(f, e));
972
+ } else if (boxes.length === 1) {
973
+ kind = "box";
974
+ value = boxes[0].checked ? "Yes" : "No";
975
+ } else if (typed) {
976
+ kind = "text";
977
+ value = typed.value;
978
+ }
979
+ return {id: f.dataset.fkitId, label: label.replace(/\\*$/, "").trim(),
980
+ required: label.endsWith("*"), kind, value, options};
981
+ });
982
+ }"""
983
+
984
+
985
+ # The open prompt list's rows: each item's name, whether it opens a category, the list's size.
986
+ _PROMPT_ROWS = r"""() => [...document.querySelectorAll(
987
+ "[data-automation-id=activeListContainer] [role=option]")]
988
+ .filter(row => row.offsetParent !== null)
989
+ .map(row => ({
990
+ name: (row.querySelector("[data-automation-id=promptOption]")?.innerText ?? "")
991
+ .replace(/\s+/g, " ").trim(),
992
+ nested: !!row.querySelector("[data-uxi-multiselectlistitem-hassidecharm=true]"),
993
+ size: Number(row.getAttribute("aria-setsize") || 0),
994
+ }))
995
+ .filter(row => row.name)"""
@@ -27,6 +27,7 @@ from camoufox.sync_api import Camoufox
27
27
  from playwright.sync_api import APIRequestContext as Request
28
28
  from playwright.sync_api import Browser, Locator, Page, Response, sync_playwright
29
29
  from playwright.sync_api import Error as BrowserError
30
+ from playwright.sync_api import Request as PageRequest # one the page sent, unlike `Request`
30
31
  from playwright.sync_api import TimeoutError as Timeout
31
32
 
32
33
  __all__ = [
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "Locator",
37
38
  "OutOfTime",
38
39
  "Page",
40
+ "PageRequest",
39
41
  "Request",
40
42
  "Response",
41
43
  "Timeout",
@@ -60,6 +60,7 @@ kind = "ats"
60
60
  share = 0.1786
61
61
  account = "to_read"
62
62
  hosting = ["hosted"]
63
+ adapter = "workday"
63
64
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
64
65
 
65
66
  [trudvsem]
File without changes