atsfill 0.0.4.dev123__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.dev123 → atsfill-0.0.4.dev124}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/README.md +1 -1
  3. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev124/src/atsfill/adapters/join.py +367 -0
  7. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/registry.toml +3 -1
  8. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/gupy.py +0 -0
  17. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/jazzhr.py +0 -0
  21. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/lever.py +0 -0
  22. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/manatal.py +0 -0
  23. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/oracle.py +0 -0
  24. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paycor.py +0 -0
  25. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/paylocity.py +0 -0
  26. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/personio.py +0 -0
  27. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/phenom.py +0 -0
  28. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/pinpoint.py +0 -0
  29. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/recruitee.py +0 -0
  30. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/rippling.py +0 -0
  31. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/smartrecruiters.py +0 -0
  32. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.js +0 -0
  33. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/successfactors.py +0 -0
  34. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/taleo.py +0 -0
  35. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/teamtailor.py +0 -0
  36. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/ukg.py +0 -0
  37. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workable.py +0 -0
  38. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/workday.py +0 -0
  39. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/wttj.py +0 -0
  40. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/adapters/zohorecruit.py +0 -0
  41. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/cli.py +0 -0
  42. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/__init__.py +0 -0
  43. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/accounts.py +0 -0
  44. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/adapter.py +0 -0
  45. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/attempts.py +0 -0
  46. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/browser.py +0 -0
  47. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/commands.py +0 -0
  48. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/conditions.py +0 -0
  49. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/html.py +0 -0
  50. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/matching.py +0 -0
  51. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/passwords.py +0 -0
  52. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/paths.py +0 -0
  53. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/registry.py +0 -0
  54. {atsfill-0.0.4.dev123 → atsfill-0.0.4.dev124}/src/atsfill/core/results.py +0 -0
  55. {atsfill-0.0.4.dev123 → 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.dev123
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) ![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)
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) ![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)
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.dev123"
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.dev123"
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"
@@ -8,6 +8,7 @@ from atsfill.adapters.gupy import Gupy
8
8
  from atsfill.adapters.hireology import Hireology
9
9
  from atsfill.adapters.hiringthing import HiringThing
10
10
  from atsfill.adapters.jazzhr import JazzHR
11
+ from atsfill.adapters.join import Join
11
12
  from atsfill.adapters.lever import Lever
12
13
  from atsfill.adapters.manatal import Manatal
13
14
  from atsfill.adapters.oracle import Oracle
@@ -36,6 +37,7 @@ def adapters() -> list[Adapter]:
36
37
  Teamtailor(),
37
38
  Greenhouse(),
38
39
  WelcomeToTheJungle(),
40
+ Join(),
39
41
  SmartRecruiters(),
40
42
  Oracle(),
41
43
  Manatal(),
@@ -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
@@ -399,9 +399,11 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
399
399
  [join]
400
400
  kind = "ats"
401
401
  share = 0.0032
402
- account = "to_read"
402
+ account = "to_send" # an unknown email reads and fills the form; a known one signs in first
403
+ adapter = "join"
403
404
  hosting = ["hosted"]
404
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" }
405
407
 
406
408
  [4dayweek]
407
409
  kind = "aggregator"
File without changes