atsfill 0.0.4.dev121__tar.gz → 0.0.4.dev123__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 (54) hide show
  1. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/README.md +1 -1
  3. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev123/src/atsfill/adapters/gupy.py +294 -0
  7. atsfill-0.0.4.dev123/src/atsfill/adapters/hrmdirect.py +482 -0
  8. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/registry.toml +4 -1
  9. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/LICENSE +0 -0
  10. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/eightfold.py +0 -0
  16. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/greenhouse.py +0 -0
  17. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/jazzhr.py +0 -0
  20. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/lever.py +0 -0
  21. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/manatal.py +0 -0
  22. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/oracle.py +0 -0
  23. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/paycor.py +0 -0
  24. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/paylocity.py +0 -0
  25. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/personio.py +0 -0
  26. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/phenom.py +0 -0
  27. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/pinpoint.py +0 -0
  28. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/recruitee.py +0 -0
  29. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/rippling.py +0 -0
  30. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/smartrecruiters.py +0 -0
  31. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/successfactors.js +0 -0
  32. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/successfactors.py +0 -0
  33. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/taleo.py +0 -0
  34. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/teamtailor.py +0 -0
  35. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/ukg.py +0 -0
  36. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/workable.py +0 -0
  37. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/workday.py +0 -0
  38. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/wttj.py +0 -0
  39. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/adapters/zohorecruit.py +0 -0
  40. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/cli.py +0 -0
  41. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/__init__.py +0 -0
  42. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/accounts.py +0 -0
  43. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/adapter.py +0 -0
  44. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/attempts.py +0 -0
  45. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/browser.py +0 -0
  46. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/commands.py +0 -0
  47. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/conditions.py +0 -0
  48. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/html.py +0 -0
  49. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/matching.py +0 -0
  50. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/passwords.py +0 -0
  51. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/paths.py +0 -0
  52. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/registry.py +0 -0
  53. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/src/atsfill/core/results.py +0 -0
  54. {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev123}/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.dev121
3
+ Version: 0.0.4.dev123
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) ![successfactors: unsupported 2026-10-26](https://img.shields.io/badge/successfactors-unsupported%202026--10--26-e0af68) ![ukg: unsupported 2026-10-26](https://img.shields.io/badge/ukg-unsupported%202026--10--26-e0af68)
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) ![gupy: dry run verified 2026-10-26](https://img.shields.io/badge/gupy-dry%20run%20verified%202026--10--26-7aa2f7) ![successfactors: unsupported 2026-10-26](https://img.shields.io/badge/successfactors-unsupported%202026--10--26-e0af68) ![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) ![successfactors: unsupported 2026-10-26](https://img.shields.io/badge/successfactors-unsupported%202026--10--26-e0af68) ![ukg: unsupported 2026-10-26](https://img.shields.io/badge/ukg-unsupported%202026--10--26-e0af68)
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) ![gupy: dry run verified 2026-10-26](https://img.shields.io/badge/gupy-dry%20run%20verified%202026--10--26-7aa2f7) ![successfactors: unsupported 2026-10-26](https://img.shields.io/badge/successfactors-unsupported%202026--10--26-e0af68) ![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.dev121"
3
+ version = "0.0.4.dev123"
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.dev121"
3
+ version = "0.0.4.dev123"
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"
@@ -4,6 +4,7 @@ from atsfill.adapters.breezy import Breezy
4
4
  from atsfill.adapters.dayforce import Dayforce
5
5
  from atsfill.adapters.eightfold import Eightfold
6
6
  from atsfill.adapters.greenhouse import Greenhouse
7
+ from atsfill.adapters.gupy import Gupy
7
8
  from atsfill.adapters.hireology import Hireology
8
9
  from atsfill.adapters.hiringthing import HiringThing
9
10
  from atsfill.adapters.jazzhr import JazzHR
@@ -55,6 +56,7 @@ def adapters() -> list[Adapter]:
55
56
  HiringThing(),
56
57
  Eightfold(),
57
58
  Workday(),
59
+ Gupy(),
58
60
  UKG(),
59
61
  SuccessFactors(), # it claims a career site's job path on any host
60
62
  Phenom(), # last: it claims a URL by its path alone
@@ -0,0 +1,294 @@
1
+ """Gupy (`<employer>.gupy.io/jobs/<id>`), as docs/adapters/gupy.md observes.
2
+
3
+ Applying needs an account, one for the whole system, that atsfill signs in to; its sign-up asks
4
+ more than `login` takes. Apply opens the application's register step: the account's resume,
5
+ filled once and shared by every application, then the posting's Additional information and the
6
+ employer's questions, which the page lists from `question-forms`. A fill saves Additional
7
+ information; the questions are saved by submit, for good.
8
+ """
9
+
10
+ import re
11
+ import time
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ from atsfill.core import browser
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
17
+ from atsfill.core.browser import Locator, Page, Request, Response, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
19
+
20
+ HOST = re.compile(r"^([a-z0-9-]+)\.gupy\.io$")
21
+ JOB = re.compile(r"^/(?:candidates/)?jobs/(\d+)")
22
+ API = "https://private-api.gupy.io"
23
+ ACCOUNT = f"{API}/authentication/candidate/account/current"
24
+ LOGIN = "https://login.gupy.io/candidates/signin"
25
+ FORMS = re.compile(r"/question-forms/candidates/applications/\d+/steps/\d+/forms$")
26
+ ADDITIONAL = "input[name=radioGroupIsIndicatedTitle]"
27
+ ANSWER_NOW = "button:has-text('Answer now')" # Additional information saved on an earlier visit
28
+ QUESTIONS = "[name^=question-], [name^=checkbox-], textarea, input[id^=input-]"
29
+ STEP_TIMEOUT_S = 40
30
+ STEP_POLL_MS = 500
31
+ RESUME_WAIT_S = 15
32
+ SAVE_TIMEOUT_MS = 30_000
33
+ SIGN_IN_TIMEOUT_MS = 30_000
34
+ FORMS_TIMEOUT_S = 10
35
+ YES_NO = ["Yes", "No"]
36
+ TYPES = {
37
+ "SELECT": FieldType.CHOICE,
38
+ "MULTI_SELECT": FieldType.MULTI_CHOICE,
39
+ "TEXT": FieldType.TEXT,
40
+ "TEXT_AREA": FieldType.TEXT,
41
+ }
42
+
43
+ # Additional information: a radio group by its name, under its fieldset's legend; a text input.
44
+ _READ_ADDITIONAL = """() => {
45
+ const groups = {};
46
+ for (const e of document.querySelectorAll("main input")) {
47
+ if (e.type === "radio") {
48
+ const g = groups[e.name] ??= {id: e.name, kind: "radio", options: [], value: null,
49
+ label: e.closest("fieldset")?.querySelector("legend")?.textContent ?? e.name};
50
+ const text = e.labels[0]?.innerText.trim() ?? e.value;
51
+ g.options.push(text);
52
+ if (e.checked) g.value = text;
53
+ } else if (e.type === "text" && e.offsetParent !== null) {
54
+ groups[e.name || e.id] = {id: e.name || e.id, kind: e.getAttribute("role") || "text",
55
+ label: e.labels[0]?.innerText.trim() ?? e.name, options: null, value: e.value || null};
56
+ }
57
+ }
58
+ return Object.values(groups).map(g => ({...g, label: g.label.replace(/\\s+/g, " ").trim()}));
59
+ }"""
60
+
61
+ # The employer's questions, by question id: the checked labels, or the text typed.
62
+ _READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
63
+ const radios = [...document.querySelectorAll(`input[name="question-${id}"]`)];
64
+ const boxes = [...document.querySelectorAll(`input[name^="checkbox-${id}-"]`)];
65
+ const picked = [...radios, ...boxes].filter(e => e.checked)
66
+ .map(e => e.labels[0]?.innerText.trim());
67
+ if (boxes.length) return [`question-${id}`, picked];
68
+ if (radios.length) return [`question-${id}`, picked[0] ?? null];
69
+ const text = [...document.querySelectorAll("textarea, input[type=text]")]
70
+ .find(e => e.name === title || e.id === `input-${title}`);
71
+ return [`question-${id}`, text?.value ?? null];
72
+ }))"""
73
+
74
+
75
+ class Gupy:
76
+ name = "gupy"
77
+ per_employer = False
78
+ known_gaps = {"resume": Gap.FLOW}
79
+ side_effects = [
80
+ SideEffect("load", "employer", "opening apply creates the application, incomplete"),
81
+ SideEffect("fill", "hiring_system", "a fill saves the Additional information"),
82
+ SideEffect("submit", "employer", "the answers are saved for good, the application sent"),
83
+ ]
84
+
85
+ def __init__(self) -> None:
86
+ self._questions: dict[str, dict[str, Any]] = {}
87
+ self._fields: dict[str, Field] = {}
88
+ self._additional: dict[str, Answer] = {}
89
+
90
+ def detect(self, url: str, page: Page | None) -> bool:
91
+ parts = urlsplit(url)
92
+ return bool(HOST.match(parts.hostname or "")) and bool(JOB.match(parts.path))
93
+
94
+ def posting(self, url: str) -> str:
95
+ parts = urlsplit(url)
96
+ match = JOB.match(parts.path)
97
+ return f"https://{parts.hostname}/jobs/{match[1]}" if match else url
98
+
99
+ def account_scope(self, url: str) -> None:
100
+ return None
101
+
102
+ def login_url(self, scope: str | None) -> str:
103
+ return LOGIN
104
+
105
+ def signed_in(self, request: Request, scope: str | None) -> bool:
106
+ """The account endpoint answers the account, 401 to a visitor; without an Origin it
107
+ answers 500."""
108
+ return request.get(ACCOUNT, headers={"origin": "https://login.gupy.io"}).status == 200
109
+
110
+ def sign_in(self, page: Page, email: str, password: str) -> None:
111
+ form = page.locator("main, body").first
112
+ try:
113
+ form.locator("input[type=password]").wait_for(timeout=SIGN_IN_TIMEOUT_MS)
114
+ except Timeout:
115
+ raise Unsupported(f"unhandled_rendering: no sign-in form on {page.url}") from None
116
+ form.locator("input:not([type=password]):not([type=hidden])").first.fill(email)
117
+ form.locator("input[type=password]").fill(password)
118
+ on_form = page.url
119
+ browser.click(page.locator("button:has-text('Access account')"))
120
+ try:
121
+ page.wait_for_url(lambda url: url != on_form, timeout=SIGN_IN_TIMEOUT_MS)
122
+ except Timeout:
123
+ said = page.locator("main").inner_text().strip().replace("\n", " ")
124
+ raise Blocked(f"sign_in: the sign-in stayed on its page: {said[:200]}") from None
125
+
126
+ def browser_read(self, page: Page) -> list[Field]:
127
+ """Opens apply and walks to the posting's Additional information, the employer's
128
+ questions listed from the page's `question-forms` answer."""
129
+ forms: list[Response] = []
130
+
131
+ def on_response(response: Response) -> None:
132
+ if FORMS.search(urlsplit(response.url).path) and response.request.method == "GET":
133
+ forms.append(response)
134
+
135
+ parts = urlsplit(page.url)
136
+ match = JOB.match(parts.path)
137
+ if not match:
138
+ raise RuntimeError(f"{page.url} is not a Gupy job")
139
+ page.on("response", on_response)
140
+ try:
141
+ page.goto(f"https://{parts.hostname}/candidates/jobs/{match[1]}/apply")
142
+ self._walk(page)
143
+ deadline = time.monotonic() + FORMS_TIMEOUT_S
144
+ while not forms and time.monotonic() < deadline:
145
+ page.wait_for_timeout(STEP_POLL_MS)
146
+ finally:
147
+ page.remove_listener("response", on_response)
148
+ fields = [_additional(raw) for raw in page.evaluate(_READ_ADDITIONAL)]
149
+ questions = (forms[-1].json().get("questionForm") or {}).get("questions") if forms else []
150
+ self._questions = {f"question-{q['questionId']}": q for q in questions or []}
151
+ fields += [_question(q) for q in self._questions.values()]
152
+ self._fields = {f.id: f for f in fields if not f.unfillable}
153
+ return fields
154
+
155
+ def _walk(self, page: Page) -> None:
156
+ """Past the interstitials to Additional information: the dialog asking to confirm the
157
+ account's contact details, Complete resume, Continue Application, Continue."""
158
+ deadline, disabled_since = time.monotonic() + STEP_TIMEOUT_S, None
159
+ while time.monotonic() < deadline:
160
+ if "/candidates/signin" in page.url:
161
+ raise Blocked("login_required: run `atsfill login gupy`")
162
+ if page.locator(f"{ADDITIONAL}, {ANSWER_NOW}").count():
163
+ return
164
+ for name in ("Remind me later", "Complete resume", "Continue Application", "Continue"):
165
+ button = page.locator(f"button:has-text('{name}'):visible").first
166
+ if not button.count():
167
+ continue
168
+ if button.is_enabled():
169
+ disabled_since = None
170
+ browser.click(button)
171
+ # Continue Application is disabled while the resume loads, for good when the
172
+ # resume is incomplete
173
+ elif (disabled_since := disabled_since or time.monotonic()) < (
174
+ time.monotonic() - RESUME_WAIT_S
175
+ ):
176
+ detail = "the account's resume is incomplete; complete it once in Gupy"
177
+ raise Unsupported(known_gap(self, "resume", detail))
178
+ break
179
+ page.wait_for_timeout(STEP_POLL_MS)
180
+ raise Unsupported(f"unhandled_rendering: no Additional information on {page.url}")
181
+
182
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
183
+ """Fills Additional information and, where the posting has questions, saves it and
184
+ opens them; the questions are filled and left unsaved for submit."""
185
+ for field_id, answer in answers.items():
186
+ if field_id not in self._questions:
187
+ _set(page, self._fields[field_id], field_id, answer)
188
+ self._additional = self._read_additional(page)
189
+ if not self._questions:
190
+ return
191
+ if page.locator(ADDITIONAL).count():
192
+ browser.click(page.locator("button:has-text('Save and continue')").first)
193
+ answer_now = page.locator(ANSWER_NOW).first
194
+ answer_now.wait_for(timeout=SAVE_TIMEOUT_MS)
195
+ browser.click(answer_now)
196
+ page.locator(QUESTIONS).first.wait_for(timeout=SAVE_TIMEOUT_MS)
197
+ for field_id, answer in answers.items():
198
+ if (question := self._questions.get(field_id)) is not None:
199
+ _answer(page, self._fields[field_id], question, answer)
200
+
201
+ def _read_additional(self, page: Page) -> dict[str, Answer]:
202
+ values = {raw["id"]: raw["value"] for raw in page.evaluate(_READ_ADDITIONAL)}
203
+ return {k: v for k, v in values.items() if k in self._fields and v}
204
+
205
+ def read_back(self, page: Page) -> dict[str, Answer]:
206
+ """Additional information as it was saved, or as the page holds it; the questions'."""
207
+ additional = self._additional or self._read_additional(page)
208
+ ids = [[q["questionId"], q["title"]] for q in self._questions.values()]
209
+ values = page.evaluate(_READ_QUESTIONS, ids) if page.locator(QUESTIONS).count() else {}
210
+ return {**additional, **{k: v for k, v in values.items() if v not in (None, "", [])}}
211
+
212
+ def submit(self, page: Page) -> str | None:
213
+ """Save and continue on the last page: the questions, or Additional information."""
214
+ with page.expect_response(
215
+ lambda r: r.request.method in ("PUT", "POST", "PATCH") and API in r.url,
216
+ timeout=SAVE_TIMEOUT_MS,
217
+ ) as saved:
218
+ browser.click(page.locator("button:has-text('Save and continue')").first)
219
+ response = saved.value
220
+ if response.ok:
221
+ return f"{response.request.method} {urlsplit(response.url).path} {response.status}"
222
+ raise RuntimeError(f"{response.request.method} {response.url} answered {response.status}")
223
+
224
+
225
+ def _additional(raw: dict[str, Any]) -> Field:
226
+ """A radio group is a choice; the source list, a combobox, is not filled."""
227
+ name, label = raw["id"], raw["label"]
228
+ match raw["kind"]:
229
+ case "radio":
230
+ options, value = raw["options"], raw["value"]
231
+ return Field(name, label, FieldType.CHOICE, True, options, prefilled=value)
232
+ case "text":
233
+ return Field(name, label, FieldType.TEXT, "optional" not in label.lower())
234
+ reason = f"unhandled_rendering: {label!r} is a {raw['kind']}"
235
+ return Field(name, label, FieldType.TEXT, "optional" not in label.lower(), unfillable=reason)
236
+
237
+
238
+ def _question(q: dict[str, Any]) -> Field:
239
+ field_id, kind = f"question-{q['questionId']}", TYPES.get(q["type"])
240
+ options = [o["label"] for o in q.get("options") or []] or None
241
+ shown = None
242
+ if q.get("parentQuestionId"):
243
+ shown = Is(f"question-{q['parentQuestionId']}", q.get("conditionalTriggerOptions") or None)
244
+ if kind is None:
245
+ reason = f"unhandled_rendering: {q['title']!r} is a {q['type']} question"
246
+ return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason)
247
+ return Field(
248
+ field_id, q["title"], kind, bool(q["required"]),
249
+ options if kind is not FieldType.TEXT else None, prefilled=q.get("answer") or None,
250
+ shown_when=shown,
251
+ ) # fmt: skip
252
+
253
+
254
+ def _label(page: Page, selector: str, text: str) -> Locator:
255
+ """The label, wrapping it, of the input `selector` matches whose text is `text`."""
256
+ inputs = page.locator(selector)
257
+ for n in range(inputs.count()):
258
+ if inputs.nth(n).evaluate("e => e.labels[0]?.innerText.trim()") == text:
259
+ return inputs.nth(n).locator("xpath=ancestor::label[1]")
260
+ raise NotTaken(f"no option {text!r} in {selector}")
261
+
262
+
263
+ def _set(page: Page, field: Field, field_id: str, answer: Answer) -> None:
264
+ match field.type, answer:
265
+ case FieldType.CHOICE, str():
266
+ browser.click(_label(page, f'main input[name="{field_id}"]', answer))
267
+ case FieldType.TEXT, str():
268
+ page.locator(f'main input[name="{field_id}"], main input[id="{field_id}"]').fill(answer)
269
+ case kind, _:
270
+ raise Unsupported(f"unhandled_rendering: cannot fill {field_id}, a {kind}")
271
+
272
+
273
+ def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer) -> None:
274
+ qid = question["questionId"]
275
+ match field.type, answer:
276
+ case FieldType.CHOICE, str():
277
+ browser.click(_label(page, f'input[name="question-{qid}"]', answer))
278
+ case FieldType.MULTI_CHOICE, list():
279
+ for option in answer:
280
+ box = _label(page, f'input[name^="checkbox-{qid}-"]', option)
281
+ if not box.locator("input").is_checked():
282
+ browser.click(box)
283
+ case FieldType.TEXT, str():
284
+ title = question["title"]
285
+ target = page.locator(f'textarea[name="{_css(title)}"], input[name="{_css(title)}"]')
286
+ if not target.count():
287
+ target = page.locator(f'[id="input-{_css(title)}"]')
288
+ target.first.fill(answer)
289
+ case kind, _:
290
+ raise Unsupported(f"unhandled_rendering: cannot fill question-{qid}, a {kind}")
291
+
292
+
293
+ def _css(text: str) -> str:
294
+ return text.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,482 @@
1
+ """HRMDirect, now ClearCompany (`<org>.hrmdirect.com/employment/job-opening.php?req=…`, which
2
+ redirects to `apply.clearcompany.com/<org>?jobId=<job>`, whose Apply opens
3
+ `<org>.clearcompany.com/careers/jobs/<job>/apply`), as docs/adapters/hrmdirect.md observes.
4
+
5
+ Applying needs an account, one per employer (the org): its sign-up is the apply page's first
6
+ step, a name and an email, and sets no password. The form is a wizard of pages, each saved to
7
+ the application as it is left, only the current one in the DOM: the read walks the pages that
8
+ save with what they hold, and the read-back is each page's values, read before it is saved.
9
+ """
10
+
11
+ import re
12
+ import urllib.parse
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, urlsplit
15
+
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
17
+ from atsfill.core.browser import Locator, Page, Request, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ OLD_HOST = re.compile(r"^([a-z0-9-]+)\.hrmdirect\.com$")
21
+ ORG_HOST = re.compile(r"^([a-z0-9-]+)\.clearcompany\.com$")
22
+ SITE_HOST = "apply.clearcompany.com"
23
+ UUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
24
+ APPLY_PATH = re.compile(rf"^/careers/jobs/({UUID})(?:/apply(?:/{UUID})?)?/?$")
25
+ JOB_ID = re.compile(rf"^{UUID}$")
26
+ FORM_TIMEOUT_MS = 30_000
27
+ PAGE_TIMEOUT_MS = 30_000
28
+ UPLOAD_TIMEOUT_MS = 60_000
29
+ SIGN_UP_TIMEOUT_MS = 30_000
30
+ SUBMIT_TIMEOUT_MS = 90_000
31
+ SETTLE_MS = 1_500
32
+ MAX_PAGES = 10
33
+ IDENTITY = "#personal-info-form"
34
+ HAVE_ACCOUNT = "button.have-account-button"
35
+ LOGIN = "a.authenticate-button"
36
+ RECAPTCHA = f"{IDENTITY} iframe[title=reCAPTCHA], {IDENTITY} .g-recaptcha"
37
+ EMAIL_SIGN_IN = "text=Sign in with username and password instead"
38
+ PAGE_FORM = ".form-page-holder form.section-form"
39
+ ACTIVE_PAGE = ".form-progress-holder li.active"
40
+ NEXT = ".form-page-holder ~ * button.submit-button, button.submit-button:visible"
41
+ ERRORS = ".has-error:visible"
42
+ CLOSE_DATEPICKERS = (
43
+ "() => document.querySelectorAll('.datepicker.dropdown-menu')"
44
+ ".forEach(d => { d.style.display = 'none'; })"
45
+ )
46
+ TOKEN = re.compile(r"Token\$\$:\$\$([^$]+)")
47
+ API_HEADERS = {"API-Realm": "CWEB", "API-AppType": "careers"}
48
+
49
+ # Each field of the current page: id, kind, label, required, options, max length, value.
50
+ _FIELDS = """form => [...form.querySelectorAll('.form-field')].flatMap(f => {
51
+ const title = f.querySelector('.field-title');
52
+ if (!title || f.closest('.child-field-holder:empty')) return [];
53
+ const label = [...title.childNodes].filter(n => !(n.classList?.contains('required-indicator')))
54
+ .map(n => n.textContent).join(' ').replace(/\\s+/g, ' ').trim();
55
+ const required = !!title.querySelector('.required-indicator');
56
+ const visible = !!f.offsetParent;
57
+ const qa = [...f.classList].find(c => c.startsWith('qa-')) || '';
58
+ const base = {label, required, visible, qa, options: null, max: null, value: null};
59
+ if (qa === 'qa-resume-upload') {
60
+ const kind = [...f.classList].find(c => c !== 'form-field' && c !== qa);
61
+ const names = [...f.querySelectorAll('.file-list tr td:first-child')]
62
+ .map(t => t.textContent.trim()).filter(n => n && !n.includes('Uploading'));
63
+ const upload = !!f.querySelector('input[type=file]');
64
+ return [{...base, id: 'file.' + kind, kind: upload ? 'file' : 'linkedin',
65
+ value: names.at(-1) || null}];
66
+ }
67
+ const boxes = [...f.querySelectorAll('input.form-radio, input.form-checkbox')];
68
+ if (boxes.length) {
69
+ const own = boxes.filter(b => b.closest('.form-field') === f);
70
+ const text = b => b.closest('label').textContent.replace(/\\s+/g, ' ').trim();
71
+ const checked = own.filter(b => b.checked).map(text);
72
+ return [{...base, id: own[0].dataset.field, kind: own[0].type,
73
+ options: own.map(text), value: checked}];
74
+ }
75
+ const control = f.querySelector('input:not([type=hidden]), select, textarea');
76
+ if (!control || control.closest('.form-field') !== f) return [{...base, id: '', kind: 'none'}];
77
+ let kind = control.tagName.toLowerCase();
78
+ if (kind === 'input') kind = control.classList.contains('date-input') ? 'date' : control.type;
79
+ const options = kind === 'select'
80
+ ? [...control.options].filter(o => o.value).map(o => o.textContent.trim()) : null;
81
+ const value = kind === 'select'
82
+ ? (control.value ? control.selectedOptions[0].textContent.trim() : null) : control.value;
83
+ const max = parseInt(control.dataset.max) || (control.maxLength > 0 ? control.maxLength : null);
84
+ return [{...base, id: control.id, kind, options, value, max}];
85
+ })"""
86
+
87
+
88
+ class HRMDirect:
89
+ name = "hrmdirect"
90
+ per_employer = True
91
+ asks_name = True
92
+ known_gaps = {"screening_first": Gap.FLOW, "sign_up_phone": Gap.FLOW}
93
+ side_effects = [
94
+ SideEffect(
95
+ "sign_up", "employer", "the account is the employer's candidate, emailed a link"
96
+ ),
97
+ SideEffect("fill", "hiring_system", "each page, the resume too, is saved when left"),
98
+ SideEffect("submit", "employer", "the application reaches the employer"),
99
+ ]
100
+
101
+ def __init__(self) -> None:
102
+ self._jobs: dict[str, str] = {} # by org, the last posting's job
103
+ self._fields: dict[str, Field] = {}
104
+ self._kinds: dict[str, str] = {}
105
+ self._values: dict[str, Answer] = {}
106
+ self._apply = ""
107
+
108
+ def detect(self, url: str, page: Page | None) -> bool:
109
+ return _job(url) is not None
110
+
111
+ def posting(self, url: str) -> str:
112
+ """The job's id, the same on the career site and its apply page's host; an HRMDirect
113
+ URL that has not redirected yet keeps its requisition."""
114
+ job = _job(url)
115
+ if job is None:
116
+ return url
117
+ org, key = job
118
+ if JOB_ID.match(key):
119
+ return f"https://{SITE_HOST}/jobs/{key.lower()}"
120
+ return f"https://{org}.hrmdirect.com/employment/job-opening.php?req={key}"
121
+
122
+ def account_scope(self, url: str) -> str | None:
123
+ """The career site's name, or the apply host's org for an apply page."""
124
+ job = _job(url)
125
+ if job is None:
126
+ return None
127
+ if JOB_ID.match(job[1]):
128
+ self._jobs[job[0]] = job[1].lower()
129
+ return job[0]
130
+
131
+ def login_url(self, scope: str | None) -> str:
132
+ """The career site's page of the last posting seen for the scope, else its job list."""
133
+ if scope in self._jobs:
134
+ return f"https://{SITE_HOST}/{scope}?jobId={self._jobs[scope]}"
135
+ return f"https://{SITE_HOST}/{scope}"
136
+
137
+ def signed_in(self, request: Request, scope: str | None) -> bool:
138
+ """An org's `users/auth/authenticate` answers 200 for the token its cookie holds; the
139
+ cookie is the apply host's, which can be another org than the career site's."""
140
+ for cookie in request.storage_state()["cookies"]:
141
+ org = ORG_HOST.match(cookie["domain"].lstrip("."))
142
+ token = TOKEN.search(urllib.parse.unquote(cookie["value"]))
143
+ if cookie["name"] != "HrmBase-careers" or not org or not token:
144
+ continue
145
+ headers = API_HEADERS | {"API-ShortName": org[1], "API-Token": token[1]}
146
+ response = request.get(
147
+ f"https://{org[1]}.clearcompany.com/api/v1/users/auth/authenticate",
148
+ headers=headers,
149
+ )
150
+ if response.ok:
151
+ return True
152
+ return False
153
+
154
+ def sign_in(self, page: Page, email: str, password: str) -> None:
155
+ """Signs in through the header's login, whose modal takes a username and a password;
156
+ an account gets a password through the link its sign-up emailed."""
157
+ _to_apply(page)
158
+ if page.locator(IDENTITY).is_visible():
159
+ page.locator(HAVE_ACCOUNT).click()
160
+ page.locator(EMAIL_SIGN_IN).click()
161
+ page.locator("input.login-username:visible").fill(email)
162
+ page.locator("input.login-password:visible").fill(password)
163
+ page.get_by_role("button", name="Continue").locator("visible=true").click()
164
+ else:
165
+ page.locator(LOGIN).click()
166
+ modal = page.locator(".modal:visible").filter(has=page.locator("input[type=password]"))
167
+ modal.locator("input:not([type=password]):visible").first.fill(email)
168
+ modal.locator("input[type=password]").fill(password)
169
+ modal.locator("button.btn-primary").click()
170
+ try:
171
+ page.locator(f"{PAGE_FORM}, {LOGIN}:text-is('logout')").first.wait_for(
172
+ timeout=SIGN_UP_TIMEOUT_MS
173
+ )
174
+ except Timeout:
175
+ raise Refused("wrong_password") from None
176
+
177
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
178
+ """Fills the apply page's first step, which creates the account signed in and sets no
179
+ password: the email that follows holds a link to set one, `blocked: email_link`."""
180
+ _to_apply(page)
181
+ if not page.locator(IDENTITY).is_visible():
182
+ raise Unsupported(
183
+ known_gap(self, "screening_first", "the form's first page comes before sign-up")
184
+ )
185
+ phone = page.locator(f"{IDENTITY} .form-group:has(input.mobile-number)")
186
+ if phone.count() and not phone.locator(".common-option-label").count():
187
+ raise Unsupported(
188
+ known_gap(self, "sign_up_phone", "the sign-up step requires a mobile phone")
189
+ )
190
+ if page.locator(RECAPTCHA).count():
191
+ raise Blocked("captcha: a reCAPTCHA box keeps the sign-up's Continue disabled")
192
+ first, _, last = (name or "").strip().partition(" ")
193
+ form = page.locator(IDENTITY)
194
+ form.locator("input.first-name").fill(first)
195
+ form.locator("input.last-name").fill(last or first)
196
+ form.locator("input.email").fill(email)
197
+ form.locator("input.email-confirm").fill(email)
198
+ for group in form.locator(".radio-group:has(input[required])").all():
199
+ group.locator("label:has(input[value=false])").click() # declines, the least given
200
+ form.locator("button.submit-button").click()
201
+ try:
202
+ moved = page.locator(f"{PAGE_FORM}, {IDENTITY} .has-error, .alert-danger:visible")
203
+ moved.or_(page.locator(EMAIL_SIGN_IN)).first.wait_for(timeout=SIGN_UP_TIMEOUT_MS)
204
+ except Timeout:
205
+ raise Unsupported("unhandled_rendering: the sign-up step did not move") from None
206
+ if page.locator(EMAIL_SIGN_IN).is_visible(): # the email has an account here
207
+ raise Refused("email_in_use")
208
+ if not page.locator(PAGE_FORM).count():
209
+ message = " ".join(page.locator(".alert-danger:visible, .has-error").all_inner_texts())
210
+ raise Refused(f"email_domain_rejected: {message.strip()[:200]}")
211
+ raise Blocked("email_link: set the account's password through the emailed link")
212
+
213
+ def browser_read(self, page: Page) -> list[Field]:
214
+ """Walks the wizard from its first page as far as its pages save with what they hold,
215
+ reading each page it reaches."""
216
+ self._open(page)
217
+ self._fields = {}
218
+ for _ in range(MAX_PAGES):
219
+ for f in self._page(page):
220
+ self._fields.setdefault(f.id, f)
221
+ if not self._continue(page):
222
+ break
223
+ return list(self._fields.values())
224
+
225
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
226
+ """Fills each page from the first and saves it, up to the last; a page showing a
227
+ required field the read did not list is refused, the pages before it saved."""
228
+ self._open(page)
229
+ self._values = {}
230
+ for _ in range(MAX_PAGES):
231
+ shown = self._page(page)
232
+ for f in shown:
233
+ if f.id in answers and f.id in self._kinds:
234
+ _fill(page, f, self._kinds[f.id], answers[f.id])
235
+ values = self._read(page)
236
+ for f in shown: # a country renders its state list again, emptying the state
237
+ answer = answers.get(f.id)
238
+ if self._kinds.get(f.id) == "select" and answer and values.get(f.id) != answer:
239
+ control = page.locator(PAGE_FORM).locator(f"[id='{f.id}']")
240
+ tag = control.evaluate("el => el.tagName") if control.count() else ""
241
+ _fill(page, f, "select" if tag == "SELECT" else "text", answer)
242
+ unread = [f.id for f in shown if f.required and f.id not in self._fields]
243
+ if unread:
244
+ raise NotTaken(f"the page shows fields the read did not list: {unread}; read again")
245
+ self._values |= self._read(page)
246
+ if _last(page) or not self._continue(page):
247
+ return
248
+
249
+ def read_back(self, page: Page) -> dict[str, Answer]:
250
+ return self._values | self._read(page)
251
+
252
+ def submit(self, page: Page) -> str | None:
253
+ """The last page's Submit; atsfill expects it to post the application. No send was
254
+ made."""
255
+ with page.expect_response(
256
+ lambda r: r.request.method == "POST" and "/api/" in r.url and "submit" in r.url,
257
+ timeout=SUBMIT_TIMEOUT_MS,
258
+ ) as posted:
259
+ page.locator(NEXT).first.click()
260
+ response = posted.value
261
+ return f"{response.url} {response.status}"
262
+
263
+ def _open(self, page: Page) -> None:
264
+ """The wizard's first page, loaded again; `posting_gone` for a page with no
265
+ application."""
266
+ parts = urlsplit(page.url)
267
+ if not self._apply or ORG_HOST.match(parts.hostname or "") is None:
268
+ self._apply = _apply_link(page)
269
+ elif APPLY_PATH.match(parts.path):
270
+ org, job = _job(page.url) or ("", "")
271
+ self._apply = _apply_url(org, job)
272
+ page.goto(self._apply)
273
+ try:
274
+ page.locator(f"{PAGE_FORM}, {IDENTITY}").first.wait_for(timeout=FORM_TIMEOUT_MS)
275
+ except Timeout:
276
+ raise Unsupported("posting_gone: no application form on the page") from None
277
+ if page.locator(IDENTITY).is_visible():
278
+ raise Unsupported("unhandled_rendering: the apply page asks to sign up again")
279
+ first = page.locator(".form-progress-holder li.page-0")
280
+ if first.count() and "active" not in (first.get_attribute("class") or ""):
281
+ first.click()
282
+ page.wait_for_timeout(SETTLE_MS)
283
+
284
+ def _page(self, page: Page) -> list[Field]:
285
+ """The current page's fields; each kept with its kind for the fill."""
286
+ page.wait_for_timeout(SETTLE_MS)
287
+ raw: list[dict[str, Any]] = page.locator(PAGE_FORM).evaluate(_FIELDS)
288
+ out = []
289
+ for r in raw:
290
+ if not r["visible"] and r["kind"] != "file":
291
+ continue
292
+ field = _field(r)
293
+ if not field.unfillable:
294
+ self._kinds[field.id] = r["kind"]
295
+ out.append(field)
296
+ return out
297
+
298
+ def _read(self, page: Page) -> dict[str, Answer]:
299
+ out: dict[str, Answer] = {}
300
+ for r in page.locator(PAGE_FORM).evaluate(_FIELDS):
301
+ value = r["value"]
302
+ if r["id"] not in self._fields or value in (None, ""):
303
+ continue
304
+ if value == [] and not (r["kind"] == "checkbox" and len(r["options"]) == 1):
305
+ continue
306
+ if r["kind"] == "date" and re.match(r"^\d\d/\d\d/\d{4}$", value):
307
+ month, day, year = value.split("/")
308
+ value = f"{year}-{month}-{day}"
309
+ if r["kind"] == "radio":
310
+ value = value[0]
311
+ elif r["kind"] == "checkbox" and len(r["options"]) == 1:
312
+ value = "Yes" if value else "No"
313
+ out[r["id"]] = value
314
+ return out
315
+
316
+ def _continue(self, page: Page) -> bool:
317
+ """Presses Continue; whether the next page showed, not errors."""
318
+ if _last(page):
319
+ return False
320
+ before = _active(page)
321
+ page.locator(NEXT).first.click()
322
+ try:
323
+ page.wait_for_function(
324
+ "([sel, before]) => { const a = document.querySelector(sel);"
325
+ " return (a && a.textContent !== before)"
326
+ " || !!document.querySelector('.has-error'); }",
327
+ arg=[ACTIVE_PAGE, before],
328
+ timeout=PAGE_TIMEOUT_MS,
329
+ )
330
+ except Timeout:
331
+ return False
332
+ return _active(page) != before
333
+
334
+
335
+ def _job(url: str) -> tuple[str, str] | None:
336
+ """The org and the job: the job's id on ClearCompany, the requisition on HRMDirect."""
337
+ parts = urlsplit(url)
338
+ host = (parts.hostname or "").lower()
339
+ query = parse_qs(parts.query)
340
+ if (old := OLD_HOST.match(host)) and parts.path == "/employment/job-opening.php":
341
+ req = query.get("req", [""])[0]
342
+ return (old[1], req) if req.isdigit() else None
343
+ if host == SITE_HOST:
344
+ org = parts.path.strip("/").split("/")[0]
345
+ job = query.get("jobId", [""])[0]
346
+ return (org.lower(), job) if org and JOB_ID.match(job) else None
347
+ org, path = ORG_HOST.match(host), APPLY_PATH.match(parts.path)
348
+ return (org[1], path[1]) if org and path else None
349
+
350
+
351
+ def _apply_url(org: str, job: str) -> str:
352
+ return f"https://{org}.clearcompany.com/careers/jobs/{job.lower()}/apply"
353
+
354
+
355
+ def _apply_link(page: Page) -> str:
356
+ """The apply page of the job page, or of an apply page already open; its host is the
357
+ org the application goes to, which the career site's Apply link names."""
358
+ if ORG_HOST.match(urlsplit(page.url).hostname or "") and "/careers/jobs/" in page.url:
359
+ org, job = _job(page.url) or ("", "")
360
+ return _apply_url(org, job)
361
+ link = page.locator("a[href*='/careers/jobs/'][href*='/apply']").first
362
+ try:
363
+ link.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
364
+ except Timeout:
365
+ raise Unsupported("posting_gone: no Apply link on the page") from None
366
+ return link.get_attribute("href") or ""
367
+
368
+
369
+ def _to_apply(page: Page) -> None:
370
+ """The apply page's first step, from the job's page or the career site's first job."""
371
+ if "jobId=" not in page.url and "/careers/jobs/" not in page.url:
372
+ jobs = page.locator("a[href*='jobId=']").first
373
+ try:
374
+ jobs.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
375
+ except Timeout:
376
+ raise Unsupported("unhandled_rendering: no job on the career site") from None
377
+ page.goto(jobs.get_attribute("href") or "")
378
+ page.goto(_apply_link(page))
379
+ page.locator(f"{IDENTITY}, {PAGE_FORM}").first.wait_for(timeout=FORM_TIMEOUT_MS)
380
+
381
+
382
+ def _field(r: dict[str, Any]) -> Field:
383
+ kinds = {
384
+ "text": FieldType.TEXT,
385
+ "email": FieldType.TEXT,
386
+ "tel": FieldType.TEXT,
387
+ "textarea": FieldType.TEXT,
388
+ "select": FieldType.CHOICE,
389
+ "radio": FieldType.CHOICE,
390
+ "checkbox": FieldType.MULTI_CHOICE,
391
+ "date": FieldType.DATE,
392
+ "file": FieldType.FILE,
393
+ }
394
+ label, required = r["label"] or r["id"], r["required"]
395
+ if r["kind"] == "linkedin":
396
+ reason = "out_of_scope: a LinkedIn profile is imported by signing in to LinkedIn"
397
+ return Field(r["id"], label, FieldType.FILE, False, unfillable=reason)
398
+ if r["kind"] not in kinds or not r["id"]:
399
+ reason = f"unhandled_rendering: {r['id'] or label} is a {r['qa'] or r['kind']}"
400
+ return Field(r["id"] or label, label, FieldType.TEXT, required, unfillable=reason)
401
+ kind = kinds[r["kind"]]
402
+ if kind is FieldType.MULTI_CHOICE and len(r["options"]) == 1:
403
+ kind = FieldType.CHOICE
404
+ return Field(r["id"], label, kind, required, ["Yes", "No"])
405
+ return Field(
406
+ r["id"],
407
+ label,
408
+ kind,
409
+ required,
410
+ r["options"],
411
+ r["max"] if kind is FieldType.TEXT else None,
412
+ )
413
+
414
+
415
+ def _last(page: Page) -> bool:
416
+ """Whether the page's primary button is Submit, not Continue."""
417
+ button = page.locator(NEXT).first
418
+ key = button.get_attribute("data-localized-key") or "" if button.count() else ""
419
+ return bool(button.count()) and not key.endswith("continue")
420
+
421
+
422
+ def _active(page: Page) -> str:
423
+ active = page.locator(ACTIVE_PAGE)
424
+ return active.first.inner_text() if active.count() else ""
425
+
426
+
427
+ def _fill(page: Page, field: Field, kind: str, answer: Answer) -> None:
428
+ values = [answer] if isinstance(answer, str) else list(answer)
429
+ form = page.locator(PAGE_FORM)
430
+ if kind in ("radio", "checkbox"):
431
+ boxes = form.locator(f"input[data-field='{field.id}']")
432
+ if kind == "checkbox" and boxes.count() == 1: # one box, answered Yes or No
433
+ box = boxes.first
434
+ if box.is_checked() != (values[0] == "Yes"):
435
+ box.locator("xpath=ancestor::label[1]").click()
436
+ return
437
+ for value in values:
438
+ box = _option(boxes, value)
439
+ if not box.is_checked():
440
+ box.locator("xpath=ancestor::label[1]").click()
441
+ return
442
+ if kind == "file":
443
+ _upload(form, field.id.removeprefix("file."), values[0])
444
+ return
445
+ control = form.locator(f"[id='{field.id}']")
446
+ if kind == "select":
447
+ options = field.options or []
448
+ if values[0] not in options:
449
+ raise NotTaken(f"{field.id}: no option {values[0]!r}")
450
+ control.select_option(label=values[0])
451
+ elif kind == "date":
452
+ year, month, day = values[0].split("-")
453
+ control.fill(f"{month}/{day}/{year}")
454
+ control.press("Tab")
455
+ page.evaluate(CLOSE_DATEPICKERS) # an open one covers the fields below it
456
+ else:
457
+ control.fill(values[0])
458
+ control.dispatch_event("change")
459
+ control.blur()
460
+
461
+
462
+ def _option(boxes: Locator, value: str) -> Locator:
463
+ for i in range(boxes.count()):
464
+ box = boxes.nth(i)
465
+ text = " ".join(box.locator("xpath=ancestor::label[1]").inner_text().split())
466
+ if text == value:
467
+ return box
468
+ raise NotTaken(f"no option {value!r}")
469
+
470
+
471
+ def _upload(form: Locator, kind: str, path: str) -> None:
472
+ """Attaches the file and waits for its row in the field's file list."""
473
+ holder = form.locator(f".qa-resume-upload.{kind}")
474
+ holder.locator("input[type=file]").set_input_files(path)
475
+ name = path.rsplit("/", 1)[-1]
476
+ holder.page.wait_for_function(
477
+ "([el, name]) => [...el.querySelectorAll('.file-list tr td:first-child')]"
478
+ ".some(t => t.textContent.trim() === name)"
479
+ " && !el.querySelector('.file-list').textContent.includes('Uploading')",
480
+ arg=[holder.element_handle(), name],
481
+ timeout=UPLOAD_TIMEOUT_MS,
482
+ )
@@ -306,6 +306,7 @@ share = 0.0059
306
306
  account = "to_read"
307
307
  hosting = ["hosted"]
308
308
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
309
+ unsupported = { date = 2026-10-26, version = "0.0.3", commit = "211fe03", by = "agent", reason = "unsupported_flow: screening_first: the form's first page comes before the sign-up step (4 of 14 orgs); sign_up_phone: the sign-up step requires a mobile phone, which login does not take (1 of 17); a reCAPTCHA box on the sign-up step (1 of 17); 3 orgs signed in, under the 5 dry runs the gate needs" }
309
310
 
310
311
  [applicantpro]
311
312
  kind = "ats"
@@ -357,9 +358,11 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
357
358
  [gupy]
358
359
  kind = "ats"
359
360
  share = 0.0040
360
- account = "to_read"
361
+ account = "to_read" # one account for the whole system; its sign-up asks an ID document number, a mobile number and a date of birth
361
362
  hosting = ["hosted"]
363
+ adapter = "gupy"
362
364
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
365
+ dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "566668d" }
363
366
 
364
367
  [eightfold]
365
368
  kind = "ats"
File without changes