atsfill 0.0.4.dev132__tar.gz → 0.0.4.dev134__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.dev132 → atsfill-0.0.4.dev134}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/README.md +1 -1
  3. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/successfactors.js +3 -0
  6. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/successfactors.py +67 -14
  7. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/ukg.py +82 -18
  8. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/accounts.py +9 -4
  9. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/adapter.py +11 -0
  10. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/commands.py +18 -6
  11. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/registry.toml +1 -1
  12. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/LICENSE +0 -0
  13. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/__init__.py +0 -0
  14. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/__init__.py +0 -0
  15. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/join.py +0 -0
  28. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/phenom.py +0 -0
  35. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/pinpoint.py +0 -0
  36. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/rippling.py +0 -0
  38. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/smartrecruiters.py +0 -0
  39. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/taleo.py +0 -0
  40. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/teamtailor.py +0 -0
  41. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/workable.py +0 -0
  42. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/workday.py +0 -0
  43. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/wttj.py +0 -0
  44. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/zohorecruit.py +0 -0
  45. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/cli.py +0 -0
  46. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/__init__.py +0 -0
  47. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/attempts.py +0 -0
  48. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/browser.py +0 -0
  49. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/conditions.py +0 -0
  50. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/html.py +0 -0
  51. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/matching.py +0 -0
  52. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/passwords.py +0 -0
  53. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/paths.py +0 -0
  54. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/registry.py +0 -0
  55. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/results.py +0 -0
  56. {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/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.dev132
3
+ Version: 0.0.4.dev134
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) ![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)
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) ![successfactors: dry run verified 2026-10-26](https://img.shields.io/badge/successfactors-dry%20run%20verified%202026--10--26-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) ![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) ![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)
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) ![successfactors: dry run verified 2026-10-26](https://img.shields.io/badge/successfactors-dry%20run%20verified%202026--10--26-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) ![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.dev132"
3
+ version = "0.0.4.dev134"
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.dev132"
3
+ version = "0.0.4.dev134"
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"
@@ -26,6 +26,9 @@
26
26
  d.kind = 'picklist';
27
27
  d.id = picklist.id;
28
28
  d.owns = picklist.getAttribute('aria-owns');
29
+ d.placeholder = picklist.placeholder; // its empty item's text, in the page's language
30
+ const opener = box.querySelector('button.rcmpaginatedselectbutton');
31
+ d.disabled = !!opener && opener.disabled; // a cascading list before its parent is chosen
29
32
  d.value = picklist.value === picklist.placeholder ? '' : picklist.value;
30
33
  d.required = d.required || picklist.getAttribute('aria-required') === 'true';
31
34
  } else if (radios.length) {
@@ -11,7 +11,7 @@ import re
11
11
  import time
12
12
  from pathlib import Path
13
13
  from typing import Any
14
- from urllib.parse import urlsplit
14
+ from urllib.parse import parse_qs, urlsplit
15
15
 
16
16
  from atsfill.core.adapter import (
17
17
  Blocked,
@@ -22,7 +22,7 @@ from atsfill.core.adapter import (
22
22
  Unsupported,
23
23
  known_gap,
24
24
  )
25
- from atsfill.core.browser import Locator, Page, Request, Timeout
25
+ from atsfill.core.browser import Locator, Page, PageRequest, Request, Timeout
26
26
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
27
27
 
28
28
  JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
@@ -30,13 +30,19 @@ DC_HOST = re.compile(r"^career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)$")
30
30
  APPLY = "a[href*='/talentcommunity/apply/']"
31
31
  COMPANY_COOKIE = "careerSiteCompanyId"
32
32
  PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
33
- SIGNED_IN = re.compile(r"\bsignOut|\blogout\b|_logout", re.IGNORECASE)
33
+ JOB_REQ = "career_job_req_id" # the job the apply link posts, the candidate pages' own id
34
+ APPLICATION = "/career?company={company}&career_ns=job_application&career_job_req_id={req}"
35
+ GUIDED = "body.cxApplyEnabled" # a site whose application is a step-by-step one, not sections
36
+ HOME = "body.careerUserLoggedIn" # any candidate page signed in, the candidate home's too
37
+ # The body's class for an account; a signed-out page also holds a log-out link and its label
38
+ SIGNED_IN = re.compile(r"<body[^>]*\bclass=[\"'][^\"']*\bcareerUserLoggedIn\b")
34
39
  FIELDS = (Path(__file__).with_suffix(".js")).read_text()
35
40
  # What the candidate pages show once loaded
36
41
  SIGN_IN = "#username"
37
42
  COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
38
43
  PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
39
44
  SIGN_UP = "#fbclc_userName"
45
+ PASSCODE = "#passcode" # an OTP some sign-ups ask before the account exists
40
46
  QUICK_APPLY = "#fbqa_apply"
41
47
  FORM = ".RCMFormField"
42
48
  LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
@@ -47,6 +53,8 @@ SIGN_IN_MODAL = "a[onclick*=openSignInModal]" # a Quick Apply page's
47
53
  REFUSAL = re.compile(r"required|exist|already|invalid|bereits|erforderlich|ungültig", re.I)
48
54
  ACTIVATE = re.compile(r"activat|verify your|verification e-?mail|bestätig", re.I)
49
55
  PRIVACY = "#dataPrivacyId"
56
+ VISIBILITY = "input[name=fbclc_searPref]" # who may see the profile
57
+ APPLIED_ONLY = "2" # its option for only the recruiters of the jobs applied to
50
58
  EXPAND = "[id$=_expandAllSections]"
51
59
  SUBMIT = "[id$=_submitBtn]"
52
60
  OVERLAY = "#sfOverlayMgr" # a confirmation dialog
@@ -88,6 +96,15 @@ def _company(request: Request) -> list[tuple[str, str]]:
88
96
  )
89
97
 
90
98
 
99
+ def _posts_job(request: PageRequest) -> bool:
100
+ """Whether the apply link's request posts a job to the candidate pages."""
101
+ return (
102
+ request.method == "POST"
103
+ and bool(DC_HOST.match(urlsplit(request.url).hostname or ""))
104
+ and JOB_REQ in (request.post_data or "")
105
+ )
106
+
107
+
91
108
  def _calling_code(select: Locator, details: SignUpDetails) -> tuple[str, str]:
92
109
  """The calling code's option for the E.164 phone, the one of `country` among the countries
93
110
  sharing it, and the national number that follows the code."""
@@ -108,7 +125,10 @@ class SuccessFactors:
108
125
  name = "successfactors"
109
126
  per_employer = True
110
127
  asks = {"name"}
111
- known_gaps = {"quick_apply_sign_up": Gap.FLOW, "sign_up_fields": Gap.FLOW}
128
+ known_gaps = {
129
+ "quick_apply_sign_up": Gap.FLOW,
130
+ "guided_application": Gap.FLOW,
131
+ }
112
132
  side_effects = [
113
133
  SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
114
134
  SideEffect("fill", "hiring_system", "an attachment is uploaded to the account's profile"),
@@ -140,7 +160,7 @@ class SuccessFactors:
140
160
  return self._postings.get(scope or "", f"https://{scope}/")
141
161
 
142
162
  def signed_in(self, request: Request, scope: str | None) -> bool:
143
- """The profile page of the session's company shows a sign-out for an account."""
163
+ """The profile page of the session's company marks its body for an account."""
144
164
  for host, company in _company(request):
145
165
  response = request.get(f"https://{host}{PROFILE.format(company=company)}")
146
166
  if response.ok and SIGNED_IN.search(response.text()):
@@ -165,8 +185,9 @@ class SuccessFactors:
165
185
 
166
186
  def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
167
187
  """Creates the account from the career site's sign-up: the name, the country of
168
- residence from `country` (the list's first without it), and the phone where the site
169
- requires one, its calling code picked from the number; a missing phone is refused
188
+ residence from `country` (the list's first without it), the phone where the site
189
+ requires one, its calling code picked from the number, and the profile visible only to
190
+ the recruiters of the jobs applied to where the site asks; a missing phone is refused
170
191
  before anything is sent."""
171
192
  self._candidate(page)
172
193
  if page.locator(SIGN_IN).count():
@@ -206,6 +227,11 @@ class SuccessFactors:
206
227
  if wanted and wanted not in values:
207
228
  raise Unsupported(f"unhandled_rendering: the sign-up's countries lack {wanted}")
208
229
  select.select_option(wanted or values[0])
230
+ if page.locator(f"{VISIBILITY}:visible").count():
231
+ least = page.locator(f"{VISIBILITY}[value='{APPLIED_ONLY}']")
232
+ if not least.count():
233
+ raise Unsupported("unhandled_rendering: the profile's visibility has no jobs only")
234
+ least.check()
209
235
  if page.locator(PRIVACY).count():
210
236
  page.locator(PRIVACY).click()
211
237
  dialog = page.locator("[role=dialog]:visible").last
@@ -216,6 +242,8 @@ class SuccessFactors:
216
242
  page.wait_for_timeout(POLL_MS)
217
243
  if page.locator(FORM).count():
218
244
  return
245
+ if page.locator(f"{PASSCODE}:visible").count():
246
+ raise Blocked("email_otp: the sign-up sent a passcode to the email")
219
247
  if not page.locator(f"{SIGN_UP}:visible").count():
220
248
  break
221
249
  if self._message(page, REFUSAL):
@@ -226,7 +254,7 @@ class SuccessFactors:
226
254
  if re.search(r"password|kennwort", message, re.IGNORECASE):
227
255
  raise Refused(f"password_rule: {message}")
228
256
  if message:
229
- raise Unsupported(known_gap(self, "sign_up_fields", message))
257
+ raise Unsupported(f"unhandled_rendering: the sign-up refused: {message}")
230
258
  text = page.locator("body").inner_text()
231
259
  if ACTIVATE.search(text):
232
260
  raise Blocked("email_link: the sign-up sent an activation email")
@@ -249,8 +277,8 @@ class SuccessFactors:
249
277
  page.wait_for_timeout(SETTLE_MS)
250
278
  fields = []
251
279
  for d in page.evaluate(FIELDS):
252
- if d["kind"] == "picklist":
253
- d["options"] = self._options(page, d)
280
+ if d["kind"] == "picklist": # a disabled one waits for its parent: no items yet
281
+ d["options"] = [] if d["disabled"] else self._options(page, d)
254
282
  self._fields[d["key"]] = d
255
283
  fields.append(self._field(d))
256
284
  return fields
@@ -288,7 +316,7 @@ class SuccessFactors:
288
316
  items = self._open(page, d)
289
317
  options = [t for t in items.evaluate_all("as => as.map(a => a.title.trim())") if t]
290
318
  self._close(page, d)
291
- return [o for o in options if o != NO_SELECTION]
319
+ return [o for o in options if o not in (NO_SELECTION, d["placeholder"])]
292
320
 
293
321
  def _open(self, page: Page, d: dict[str, Any]) -> Locator:
294
322
  """Opens a picklist; a click can go unheard, or close a list another left open."""
@@ -343,6 +371,8 @@ class SuccessFactors:
343
371
  self._upload(page, d, value)
344
372
 
345
373
  def _pick(self, page: Page, d: dict[str, Any], value: str) -> None:
374
+ if d["disabled"]:
375
+ raise NotTaken(f"{d['key']} stays disabled: its parent's answer offers no items")
346
376
  items = self._open(page, d)
347
377
  titles = [t.strip() for t in items.evaluate_all("as => as.map(a => a.title)")]
348
378
  wanted = next((i for i, t in enumerate(titles) if t == value), None)
@@ -364,8 +394,8 @@ class SuccessFactors:
364
394
  confirm.first.wait_for(timeout=LIST_TIMEOUT_MS)
365
395
  confirm.first.click()
366
396
  deadline = time.monotonic() + UPLOAD_TIMEOUT_S
367
- while self._current(page, d["key"])["multiple"]:
368
- if time.monotonic() > deadline:
397
+ while (now := self._current(page, d["key"]))["multiple"] or now["id"] is None:
398
+ if time.monotonic() > deadline: # deleting, it shows neither button
369
399
  raise NotTaken(f"{d['key']} kept its file")
370
400
  page.wait_for_timeout(POLL_MS)
371
401
  source = page.locator("[role=dialog]:visible input[type=file]")
@@ -407,16 +437,39 @@ class SuccessFactors:
407
437
  return page.url
408
438
 
409
439
  def _candidate(self, page: Page) -> None:
410
- """From the posting, follows its apply link to the candidate pages."""
440
+ """From the posting, follows its apply link to the candidate pages. The link posts the
441
+ job there; signed in, some sites drop it and land on the candidate home, so the
442
+ application is opened from the job the link posted."""
411
443
  host = urlsplit(page.url).hostname or ""
444
+ posted: list[PageRequest] = []
412
445
  if not DC_HOST.match(host):
413
446
  apply = page.locator(APPLY)
414
447
  if not apply.count():
415
448
  raise Unsupported("posting_gone: the posting has no apply link")
449
+
450
+ def keep(request: PageRequest) -> None:
451
+ if _posts_job(request):
452
+ posted.append(request)
453
+
454
+ page.on("request", keep)
416
455
  apply.first.evaluate("a => a.click()") # some sites open a dialog on a real click
417
456
  try:
457
+ page.locator(f"{LANDED}, {HOME}").first.wait_for(
458
+ state="attached", timeout=LANDING_TIMEOUT_MS
459
+ )
460
+ page.wait_for_load_state(timeout=LANDING_TIMEOUT_MS)
461
+ if posted and JOB_REQ not in page.url and not page.locator(LANDED).count():
462
+ post = posted[0]
463
+ parts = urlsplit(post.url)
464
+ job = parse_qs(parts.query) | parse_qs(post.post_data or "")
465
+ application = APPLICATION.format(company=job["company"][0], req=job[JOB_REQ][0])
466
+ page.goto(f"https://{parts.hostname}{application}")
418
467
  page.locator(LANDED).first.wait_for(state="attached", timeout=LANDING_TIMEOUT_MS)
419
468
  except Timeout:
469
+ if page.locator(GUIDED).count():
470
+ raise Unsupported(
471
+ known_gap(self, "guided_application", "the application is a step-by-step one")
472
+ ) from None
420
473
  raise Unsupported(
421
474
  f"unhandled_rendering: the apply link led to {urlsplit(page.url).path}"
422
475
  ) from None
@@ -35,6 +35,8 @@ SETTLE_MS = 500
35
35
  YES_NO = ["Yes", "No"]
36
36
  DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
37
37
  COUNTRY, STATE = "Country", "State"
38
+ HISPANIC = "HispanicOrigin" # race (`EthnicOrigin`) is asked when not Hispanic or Latino
39
+ DECLINE_BOX = "input[type=checkbox][data-automation$='-decline-checkbox']"
38
40
  DAYS, DOCUMENTS = "availability_days", "documents"
39
41
 
40
42
  # Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
@@ -66,10 +68,25 @@ _READ = """root => {
66
68
  return el.closest(".form-group")?.querySelector("label");
67
69
  };
68
70
  const required = (el, label) => el.required || !!label?.classList.contains("required");
71
+ const select = (el, field) => {
72
+ field.kind = "select";
73
+ field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
74
+ field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
75
+ const box = el.closest(".form-group")?.querySelector(
76
+ "input[type=checkbox][data-automation$='-decline-checkbox']");
77
+ if (box) {
78
+ field.decline = clean(box.closest("label")?.querySelector("span:not(.sr-only)")
79
+ ?.textContent);
80
+ field.options.push(field.decline);
81
+ if (box.checked) field.value = field.decline;
82
+ }
83
+ return field;
84
+ };
69
85
  const out = [], seen = new Set();
70
86
  for (const el of root.querySelectorAll("input, select, textarea")) {
71
87
  const type = (el.type || "").toLowerCase();
72
88
  if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
89
+ if (el.matches("[data-automation$='-decline-checkbox']")) continue; // its select's option
73
90
  if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
74
91
  const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
75
92
  if (!id || seen.has(id)) continue;
@@ -79,11 +96,8 @@ _READ = """root => {
79
96
  kind: type, options: [], value: el.value,
80
97
  readonly: el.readOnly || el.disabled, maxLength: el.maxLength > 0 ?
81
98
  el.maxLength : null};
82
- if (el.tagName === "SELECT") {
83
- field.kind = "select";
84
- field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
85
- field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
86
- } else if (el.tagName === "TEXTAREA") field.kind = "text";
99
+ if (el.tagName === "SELECT") select(el, field);
100
+ else if (el.tagName === "TEXTAREA") field.kind = "text";
87
101
  else if (type === "radio") {
88
102
  const all = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)];
89
103
  field.options = all.map(r => clean(r.closest("label")?.textContent));
@@ -109,6 +123,17 @@ _READ = """root => {
109
123
  !!label.classList?.contains("required"),
110
124
  options: [], value: box.getAttribute("value") || ""});
111
125
  }
126
+ const race = root.querySelector("select#EthnicOrigin");
127
+ const hispanic = root.querySelector("select#HispanicOrigin");
128
+ if (race && hispanic && out.some(f => f.id === "HispanicOrigin")) {
129
+ const kept = out.findIndex(f => f.id === "EthnicOrigin");
130
+ if (kept >= 0) out.splice(kept, 1);
131
+ const label = labelOf(race);
132
+ out.splice(out.findIndex(f => f.id === "HispanicOrigin") + 1, 0, select(race, {
133
+ id: race.id, label: clean(label?.textContent), required: required(race, label),
134
+ readonly: false, maxLength: null,
135
+ shownWhen: clean([...hispanic.options].find(o => o.value === "No")?.textContent)}));
136
+ }
112
137
  const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
113
138
  if (days.length) {
114
139
  const label = document.getElementById("AvailabilityDaysQuestion");
@@ -151,11 +176,19 @@ _READ_BACK = """(root, kinds) => {
151
176
  .find(r => r.checked);
152
177
  out[id] = checked ? clean(checked.closest("label")?.textContent) : null;
153
178
  } else {
154
- const el = [...root.querySelectorAll(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`)]
155
- .find(shown);
179
+ const named = CSS.escape(id);
180
+ const el = [...root.querySelectorAll(
181
+ `[id="${named}"], [name="${named}"], [data-automation="${named}"]`)].find(shown);
156
182
  if (!el) continue;
157
- if (kind === "select") out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
158
- else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
183
+ const box = el.closest(".form-group")?.querySelector(
184
+ "input[type=checkbox][data-automation$='-decline-checkbox']");
185
+ if (kind === "select" && box?.checked) {
186
+ out[id] = clean(box.closest("label")?.querySelector("span:not(.sr-only)")?.textContent);
187
+ } else if (kind === "select") {
188
+ out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
189
+ } else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
190
+ // a Numeric question's autoNumeric input groups its number: 50,000
191
+ else if (el.dataset.automation === "numeric-response") out[id] = el.value.replace(/,/g, "");
159
192
  else out[id] = el.value;
160
193
  }
161
194
  }
@@ -228,6 +261,7 @@ class UKG:
228
261
  def __init__(self) -> None:
229
262
  self._boards: dict[str, Board] = {} # by tenant, the last posting's
230
263
  self._kinds: dict[str, str] = {}
264
+ self._declines: dict[str, str] = {} # a select's decline box's label, by the select
231
265
 
232
266
  def detect(self, url: str, page: Page | None) -> bool:
233
267
  return board(url) is not None
@@ -266,13 +300,18 @@ class UKG:
266
300
  ) # fmt: skip
267
301
 
268
302
  def sign_in(self, page: Page, email: str, password: str) -> None:
269
- page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
270
- page.locator("input#username").fill(email)
271
- page.locator("input#password").fill(password)
272
- page.locator("button[type=submit][name=action][data-action-button-primary]").click()
273
- _left_sign_in(page)
303
+ _sign_in(page, email, password)
274
304
  if "/AuthCode/Register" in page.url:
275
- raise Unsupported("unhandled_rendering: the tenant asks the account's name")
305
+ raise Refused(
306
+ "sign_up_details: the account was left at the tenant's details step: "
307
+ "`login --create --details` finishes it"
308
+ )
309
+
310
+ def finish_sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
311
+ """Signs in and, where the account was left without its tenant details, gives them."""
312
+ _sign_in(page, email, password)
313
+ if "/AuthCode/Register" in page.url:
314
+ _register(page, details)
276
315
 
277
316
  def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
278
317
  page.locator("a[href*='/u/signup']").first.click()
@@ -302,7 +341,9 @@ class UKG:
302
341
  raws = page.locator(FORM).evaluate(_READ)
303
342
  required_resume = '"IsResumeRequired":true' in page.content()
304
343
  states = None
305
- if page.locator(f"{FORM} select#{STATE}").count():
344
+ # with the address optional, the contact information is the account's, a read-only
345
+ # panel whose Country and State are in a closed edit dialog
346
+ if page.locator(f"{FORM} select#{STATE}").count() and any(r["id"] == COUNTRY for r in raws):
306
347
  states = page.locator(FORM).evaluate(_STATES, f"{found.base}/Candidate/GetStates")
307
348
  if not any(r["id"] == STATE for r in raws): # shown once a country is picked
308
349
  label = page.locator(f"{FORM} label[for={STATE}]").first
@@ -313,6 +354,7 @@ class UKG:
313
354
  "required": "required" in (label.get_attribute("class") or ""),
314
355
  }) # fmt: skip
315
356
  self._kinds = {raw["id"]: raw["kind"] for raw in raws}
357
+ self._declines = {raw["id"]: raw["decline"] for raw in raws if raw.get("decline")}
316
358
  fields = []
317
359
  for raw in raws:
318
360
  if raw["id"] == DOCUMENTS:
@@ -328,6 +370,11 @@ class UKG:
328
370
  values = [answer] if isinstance(answer, str) else answer
329
371
  root = page.locator(FORM)
330
372
  match kind:
373
+ case "select" if field_id in self._declines:
374
+ declined = values[0] == self._declines[field_id]
375
+ _decline_box(root, field_id).set_checked(declined)
376
+ if not declined:
377
+ _control(root, field_id).select_option(label=values[0])
331
378
  case "select":
332
379
  control = _control(root, field_id)
333
380
  control.select_option(label=values[0])
@@ -366,6 +413,14 @@ class UKG:
366
413
  return page.url
367
414
 
368
415
 
416
+ def _sign_in(page: Page, email: str, password: str) -> None:
417
+ page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
418
+ page.locator("input#username").fill(email)
419
+ page.locator("input#password").fill(password)
420
+ page.locator("button[type=submit][name=action][data-action-button-primary]").click()
421
+ _left_sign_in(page)
422
+
423
+
369
424
  def _left_sign_in(page: Page) -> None:
370
425
  """Waits to leave the sign-in host, or for the error the page shows."""
371
426
  deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
@@ -441,12 +496,13 @@ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
441
496
  return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
442
497
  unfillable=reason) # fmt: skip
443
498
  options = raw["options"] if kind in ("select", "radio") else None
499
+ shown_when = Is(HISPANIC, [raw["shownWhen"]]) if raw.get("shownWhen") else None
444
500
  if field_id == STATE and states is not None:
445
501
  sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
446
502
  return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
447
503
  return Field(
448
504
  field_id, label, KINDS[kind], raw["required"], options, raw.get("maxLength"),
449
- default=value if kind in ("date", "select") else None,
505
+ default=value if kind in ("date", "select") else None, shown_when=shown_when,
450
506
  ) # fmt: skip
451
507
 
452
508
 
@@ -459,7 +515,15 @@ def _wait_for_states(page: Page) -> None:
459
515
 
460
516
 
461
517
  def _control(root: Locator, field_id: str) -> Locator:
462
- return root.locator(f'[id="{field_id}"]:visible, [name="{field_id}"]:visible').first
518
+ named = f'[id="{field_id}"]:visible, [name="{field_id}"]:visible'
519
+ return root.locator(f'{named}, [data-automation="{field_id}"]:visible').first
520
+
521
+
522
+ def _decline_box(root: Locator, field_id: str) -> Locator:
523
+ """The "I decline to say" box under an equal-opportunity question's select: ticked, it
524
+ clears the select."""
525
+ group = "xpath=ancestor::div[contains(@class,'form-group')][1]"
526
+ return _control(root, field_id).locator(group).locator(DECLINE_BOX)
463
527
 
464
528
 
465
529
  def _option(root: Locator, name: str, text: str) -> Locator:
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from atsfill.core import browser
9
9
  from atsfill.core.adapter import (
10
10
  CreatesAccount,
11
+ FinishesSignUp,
11
12
  NeedsAccount,
12
13
  SignsIn,
13
14
  SignUpDetails,
@@ -60,15 +61,19 @@ def sign_in(
60
61
  password: str,
61
62
  session: Path,
62
63
  details: SignUpDetails | None = None,
64
+ create: bool = False,
63
65
  ) -> None:
64
- """Signs in, or creates the account from the user's sign-up `details` when given, from the
65
- system's login page and stores the session.
66
+ """Signs in from the system's login page and stores the session: with `create`, creates
67
+ the account from the user's sign-up `details`; without, `details` finish a sign-up the
68
+ account was left in, where the adapter knows that step.
66
69
  Raises what the adapter raises, without the password in it."""
67
70
  page.goto(adapter.login_url(scope))
68
71
  try:
69
- if details is not None:
70
- assert isinstance(adapter, CreatesAccount)
72
+ if create:
73
+ assert isinstance(adapter, CreatesAccount) and details is not None
71
74
  adapter.sign_up(page, address, password, details)
75
+ elif details is not None and isinstance(adapter, FinishesSignUp):
76
+ adapter.finish_sign_up(page, address, password, details)
72
77
  else:
73
78
  adapter.sign_in(page, address, password)
74
79
  except Exception as e:
@@ -183,6 +183,17 @@ class CreatesAccount(SignsIn, Protocol):
183
183
  ...
184
184
 
185
185
 
186
+ @runtime_checkable
187
+ class FinishesSignUp(CreatesAccount, Protocol):
188
+ """An adapter whose system can keep an account half made: signed up, but a step the
189
+ sign-up shows after it left unfinished, which every sign-in then shows again."""
190
+
191
+ def finish_sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
192
+ """Signs in as `sign_in` does and, where the system shows the unfinished step, gives it
193
+ `details` as `sign_up` would."""
194
+ ...
195
+
196
+
186
197
  @runtime_checkable
187
198
  class ReadsByRequest(Adapter, Protocol):
188
199
  """An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
@@ -491,7 +491,8 @@ def login(
491
491
  `email` is remembered for the account; `password` is used for this sign-in only. `create`
492
492
  creates the account with a password atsfill generates and stores first (a side effect),
493
493
  giving the sign-up the user's `details`; with a password already stored, the account may
494
- exist, so it signs in with that password and keeps the account's email instead."""
494
+ exist, so it signs in with that password and keeps the account's email instead, the
495
+ `details` finishing a sign-up the account was left in."""
495
496
  account, data = _account(adapter), paths.data_dir(data_dir)
496
497
  scope = _scope(account, url)
497
498
  mine = paths.user_dir(data, user)
@@ -513,6 +514,15 @@ def login(
513
514
  return _create(report, account, url, mine, email, details or SignUpDetails(), data)
514
515
  if kept:
515
516
  report.password = "kept"
517
+ # the details finish a sign-up the account may have been left in
518
+ if (
519
+ details
520
+ and isinstance(account, CreatesAccount)
521
+ and (missing := details.missing(account.asks))
522
+ ):
523
+ report.status = Status.REFUSED
524
+ report.reason = f"sign_up_details: no {', '.join(missing)}"
525
+ return report
516
526
  if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
517
527
  raise ValueError(f"signing in to {adapter} with a password needs --email")
518
528
  try:
@@ -526,7 +536,7 @@ def login(
526
536
  accounts.wait_for_person(account, scope, data, session),
527
537
  )
528
538
  else:
529
- result = _headless(data, account, scope, credentials, session)
539
+ result = _headless(data, account, scope, credentials, session, details)
530
540
  command = login_command(adapter, user, url)
531
541
  report.status, report.reason = (
532
542
  result.status,
@@ -563,7 +573,8 @@ def _create(
563
573
  except passwords.PasswordError as e:
564
574
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
565
575
  return report
566
- result = _headless(data, account, report.scope, (email, password), report.path, details)
576
+ credentials = (email, password)
577
+ result = _headless(data, account, report.scope, credentials, report.path, details, create=True)
567
578
  command = login_command(account.name, report.user, url)
568
579
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
569
580
  report.password = "stored"
@@ -582,16 +593,17 @@ def _headless(
582
593
  credentials: Credentials,
583
594
  session: Path,
584
595
  details: SignUpDetails | None = None,
596
+ create: bool = False,
585
597
  ) -> Result:
586
- """Signs in, or creates the account, through the adapter: `ready` once the session is
587
- stored."""
598
+ """Signs in, or creates the account with `create`, through the adapter: `ready` once the
599
+ session is stored."""
588
600
  assert isinstance(account, SignsIn)
589
601
  url = account.login_url(scope)
590
602
 
591
603
  def run(_: list[str]) -> Result:
592
604
  result = Result(Status.READY, url, account.name)
593
605
  with open_page(data, None) as page, _stops(result):
594
- accounts.sign_in(page, account, scope, *credentials, session, details)
606
+ accounts.sign_in(page, account, scope, *credentials, session, details, create)
595
607
  return result
596
608
 
597
609
  return _in_budget(url, run)
@@ -96,8 +96,8 @@ share = 0.0350
96
96
  account = "to_read"
97
97
  hosting = ["hosted"]
98
98
  adapter = "successfactors"
99
- unsupported = { date = 2026-10-26, version = "0.0.3", commit = "0d8baea", by = "agent", reason = "quick_apply_sign_up: a Quick Apply page creates the account and sends the application in one submit, its sign-in offering no sign-up; sign_up_fields: a sign-up asking who may see the profile" }
100
99
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
100
+ dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "79c8327" }
101
101
 
102
102
  [ukg]
103
103
  kind = "ats"
File without changes