atsfill 0.0.4.dev133__tar.gz → 0.0.4.dev135__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 (57) hide show
  1. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/PKG-INFO +3 -2
  2. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/README.md +2 -1
  3. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/isolvedhire.py +0 -1
  6. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/oracle.py +27 -13
  7. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/successfactors.js +3 -0
  8. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/successfactors.py +84 -15
  9. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/commands.py +18 -11
  10. atsfill-0.0.4.dev135/src/atsfill/core/otp.py +102 -0
  11. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/registry.toml +2 -2
  12. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/LICENSE +0 -0
  13. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/__init__.py +0 -0
  14. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/__init__.py +0 -0
  15. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/jazzhr.py +0 -0
  26. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/join.py +0 -0
  27. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/lever.py +0 -0
  28. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/manatal.py +0 -0
  29. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/paycor.py +0 -0
  30. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/paylocity.py +0 -0
  31. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/personio.py +0 -0
  32. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/phenom.py +0 -0
  33. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/pinpoint.py +0 -0
  34. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/recruitee.py +0 -0
  35. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/rippling.py +0 -0
  36. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/smartrecruiters.py +0 -0
  37. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/taleo.py +0 -0
  38. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/teamtailor.py +0 -0
  39. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/ukg.py +0 -0
  40. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/workable.py +0 -0
  41. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/workday.py +0 -0
  42. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/wttj.py +0 -0
  43. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/adapters/zohorecruit.py +0 -0
  44. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/cli.py +0 -0
  45. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/__init__.py +0 -0
  46. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/accounts.py +0 -0
  47. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/adapter.py +0 -0
  48. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/attempts.py +0 -0
  49. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/browser.py +0 -0
  50. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/conditions.py +0 -0
  51. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/html.py +0 -0
  52. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/matching.py +0 -0
  53. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/passwords.py +0 -0
  54. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/paths.py +0 -0
  55. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/registry.py +0 -0
  56. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/src/atsfill/core/results.py +0 -0
  57. {atsfill-0.0.4.dev133 → atsfill-0.0.4.dev135}/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.dev133
3
+ Version: 0.0.4.dev135
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 |
@@ -137,6 +137,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
137
137
 
138
138
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
139
139
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
140
+ When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: email_otp`.
140
141
  Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
141
142
 
142
143
  ## How it works
@@ -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 |
@@ -109,6 +109,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
109
109
 
110
110
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
111
111
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
112
+ When a system emails an OTP, on a sign-in, a sign-up or a form, atsfill gets it from the user's `[otp-commands]` entry, a command that reads the mailbox (`{email}` and `{since}` are filled in), and types it in; without one the command ends `blocked: email_otp`.
112
113
  Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--details <file.json>` where the sign-up asks the user's name, phone or address) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
113
114
 
114
115
  ## How it works
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev133"
3
+ version = "0.0.4.dev135"
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.dev133"
3
+ version = "0.0.4.dev135"
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"
@@ -173,7 +173,6 @@ class IsolvedHire:
173
173
  per_employer = False
174
174
  asks = {"name", "phone", "street", "city", "state", "zip", "country"}
175
175
  known_gaps = {
176
- "sign_up_otp": Gap.FLOW, # why the sign-up goes through the sign-in, not Apply now
177
176
  "entry_list": Gap.RENDERING,
178
177
  "drawn_signature": Gap.RENDERING,
179
178
  }
@@ -2,8 +2,9 @@
2
2
  as docs/adapters/oracle.md observes.
3
3
 
4
4
  The form opens only past an email step, and an email the employer's Oracle already knows gets an
5
- emailed OTP instead. Reading passes the step with a throwaway address; filling starts over with
6
- the answer's. Oracle keeps a draft of the application from then on, autosaved as fields change.
5
+ emailed OTP first, typed in by the core. Reading passes the step with a throwaway address;
6
+ filling starts over with the answer's. Oracle keeps a draft of the application from then on,
7
+ autosaved as fields change.
7
8
  """
8
9
 
9
10
  import re
@@ -15,7 +16,7 @@ from pathlib import PurePath
15
16
  from typing import Any
16
17
  from urllib.parse import urlsplit
17
18
 
18
- from atsfill.core import browser, matching
19
+ from atsfill.core import browser, matching, otp
19
20
  from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
20
21
  from atsfill.core.browser import Locator, Page
21
22
  from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
@@ -32,9 +33,12 @@ PAGINATION = ".apply-flow-pagination__button"
32
33
  PAGE_NUMBERS = ".apply-flow-navigation-pages__link:visible"
33
34
  LOADING = ".app-loading-spinner, .cx-select--loading, .apply-flow-block .skeleton"
34
35
  OTP_PAGE = ".apply-flow-pin-code-required"
36
+ PIN = f"{OTP_PAGE} .pin-code-input__input" # one per digit
37
+ VERIFY = f"{OTP_PAGE} button[type=submit]"
35
38
  CAPTCHA = "iframe[src*='hcaptcha.com']"
36
39
  MANUALLY = ".quick-apply-flow__manually--link:visible" # past an offer to import a profile
37
40
  ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-name"
41
+ REMOVE = ".attachment-upload-button__filled .attachment-upload-button__bottom-button"
38
42
  STEP_TIMEOUT_MS = 30_000
39
43
  LIST_TIMEOUT_MS = 3_000
40
44
  SEARCH_TIMEOUT_MS = 6_000 # for a typed search's results
@@ -356,21 +360,26 @@ class Oracle:
356
360
  if "--checked" not in (box.get_attribute("class") or ""):
357
361
  _click(page, box)
358
362
  _click(page, page.locator(NEXT).first)
359
- opened = page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}")
360
363
  try:
361
- opened.first.wait_for(timeout=STEP_TIMEOUT_MS)
362
- if page.locator(MANUALLY).count():
363
- _click(page, page.locator(MANUALLY).first)
364
- page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
364
+ page.locator(f"apply-flow-block, {OTP_PAGE}, {MANUALLY}").first.wait_for(
365
+ timeout=STEP_TIMEOUT_MS
366
+ )
365
367
  except browser.Timeout:
366
368
  if page.locator(CAPTCHA).count():
367
369
  raise Blocked("captcha: the email step asks for an hCaptcha") from None
368
370
  raise RuntimeError("the email step did not open the form") from None
369
- if page.locator(OTP_PAGE).count():
370
- raise Blocked(
371
- f"email_otp: {email} is known to this employer's Oracle, which emails it an"
372
- " OTP before it shows the form"
373
- )
371
+ if page.locator(OTP_PAGE).count(): # an address this employer's Oracle knows
372
+ otp.enter(page.locator(PIN), email, "the email step")
373
+ _click(page, page.locator(VERIFY))
374
+ try:
375
+ page.locator(f"apply-flow-block, {MANUALLY}").first.wait_for(
376
+ timeout=STEP_TIMEOUT_MS
377
+ )
378
+ except browser.Timeout:
379
+ raise Blocked("email_otp: the email step did not take the OTP") from None
380
+ if page.locator(MANUALLY).count():
381
+ _click(page, page.locator(MANUALLY).first)
382
+ page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
374
383
  page.locator(f"{PAGINATION}:visible").first.wait_for(timeout=STEP_TIMEOUT_MS)
375
384
  _settle(page)
376
385
 
@@ -428,6 +437,11 @@ class Oracle:
428
437
  return
429
438
  if kind == "file":
430
439
  assert isinstance(answer, str)
440
+ # a draft from an earlier visit keeps its file, the input gone until it is removed
441
+ remove = page.locator(f"{_upload(field_id)} {REMOVE}")
442
+ if remove.count():
443
+ _click(page, remove.first)
444
+ self._file_input(page, field_id).wait_for(state="attached", timeout=STEP_TIMEOUT_MS)
431
445
  self._file_input(page, field_id).set_input_files(answer)
432
446
  return
433
447
  row = self._row(page, field_id)
@@ -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,8 +11,9 @@ 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
+ from atsfill.core import otp
16
17
  from atsfill.core.adapter import (
17
18
  Blocked,
18
19
  Gap,
@@ -22,21 +23,30 @@ from atsfill.core.adapter import (
22
23
  Unsupported,
23
24
  known_gap,
24
25
  )
25
- from atsfill.core.browser import Locator, Page, Request, Timeout
26
+ from atsfill.core.browser import Locator, Page, PageRequest, Request, Timeout
26
27
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
27
28
 
28
29
  JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
29
- DC_HOST = re.compile(r"^career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)$")
30
+ DC_HOST = re.compile(
31
+ r"^(?:career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)|career-[a-z]+\d*\.ns2cloud\.com)$"
32
+ )
30
33
  APPLY = "a[href*='/talentcommunity/apply/']"
31
34
  COMPANY_COOKIE = "careerSiteCompanyId"
32
35
  PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
33
- SIGNED_IN = re.compile(r"\bsignOut|\blogout\b|_logout", re.IGNORECASE)
36
+ JOB_REQ = "career_job_req_id" # the job the apply link posts, the candidate pages' own id
37
+ APPLICATION = "/career?company={company}&career_ns=job_application&career_job_req_id={req}"
38
+ GUIDED = "body.cxApplyEnabled" # a site whose application is a step-by-step one, not sections
39
+ HOME = "body.careerUserLoggedIn" # any candidate page signed in, the candidate home's too
40
+ # The body's class for an account; a signed-out page also holds a log-out link and its label
41
+ SIGNED_IN = re.compile(r"<body[^>]*\bclass=[\"'][^\"']*\bcareerUserLoggedIn\b")
34
42
  FIELDS = (Path(__file__).with_suffix(".js")).read_text()
35
43
  # What the candidate pages show once loaded
36
44
  SIGN_IN = "#username"
37
45
  COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
38
46
  PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
39
47
  SIGN_UP = "#fbclc_userName"
48
+ PASSCODE = "#passcode" # an OTP some sign-ups ask before the account exists
49
+ PASSCODE_STEP = "#otpContainer"
40
50
  QUICK_APPLY = "#fbqa_apply"
41
51
  FORM = ".RCMFormField"
42
52
  LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
@@ -47,6 +57,8 @@ SIGN_IN_MODAL = "a[onclick*=openSignInModal]" # a Quick Apply page's
47
57
  REFUSAL = re.compile(r"required|exist|already|invalid|bereits|erforderlich|ungültig", re.I)
48
58
  ACTIVATE = re.compile(r"activat|verify your|verification e-?mail|bestätig", re.I)
49
59
  PRIVACY = "#dataPrivacyId"
60
+ VISIBILITY = "input[name=fbclc_searPref]" # who may see the profile
61
+ APPLIED_ONLY = "2" # its option for only the recruiters of the jobs applied to
50
62
  EXPAND = "[id$=_expandAllSections]"
51
63
  SUBMIT = "[id$=_submitBtn]"
52
64
  OVERLAY = "#sfOverlayMgr" # a confirmation dialog
@@ -88,6 +100,15 @@ def _company(request: Request) -> list[tuple[str, str]]:
88
100
  )
89
101
 
90
102
 
103
+ def _posts_job(request: PageRequest) -> bool:
104
+ """Whether the apply link's request posts a job to the candidate pages."""
105
+ return (
106
+ request.method == "POST"
107
+ and bool(DC_HOST.match(urlsplit(request.url).hostname or ""))
108
+ and JOB_REQ in (request.post_data or "")
109
+ )
110
+
111
+
91
112
  def _calling_code(select: Locator, details: SignUpDetails) -> tuple[str, str]:
92
113
  """The calling code's option for the E.164 phone, the one of `country` among the countries
93
114
  sharing it, and the national number that follows the code."""
@@ -108,7 +129,10 @@ class SuccessFactors:
108
129
  name = "successfactors"
109
130
  per_employer = True
110
131
  asks = {"name"}
111
- known_gaps = {"quick_apply_sign_up": Gap.FLOW, "sign_up_fields": Gap.FLOW}
132
+ known_gaps = {
133
+ "quick_apply_sign_up": Gap.FLOW,
134
+ "guided_application": Gap.FLOW,
135
+ }
112
136
  side_effects = [
113
137
  SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
114
138
  SideEffect("fill", "hiring_system", "an attachment is uploaded to the account's profile"),
@@ -140,7 +164,7 @@ class SuccessFactors:
140
164
  return self._postings.get(scope or "", f"https://{scope}/")
141
165
 
142
166
  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."""
167
+ """The profile page of the session's company marks its body for an account."""
144
168
  for host, company in _company(request):
145
169
  response = request.get(f"https://{host}{PROFILE.format(company=company)}")
146
170
  if response.ok and SIGNED_IN.search(response.text()):
@@ -165,8 +189,9 @@ class SuccessFactors:
165
189
 
166
190
  def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
167
191
  """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
192
+ residence from `country` (the list's first without it), the phone where the site
193
+ requires one, its calling code picked from the number, and the profile visible only to
194
+ the recruiters of the jobs applied to where the site asks; a missing phone is refused
170
195
  before anything is sent."""
171
196
  self._candidate(page)
172
197
  if page.locator(SIGN_IN).count():
@@ -206,6 +231,11 @@ class SuccessFactors:
206
231
  if wanted and wanted not in values:
207
232
  raise Unsupported(f"unhandled_rendering: the sign-up's countries lack {wanted}")
208
233
  select.select_option(wanted or values[0])
234
+ if page.locator(f"{VISIBILITY}:visible").count():
235
+ least = page.locator(f"{VISIBILITY}[value='{APPLIED_ONLY}']")
236
+ if not least.count():
237
+ raise Unsupported("unhandled_rendering: the profile's visibility has no jobs only")
238
+ least.check()
209
239
  if page.locator(PRIVACY).count():
210
240
  page.locator(PRIVACY).click()
211
241
  dialog = page.locator("[role=dialog]:visible").last
@@ -216,6 +246,10 @@ class SuccessFactors:
216
246
  page.wait_for_timeout(POLL_MS)
217
247
  if page.locator(FORM).count():
218
248
  return
249
+ if page.locator(f"{PASSCODE}:visible").count():
250
+ self._passcode(page, email)
251
+ deadline = time.monotonic() + SIGN_IN_TIMEOUT_MS / 1000
252
+ continue
219
253
  if not page.locator(f"{SIGN_UP}:visible").count():
220
254
  break
221
255
  if self._message(page, REFUSAL):
@@ -226,7 +260,7 @@ class SuccessFactors:
226
260
  if re.search(r"password|kennwort", message, re.IGNORECASE):
227
261
  raise Refused(f"password_rule: {message}")
228
262
  if message:
229
- raise Unsupported(known_gap(self, "sign_up_fields", message))
263
+ raise Unsupported(f"unhandled_rendering: the sign-up refused: {message}")
230
264
  text = page.locator("body").inner_text()
231
265
  if ACTIVATE.search(text):
232
266
  raise Blocked("email_link: the sign-up sent an activation email")
@@ -237,6 +271,16 @@ class SuccessFactors:
237
271
  return
238
272
  raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
239
273
 
274
+ def _passcode(self, page: Page, email: str) -> None:
275
+ """Types the passcode the sign-up emailed; the account exists once it is taken."""
276
+ otp.enter(page.locator(PASSCODE), email, "the sign-up")
277
+ page.locator(PASSCODE_STEP).get_by_role("button").first.click()
278
+ try:
279
+ page.locator(PASSCODE).wait_for(state="hidden", timeout=SIGN_IN_TIMEOUT_MS)
280
+ except Timeout:
281
+ message = self._message(page)
282
+ raise Blocked(f"email_otp: the sign-up did not take the OTP: {message}") from None
283
+
240
284
  def browser_read(self, page: Page) -> list[Field]:
241
285
  self._candidate(page)
242
286
  if not page.locator(FORM).count():
@@ -249,8 +293,8 @@ class SuccessFactors:
249
293
  page.wait_for_timeout(SETTLE_MS)
250
294
  fields = []
251
295
  for d in page.evaluate(FIELDS):
252
- if d["kind"] == "picklist":
253
- d["options"] = self._options(page, d)
296
+ if d["kind"] == "picklist": # a disabled one waits for its parent: no items yet
297
+ d["options"] = [] if d["disabled"] else self._options(page, d)
254
298
  self._fields[d["key"]] = d
255
299
  fields.append(self._field(d))
256
300
  return fields
@@ -288,7 +332,7 @@ class SuccessFactors:
288
332
  items = self._open(page, d)
289
333
  options = [t for t in items.evaluate_all("as => as.map(a => a.title.trim())") if t]
290
334
  self._close(page, d)
291
- return [o for o in options if o != NO_SELECTION]
335
+ return [o for o in options if o not in (NO_SELECTION, d["placeholder"])]
292
336
 
293
337
  def _open(self, page: Page, d: dict[str, Any]) -> Locator:
294
338
  """Opens a picklist; a click can go unheard, or close a list another left open."""
@@ -343,6 +387,8 @@ class SuccessFactors:
343
387
  self._upload(page, d, value)
344
388
 
345
389
  def _pick(self, page: Page, d: dict[str, Any], value: str) -> None:
390
+ if d["disabled"]:
391
+ raise NotTaken(f"{d['key']} stays disabled: its parent's answer offers no items")
346
392
  items = self._open(page, d)
347
393
  titles = [t.strip() for t in items.evaluate_all("as => as.map(a => a.title)")]
348
394
  wanted = next((i for i, t in enumerate(titles) if t == value), None)
@@ -364,8 +410,8 @@ class SuccessFactors:
364
410
  confirm.first.wait_for(timeout=LIST_TIMEOUT_MS)
365
411
  confirm.first.click()
366
412
  deadline = time.monotonic() + UPLOAD_TIMEOUT_S
367
- while self._current(page, d["key"])["multiple"]:
368
- if time.monotonic() > deadline:
413
+ while (now := self._current(page, d["key"]))["multiple"] or now["id"] is None:
414
+ if time.monotonic() > deadline: # deleting, it shows neither button
369
415
  raise NotTaken(f"{d['key']} kept its file")
370
416
  page.wait_for_timeout(POLL_MS)
371
417
  source = page.locator("[role=dialog]:visible input[type=file]")
@@ -407,16 +453,39 @@ class SuccessFactors:
407
453
  return page.url
408
454
 
409
455
  def _candidate(self, page: Page) -> None:
410
- """From the posting, follows its apply link to the candidate pages."""
456
+ """From the posting, follows its apply link to the candidate pages. The link posts the
457
+ job there; signed in, some sites drop it and land on the candidate home, so the
458
+ application is opened from the job the link posted."""
411
459
  host = urlsplit(page.url).hostname or ""
460
+ posted: list[PageRequest] = []
412
461
  if not DC_HOST.match(host):
413
462
  apply = page.locator(APPLY)
414
463
  if not apply.count():
415
464
  raise Unsupported("posting_gone: the posting has no apply link")
465
+
466
+ def keep(request: PageRequest) -> None:
467
+ if _posts_job(request):
468
+ posted.append(request)
469
+
470
+ page.on("request", keep)
416
471
  apply.first.evaluate("a => a.click()") # some sites open a dialog on a real click
417
472
  try:
473
+ page.locator(f"{LANDED}, {HOME}").first.wait_for(
474
+ state="attached", timeout=LANDING_TIMEOUT_MS
475
+ )
476
+ page.wait_for_load_state(timeout=LANDING_TIMEOUT_MS)
477
+ if posted and JOB_REQ not in page.url and not page.locator(LANDED).count():
478
+ post = posted[0]
479
+ parts = urlsplit(post.url)
480
+ job = parse_qs(parts.query) | parse_qs(post.post_data or "")
481
+ application = APPLICATION.format(company=job["company"][0], req=job[JOB_REQ][0])
482
+ page.goto(f"https://{parts.hostname}{application}")
418
483
  page.locator(LANDED).first.wait_for(state="attached", timeout=LANDING_TIMEOUT_MS)
419
484
  except Timeout:
485
+ if page.locator(GUIDED).count():
486
+ raise Unsupported(
487
+ known_gap(self, "guided_application", "the application is a step-by-step one")
488
+ ) from None
420
489
  raise Unsupported(
421
490
  f"unhandled_rendering: the apply link led to {urlsplit(page.url).path}"
422
491
  ) from None
@@ -11,6 +11,7 @@ from atsfill.core import (
11
11
  browser,
12
12
  conditions,
13
13
  matching,
14
+ otp,
14
15
  passwords,
15
16
  paths,
16
17
  registry,
@@ -130,12 +131,15 @@ def _opened(
130
131
  detected.append(adapter.name)
131
132
  if adapter is None:
132
133
  yield _no_adapter(url)
133
- elif isinstance(adapter, NeedsAccount) and not (
134
- session and adapter.signed_in(page.request, adapter.account_scope(url))
135
- ):
136
- yield _sign_in_again(page, adapter, user, mine, url, credentials) or (page, adapter)
137
- else:
138
- yield page, adapter
134
+ return
135
+ scope = adapter.account_scope(url) if isinstance(adapter, NeedsAccount) else None
136
+ with otp.source(user, adapter.name, scope):
137
+ if isinstance(adapter, NeedsAccount) and not (
138
+ session and adapter.signed_in(page.request, scope)
139
+ ):
140
+ yield _sign_in_again(page, adapter, user, mine, url, credentials) or (page, adapter)
141
+ else:
142
+ yield page, adapter
139
143
 
140
144
 
141
145
  def _sign_in_again(
@@ -536,7 +540,7 @@ def login(
536
540
  accounts.wait_for_person(account, scope, data, session),
537
541
  )
538
542
  else:
539
- result = _headless(data, account, scope, credentials, session, details)
543
+ result = _headless(data, user, account, scope, credentials, session, details)
540
544
  command = login_command(adapter, user, url)
541
545
  report.status, report.reason = (
542
546
  result.status,
@@ -574,7 +578,9 @@ def _create(
574
578
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
575
579
  return report
576
580
  credentials = (email, password)
577
- result = _headless(data, account, report.scope, credentials, report.path, details, create=True)
581
+ result = _headless(
582
+ data, report.user, account, report.scope, credentials, report.path, details, create=True
583
+ )
578
584
  command = login_command(account.name, report.user, url)
579
585
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
580
586
  report.password = "stored"
@@ -588,6 +594,7 @@ def _create(
588
594
 
589
595
  def _headless(
590
596
  data: Path,
597
+ user: str,
591
598
  account: NeedsAccount,
592
599
  scope: str | None,
593
600
  credentials: Credentials,
@@ -595,14 +602,14 @@ def _headless(
595
602
  details: SignUpDetails | None = None,
596
603
  create: bool = False,
597
604
  ) -> Result:
598
- """Signs in, or creates the account with `create`, through the adapter: `ready` once the
599
- session is stored."""
605
+ """Signs in, or creates the account with `create`, through the adapter, an OTP it meets
606
+ from the user's `otp-commands` entry: `ready` once the session is stored."""
600
607
  assert isinstance(account, SignsIn)
601
608
  url = account.login_url(scope)
602
609
 
603
610
  def run(_: list[str]) -> Result:
604
611
  result = Result(Status.READY, url, account.name)
605
- with open_page(data, None) as page, _stops(result):
612
+ with open_page(data, None) as page, _stops(result), otp.source(user, account.name, scope):
606
613
  accounts.sign_in(page, account, scope, *credentials, session, details, create)
607
614
  return result
608
615
 
@@ -0,0 +1,102 @@
1
+ """OTPs a hiring system emails before it goes on (ADR 0015): the user's `otp-commands` entry
2
+ gives each one, and atsfill types it where the adapter found the OTP step's input. The core
3
+ sets the source for the command; an adapter only calls `enter`."""
4
+
5
+ import shlex
6
+ import subprocess
7
+ import time
8
+ from collections.abc import Generator
9
+ from contextlib import contextmanager
10
+ from contextvars import ContextVar
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+
14
+ from atsfill.core import paths
15
+ from atsfill.core.adapter import Blocked
16
+ from atsfill.core.browser import Locator
17
+
18
+ OTP_TIMEOUT_S = 90 # from the step showing to the OTP typed in: a mail takes seconds
19
+ POLL_S = 3 # between runs of a command that has no OTP yet
20
+ EVERY_ADAPTER = "all" # the `otp-commands` key for the adapters without their own
21
+
22
+
23
+ @dataclass
24
+ class Source:
25
+ """The user's `otp-commands` entry for the adapter, None without one, and when the last
26
+ OTP was asked for: an OTP from before is not this one."""
27
+
28
+ adapter: str
29
+ command: str | None
30
+ scope: str | None
31
+ since: datetime
32
+
33
+
34
+ _source: ContextVar[Source | None] = ContextVar("otp_source", default=None)
35
+
36
+
37
+ def command(user: str, adapter: str) -> str | None:
38
+ """The user's `otp-commands` entry for the adapter, else for every adapter."""
39
+ entries = paths.config(user).get("otp-commands", {})
40
+ return entries.get(adapter) or entries.get(EVERY_ADAPTER)
41
+
42
+
43
+ @contextmanager
44
+ def source(user: str, adapter: str, scope: str | None) -> Generator[None]:
45
+ """Makes the user's entry the source of every OTP the adapter meets within, the first not
46
+ older than now."""
47
+ token = _source.set(Source(adapter, command(user, adapter), scope, _now()))
48
+ try:
49
+ yield
50
+ finally:
51
+ _source.reset(token)
52
+
53
+
54
+ def enter(inputs: Locator, email: str, step: str) -> None:
55
+ """Types the OTP emailed to `email` into the step's input, or one character into each of
56
+ its inputs. Raises `Blocked` (`email_otp`) when there is no entry, or no OTP within
57
+ `OTP_TIMEOUT_S`; `step` names where the OTP was asked, for the reason."""
58
+ current = _source.get()
59
+ asked = f"email_otp: {step} sent an OTP to {email}"
60
+ if current is None or current.command is None:
61
+ adapter = current.adapter if current else "the adapter"
62
+ raise Blocked(f"{asked}; no `otp-commands` entry for {adapter}")
63
+ otp = _wait(current, email, asked)
64
+ count = inputs.count()
65
+ if count > 1 and count != len(otp):
66
+ raise Blocked(f"{asked}; the OTP command printed {len(otp)} characters for {count} inputs")
67
+ if count > 1:
68
+ for box, character in zip(inputs.all(), otp, strict=True):
69
+ box.press_sequentially(character)
70
+ else:
71
+ inputs.fill("")
72
+ inputs.press_sequentially(otp)
73
+ current.since = _now()
74
+
75
+
76
+ def _wait(current: Source, email: str, asked: str) -> str:
77
+ """Runs the command until it prints an OTP, each run within the time left: one that waits
78
+ for the mail itself runs once."""
79
+ assert current.command is not None
80
+ since = current.since.isoformat(timespec="seconds").replace("+00:00", "Z")
81
+ filled = {"{email}": email, "{scope}": current.scope or "", "{since}": since}
82
+ args = shlex.split(current.command)
83
+ for placeholder, value in filled.items():
84
+ args = [a.replace(placeholder, value) for a in args]
85
+ deadline = time.monotonic() + OTP_TIMEOUT_S
86
+ while (left := deadline - time.monotonic()) > 0:
87
+ try:
88
+ run = subprocess.run(args, capture_output=True, text=True, timeout=left)
89
+ except subprocess.TimeoutExpired:
90
+ break
91
+ except OSError as e:
92
+ raise Blocked(
93
+ f"{asked}; the OTP command `{args[0]}` failed: {type(e).__name__}"
94
+ ) from None
95
+ if run.returncode == 0 and (otp := next(iter(run.stdout.split()), "")):
96
+ return otp
97
+ time.sleep(min(POLL_S, max(0.0, deadline - time.monotonic())))
98
+ raise Blocked(f"{asked}; the OTP command `{args[0]}` printed none within {OTP_TIMEOUT_S}s")
99
+
100
+
101
+ def _now() -> datetime:
102
+ return datetime.now(UTC).replace(microsecond=0)
@@ -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"
@@ -327,7 +327,7 @@ 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, 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
330
+ account = "to_read" # one account for the whole system, signed in per employer's site; Apply now verifies the email or phone with an OTP and asks the same details as the sign-in's sign-up, which sets a password instead
331
331
  hosting = ["hosted"]
332
332
  adapter = "isolvedhire"
333
333
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
File without changes