atsfill 0.0.4.dev129__tar.gz → 0.0.4.dev132__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 (56) hide show
  1. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/README.md +1 -1
  3. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev132/src/atsfill/adapters/isolvedhire.py +614 -0
  7. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/registry.toml +3 -2
  8. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/gupy.py +0 -0
  17. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/hrmdirect.py +0 -0
  20. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/jazzhr.py +0 -0
  21. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/join.py +0 -0
  22. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/lever.py +0 -0
  23. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/manatal.py +0 -0
  24. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/oracle.py +0 -0
  25. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/paycor.py +0 -0
  26. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/paylocity.py +0 -0
  27. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/personio.py +0 -0
  28. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/phenom.py +0 -0
  29. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/pinpoint.py +0 -0
  30. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/recruitee.py +0 -0
  31. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/rippling.py +0 -0
  32. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/smartrecruiters.py +0 -0
  33. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/successfactors.js +0 -0
  34. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/successfactors.py +0 -0
  35. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/taleo.py +0 -0
  36. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/teamtailor.py +0 -0
  37. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/ukg.py +0 -0
  38. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/workable.py +0 -0
  39. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/workday.py +0 -0
  40. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/wttj.py +0 -0
  41. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/adapters/zohorecruit.py +0 -0
  42. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/cli.py +0 -0
  43. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/__init__.py +0 -0
  44. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/accounts.py +0 -0
  45. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/adapter.py +0 -0
  46. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/attempts.py +0 -0
  47. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/browser.py +0 -0
  48. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/commands.py +0 -0
  49. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/conditions.py +0 -0
  50. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/html.py +0 -0
  51. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/matching.py +0 -0
  52. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/passwords.py +0 -0
  53. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/paths.py +0 -0
  54. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/registry.py +0 -0
  55. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/src/atsfill/core/results.py +0 -0
  56. {atsfill-0.0.4.dev129 → atsfill-0.0.4.dev132}/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.dev129
3
+ Version: 0.0.4.dev132
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) ![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)
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) ![isolvedhire: dry run verified 2026-10-26](https://img.shields.io/badge/isolvedhire-dry%20run%20verified%202026--10--26-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) ![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)
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) ![isolvedhire: dry run verified 2026-10-26](https://img.shields.io/badge/isolvedhire-dry%20run%20verified%202026--10--26-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.dev129"
3
+ version = "0.0.4.dev132"
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.dev129"
3
+ version = "0.0.4.dev132"
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"
@@ -7,6 +7,7 @@ from atsfill.adapters.greenhouse import Greenhouse
7
7
  from atsfill.adapters.gupy import Gupy
8
8
  from atsfill.adapters.hireology import Hireology
9
9
  from atsfill.adapters.hiringthing import HiringThing
10
+ from atsfill.adapters.isolvedhire import IsolvedHire
10
11
  from atsfill.adapters.jazzhr import JazzHR
11
12
  from atsfill.adapters.join import Join
12
13
  from atsfill.adapters.lever import Lever
@@ -60,6 +61,7 @@ def adapters() -> list[Adapter]:
60
61
  Workday(),
61
62
  Gupy(),
62
63
  UKG(),
64
+ IsolvedHire(),
63
65
  SuccessFactors(), # it claims a career site's job path on any host
64
66
  Phenom(), # last: it claims a URL by its path alone
65
67
  ]
@@ -0,0 +1,614 @@
1
+ """isolved Hire (`<employer>.isolvedhire.com/jobs/<id>`), as docs/adapters/isolvedhire.md
2
+ observes.
3
+
4
+ Applying needs an account, one for the whole system, kept signed in per employer's site: a form
5
+ on a site not signed in yet signs in there again. The form is a wizard at `/apply/<id>-0.php`
6
+ whose steps the page loads from `/apply/view.php`; the read fetches each step from there, the
7
+ fill walks the steps to Submit Application, saving each with its Proceed to Next Step, and the
8
+ read-back fetches the steps again, as the system saved them.
9
+ """
10
+
11
+ import re
12
+ import time
13
+ from contextlib import suppress
14
+ from typing import Any
15
+ from urllib.parse import parse_qs, urlsplit
16
+
17
+ from atsfill.core.adapter import (
18
+ Blocked,
19
+ Gap,
20
+ NotTaken,
21
+ Refused,
22
+ SignUpDetails,
23
+ Unsupported,
24
+ known_gap,
25
+ )
26
+ from atsfill.core.browser import Locator, Page, Request, Timeout
27
+ from atsfill.core.results import (
28
+ Answer,
29
+ AnyOf,
30
+ Condition,
31
+ Field,
32
+ FieldType,
33
+ Is,
34
+ OptionSet,
35
+ SideEffect,
36
+ )
37
+
38
+ HOST = re.compile(r"^(?!(?:www|app|admin)\.)([a-z0-9-]+)\.isolvedhire\.com$")
39
+ JOB = re.compile(r"^/(?:jobs/(\d+)|apply/(\d+)-\d+\.php)/?$")
40
+ LOGIN, SIGN_UP, LOGOUT = "/account/login.php", "/account/", "/account/logout.php"
41
+ SIGNED_IN = f"a[href='{LOGOUT}']"
42
+ THANKS = "/apply/thanks.php"
43
+ ERRORS = "#landingStrip [id^=message_]:visible, #landingStrip .text-danger:visible"
44
+ PAGE_TIMEOUT_MS = 30_000
45
+ STEP_TIMEOUT_MS = 30_000
46
+ LIST_TIMEOUT_MS = 10_000 # a dependent list's options, a follow-up question
47
+ UPLOAD_TIMEOUT_MS = 60_000
48
+ SUBMIT_TIMEOUT_MS = 60_000
49
+ SETTLE_TIMEOUT_MS = 5_000 # an answer's save, sent as it is entered
50
+ ACCOUNT_TIMEOUT_S = 30
51
+ POLL_MS = 250
52
+ MAX_DEPTH = 3 # follow-up questions under follow-up questions
53
+
54
+ MOBILE = "2" # the phone type's option for a cell phone
55
+ NO_TEXTS = "2" # the text message consent's option that declines it
56
+ NANP = "+1" # the calling code a US or Canadian number goes without
57
+ NANP_COUNTRIES = ("US", "CA")
58
+ YES_NO = ["Yes", "No"]
59
+ OTHER_SOURCE = "1" # the source Other's value, added to every type's list
60
+ REFERRAL = "4" # the source type whose source the applicant describes
61
+ FILES = [".pdf", ".doc", ".docx", ".txt"]
62
+ SKIPPED = ("instructions", "submit") # steps without fields: text, then the send
63
+ ENTRY_LISTS = ("employment", "education") # a list of entries, each added with its own form
64
+
65
+ # A step's fields from its HTML, in page order: each control's key (a group's name), kind,
66
+ # label, whether required, options, value, and for a question that opens follow-ups, the
67
+ # option that opens them.
68
+ _READ = """html => {
69
+ const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, "text/html");
70
+ const root = doc.getElementById("root");
71
+ const clean = s => (s || "").replace(/\\s+/g, " ").trim();
72
+ const bare = s => clean(s).replace(/[*:]+$/, "").trim();
73
+ const heading = el => {
74
+ const item = el.closest("li.list-group-item");
75
+ let prev = item && item.previousElementSibling;
76
+ return prev ? clean(prev.querySelector("h2, h4")?.textContent) : "";
77
+ };
78
+ const labelOf = el => {
79
+ const byFor = el.id && root.querySelector(`label[for="${CSS.escape(el.id)}"]`);
80
+ if (byFor && bare(byFor.textContent)) return bare(byFor.textContent);
81
+ if (el.dataset.questionTitle) return clean(el.dataset.questionTitle);
82
+ const legend = el.closest("fieldset")?.querySelector("legend");
83
+ if (legend) return clean(legend.textContent);
84
+ return heading(el) || el.name || el.id;
85
+ };
86
+ const placeholder = o => !o.value || /^[.\\-\\s]*$/.test(o.textContent) ||
87
+ /^--/.test(clean(o.textContent));
88
+ const out = [], seen = new Set();
89
+ if (root.querySelector("canvas.pad")) {
90
+ out.push({key: "signature", kind: "signature", label: "Signature", required: true,
91
+ options: [], value: "", disabled: false});
92
+ }
93
+ for (const el of root.querySelectorAll("input, select, textarea")) {
94
+ const type = (el.type || el.tagName).toLowerCase();
95
+ if (["hidden", "submit", "button"].includes(type)) continue;
96
+ if (el.closest("#faq_bar_form, #text_resume, #verify_contact_info_popup")) continue;
97
+ if (el.name === "email_pls") continue;
98
+ const group = ["radio", "checkbox"].includes(type) &&
99
+ root.querySelectorAll(`input[type=${type}][name="${CSS.escape(el.name)}"]`).length > 1;
100
+ const key = group || type === "file" ? el.name : el.id || el.name;
101
+ if (!key || seen.has(key)) continue;
102
+ seen.add(key);
103
+ const field = {key, kind: type, label: labelOf(el), required: el.classList.contains("required"),
104
+ options: [], value: el.value || "", disabled: el.disabled,
105
+ maxLength: Number(el.dataset.maxlength) || null};
106
+ if (el.tagName === "SELECT") {
107
+ field.kind = "select";
108
+ const options = [...el.options];
109
+ field.options = options.filter(o => !placeholder(o)).map(o => clean(o.textContent));
110
+ field.values = options.filter(o => !placeholder(o)).map(o => o.value);
111
+ const picked = options.find(o => o.hasAttribute("selected"));
112
+ field.value = picked && !placeholder(picked) ? clean(picked.textContent) : "";
113
+ field.picked = picked?.value || "";
114
+ field.saved = root.querySelector(`input[type=hidden][name="${CSS.escape(el.name)}"]`)
115
+ ?.value || "";
116
+ if (el.classList.contains("conditional-answer-select")) {
117
+ const at = Number(el.dataset.conditional_answer);
118
+ field.opens = {option: clean(options[at]?.textContent), question: el.dataset.id,
119
+ group: el.dataset.group_id};
120
+ }
121
+ } else if (el.tagName === "TEXTAREA") {
122
+ field.kind = "text";
123
+ field.value = el.textContent;
124
+ } else if (group) {
125
+ const all = [...root.querySelectorAll(`input[type=${type}][name="${CSS.escape(el.name)}"]`)];
126
+ field.kind = type === "radio" ? "radio" : "checkboxes";
127
+ field.options = all.map(r => clean(r.closest("label")?.textContent || r.value));
128
+ field.value = all.filter(r => r.hasAttribute("checked"))
129
+ .map(r => clean(r.closest("label")?.textContent || r.value));
130
+ field.required = all.some(r => r.classList.contains("required"));
131
+ if (type === "radio") field.value = field.value[0] || "";
132
+ } else if (type === "checkbox") {
133
+ field.label = clean(el.closest("label")?.textContent) || field.label;
134
+ field.value = el.hasAttribute("checked") ? "Yes" : "No";
135
+ } else if (type === "file") {
136
+ const files = [...root.querySelectorAll(".uploaded-resumes-container .file p")];
137
+ field.label = "Resume";
138
+ field.value = clean(files[files.length - 1]?.textContent);
139
+ } else field.kind = "text";
140
+ out.push(field);
141
+ }
142
+ const app = root.querySelector("input[name=applications_id]")?.value;
143
+ return {fields: out, app};
144
+ }"""
145
+
146
+ # The page's own request for a step, a follow-up question or a source list.
147
+ _VIEW = """async ([f, params]) => {
148
+ const token = document.querySelector("input[name=csrf_token]")?.value || "";
149
+ const query = new URLSearchParams({n: "apply", f, csrf_token: token, ...params});
150
+ const response = await fetch(`/apply/view.php?${query}`, {credentials: "include"});
151
+ return response.ok ? await response.text() : null;
152
+ }"""
153
+
154
+ _OPTIONS = """html => {
155
+ const doc = new DOMParser().parseFromString(`<select>${html}</select>`, "text/html");
156
+ return [...doc.querySelectorAll("option")].filter(o => o.value)
157
+ .map(o => [o.value, o.textContent.replace(/\\s+/g, " ").trim()]);
158
+ }"""
159
+
160
+
161
+ def _job(url: str) -> tuple[str, str] | None:
162
+ """The employer's host and the job's id."""
163
+ parts = urlsplit(url)
164
+ host = (parts.hostname or "").lower()
165
+ match = JOB.match(parts.path)
166
+ if not HOST.match(host) or not match:
167
+ return None
168
+ return host, match[1] or match[2]
169
+
170
+
171
+ class IsolvedHire:
172
+ name = "isolvedhire"
173
+ per_employer = False
174
+ asks = {"name", "phone", "street", "city", "state", "zip", "country"}
175
+ known_gaps = {
176
+ "sign_up_otp": Gap.FLOW, # why the sign-up goes through the sign-in, not Apply now
177
+ "entry_list": Gap.RENDERING,
178
+ "drawn_signature": Gap.RENDERING,
179
+ }
180
+ side_effects = [
181
+ SideEffect("sign_up", "hiring_system", "login --create creates the job profile"),
182
+ SideEffect("load", "employer", "opening the form starts an application, unfinished"),
183
+ SideEffect("fill", "employer", "each step's answers are saved to the application"),
184
+ SideEffect("upload", "employer", "a resume is attached to the application on upload"),
185
+ SideEffect("submit", "employer", "the application reaches the employer"),
186
+ ]
187
+
188
+ def __init__(self) -> None:
189
+ self._host: str | None = None # the last posting's employer site
190
+ self._job: str | None = None
191
+ self._steps: list[str] = []
192
+ self._fields: dict[str, Field] = {}
193
+ self._raws: dict[str, dict[str, Any]] = {} # by field id, as the read found it
194
+
195
+ def detect(self, url: str, page: Page | None) -> bool:
196
+ return _job(url) is not None
197
+
198
+ def posting(self, url: str) -> str:
199
+ found = _job(url)
200
+ return f"https://{found[0]}/jobs/{found[1]}" if found else url
201
+
202
+ def account_scope(self, url: str) -> None:
203
+ if found := _job(url):
204
+ self._host, self._job = found
205
+ return None
206
+
207
+ def login_url(self, scope: str | None) -> str:
208
+ """The sign-in on the last posting's employer site: the system has none of its own."""
209
+ if self._host is None:
210
+ raise ValueError("isolvedhire signs in on an employer's site: pass --url <posting>")
211
+ return f"https://{self._host}{LOGIN}"
212
+
213
+ def signed_in(self, request: Request, scope: str | None) -> bool:
214
+ """The last posting's employer site shows the account's Logout link."""
215
+ if self._host is None:
216
+ return False
217
+ response = request.get(f"https://{self._host}{SIGN_UP}")
218
+ return response.ok and f'href="{LOGOUT}"' in response.text()
219
+
220
+ def sign_in(self, page: Page, email: str, password: str) -> None:
221
+ page.locator("#login #email").wait_for(timeout=PAGE_TIMEOUT_MS)
222
+ page.locator("#login #email").fill(email)
223
+ page.locator("#login #password").fill(password)
224
+ page.locator("#login [type=submit]").click()
225
+ _signed(page)
226
+
227
+ def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
228
+ """Creates the job profile from the sign-in's "Need an account?", signed in once
229
+ submitted: the name, the phone as a cell phone, the address, the country by its English
230
+ name and the state as the country's list names it; text messages declined, the privacy
231
+ policy agreed to."""
232
+ page.locator(f"a[href='{SIGN_UP}']").first.click()
233
+ form = page.locator("form#apply")
234
+ form.wait_for(timeout=PAGE_TIMEOUT_MS)
235
+ first, _, last = (details.name or "").strip().rpartition(" ")
236
+ country = page.evaluate(
237
+ "code => new Intl.DisplayNames(['en'], {type: 'region'}).of(code)", details.country
238
+ )
239
+ if country not in _texts(form.locator("#country_id option")):
240
+ raise Refused(f"sign_up_details: no country {country} in the sign-up's list")
241
+ form.locator("#country_id").select_option(label=country)
242
+ named = re.compile(rf"^\s*{re.escape(details.state or '')}\s*$", re.I)
243
+ state = form.locator("#state_id option", has_text=named)
244
+ try:
245
+ state.first.wait_for(state="attached", timeout=LIST_TIMEOUT_MS)
246
+ except Timeout:
247
+ detail = f"no state {details.state} in {country}'s list"
248
+ raise Refused(f"sign_up_details: {detail}") from None
249
+ phone = details.phone or ""
250
+ if details.country in NANP_COUNTRIES:
251
+ phone = phone.removeprefix(NANP)
252
+ for field, value in [
253
+ ("first_name", first or last), ("last_name", last), ("email", email),
254
+ ("email_confirm", email), ("contact_number", phone),
255
+ ("street1", details.street), ("city", details.city), ("zip_code", details.zip),
256
+ ("password", password), ("password_confirm", password),
257
+ ]: # fmt: skip
258
+ form.locator(f"#{field}").fill(value or "")
259
+ form.locator("#state_id").select_option(state.first.get_attribute("value"))
260
+ form.locator("#contact_number_type").select_option(MOBILE)
261
+ form.locator("#applicant_consent_approval").select_option(NO_TEXTS)
262
+ form.locator("#info_use_consent").check()
263
+ form.locator("#submit_apply").click()
264
+ _signed(page)
265
+
266
+ def browser_read(self, page: Page) -> list[Field]:
267
+ """Opens the wizard, then lists each step's fields as `/apply/view.php` serves it."""
268
+ self._open(page)
269
+ self._steps = [
270
+ s for s in page.locator("#steps a.step").evaluate_all(
271
+ "as => as.map(a => a.dataset.file)") if s not in SKIPPED
272
+ ] # fmt: skip
273
+ fields: list[Field] = []
274
+ for step in self._steps:
275
+ fields += self._step_fields(page, step)
276
+ self._fields = {f.id: f for f in fields}
277
+ return fields
278
+
279
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
280
+ """Confirms the account's contact details, then walks the steps in order: each answered
281
+ and saved with Proceed to Next Step, which goes no further while one is invalid."""
282
+ _confirm_contact(page)
283
+ for step in self._steps:
284
+ _go(page, step)
285
+ for field_id, field in self._fields.items():
286
+ if field_id.startswith(f"{step}.") and field_id in answers:
287
+ self._answer(page, field, answers[field_id])
288
+ _settle(page)
289
+ _next(page, step)
290
+
291
+ def read_back(self, page: Page) -> dict[str, Answer]:
292
+ """Each step as the system saved it, fetched again."""
293
+ values: dict[str, Answer] = {}
294
+ for step in self._steps:
295
+ if step in ENTRY_LISTS:
296
+ continue
297
+ raws, app = _read_step(page, step)
298
+ for raw in list(raws):
299
+ raws += self._children(page, raw, app, 1, values_only=True)
300
+ for raw in raws:
301
+ field_id = f"{step}.{raw['key']}"
302
+ if field_id in self._fields and raw["value"] not in ("", [], None):
303
+ values[field_id] = raw["value"]
304
+ return values
305
+
306
+ def submit(self, page: Page) -> str | None:
307
+ """Submit Application, on the last step once every required one is complete; the page
308
+ moves to the thanks page, the evidence."""
309
+ _go(page, "submit")
310
+ button = page.locator("#landingStrip #next")
311
+ if not button.count():
312
+ text = " ".join(page.locator("#landingStrip").inner_text().split())
313
+ raise RuntimeError(f"the Submit Application step is not ready: {text}")
314
+ button.click()
315
+ page.wait_for_url(f"**{THANKS}*", timeout=SUBMIT_TIMEOUT_MS)
316
+ return page.url
317
+
318
+ def _open(self, page: Page) -> None:
319
+ found = _job(page.url) or ((self._host, self._job) if self._host and self._job else None)
320
+ if found is None:
321
+ raise Unsupported("unhandled_rendering: not an isolved Hire posting")
322
+ self._host, self._job = found
323
+ page.goto(f"https://{self._host}/apply/{self._job}-0.php")
324
+ if urlsplit(page.url).path == LOGIN:
325
+ raise Blocked("login_wall: the application redirects to the sign-in")
326
+ try:
327
+ page.locator("#steps a.step").first.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
328
+ except Timeout:
329
+ text = " ".join(page.locator("body").inner_text().split())[:300]
330
+ raise Unsupported(f"posting_gone: no application wizard: {text}") from None
331
+
332
+ def _step_fields(self, page: Page, step: str) -> list[Field]:
333
+ if step in ENTRY_LISTS:
334
+ reason = known_gap(self, "entry_list", f"the {step} step adds entries one by one")
335
+ return [Field(f"{step}.entries", step.title(), FieldType.TEXT, True,
336
+ unfillable=reason)] # fmt: skip
337
+ raws, app = _read_step(page, step)
338
+ fields = []
339
+ for raw in raws:
340
+ fields.append(self._field(page, step, raw))
341
+ for child, condition in self._children(page, raw, app, 1):
342
+ fields.append(self._field(page, step, child, condition))
343
+ return fields
344
+
345
+ def _children(
346
+ self,
347
+ page: Page,
348
+ raw: dict[str, Any],
349
+ app: str | None,
350
+ depth: int,
351
+ values_only: bool = False,
352
+ ) -> list[Any]:
353
+ """The follow-up questions a question opens, fetched as the page fetches them, each with
354
+ the condition that shows it; only the raws, for the read-back."""
355
+ opens = raw.get("opens")
356
+ if not opens or depth > MAX_DEPTH or (values_only and raw["value"] != opens["option"]):
357
+ return []
358
+ params = {"parent_question_id": opens["question"], "group_id": opens["group"],
359
+ "app_id": app or ""} # fmt: skip
360
+ html = page.evaluate(_VIEW, ["buildAdditionalQuestions", params]) or ""
361
+ found = []
362
+ for child in page.evaluate(_READ, html)["fields"]:
363
+ found.append(child if values_only else (child, Is(raw["key"], [opens["option"]])))
364
+ found += self._children(page, child, app, depth + 1, values_only)
365
+ return found
366
+
367
+ def _field(self, page: Page, step: str, raw: dict[str, Any], shown: Is | None = None) -> Field:
368
+ field_id, label, required = f"{step}.{raw['key']}", raw["label"], raw["required"]
369
+ if shown is not None:
370
+ shown = Is(f"{step}.{shown.field_id}", shown.values)
371
+ self._raws[field_id] = raw
372
+ locked = raw["value"] if raw["disabled"] and raw["value"] else None # a saved source
373
+ match raw["kind"]:
374
+ case "signature":
375
+ reason = known_gap(self, "drawn_signature", "the step asks a drawn signature")
376
+ return Field(field_id, label, FieldType.TEXT, True, unfillable=reason)
377
+ case "select" if raw["key"] == "source_id":
378
+ return Field(field_id, label, FieldType.CHOICE, required, [],
379
+ option_sets=_sources(page, step), prefilled=locked) # fmt: skip
380
+ case "select" | "radio":
381
+ return Field(field_id, label, FieldType.CHOICE, required, raw["options"],
382
+ prefilled=locked, default=raw["value"] or None,
383
+ shown_when=shown) # fmt: skip
384
+ case "checkboxes":
385
+ return Field(field_id, label, FieldType.MULTI_CHOICE, required, raw["options"],
386
+ shown_when=shown) # fmt: skip
387
+ case "checkbox":
388
+ return Field(field_id, label, FieldType.CHOICE, required, YES_NO, default="No",
389
+ shown_when=shown) # fmt: skip
390
+ case "file":
391
+ return Field(field_id, label, FieldType.FILE, required, accept=FILES,
392
+ prefilled=raw["value"] or None) # fmt: skip
393
+ case "text" if raw["key"] == "source_other":
394
+ return Field(field_id, label, FieldType.TEXT, True, None, raw["maxLength"],
395
+ prefilled=locked, shown_when=self._other_source(step)) # fmt: skip
396
+ case "text":
397
+ required = required and not raw["disabled"]
398
+ return Field(field_id, label, FieldType.TEXT, required, None, raw["maxLength"],
399
+ shown_when=shown) # fmt: skip
400
+ reason = f"unhandled_rendering: {field_id} is a {raw['kind']} input"
401
+ return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
402
+
403
+ def _other_source(self, step: str) -> Condition:
404
+ """Where the source asks to describe it: Other, or a referral (its type's value 4)."""
405
+ other: Condition = Is(f"{step}.source_id", ["Other"])
406
+ types = self._raws.get(f"{step}.source_type_id", {})
407
+ referral = dict(zip(types.get("values", []), types.get("options", []), strict=False))
408
+ if REFERRAL in referral:
409
+ return AnyOf([other, Is(f"{step}.source_type_id", [referral[REFERRAL]])])
410
+ return other
411
+
412
+ def _answer(self, page: Page, field: Field, answer: Answer) -> None:
413
+ raw = self._raws[field.id]
414
+ key = raw["key"]
415
+ strip = page.locator("#landingStrip")
416
+ values = [answer] if isinstance(answer, str) else answer
417
+ if raw["disabled"] and raw["value"]:
418
+ if answer != raw["value"]:
419
+ raise NotTaken(f"{field.id} keeps {raw['value']!r}, saved before")
420
+ return
421
+ match raw["kind"]:
422
+ case "select":
423
+ control = strip.locator(f'select[id="{key}"], select[name="{key}"]').first
424
+ if key == "source_id": # its list loads once a source type is picked
425
+ _wait_option(control, values[0])
426
+ control.select_option(_option_value(control, values[0], field.id))
427
+ if raw.get("opens"):
428
+ _follow_ups(page, raw["opens"]["question"], values[0] == raw["opens"]["option"])
429
+ case "radio" | "checkboxes":
430
+ boxes = f'input:is([type=radio], [type=checkbox])[name="{key}"]'
431
+ for box in strip.locator(boxes).all():
432
+ text = " ".join((box.locator("xpath=ancestor::label[1]").text_content()
433
+ or box.get_attribute("value") or "").split()) # fmt: skip
434
+ if raw["kind"] == "radio" and text == values[0]:
435
+ box.check()
436
+ break
437
+ if raw["kind"] == "checkboxes":
438
+ box.set_checked(text in values)
439
+ else:
440
+ if raw["kind"] == "radio":
441
+ raise NotTaken(f"no option {values[0]!r} in {field.id}")
442
+ case "checkbox":
443
+ strip.locator(f'input[type=checkbox][id="{key}"]').set_checked(values[0] == "Yes")
444
+ case "file":
445
+ _upload(page, values[0])
446
+ case _:
447
+ control = strip.locator(f'[id="{key}"], [name="{key}"]').first
448
+ if control.is_disabled():
449
+ raise NotTaken(f"{field.id} is disabled for the answers given")
450
+ control.fill(values[0])
451
+ control.blur() # a question's answer is saved on blur
452
+
453
+
454
+ def _read_step(page: Page, step: str) -> tuple[list[dict[str, Any]], str | None]:
455
+ """A step's raw fields as `/apply/view.php` serves it, and its application's id. A saved
456
+ source is locked, its source kept in a hidden input: its name is looked up in its type's
457
+ list."""
458
+ html = page.evaluate(_VIEW, [step, {}])
459
+ if html is None:
460
+ raise RuntimeError(f"the {step} step did not load")
461
+ read = page.evaluate(_READ, html)
462
+ raws = read["fields"]
463
+ by_key = {r["key"]: r for r in raws}
464
+ source, kind = by_key.get("source_id"), by_key.get("source_type_id")
465
+ if source and kind and source.get("saved"):
466
+ if source["saved"] == OTHER_SOURCE:
467
+ source["value"] = "Other"
468
+ else:
469
+ params = {"source_type_id": kind["picked"], "selected_id": "", "hide_internal": "1"}
470
+ listed = page.evaluate(_OPTIONS, page.evaluate(_VIEW, ["getSources", params]) or "")
471
+ source["value"] = dict(listed).get(source["saved"], "")
472
+ return raws, read["app"]
473
+
474
+
475
+ def _texts(options: Locator) -> list[str]:
476
+ return [" ".join(t.split()) for t in options.all_text_contents()]
477
+
478
+
479
+ def _option_value(select: Locator, text: str, field_id: str) -> str:
480
+ for option in select.locator("option").all():
481
+ if " ".join((option.text_content() or "").split()) == text:
482
+ return option.get_attribute("value") or ""
483
+ raise NotTaken(f"no option {text!r} in {field_id}")
484
+
485
+
486
+ def _sources(page: Page, step: str) -> list[OptionSet]:
487
+ """The sources of each source type, fetched as the page fetches them once one is picked;
488
+ Other ends every list."""
489
+ html = page.evaluate(_VIEW, [step, {}]) or ""
490
+ types = page.evaluate(
491
+ """html => [...new DOMParser().parseFromString(html, "text/html")
492
+ .querySelectorAll("#source_type_id option")].filter(o => o.value)
493
+ .map(o => [o.value, o.textContent.replace(/\\s+/g, " ").trim()])""",
494
+ html,
495
+ )
496
+ sets = []
497
+ for value, text in types:
498
+ params = {"source_type_id": value, "selected_id": "", "hide_internal": "1"}
499
+ listed = page.evaluate(_OPTIONS, page.evaluate(_VIEW, ["getSources", params]) or "")
500
+ options = [name for _, name in listed]
501
+ sets.append(OptionSet(Is(f"{step}.source_type_id", [text]), [*options, "Other"]))
502
+ return sets
503
+
504
+
505
+ def _wait_option(select: Locator, text: str) -> None:
506
+ with suppress(Timeout):
507
+ select.locator("option", has_text=text).first.wait_for(
508
+ state="attached", timeout=LIST_TIMEOUT_MS
509
+ )
510
+
511
+
512
+ def _follow_ups(page: Page, question: str, opened: bool) -> None:
513
+ """Waits for the follow-up questions an answer opens to load, or for them to go."""
514
+ div = page.locator(f"#conditional_questions_div_{question}")
515
+ with suppress(Timeout):
516
+ if opened:
517
+ div.locator("input, select, textarea").first.wait_for(timeout=LIST_TIMEOUT_MS)
518
+ else:
519
+ div.locator("input, select, textarea").first.wait_for(
520
+ state="detached", timeout=LIST_TIMEOUT_MS
521
+ )
522
+
523
+
524
+ def _confirm_contact(page: Page) -> None:
525
+ """The wizard opens on the account's contact details, asking on each employer's site to
526
+ agree to the privacy policy again: saved as they are, text messages declined where no
527
+ choice is saved yet."""
528
+ popup = page.locator("#verify_contact_info_popup")
529
+ with suppress(Timeout): # shown once faded in
530
+ page.locator("#verify_contact_info_popup.in").wait_for(timeout=SETTLE_TIMEOUT_MS)
531
+ if not popup.is_visible():
532
+ return
533
+ consent = popup.locator("#applicant_consent_approval")
534
+ if consent.count() and not consent.input_value():
535
+ consent.select_option(NO_TEXTS)
536
+ popup.locator("#info_use_consent").check()
537
+ popup.locator("#verify_contact_info_button").click()
538
+ try:
539
+ popup.wait_for(state="hidden", timeout=STEP_TIMEOUT_MS)
540
+ except Timeout:
541
+ said = " ".join(" ".join(popup.locator("[id^=message_]:visible").all_inner_texts()).split())
542
+ raise NotTaken(f"the contact details were not saved: {said or 'no error shown'}") from None
543
+
544
+
545
+ def _current(page: Page) -> str | None:
546
+ return page.locator("#steps a.step.current").first.get_attribute("data-file")
547
+
548
+
549
+ def _go(page: Page, step: str) -> None:
550
+ """Opens a step from the steps list, once it has loaded."""
551
+ if _current(page) != step:
552
+ page.locator(f"#steps a.step[data-file='{step}']").click()
553
+ page.locator(f"#steps a.step.current[data-file='{step}']").wait_for(
554
+ state="attached", timeout=STEP_TIMEOUT_MS
555
+ )
556
+ page.locator("#landingStrip #next, #landingStrip #previous").first.wait_for(
557
+ timeout=STEP_TIMEOUT_MS
558
+ )
559
+
560
+
561
+ def _next(page: Page, step: str) -> None:
562
+ """Proceed to Next Step: the step is saved once the next one is current, and stays with its
563
+ errors shown while an answer is missing or invalid."""
564
+ page.locator("#landingStrip #next").click()
565
+ deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
566
+ errors = page.locator(ERRORS)
567
+ while time.monotonic() < deadline:
568
+ if _current(page) != step:
569
+ page.locator("#landingStrip #previous").first.wait_for(timeout=STEP_TIMEOUT_MS)
570
+ return
571
+ if errors.count():
572
+ said = " ".join(" ".join(errors.all_inner_texts()).split())
573
+ raise NotTaken(f"the {step} step was not saved: {said}")
574
+ page.wait_for_timeout(POLL_MS)
575
+ raise RuntimeError(f"Proceed to Next Step on the {step} step went nowhere")
576
+
577
+
578
+ def _settle(page: Page) -> None:
579
+ """Lets the answers saved as they were entered reach the system."""
580
+ with suppress(Timeout):
581
+ page.wait_for_load_state("networkidle", timeout=SETTLE_TIMEOUT_MS)
582
+
583
+
584
+ def _upload(page: Page, path: str) -> None:
585
+ """Chooses the file, then Upload Files, which the step shows once a file is chosen; the step
586
+ loads again listing the file."""
587
+ name = path.rsplit("/", 1)[-1]
588
+ listed = page.locator(".uploaded-resumes-container .file p", has_text=name)
589
+ before = listed.count()
590
+ page.locator("#default_resume").set_input_files(path)
591
+ page.locator("#new_upload_button").click(timeout=STEP_TIMEOUT_MS)
592
+ deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
593
+ while time.monotonic() < deadline:
594
+ with suppress(Exception):
595
+ if listed.count() > before:
596
+ return
597
+ page.wait_for_timeout(POLL_MS)
598
+ raise RuntimeError(f"the upload of {name} was not listed")
599
+
600
+
601
+ def _signed(page: Page) -> None:
602
+ """Waits for the page to show the account signed in, or for the error the sign-in shows."""
603
+ deadline = time.monotonic() + ACCOUNT_TIMEOUT_S
604
+ while time.monotonic() < deadline:
605
+ if parse_qs(urlsplit(page.url).query).get("error"):
606
+ raise Refused(f"wrong_password: {_said(page)}")
607
+ if page.locator(SIGNED_IN).count():
608
+ return
609
+ page.wait_for_timeout(POLL_MS)
610
+ raise Unsupported(f"unhandled_rendering: the account page says {_said(page)!r}")
611
+
612
+
613
+ def _said(page: Page) -> str:
614
+ return " ".join(page.locator("body").inner_text().split())[:500]
@@ -327,10 +327,11 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
327
327
  [isolvedhire]
328
328
  kind = "ats"
329
329
  share = 0.0047
330
- account = "to_read" # one account for the whole system: Apply now verifies the email or phone with a one-time code, the sign-in's sign-up sets a password; the application wizard (/apply/<job>-0.php) redirects to the sign-in until then
330
+ account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with a one-time code, the sign-in's sign-up sets a password instead
331
331
  hosting = ["hosted"]
332
+ adapter = "isolvedhire"
332
333
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
333
- unsupported = { date = 2026-10-26, version = "0.0.3", commit = "e48c78a", by = "agent", reason = "unsupported_flow: sign_up_otp: the posting page's Apply sends a one-time code to the email or phone; sign_up_fields: the sign-up with a password asks a phone number and a postal address, which login does not take (15 of 15 employers)" }
334
+ dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "867de7d" }
334
335
 
335
336
  [jobstreet]
336
337
  kind = "board"
File without changes