atsfill 0.0.4.dev114__tar.gz → 0.0.4.dev115__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 (51) hide show
  1. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/README.md +1 -1
  3. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev115/src/atsfill/adapters/successfactors.js +72 -0
  7. atsfill-0.0.4.dev115/src/atsfill/adapters/successfactors.py +386 -0
  8. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/passwords.py +1 -1
  9. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/registry.toml +2 -0
  10. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/LICENSE +0 -0
  11. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/bamboohr.py +0 -0
  14. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/dayforce.py +0 -0
  16. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/eightfold.py +0 -0
  17. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/greenhouse.py +0 -0
  18. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/hireology.py +0 -0
  19. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/hiringthing.py +0 -0
  20. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/jazzhr.py +0 -0
  21. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/lever.py +0 -0
  22. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/manatal.py +0 -0
  23. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/oracle.py +0 -0
  24. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/paycor.py +0 -0
  25. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/paylocity.py +0 -0
  26. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/personio.py +0 -0
  27. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/phenom.py +0 -0
  28. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/pinpoint.py +0 -0
  29. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/recruitee.py +0 -0
  30. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/rippling.py +0 -0
  31. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/smartrecruiters.py +0 -0
  32. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/teamtailor.py +0 -0
  33. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/ukg.py +0 -0
  34. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/workable.py +0 -0
  35. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/workday.py +0 -0
  36. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/wttj.py +0 -0
  37. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/adapters/zohorecruit.py +0 -0
  38. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/cli.py +0 -0
  39. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/__init__.py +0 -0
  40. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/accounts.py +0 -0
  41. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/adapter.py +0 -0
  42. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/attempts.py +0 -0
  43. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/browser.py +0 -0
  44. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/commands.py +0 -0
  45. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/conditions.py +0 -0
  46. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/html.py +0 -0
  47. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/matching.py +0 -0
  48. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/paths.py +0 -0
  49. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/registry.py +0 -0
  50. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/src/atsfill/core/results.py +0 -0
  51. {atsfill-0.0.4.dev114 → atsfill-0.0.4.dev115}/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.dev114
3
+ Version: 0.0.4.dev115
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
57
57
  ## Supported hiring systems
58
58
 
59
59
  <!-- badges -->
60
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-25](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--25-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7) ![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) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7) ![successfactors: unsupported 2026-10-26](https://img.shields.io/badge/successfactors-unsupported%202026--10--26-e0af68) ![ukg: unsupported 2026-10-26](https://img.shields.io/badge/ukg-unsupported%202026--10--26-e0af68)
61
61
  <!-- /badges -->
62
62
 
63
63
  | System | Account needed | `read` without a browser |
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-25](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--25-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7) ![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) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7) ![successfactors: unsupported 2026-10-26](https://img.shields.io/badge/successfactors-unsupported%202026--10--26-e0af68) ![ukg: unsupported 2026-10-26](https://img.shields.io/badge/ukg-unsupported%202026--10--26-e0af68)
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.dev114"
3
+ version = "0.0.4.dev115"
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.dev114"
3
+ version = "0.0.4.dev115"
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"
@@ -18,6 +18,7 @@ from atsfill.adapters.pinpoint import Pinpoint
18
18
  from atsfill.adapters.recruitee import Recruitee
19
19
  from atsfill.adapters.rippling import Rippling
20
20
  from atsfill.adapters.smartrecruiters import SmartRecruiters
21
+ from atsfill.adapters.successfactors import SuccessFactors
21
22
  from atsfill.adapters.teamtailor import Teamtailor
22
23
  from atsfill.adapters.ukg import UKG
23
24
  from atsfill.adapters.workable import Workable
@@ -55,6 +56,7 @@ def adapters() -> list[Adapter]:
55
56
  Eightfold(),
56
57
  Workday(),
57
58
  UKG(),
59
+ SuccessFactors(), # it claims a career site's job path on any host
58
60
  Phenom(), # last: it claims a URL by its path alone
59
61
  ]
60
62
 
@@ -0,0 +1,72 @@
1
+ // The application page's fields, one descriptor each, keyed as the adapter names them.
2
+ () => {
3
+ const slug = s => s.toLowerCase().normalize('NFKD').replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
4
+ const seen = {};
5
+ const out = [];
6
+ for (const box of document.querySelectorAll('.RCMFormField')) {
7
+ if (!box.offsetParent || box.closest('[id$="_bgElementContainer"]')) continue;
8
+ const labelEl = box.querySelector('label.rcmFormFieldLabel, .rcmFormFieldLabel');
9
+ const label = (labelEl ? labelEl.innerText : '').replace(/\*/g, '').replace(/\s+/g, ' ').trim();
10
+ const required = !!box.querySelector('.requiredField');
11
+ const d = { label, required, kind: 'unknown', value: '', options: null, id: null, owns: null, name: null };
12
+ const picklist = box.querySelector('input.rcmpaginatedselectinput');
13
+ const text = box.querySelector('input[data-testid="sfTextField"], input.fd-input[type="text"]');
14
+ const area = box.querySelector('textarea');
15
+ const select = box.querySelector('select');
16
+ const radios = [...box.querySelectorAll('input[type="radio"]')];
17
+ const check = box.querySelector('input[type="checkbox"]');
18
+ if (box.classList.contains('attachmentField')) {
19
+ d.kind = 'file';
20
+ d.files = [...box.querySelectorAll('[id$="_attachDownloadLabelLink"]')].filter(e => e.offsetParent).map(e => e.innerText.trim());
21
+ d.value = d.files.length ? d.files[d.files.length - 1] : '';
22
+ const icon = [...box.querySelectorAll('[id$="_attachIcon"]')].find(e => e.offsetParent);
23
+ d.multiple = !!icon && icon.classList.contains('removeAttachments'); // it holds a file
24
+ d.id = icon ? icon.id : null;
25
+ } else if (picklist) {
26
+ d.kind = 'picklist';
27
+ d.id = picklist.id;
28
+ d.owns = picklist.getAttribute('aria-owns');
29
+ d.value = picklist.value === picklist.placeholder ? '' : picklist.value;
30
+ d.required = d.required || picklist.getAttribute('aria-required') === 'true';
31
+ } else if (radios.length) {
32
+ d.kind = 'radio';
33
+ d.options = radios.map(r => {
34
+ const l = r.labels && r.labels[0] ? r.labels[0].innerText : (r.closest('label') || r.parentElement).innerText;
35
+ return l.trim();
36
+ });
37
+ d.ids = radios.map(r => r.id);
38
+ const on = radios.findIndex(r => r.checked);
39
+ d.value = on >= 0 ? d.options[on] : '';
40
+ } else if (select) {
41
+ d.kind = 'select';
42
+ d.id = select.id;
43
+ d.options = [...select.options].filter(o => o.value).map(o => o.text.trim());
44
+ d.value = select.value ? select.options[select.selectedIndex].text.trim() : '';
45
+ } else if (area) {
46
+ d.kind = 'text';
47
+ d.id = area.id;
48
+ d.name = area.name;
49
+ d.value = area.value;
50
+ d.max = area.maxLength > 0 ? area.maxLength : null;
51
+ } else if (text) {
52
+ d.kind = text.closest('[class*="DatePicker"], [class*="datePicker"], .sfDateInput') ? 'date' : 'text';
53
+ d.id = text.id;
54
+ d.name = text.getAttribute('name');
55
+ d.value = text.value;
56
+ d.max = text.maxLength > 0 ? text.maxLength : null;
57
+ d.required = d.required || text.getAttribute('aria-required') === 'true';
58
+ } else if (check) {
59
+ d.kind = 'checkbox';
60
+ d.id = check.id;
61
+ d.value = check.checked ? 'Yes' : 'No';
62
+ } else if (!box.querySelector('input, select, textarea')) {
63
+ continue; // a label alone: a heading or an instruction
64
+ }
65
+ let key = d.name || slug(label) || d.kind;
66
+ seen[key] = (seen[key] || 0) + 1;
67
+ if (seen[key] > 1) key += '-' + seen[key];
68
+ d.key = key;
69
+ out.push(d);
70
+ }
71
+ return out;
72
+ }
@@ -0,0 +1,386 @@
1
+ """SAP SuccessFactors Recruiting (`<career site>[/<subsite>]/job/<slug>/<id>/`), as
2
+ docs/adapters/successfactors.md observes.
3
+
4
+ A posting is on the employer's career site; its apply link leads to the candidate pages on a
5
+ SuccessFactors data center host, where applying needs an account, one per employer: the
6
+ account scope is the career site's host. The application is one page of sections; the
7
+ fields are read from it, each by its input's name, else its label.
8
+ """
9
+
10
+ import re
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+
16
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
17
+ from atsfill.core.browser import Locator, Page, Request, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
21
+ DC_HOST = re.compile(r"^career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)$")
22
+ APPLY = "a[href*='/talentcommunity/apply/']"
23
+ COMPANY_COOKIE = "careerSiteCompanyId"
24
+ PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
25
+ SIGNED_IN = re.compile(r"\bsignOut|\blogout\b|_logout", re.IGNORECASE)
26
+ FIELDS = (Path(__file__).with_suffix(".js")).read_text()
27
+ # What the candidate pages show once loaded
28
+ SIGN_IN = "#username"
29
+ SIGN_UP = "#fbclc_userName"
30
+ QUICK_APPLY = "#fbqa_apply"
31
+ FORM = ".RCMFormField"
32
+ LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
33
+ REGISTER = "a[href*='login_ns=register']"
34
+ SIGN_IN_LINK = "a[href*='login_ns=login'], a[href*='career_ns=login']"
35
+ SIGN_IN_MODAL = "a[onclick*=openSignInModal]" # a Quick Apply page's
36
+ # An error a sign-up shows; the password rules beside the field show whatever is typed
37
+ REFUSAL = re.compile(r"required|exist|already|invalid|bereits|erforderlich|ungültig", re.I)
38
+ ACTIVATE = re.compile(r"activat|verify your|verification e-?mail|bestätig", re.I)
39
+ PRIVACY = "#dataPrivacyId"
40
+ EXPAND = "[id$=_expandAllSections]"
41
+ SUBMIT = "[id$=_submitBtn]"
42
+ OVERLAY = "#sfOverlayMgr" # a confirmation dialog
43
+ ERROR = "[role=alert]:visible" # a validation message area, shown
44
+ LANDING_TIMEOUT_MS = 45_000
45
+ SIGN_IN_TIMEOUT_MS = 30_000
46
+ LIST_TIMEOUT_MS = 5_000
47
+ OPEN_TRIES = 3
48
+ UPLOAD_TIMEOUT_S = 30
49
+ POLL_MS = 500
50
+ SETTLE_MS = 1_000
51
+ SUBMIT_TIMEOUT_MS = 60_000
52
+ NO_SELECTION = "No Selection"
53
+ YES_NO = ["Yes", "No"]
54
+ TYPES = {
55
+ "text": FieldType.TEXT,
56
+ "date": FieldType.DATE,
57
+ "picklist": FieldType.CHOICE,
58
+ "select": FieldType.CHOICE,
59
+ "radio": FieldType.CHOICE,
60
+ "checkbox": FieldType.CHOICE,
61
+ "file": FieldType.FILE,
62
+ }
63
+
64
+
65
+ def _id(element_id: str) -> str:
66
+ """A CSS selector for a juic id, which holds a colon."""
67
+ return f'[id="{element_id}"]'
68
+
69
+
70
+ def _company(request: Request) -> list[tuple[str, str]]:
71
+ """The data center hosts and company ids a session's cookies are for."""
72
+ return sorted(
73
+ {
74
+ (c["domain"].lstrip("."), c["value"])
75
+ for c in request.storage_state()["cookies"]
76
+ if c["name"] == COMPANY_COOKIE and DC_HOST.match(c["domain"].lstrip("."))
77
+ }
78
+ )
79
+
80
+
81
+ class SuccessFactors:
82
+ name = "successfactors"
83
+ per_employer = True
84
+ asks_name = True
85
+ known_gaps = {"quick_apply_sign_up": Gap.FLOW, "sign_up_fields": Gap.FLOW}
86
+ side_effects = [
87
+ SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
88
+ SideEffect("fill", "hiring_system", "an attachment is uploaded to the account's profile"),
89
+ SideEffect("submit", "employer", "the application reaches the employer"),
90
+ ]
91
+
92
+ def __init__(self) -> None:
93
+ self._postings: dict[str, str] = {} # by career site host, the last posting's URL
94
+ self._fields: dict[str, dict[str, Any]] = {}
95
+ self._uploads: dict[str, str] = {} # by field, the name of the file uploaded to it
96
+
97
+ def detect(self, url: str, page: Page | None) -> bool:
98
+ if not JOB.match(urlsplit(url).path):
99
+ return False
100
+ return page is None or page.locator(APPLY).count() > 0
101
+
102
+ def posting(self, url: str) -> str:
103
+ parts = urlsplit(url)
104
+ job = JOB.match(parts.path)
105
+ return f"https://{parts.hostname}/job/{job['id']}" if job else url
106
+
107
+ def account_scope(self, url: str) -> str | None:
108
+ host = (urlsplit(url).hostname or "").lower()
109
+ if JOB.match(urlsplit(url).path):
110
+ self._postings[host] = url
111
+ return host
112
+
113
+ def login_url(self, scope: str | None) -> str:
114
+ return self._postings.get(scope or "", f"https://{scope}/")
115
+
116
+ def signed_in(self, request: Request, scope: str | None) -> bool:
117
+ """The profile page of the session's company shows a sign-out for an account."""
118
+ for host, company in _company(request):
119
+ response = request.get(f"https://{host}{PROFILE.format(company=company)}")
120
+ if response.ok and SIGNED_IN.search(response.text()):
121
+ return True
122
+ return False
123
+
124
+ def sign_in(self, page: Page, email: str, password: str) -> None:
125
+ self._candidate(page)
126
+ if not page.locator(SIGN_IN).count():
127
+ page.locator(SIGN_IN_LINK).first.click()
128
+ page.locator(SIGN_IN).wait_for(timeout=LANDING_TIMEOUT_MS)
129
+ page.fill(SIGN_IN, email)
130
+ page.fill("#password", password)
131
+ page.locator("button[onclick*=validateFields]").click()
132
+ try:
133
+ page.locator(FORM).first.wait_for(state="attached", timeout=SIGN_IN_TIMEOUT_MS)
134
+ except Timeout:
135
+ message = self._message(page)
136
+ if ACTIVATE.search(message):
137
+ raise Blocked(f"email_link: {message}") from None
138
+ raise Refused(f"wrong_password: {message}") from None
139
+
140
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
141
+ self._candidate(page)
142
+ if page.locator(SIGN_IN).count():
143
+ page.locator(REGISTER).first.click()
144
+ elif page.locator(QUICK_APPLY).count(): # its sign-up sends the application too
145
+ page.locator(SIGN_IN_MODAL).first.click()
146
+ register = page.locator(f"{REGISTER}:visible")
147
+ try:
148
+ register.first.wait_for(timeout=LIST_TIMEOUT_MS)
149
+ except Timeout:
150
+ raise Unsupported(
151
+ known_gap(self, "quick_apply_sign_up", "its sign-in offers no sign-up")
152
+ ) from None
153
+ register.first.click()
154
+ page.locator(SIGN_UP).wait_for(timeout=LANDING_TIMEOUT_MS)
155
+ first, _, last = (name or "").strip().partition(" ")
156
+ for selector, value in [
157
+ (SIGN_UP, email),
158
+ ("#fbclc_emailConf", email),
159
+ ("#fbclc_pwd", password),
160
+ ("#fbclc_pwdConf", password),
161
+ ("#fbclc_fName", first),
162
+ ("#fbclc_lName", last or first),
163
+ ]:
164
+ page.fill(selector, value)
165
+ for select in page.locator("select:visible").all(): # the country, a phone code
166
+ if not select.input_value():
167
+ values = select.evaluate("s => [...s.options].map(o => o.value).filter(v => v)")
168
+ select.select_option(values[0])
169
+ if page.locator(PRIVACY).count():
170
+ page.locator(PRIVACY).click()
171
+ dialog = page.locator("[role=dialog]:visible").last
172
+ dialog.locator("[id^=dlgButton]").first.click()
173
+ page.locator("#fbclc_createAccountButton").click()
174
+ deadline = time.monotonic() + SIGN_IN_TIMEOUT_MS / 1000
175
+ while time.monotonic() < deadline:
176
+ page.wait_for_timeout(POLL_MS)
177
+ if page.locator(FORM).count():
178
+ return
179
+ if not page.locator(f"{SIGN_UP}:visible").count():
180
+ break
181
+ if self._message(page, REFUSAL):
182
+ break
183
+ message = self._message(page, REFUSAL)
184
+ if re.search(r"exist|already|bereits|registered", message, re.IGNORECASE):
185
+ raise Refused(f"email_in_use: {message}")
186
+ if re.search(r"password|kennwort", message, re.IGNORECASE):
187
+ raise Refused(f"password_rule: {message}")
188
+ if message:
189
+ raise Unsupported(known_gap(self, "sign_up_fields", message))
190
+ text = page.locator("body").inner_text()
191
+ if ACTIVATE.search(text):
192
+ raise Blocked("email_link: the sign-up sent an activation email")
193
+ if page.locator(f"{SIGN_IN}:visible").count(): # created, signed out
194
+ self.sign_in(page, email, password)
195
+ return
196
+ raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
197
+
198
+ def browser_read(self, page: Page) -> list[Field]:
199
+ self._candidate(page)
200
+ if not page.locator(FORM).count():
201
+ if page.locator(f"{SIGN_IN}, {SIGN_UP}").count():
202
+ raise Blocked("login_wall: the application asks to sign in")
203
+ raise Unsupported(f"unhandled_rendering: no application form: {self._message(page)}")
204
+ expand = page.locator(EXPAND)
205
+ if expand.count() and expand.first.is_visible():
206
+ expand.first.click()
207
+ page.wait_for_timeout(SETTLE_MS)
208
+ fields = []
209
+ for d in page.evaluate(FIELDS):
210
+ if d["kind"] == "picklist":
211
+ d["options"] = self._options(page, d)
212
+ self._fields[d["key"]] = d
213
+ fields.append(self._field(d))
214
+ return fields
215
+
216
+ def _field(self, d: dict[str, Any]) -> Field:
217
+ kind = d["kind"]
218
+ options = d["options"]
219
+ if kind == "checkbox":
220
+ options = YES_NO
221
+ if kind == "picklist" and not options: # a cascading list, filled once its parent is
222
+ return Field(
223
+ d["key"],
224
+ d["label"],
225
+ FieldType.TYPEAHEAD,
226
+ d["required"],
227
+ prefilled=d["value"] or None,
228
+ )
229
+ unfillable = None
230
+ if kind == "unknown":
231
+ unfillable = f"unhandled_rendering: {d['key']} is a control atsfill does not know"
232
+ return Field(
233
+ d["key"],
234
+ d["label"],
235
+ TYPES.get(kind, FieldType.TEXT),
236
+ d["required"],
237
+ options=options if TYPES.get(kind) == FieldType.CHOICE else None,
238
+ max_length=d.get("max"),
239
+ prefilled=(d["value"] or None) if kind not in ("checkbox", "radio") else None,
240
+ default=d["value"] if kind in ("checkbox", "radio") and d["value"] else None,
241
+ unfillable=unfillable,
242
+ )
243
+
244
+ def _options(self, page: Page, d: dict[str, Any]) -> list[str]:
245
+ """A picklist's items, read by opening it."""
246
+ items = self._open(page, d)
247
+ options = [t for t in items.evaluate_all("as => as.map(a => a.title.trim())") if t]
248
+ self._close(page, d)
249
+ return [o for o in options if o != NO_SELECTION]
250
+
251
+ def _open(self, page: Page, d: dict[str, Any]) -> Locator:
252
+ """Opens a picklist; a click can go unheard, or close a list another left open."""
253
+ button = page.locator(_id(d["id"])).locator("xpath=following-sibling::button")
254
+ items = page.locator(f"{_id(d['owns'])} li[role=option] a")
255
+ for _ in range(OPEN_TRIES):
256
+ button.click()
257
+ try:
258
+ items.first.wait_for(timeout=LIST_TIMEOUT_MS)
259
+ return items
260
+ except Timeout:
261
+ continue
262
+ return items
263
+
264
+ def _close(self, page: Page, d: dict[str, Any]) -> None:
265
+ if page.locator(f"{_id(d['owns'])}:visible").count():
266
+ page.keyboard.press("Escape")
267
+ if page.locator(f"{_id(d['owns'])}:visible").count():
268
+ page.locator(_id(d["id"])).locator("xpath=following-sibling::button").click()
269
+
270
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
271
+ """Fills in the page's order, a country before its states. A widget re-renders with new
272
+ ids as it changes, so each field is looked up again before it is filled."""
273
+ if not self._fields:
274
+ self.browser_read(page)
275
+ for key in [k for k in self._fields if k in answers]:
276
+ d = self._current(page, key)
277
+ answer = answers[key]
278
+ value = answer if isinstance(answer, str) else ", ".join(answer)
279
+ kind = d["kind"]
280
+ if kind == "file":
281
+ self._uploads[key] = Path(value).name
282
+ if value == d["value"] or self._uploads.get(key) in d.get("files", []):
283
+ continue
284
+ if d["id"] is None and kind != "radio":
285
+ raise NotTaken(f"{key} shows no control to fill")
286
+ if kind in ("text", "date"):
287
+ target = page.locator(_id(d["id"]))
288
+ target.fill(value)
289
+ target.press("Tab")
290
+ elif kind == "picklist":
291
+ self._pick(page, d, value)
292
+ elif kind == "select":
293
+ page.locator(_id(d["id"])).select_option(label=value)
294
+ elif kind == "radio":
295
+ page.locator(_id(d["ids"][d["options"].index(value)])).check()
296
+ elif kind == "checkbox":
297
+ box = page.locator(_id(d["id"]))
298
+ if box.is_checked() != (value == "Yes"):
299
+ box.click()
300
+ elif kind == "file":
301
+ self._upload(page, d, value)
302
+
303
+ def _pick(self, page: Page, d: dict[str, Any], value: str) -> None:
304
+ items = self._open(page, d)
305
+ titles = [t.strip() for t in items.evaluate_all("as => as.map(a => a.title)")]
306
+ wanted = next((i for i, t in enumerate(titles) if t == value), None)
307
+ if wanted is None:
308
+ folded = [t.casefold() for t in titles]
309
+ wanted = next((i for i, t in enumerate(folded) if t.startswith(value.casefold())), None)
310
+ if wanted is None:
311
+ self._close(page, d)
312
+ raise NotTaken(f"{d['key']} lists no {value!r}")
313
+ items.nth(wanted).click()
314
+ page.wait_for_timeout(SETTLE_MS)
315
+
316
+ def _upload(self, page: Page, d: dict[str, Any], path: str) -> None:
317
+ """The attachment's button opens a callout of sources, the device's a file input that
318
+ uploads once set."""
319
+ if self._current(page, d["key"])["multiple"]: # it holds a file: deleted first
320
+ page.locator(_id(self._current(page, d["key"])["id"])).click()
321
+ confirm = page.locator(f"{OVERLAY} button.globalPrimaryButton:visible")
322
+ confirm.first.wait_for(timeout=LIST_TIMEOUT_MS)
323
+ confirm.first.click()
324
+ deadline = time.monotonic() + UPLOAD_TIMEOUT_S
325
+ while self._current(page, d["key"])["multiple"]:
326
+ if time.monotonic() > deadline:
327
+ raise NotTaken(f"{d['key']} kept its file")
328
+ page.wait_for_timeout(POLL_MS)
329
+ source = page.locator("[role=dialog]:visible input[type=file]")
330
+ for _ in range(OPEN_TRIES): # a first click can only close a list left open
331
+ page.locator(_id(self._current(page, d["key"])["id"])).click()
332
+ try:
333
+ source.last.wait_for(state="attached", timeout=LIST_TIMEOUT_MS)
334
+ break
335
+ except Timeout:
336
+ continue
337
+ source.last.set_input_files(path)
338
+ deadline = time.monotonic() + UPLOAD_TIMEOUT_S
339
+ while Path(path).name not in self._current(page, d["key"])["files"]:
340
+ if time.monotonic() > deadline:
341
+ raise NotTaken(f"{d['key']} did not show {Path(path).name} once uploaded")
342
+ page.wait_for_timeout(POLL_MS)
343
+
344
+ def _current(self, page: Page, key: str) -> dict[str, Any]:
345
+ found = next((d for d in page.evaluate(FIELDS) if d["key"] == key), None)
346
+ if found is None:
347
+ raise NotTaken(f"{key} is no longer on the page")
348
+ return found | {"options": self._fields[key]["options"]}
349
+
350
+ def read_back(self, page: Page) -> dict[str, Answer]:
351
+ """Each field's value; an attachment's, the file uploaded to it when it lists several."""
352
+ values = {}
353
+ for d in page.evaluate(FIELDS):
354
+ uploaded = self._uploads.get(d["key"])
355
+ value = uploaded if uploaded in d.get("files", []) else d["value"]
356
+ if value:
357
+ values[d["key"]] = value
358
+ return values
359
+
360
+ def submit(self, page: Page) -> str | None:
361
+ page.locator(SUBMIT).first.click()
362
+ page.wait_for_load_state(timeout=SUBMIT_TIMEOUT_MS)
363
+ if message := self._message(page):
364
+ raise Blocked(f"not_submitted: {message}")
365
+ return page.url
366
+
367
+ def _candidate(self, page: Page) -> None:
368
+ """From the posting, follows its apply link to the candidate pages."""
369
+ host = urlsplit(page.url).hostname or ""
370
+ if not DC_HOST.match(host):
371
+ apply = page.locator(APPLY)
372
+ if not apply.count():
373
+ raise Unsupported("posting_gone: the posting has no apply link")
374
+ apply.first.evaluate("a => a.click()") # some sites open a dialog on a real click
375
+ try:
376
+ page.locator(LANDED).first.wait_for(state="attached", timeout=LANDING_TIMEOUT_MS)
377
+ except Timeout:
378
+ raise Unsupported(
379
+ f"unhandled_rendering: the apply link led to {urlsplit(page.url).path}"
380
+ ) from None
381
+ page.wait_for_timeout(SETTLE_MS)
382
+
383
+ def _message(self, page: Page, only: re.Pattern | None = None) -> str:
384
+ """The errors the page shows, those matching `only` when given."""
385
+ texts = [" ".join(t.split()) for t in page.locator(ERROR).all_inner_texts()]
386
+ return " ".join(t for t in texts if t and (only is None or only.search(t)))[:300]
@@ -18,7 +18,7 @@ from atsfill.core import paths
18
18
  SERVICE = "atsfill"
19
19
  COMMAND_TIMEOUT_S = 60 # a password manager may wait for its unlock
20
20
  FILE = "passwords.json" # in the user's directory, only without a secret store
21
- GENERATED_LENGTH = 24
21
+ GENERATED_LENGTH = 16 # SuccessFactors refuses a password longer than 18
22
22
  CLASSES = (string.ascii_lowercase, string.ascii_uppercase, string.digits, "!#%+-.:=?@_")
23
23
 
24
24
 
@@ -95,6 +95,8 @@ kind = "ats"
95
95
  share = 0.0350
96
96
  account = "to_read"
97
97
  hosting = ["hosted"]
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 a phone number" }
98
100
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
99
101
 
100
102
  [ukg]
File without changes