atsfill 0.0.4.dev122__tar.gz → 0.0.4.dev124__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 (55) hide show
  1. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/README.md +1 -1
  3. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/__init__.py +4 -0
  6. atsfill-0.0.4.dev124/src/atsfill/adapters/gupy.py +294 -0
  7. atsfill-0.0.4.dev124/src/atsfill/adapters/join.py +367 -0
  8. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/registry.toml +6 -2
  9. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/LICENSE +0 -0
  10. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/eightfold.py +0 -0
  16. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/greenhouse.py +0 -0
  17. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/jazzhr.py +0 -0
  21. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/lever.py +0 -0
  22. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/manatal.py +0 -0
  23. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/oracle.py +0 -0
  24. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paycor.py +0 -0
  25. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paylocity.py +0 -0
  26. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/personio.py +0 -0
  27. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/phenom.py +0 -0
  28. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/pinpoint.py +0 -0
  29. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/recruitee.py +0 -0
  30. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/rippling.py +0 -0
  31. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/smartrecruiters.py +0 -0
  32. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.js +0 -0
  33. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.py +0 -0
  34. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/taleo.py +0 -0
  35. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/teamtailor.py +0 -0
  36. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ukg.py +0 -0
  37. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workable.py +0 -0
  38. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workday.py +0 -0
  39. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/wttj.py +0 -0
  40. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/adapters/zohorecruit.py +0 -0
  41. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/cli.py +0 -0
  42. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/__init__.py +0 -0
  43. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/accounts.py +0 -0
  44. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/adapter.py +0 -0
  45. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/attempts.py +0 -0
  46. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/browser.py +0 -0
  47. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/commands.py +0 -0
  48. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/conditions.py +0 -0
  49. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/html.py +0 -0
  50. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/matching.py +0 -0
  51. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/passwords.py +0 -0
  52. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/paths.py +0 -0
  53. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/registry.py +0 -0
  54. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/src/atsfill/core/results.py +0 -0
  55. {atsfill-0.0.4.dev122 → atsfill-0.0.4.dev124}/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.dev124
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) ![join: dry run verified 2026-10-26](https://img.shields.io/badge/join-dry%20run%20verified%202026--10--26-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) ![join: dry run verified 2026-10-26](https://img.shields.io/badge/join-dry%20run%20verified%202026--10--26-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.dev124"
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.dev124"
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,9 +4,11 @@ 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
11
+ from atsfill.adapters.join import Join
10
12
  from atsfill.adapters.lever import Lever
11
13
  from atsfill.adapters.manatal import Manatal
12
14
  from atsfill.adapters.oracle import Oracle
@@ -35,6 +37,7 @@ def adapters() -> list[Adapter]:
35
37
  Teamtailor(),
36
38
  Greenhouse(),
37
39
  WelcomeToTheJungle(),
40
+ Join(),
38
41
  SmartRecruiters(),
39
42
  Oracle(),
40
43
  Manatal(),
@@ -55,6 +58,7 @@ def adapters() -> list[Adapter]:
55
58
  HiringThing(),
56
59
  Eightfold(),
57
60
  Workday(),
61
+ Gupy(),
58
62
  UKG(),
59
63
  SuccessFactors(), # it claims a career site's job path on any host
60
64
  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,367 @@
1
+ """JOIN (`join.com/companies/<company>/<job id>-<slug>`), as docs/adapters/join.md observes.
2
+
3
+ Applying is a wizard of one step per page, ending on a review page whose Confirm & apply is the
4
+ send. Signed in, the wizard takes the name from the account and asks the CV, a cover letter and
5
+ the job's screening questions, which the page's own GraphQL query lists. The fill walks the
6
+ steps to the review page, and the read-back is what the review page lists.
7
+ """
8
+
9
+ import json
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, Refused, Unsupported, known_gap
17
+ from atsfill.core.browser import Locator, Page, Request, Response, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ HOST = "join.com"
21
+ JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/(\d+)(?:-[^/]*)?(?:/apply(?:/.*)?)?/?$")
22
+ ME = f"https://{HOST}/api/me"
23
+ GRAPHQL = f"https://{HOST}/candidate-api/graphql"
24
+ LOGIN = f"https://{HOST}/auth/login/candidate?redirectUrl=%2Ftalent%2Fhome"
25
+ UNVERIFIED = "/auth/candidates/unverified-email"
26
+ STEP_TIMEOUT_MS = 20_000
27
+ UPLOAD_TIMEOUT_MS = 30_000
28
+ ACCOUNT_TIMEOUT_MS = 20_000
29
+ SUBMIT_TIMEOUT_MS = 60_000
30
+ BUTTON_TIMEOUT_MS = 2_000
31
+ OPENING_TIMEOUT_S = 20
32
+ CONTINUE_TIMEOUT_S = 15
33
+ POLL_MS = 250
34
+ MAX_STEPS = 60
35
+ MAX_MONTHS = 36 # how far the calendar is paged for a date
36
+
37
+ QUESTIONS = """query JobScreeningQuestions($input: JobScreeningQuestionsInput!) {
38
+ jobScreeningQuestions(input: $input) {
39
+ questions { id question options type isRequired position questionClass }
40
+ }
41
+ }"""
42
+ YES_NO = ["Yes", "No"] # a boolean's cards, in this order, in the job's language
43
+ FILES = {"cv": "cv"} # a file field, by its step
44
+ # A question of a class has a step of its own; the others are `question-<n>`, in order.
45
+ CLASS_STEPS = {
46
+ "workAuthorization": "WORK_AUTHORIZATION",
47
+ "salaryExpectation": "SALARY_EXPECTATION",
48
+ "startAvailability": "AVAILABILITY",
49
+ "availability": "AVAILABILITY",
50
+ }
51
+ PROFILE_STEPS = ("personalInformation", "professionalLinks") # the account profile's
52
+ PDF = [".pdf", "application/pdf"]
53
+
54
+ # The page's own GraphQL request, from the page.
55
+ _FETCH = """async ([url, body]) => (await fetch(url, {method: "POST", body: JSON.stringify(body),
56
+ headers: {"content-type": "application/json"}})).json()"""
57
+
58
+ # What the review page lists: each entry's label, then its value.
59
+ _REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
60
+ const [label, value] = div.children;
61
+ return div.children.length === 2 && label.tagName === "P"
62
+ ? [label.textContent.trim(), value.textContent.trim()] : null;
63
+ }).filter(Boolean)"""
64
+
65
+
66
+ class Join:
67
+ name = "join"
68
+ per_employer = False
69
+ asks_name = True # the account's first and last name
70
+ known_gaps = {"profile_incomplete": Gap.FLOW}
71
+ side_effects = [
72
+ SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
73
+ SideEffect("upload", "hiring_system", "a file is stored as soon as it is chosen"),
74
+ SideEffect("submit", "employer", "the application reaches the employer"),
75
+ ]
76
+
77
+ def __init__(self) -> None:
78
+ self._fields: dict[str, Field] = {}
79
+ self._questions: list[dict[str, Any]] = []
80
+ self._seen: dict[str, Answer] = {} # each question as its step was left
81
+ self._files: list[str] = [] # the file fields answered, in the steps' order
82
+
83
+ def detect(self, url: str, page: Page | None) -> bool:
84
+ parts = urlsplit(url)
85
+ return (parts.hostname or "").removeprefix("www.") == HOST and bool(JOB.match(parts.path))
86
+
87
+ def posting(self, url: str) -> str:
88
+ match = JOB.match(urlsplit(url).path)
89
+ return f"https://{HOST}/companies/{match[1]}/{match[2]}" if match else url
90
+
91
+ def account_scope(self, url: str) -> None:
92
+ return None
93
+
94
+ def login_url(self, scope: str | None) -> str:
95
+ return LOGIN
96
+
97
+ def signed_in(self, request: Request, scope: str | None) -> bool:
98
+ """`api/me` answers the candidate for an account."""
99
+ response = request.get(ME)
100
+ return response.ok and response.json().get("type") == "candidate"
101
+
102
+ def sign_in(self, page: Page, email: str, password: str) -> None:
103
+ if not _email_step(page, email):
104
+ raise Refused("wrong_password: no account has this email")
105
+ page.locator("#password").fill(password)
106
+ login = _account_post(page, "LoginWithCredentials")
107
+ if login.json().get("errors"):
108
+ raise Refused("wrong_password")
109
+ page.wait_for_url(lambda url: "/auth/login" not in url, timeout=ACCOUNT_TIMEOUT_MS)
110
+ if urlsplit(page.url).path == UNVERIFIED:
111
+ raise Blocked("email_link: the account is not confirmed")
112
+
113
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
114
+ """Creates the account, signed in, then waits for its confirmation link: until it is
115
+ clicked the wizard does not open."""
116
+ first, _, last = (name or "").strip().rpartition(" ")
117
+ if not first or not last:
118
+ raise Unsupported("unhandled_rendering: the sign-up asks a first and a last name")
119
+ if _email_step(page, email):
120
+ raise Refused("email_in_use")
121
+ page.locator("#firstName").fill(first)
122
+ page.locator("#lastName").fill(last)
123
+ page.locator("#password").fill(password)
124
+ submit = page.locator("[data-testid=SubmitButton]")
125
+ try:
126
+ page.wait_for_function(
127
+ "e => !e.disabled", arg=submit.element_handle(), timeout=BUTTON_TIMEOUT_MS
128
+ )
129
+ except Timeout:
130
+ raise Refused("password_rule: the sign-up keeps its button disabled") from None
131
+ registered = _account_post(page, "RegisterCandidate")
132
+ if errors := registered.json().get("errors"):
133
+ raise Refused(f"password_rule: {errors[0].get('message')}")
134
+ raise Blocked("email_link: the sign-up sent a confirmation link")
135
+
136
+ def browser_read(self, page: Page) -> list[Field]:
137
+ """Lists the fields, then opens the wizard on its first step."""
138
+ if not JOB.match(urlsplit(page.url).path):
139
+ raise RuntimeError(f"{page.url} is not a JOIN job")
140
+ raw = page.locator("script#__NEXT_DATA__").text_content() or "{}"
141
+ job = json.loads(raw)["props"]["pageProps"].get("pageData", {}).get("job")
142
+ if not job or job.get("status") != "ONLINE":
143
+ raise Unsupported(f"posting_gone: the job is {job and job.get('status')}")
144
+ variables = {"input": {"companyId": job["companyId"], "jobId": job["id"],
145
+ "paginationPage": {"page": 1, "pageSize": 100}}} # fmt: skip
146
+ body = {
147
+ "operationName": "JobScreeningQuestions",
148
+ "variables": variables,
149
+ "query": QUESTIONS,
150
+ }
151
+ answered = page.evaluate(_FETCH, [f"{GRAPHQL}?operationName=JobScreeningQuestions", body])
152
+ questions = answered["data"]["jobScreeningQuestions"]["questions"]
153
+ self._questions = sorted(questions, key=lambda q: q.get("position") or 0)
154
+ fields = [
155
+ Field("cv", "CV", FieldType.FILE, True, accept=PDF),
156
+ *(_question(q) for q in self._questions),
157
+ ]
158
+ self._fields = {f.id: f for f in fields if not f.unfillable}
159
+ browser.click(page.locator("[data-testid=ApplyButton]").locator("visible=true").first)
160
+ page.wait_for_url("**/apply/**", timeout=STEP_TIMEOUT_MS)
161
+ # the wizard opens on `authentication`, and leaves it once it knows the account
162
+ signed_out = page.locator("#email:visible")
163
+ deadline = time.monotonic() + OPENING_TIMEOUT_S
164
+ while _step(page) == "authentication" and time.monotonic() < deadline:
165
+ if signed_out.count():
166
+ break
167
+ page.wait_for_timeout(POLL_MS)
168
+ if _step(page) == "authentication":
169
+ raise Blocked("login_required: run `atsfill login join`")
170
+ return fields
171
+
172
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
173
+ """Walks the wizard from the step it is on to the review page, answering each step; a
174
+ step without a listed field (the account's name, its links) is left as it is."""
175
+ for _ in range(MAX_STEPS):
176
+ step = _step(page)
177
+ if step == "review":
178
+ page.locator("a > div > p + p").first.wait_for(timeout=STEP_TIMEOUT_MS)
179
+ return
180
+ if step in FILES:
181
+ self._file(page, FILES[step], answers.get(FILES[step]))
182
+ elif question := self._step_question(step):
183
+ field_id = f"question.{question['id']}"
184
+ if field_id in self._fields:
185
+ self._question(page, question, answers.get(field_id))
186
+ elif step == "coverLetter": # asked by some jobs only: left without one
187
+ pass
188
+ elif step in PROFILE_STEPS:
189
+ try: # shown when the job requires what the account profile lacks
190
+ _next(page, step)
191
+ except NotTaken, RuntimeError:
192
+ detail = f"the {step} step asks what the account profile lacks"
193
+ raise Unsupported(known_gap(self, "profile_incomplete", detail)) from None
194
+ continue
195
+ else:
196
+ raise Unsupported(f"unhandled_rendering: a wizard step {step!r}")
197
+ _next(page, step)
198
+ raise RuntimeError(f"the wizard did not reach its review page in {MAX_STEPS} steps")
199
+
200
+ def _step_question(self, step: str) -> dict[str, Any] | None:
201
+ if kind := CLASS_STEPS.get(step):
202
+ return next((q for q in self._questions if q.get("questionClass") == kind), None)
203
+ if match := re.fullmatch(r"question-(\d+)", step):
204
+ plain = [q for q in self._questions if not q.get("questionClass")]
205
+ if int(match[1]) > len(plain):
206
+ raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
207
+ return plain[int(match[1]) - 1]
208
+ return None
209
+
210
+ def read_back(self, page: Page) -> dict[str, Answer]:
211
+ """The files as the review page lists them; each question as its step was left, since
212
+ the review shows a classed question's answer reworded (`€50,000`)."""
213
+ values: dict[str, Answer] = dict(self._seen)
214
+ files = iter(self._files) # the review lists the files by name, in the steps' order
215
+ for _, value in page.evaluate(_REVIEW):
216
+ if value.lower().endswith(".pdf") and (field_id := next(files, None)):
217
+ values[field_id] = value
218
+ return {k: v for k, v in values.items() if v}
219
+
220
+ def submit(self, page: Page) -> str | None:
221
+ with page.expect_response(
222
+ lambda r: r.request.method == "POST" and r.url.startswith(GRAPHQL)
223
+ and "apply" in r.url.lower(),
224
+ timeout=SUBMIT_TIMEOUT_MS,
225
+ ) as sent: # fmt: skip
226
+ browser.click(page.locator("button[type=submit]:visible").last)
227
+ response = sent.value
228
+ body = response.json()
229
+ if response.ok and not body.get("errors"):
230
+ parts = urlsplit(response.url)
231
+ return f"POST {parts.path}?{parts.query} {response.status}"
232
+ raise RuntimeError(f"POST {response.url} answered {response.status}: {body.get('errors')}")
233
+
234
+ def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
235
+ if not isinstance(answer, str):
236
+ return
237
+ page.locator("input[type=file]").first.set_input_files(answer)
238
+ # once stored, the step shows the file in place of its input
239
+ page.wait_for_function(
240
+ "() => !document.querySelector('input[type=file]')", timeout=UPLOAD_TIMEOUT_MS
241
+ )
242
+ self._files.append(field_id)
243
+
244
+ def _question(self, page: Page, question: dict[str, Any], answer: Answer | None) -> None:
245
+ field_id = f"question.{question['id']}"
246
+ cards = page.locator("[role=radio]")
247
+ match question["type"]:
248
+ case "BOOLEAN" | "SINGLE_CHOICE":
249
+ cards.first.wait_for(timeout=STEP_TIMEOUT_MS)
250
+ # the cards are the options in order: a letter, the option, a description
251
+ texts = [_card(t) for t in cards.all_inner_texts()]
252
+ options = YES_NO if question["type"] == "BOOLEAN" else question["options"]
253
+ if isinstance(answer, str):
254
+ if answer not in options or options.index(answer) >= len(texts):
255
+ raise NotTaken(f"no option {answer!r} in {field_id}: {texts}")
256
+ card = cards.nth(options.index(answer))
257
+ if card.get_attribute("aria-checked") != "true":
258
+ card.click()
259
+ checked = [c.get_attribute("aria-checked") == "true" for c in cards.all()]
260
+ if True in checked and checked.index(True) < len(options):
261
+ self._seen[field_id] = options[checked.index(True)]
262
+ case "DATE":
263
+ if isinstance(answer, str):
264
+ _date(page, answer)
265
+ picked = page.locator("td[data-part=table-cell][aria-selected=true]")
266
+ if picked.count():
267
+ self._seen[field_id] = picked.first.get_attribute("data-value") or ""
268
+ case _ if isinstance(answer, str):
269
+ name = f"screeningQuestions.answer-{question['id']}"
270
+ target = page.locator(f'[name="{name}"], [id="{name}"], #salaryExpectationAmount')
271
+ if not target.count():
272
+ target = page.locator(
273
+ "textarea:visible, input:visible:not([type=file]):not([role=combobox])"
274
+ )
275
+ target.first.fill(answer)
276
+ self._seen[field_id] = target.first.input_value()
277
+
278
+
279
+ def _step(page: Page) -> str:
280
+ return urlsplit(page.url).path.rstrip("/").rsplit("/", 1)[-1]
281
+
282
+
283
+ def _continue(page: Page) -> Locator:
284
+ """The footer's last button, after Back: its label is in the job's language."""
285
+ return page.locator("button:visible:has(> [data-button-label])").last
286
+
287
+
288
+ def _next(page: Page, step: str) -> None:
289
+ button = _continue(page)
290
+ deadline = time.monotonic() + CONTINUE_TIMEOUT_S # an upload enables it once stored
291
+ while button.is_disabled() and time.monotonic() < deadline:
292
+ page.wait_for_timeout(POLL_MS)
293
+ if button.is_disabled():
294
+ raise NotTaken(f"the {step} step keeps Continue disabled: a required answer is missing")
295
+ on = page.url
296
+ try:
297
+ button.click(timeout=STEP_TIMEOUT_MS)
298
+ page.wait_for_url(lambda url: url != on, timeout=STEP_TIMEOUT_MS)
299
+ except Timeout:
300
+ raise RuntimeError(f"Continue on the {step} step went nowhere") from None
301
+
302
+
303
+ def _question(q: dict[str, Any]) -> Field:
304
+ field_id, label, required = f"question.{q['id']}", q["question"], bool(q["isRequired"])
305
+ match q["type"]:
306
+ case "SINGLE_CHOICE":
307
+ return Field(field_id, label, FieldType.CHOICE, required, list(q["options"] or []))
308
+ case "BOOLEAN":
309
+ return Field(field_id, label, FieldType.CHOICE, required, YES_NO)
310
+ case "DATE":
311
+ return Field(field_id, label, FieldType.DATE, required)
312
+ case "INTEGER":
313
+ return Field(field_id, f"{label} (a number)", FieldType.TEXT, required)
314
+ case "TEXT":
315
+ return Field(field_id, label, FieldType.TEXT, required)
316
+ reason = f"unhandled_rendering: {label!r} is a {q['type']} question"
317
+ return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
318
+
319
+
320
+ def _card(text: str) -> str:
321
+ """A choice card's option: the line after its letter."""
322
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
323
+ return lines[1] if len(lines) > 1 else (lines[0] if lines else "")
324
+
325
+
326
+ def _date(page: Page, answer: str) -> None:
327
+ """Pages the calendar to the answer's month, then picks its day."""
328
+ day = page.locator(f'td[data-part=table-cell][data-value="{answer}"]')
329
+ for _ in range(MAX_MONTHS):
330
+ if day.count() and day.first.is_visible():
331
+ break
332
+ shown = page.locator("td[data-part=table-cell][data-value]").evaluate_all(
333
+ "es => es.map(e => e.dataset.value)"
334
+ )
335
+ side = "next" if shown and answer > max(shown) else "prev"
336
+ page.locator(f"button[data-part={side}-trigger]").first.click()
337
+ else:
338
+ raise NotTaken(f"the calendar shows no {answer}")
339
+ trigger = day.first.locator("[data-part=table-cell-trigger]")
340
+ (trigger if trigger.count() else day.first).click()
341
+
342
+
343
+ def _email_step(page: Page, email: str) -> bool:
344
+ """The account page's first step: the email. Whether an account has it, from what the page
345
+ asks next: a password alone for one, a name and a password for none."""
346
+ try:
347
+ page.locator("#email").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
348
+ except Timeout:
349
+ raise Unsupported(f"unhandled_rendering: no email step on {page.url}") from None
350
+ page.locator("#email").fill(email)
351
+ with page.expect_response(
352
+ lambda r: "operationName=CheckCandidate" in r.url, timeout=ACCOUNT_TIMEOUT_MS
353
+ ) as checked:
354
+ page.locator("button[type=submit]").first.click()
355
+ page.locator("#password").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
356
+ return bool(checked.value.json()["data"]["checkCandidate"]["exists"])
357
+
358
+
359
+ def _account_post(page: Page, operation: str) -> Response:
360
+ with page.expect_response(
361
+ lambda r: f"operationName={operation}" in r.url, timeout=ACCOUNT_TIMEOUT_MS
362
+ ) as answered:
363
+ page.locator("[data-testid=SubmitButton]").click()
364
+ response = answered.value
365
+ if response.status == 429:
366
+ raise Blocked(f"rate_limited: {operation} answered 429")
367
+ return response
@@ -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"
@@ -397,9 +399,11 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
397
399
  [join]
398
400
  kind = "ats"
399
401
  share = 0.0032
400
- account = "to_read"
402
+ account = "to_send" # an unknown email reads and fills the form; a known one signs in first
403
+ adapter = "join"
401
404
  hosting = ["hosted"]
402
405
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
406
+ dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "bfa5b31" }
403
407
 
404
408
  [4dayweek]
405
409
  kind = "aggregator"
File without changes