atsfill 0.0.4.dev122__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.dev122 → atsfill-0.0.4.dev123}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/README.md +1 -1
  3. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev122 → 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.dev122 → atsfill-0.0.4.dev123}/src/atsfill/registry.toml +3 -1
  8. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hiringthing.py +0 -0
  18. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/hrmdirect.py +0 -0
  19. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/jazzhr.py +0 -0
  20. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/lever.py +0 -0
  21. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/manatal.py +0 -0
  22. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/oracle.py +0 -0
  23. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/paycor.py +0 -0
  24. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/paylocity.py +0 -0
  25. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/personio.py +0 -0
  26. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/phenom.py +0 -0
  27. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/pinpoint.py +0 -0
  28. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/recruitee.py +0 -0
  29. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/rippling.py +0 -0
  30. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/smartrecruiters.py +0 -0
  31. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/successfactors.js +0 -0
  32. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/successfactors.py +0 -0
  33. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/taleo.py +0 -0
  34. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/teamtailor.py +0 -0
  35. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/ukg.py +0 -0
  36. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/workable.py +0 -0
  37. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/workday.py +0 -0
  38. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/wttj.py +0 -0
  39. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/adapters/zohorecruit.py +0 -0
  40. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/cli.py +0 -0
  41. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/__init__.py +0 -0
  42. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/accounts.py +0 -0
  43. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/adapter.py +0 -0
  44. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/attempts.py +0 -0
  45. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/browser.py +0 -0
  46. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/commands.py +0 -0
  47. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/conditions.py +0 -0
  48. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/html.py +0 -0
  49. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/matching.py +0 -0
  50. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/passwords.py +0 -0
  51. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/paths.py +0 -0
  52. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/registry.py +0 -0
  53. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev123}/src/atsfill/core/results.py +0 -0
  54. {atsfill-0.0.4.dev122 → 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.dev122
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.dev122"
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.dev122"
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('"', '\\"')
@@ -358,9 +358,11 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
358
358
  [gupy]
359
359
  kind = "ats"
360
360
  share = 0.0040
361
- 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
362
362
  hosting = ["hosted"]
363
+ adapter = "gupy"
363
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" }
364
366
 
365
367
  [eightfold]
366
368
  kind = "ats"
File without changes