atsfill 0.0.4.dev76__tar.gz → 0.0.4.dev79__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 (43) hide show
  1. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/README.md +1 -1
  3. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/__init__.py +4 -0
  6. atsfill-0.0.4.dev79/src/atsfill/adapters/eightfold.py +582 -0
  7. atsfill-0.0.4.dev79/src/atsfill/adapters/rippling.py +457 -0
  8. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/registry.toml +4 -0
  9. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/LICENSE +0 -0
  10. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/jazzhr.py +0 -0
  18. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/lever.py +0 -0
  19. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/manatal.py +0 -0
  20. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/oracle.py +0 -0
  21. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/paycor.py +0 -0
  22. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/paylocity.py +0 -0
  23. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/personio.py +0 -0
  24. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/phenom.py +0 -0
  25. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/pinpoint.py +0 -0
  26. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/recruitee.py +0 -0
  27. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/smartrecruiters.py +0 -0
  28. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/teamtailor.py +0 -0
  29. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/workable.py +0 -0
  30. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/wttj.py +0 -0
  31. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/adapters/zohorecruit.py +0 -0
  32. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/cli.py +0 -0
  33. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/__init__.py +0 -0
  34. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/accounts.py +0 -0
  35. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/adapter.py +0 -0
  36. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/attempts.py +0 -0
  37. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/browser.py +0 -0
  38. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/commands.py +0 -0
  39. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/matching.py +0 -0
  40. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/paths.py +0 -0
  41. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/registry.py +0 -0
  42. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/src/atsfill/core/results.py +0 -0
  43. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev79}/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.dev76
3
+ Version: 0.0.4.dev79
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
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
54
54
  ## Supported hiring systems
55
55
 
56
56
  <!-- badges -->
57
- ![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-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-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) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
57
+ ![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-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-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) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
58
58
  <!-- /badges -->
59
59
 
60
60
  | 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-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-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) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-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) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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.dev76"
3
+ version = "0.0.4.dev79"
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.dev76"
3
+ version = "0.0.4.dev79"
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"
@@ -2,6 +2,7 @@ from atsfill.adapters.ashby import Ashby
2
2
  from atsfill.adapters.bamboohr import BambooHR
3
3
  from atsfill.adapters.breezy import Breezy
4
4
  from atsfill.adapters.dayforce import Dayforce
5
+ from atsfill.adapters.eightfold import Eightfold
5
6
  from atsfill.adapters.greenhouse import Greenhouse
6
7
  from atsfill.adapters.hireology import Hireology
7
8
  from atsfill.adapters.jazzhr import JazzHR
@@ -14,6 +15,7 @@ from atsfill.adapters.personio import Personio
14
15
  from atsfill.adapters.phenom import Phenom
15
16
  from atsfill.adapters.pinpoint import Pinpoint
16
17
  from atsfill.adapters.recruitee import Recruitee
18
+ from atsfill.adapters.rippling import Rippling
17
19
  from atsfill.adapters.smartrecruiters import SmartRecruiters
18
20
  from atsfill.adapters.teamtailor import Teamtailor
19
21
  from atsfill.adapters.workable import Workable
@@ -44,6 +46,8 @@ def adapters() -> list[Adapter]:
44
46
  BambooHR(),
45
47
  ZohoRecruit(),
46
48
  Personio(),
49
+ Rippling(),
47
50
  Pinpoint(),
51
+ Eightfold(),
48
52
  Phenom(), # last: it claims a URL by its path alone
49
53
  ]
@@ -0,0 +1,582 @@
1
+ """Eightfold (`<employer>.eightfold.ai/careers/job/<pid>` career sites, and the same pages on
2
+ the employer's own domain), as docs/adapters/eightfold.md observes.
3
+
4
+ A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
5
+ sections of questions by id, type, requirement and options, and the questions another one's
6
+ answer shows. `read_api` fetches it over HTTP, `read_form` through the page's browser context,
7
+ and both list the fields from it. Each question renders as a control whose element id is its
8
+ section's title and its id, with every other character an underscore.
9
+ """
10
+
11
+ import http.cookiejar
12
+ import json
13
+ import re
14
+ import urllib.request
15
+ from contextlib import suppress
16
+ from dataclasses import dataclass, field, replace
17
+ from datetime import date, datetime
18
+ from typing import Any
19
+ from urllib.parse import parse_qs, urlencode, urlsplit
20
+
21
+ from atsfill.core import browser
22
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
23
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
24
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
25
+
26
+ HOST = re.compile(r"^[\w-]+\.eightfold\.ai$")
27
+ JOB = re.compile(r"^/careers/job/(\d+)")
28
+ APPLY_PATH = "/careers/apply"
29
+ KEPT = ("domain", "microsite") # the query parameters that pick the employer's site
30
+ POSITION = "/api/pcsx/position_details?position_id={pid}&hl=en"
31
+ QUESTIONS = "/api/application/v2/questions?pids={pid}&user_mode=logged_out_candidate"
32
+ CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
33
+ SUBMIT = "[data-test-id=submitApplicationButton]"
34
+ AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
35
+ COOKIES = [
36
+ "#onetrust-reject-all-handler",
37
+ "#truste-consent-required",
38
+ "#didomi-notice-disagree-button",
39
+ ]
40
+ # An employer's own banner, declined by its button's name.
41
+ BANNERS = ", ".join(
42
+ f"[{attribute}*={word} i]"
43
+ for word in ("cookie", "consent", "truste")
44
+ for attribute in ("id", "class")
45
+ )
46
+ DECLINE = re.compile(
47
+ r"^\s*((reject|decline|refuse|deny)( all)?( cookies)?|(required|necessary|essential) only)\s*$",
48
+ re.IGNORECASE,
49
+ )
50
+ PICKER = "[data-testid=picker-dialog]"
51
+ MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
52
+ FORM_TIMEOUT_MS = 30_000
53
+ COOKIE_TIMEOUT_MS = 5_000
54
+ SHOWN_TIMEOUT_MS = 2_000
55
+ OPTIONS_TIMEOUT_MS = 5_000
56
+ UPLOAD_TIMEOUT_MS = 30_000
57
+ SUBMIT_TIMEOUT_MS = 90_000
58
+ PARSE_SETTLE_MS = 2_000
59
+ RESUME_UPLOAD = re.compile(r"/api/application/v2/resume_upload")
60
+ PICKER_MONTHS = 1200 # the furthest a date is paged to, in months
61
+ DATE_FORMATS = ["%d %b %Y", "%m/%d/%Y", "%d/%m/%Y", "%Y-%m-%d", "%b %d, %Y"]
62
+ UA = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"
63
+ DIAL = re.compile(r"\(\+([\d ]+)\)")
64
+ FLAG = 0x1F1E6 # regional indicator A: a flag emoji is the country's code in these letters
65
+ PRIMARY = {"1": "US", "7": "RU", "44": "GB", "47": "NO", "61": "AU", "358": "FI"}
66
+
67
+ # A question's kind, by its type; a dropdown whose options load from another answer is typed.
68
+ KINDS = {
69
+ "text": "text",
70
+ "select": "select",
71
+ "radio": "radio",
72
+ "checkbox": "checkbox",
73
+ "date": "date",
74
+ "phone": "phone",
75
+ "resumeUpload": "file",
76
+ "fileUpload": "file",
77
+ "positionLocation": "pills",
78
+ }
79
+ TYPES = {
80
+ "text": FieldType.TEXT,
81
+ "select": FieldType.CHOICE,
82
+ "lookup": FieldType.TYPEAHEAD,
83
+ "radio": FieldType.CHOICE,
84
+ "checkbox": FieldType.MULTI_CHOICE,
85
+ "date": FieldType.DATE,
86
+ "phone": FieldType.PHONE,
87
+ "file": FieldType.FILE,
88
+ "pills": FieldType.MULTI_CHOICE,
89
+ }
90
+ NOT_FIELDS = {"instruction"}
91
+
92
+ _FETCH = """async ([url, body]) => {
93
+ const csrf = document.querySelector('meta[name=_csrf]')?.content;
94
+ const init = body === null ? {} : {method: 'POST', body, headers: {
95
+ 'Content-Type': 'application/json', 'X-CSRF-Token': csrf}};
96
+ const r = await fetch(url, init);
97
+ return r.ok ? {text: await r.text()} : {status: r.status};
98
+ }"""
99
+
100
+ # The element id of each question's control, by its id: the shortest id on the page that ends
101
+ # with it, among the ids each field's error line carries.
102
+ _DOM_IDS = """keys => {
103
+ const ids = [...document.querySelectorAll('[id$="_error"]')].map(e => e.id.slice(0, -6));
104
+ const out = {};
105
+ for (const [qid, ends] of keys) {
106
+ const hits = ids.filter(id => ends.some(end => id === end || id.endsWith('_' + end)));
107
+ if (hits.length) out[qid] = hits.sort((a, b) => a.length - b.length)[0];
108
+ }
109
+ return out;
110
+ }"""
111
+
112
+ _READ_BACK = """controls => {
113
+ const out = {};
114
+ for (const [qid, dom, kind] of controls) {
115
+ const err = document.getElementById(dom + '_error');
116
+ const box = err?.parentElement;
117
+ if (!box) continue;
118
+ const text = el => (el?.textContent || '').trim();
119
+ if (kind === 'text') {
120
+ out[qid] = box.querySelector('input:not([type=file]), textarea')?.value ?? null;
121
+ } else if (kind === 'select' || kind === 'lookup' || kind === 'date') {
122
+ out[qid] = box.querySelector('input[role=combobox]')?.value ?? null;
123
+ } else if (kind === 'radio') {
124
+ const on = box.querySelector('input[type=radio]:checked');
125
+ out[qid] = on ? text(box.querySelector(`label[for="${CSS.escape(on.id)}"]`)) : null;
126
+ } else if (kind === 'checkbox') {
127
+ out[qid] = [...box.querySelectorAll('input[type=checkbox]:checked')]
128
+ .map(on => text(box.querySelector(`label[for="${CSS.escape(on.id)}"]`)));
129
+ } else if (kind === 'pills') {
130
+ out[qid] = [...box.querySelectorAll('[id^=selectPilllist] > span')].map(text);
131
+ } else if (kind === 'phone') {
132
+ const code = box.querySelector('input[role=combobox]')?.value || '';
133
+ const dial = (code.match(/\\(\\+([\\d ]+)\\)/) || [])[1];
134
+ const number = box.querySelector('input:not([role=combobox])')?.value || '';
135
+ out[qid] = number ? (dial ? `+${dial.replace(/ /g, '')} ${number}` : number) : null;
136
+ } else if (kind === 'file') {
137
+ const names = [...box.querySelectorAll('li span[title]')].map(s => s.title);
138
+ out[qid] = names[0] ?? null;
139
+ }
140
+ }
141
+ return out;
142
+ }"""
143
+
144
+
145
+ @dataclass
146
+ class _Control:
147
+ field: Field
148
+ kind: str
149
+ ends: list[str] # what the control's element id ends with
150
+ # the questions whose answer renders it, each with the answers that do (any when empty)
151
+ parents: list[tuple[str, list[str]]] = field(default_factory=list)
152
+ resume: bool = False
153
+ dom: str | None = None
154
+
155
+
156
+ class Eightfold:
157
+ name = "eightfold"
158
+ known_gaps: dict[str, Gap] = {}
159
+ side_effects = [
160
+ SideEffect(
161
+ "upload",
162
+ "hiring_system",
163
+ "the resume is uploaded to Eightfold and parsed at once, after agreeing to the "
164
+ "dialog that says job recommendations follow by email",
165
+ ),
166
+ SideEffect("submit", "employer", "the application reaches the employer"),
167
+ ]
168
+
169
+ def __init__(self) -> None:
170
+ self._controls: list[_Control] = [] # in the form's order
171
+
172
+ def detect(self, url: str, page: Page | None) -> bool:
173
+ if _pid(url) is None:
174
+ return False
175
+ if page is None or HOST.match(urlsplit(url).hostname or ""):
176
+ return True
177
+ return page.locator("meta[name=_csrf]").count() > 0 and "_EF_GROUP_ID" in page.content()
178
+
179
+ def posting(self, url: str) -> str:
180
+ parts, pid = urlsplit(url), _pid(url)
181
+ return f"https://{parts.hostname}/careers/job/{pid}" if pid else url
182
+
183
+ def read_api(self, url: str) -> list[Field] | None:
184
+ if (pid := _pid(url)) is None:
185
+ return None
186
+ try:
187
+ jar = urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
188
+ opener = urllib.request.build_opener(jar)
189
+ opener.addheaders = [("User-Agent", UA)]
190
+ base = _base(url)
191
+ position = json.loads(_get(opener, base + _query(POSITION.format(pid=pid), url)))
192
+ _applies(position)
193
+ html = _get(opener, _apply_url(url, pid))
194
+ if (csrf := CSRF.search(html)) is None:
195
+ return None
196
+ request = urllib.request.Request(
197
+ base + _query(QUESTIONS.format(pid=pid), url),
198
+ data=b'{"answers":{}}',
199
+ headers={"Content-Type": "application/json", "X-CSRF-Token": csrf[1]},
200
+ )
201
+ api = json.loads(_get(opener, request))
202
+ return [c.field for c in _form(api["data"])]
203
+ except OSError, ValueError, KeyError, TypeError, Unsupported, Blocked:
204
+ return None
205
+
206
+ def read_form(self, page: Page) -> list[Field]:
207
+ pid = _pid(page.url)
208
+ if pid is None:
209
+ raise Unsupported(f"posting_gone: {page.url} is not a job page")
210
+ position = page.evaluate(_FETCH, [_query(POSITION.format(pid=pid), page.url), None])
211
+ if "status" in position:
212
+ raise Unsupported(f"posting_gone: the position API answered {position['status']}")
213
+ _applies(json.loads(position["text"]))
214
+ if urlsplit(page.url).path != APPLY_PATH:
215
+ page.goto(_apply_url(page.url, pid))
216
+ try:
217
+ page.locator(SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
218
+ except Timeout:
219
+ raise Unsupported("posting_gone: no application form on the apply page") from None
220
+ api = page.evaluate(_FETCH, [_query(QUESTIONS.format(pid=pid), page.url), '{"answers":{}}'])
221
+ if "status" in api:
222
+ raise Unsupported(f"posting_gone: the questions API answered {api['status']}")
223
+ self._controls = _form(json.loads(api["text"])["data"])
224
+ _decline_cookies(page)
225
+ doms = page.evaluate(_DOM_IDS, [[c.field.id, c.ends] for c in self._controls])
226
+ for control in self._controls:
227
+ control.dom = doms.get(control.field.id)
228
+ if control.field.unfillable:
229
+ continue
230
+ if control.dom is None and not control.parents:
231
+ reason = f"unhandled_rendering: {control.field.id} is not on the page"
232
+ control.field = replace(control.field, unfillable=reason)
233
+ elif control.dom is not None and _prefilled(page, control):
234
+ control.field = replace(control.field, prefilled=True)
235
+ fields = [c.field for c in self._controls]
236
+ self._controls = [c for c in self._controls if not c.field.unfillable]
237
+ return fields
238
+
239
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
240
+ # the resume first: what its parse finds is written over the fields filled before it
241
+ order = sorted(self._controls, key=lambda c: not c.resume)
242
+ _decline_cookies(page, 0) # a banner shown since the read
243
+ given: dict[str, Answer] = {}
244
+ for control in order:
245
+ if (answer := answers.get(control.field.id)) is None:
246
+ continue
247
+ if control.dom is None: # rendered once a question that shows it is answered
248
+ if not any(_shows(given.get(p), values) for p, values in control.parents):
249
+ continue
250
+ with suppress(Timeout):
251
+ _box_of(page, control).wait_for(timeout=SHOWN_TIMEOUT_MS)
252
+ control.dom = _find(page, control)
253
+ if control.dom is None:
254
+ continue
255
+ try:
256
+ _fill(page, control, answer)
257
+ except browser.Covered: # a banner that showed late
258
+ page.keyboard.press("Escape")
259
+ _decline_cookies(page)
260
+ _fill(page, control, answer)
261
+ given[control.field.id] = answer
262
+
263
+ def read_back(self, page: Page) -> dict[str, Answer]:
264
+ unplaced = [[c.field.id, c.ends] for c in self._controls if c.dom is None]
265
+ doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
266
+ for control in self._controls:
267
+ control.dom = control.dom or doms.get(control.field.id)
268
+ shown = [c for c in self._controls if c.dom]
269
+ values = page.evaluate(_READ_BACK, [[c.field.id, c.dom, c.kind] for c in shown])
270
+ out: dict[str, Answer] = {}
271
+ for control in shown:
272
+ value = values.get(control.field.id)
273
+ if control.kind == "date" and value:
274
+ value = _iso(value)
275
+ if value not in (None, "", []):
276
+ out[control.field.id] = value
277
+ return out
278
+
279
+ def submit(self, page: Page) -> str | None:
280
+ """Clicks submit; the evidence is the application API's answer."""
281
+ with page.expect_response(
282
+ lambda r: (
283
+ r.request.method == "POST" and "/api/application/" in r.url and "apply" in r.url
284
+ ),
285
+ timeout=SUBMIT_TIMEOUT_MS,
286
+ ) as applied:
287
+ _click(page.locator(SUBMIT))
288
+ response = applied.value
289
+ if not response.ok:
290
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
291
+ return f"{response.url.split('?')[0]} {response.status}"
292
+
293
+
294
+ def _pid(url: str) -> str | None:
295
+ parts = urlsplit(url)
296
+ if job := JOB.match(parts.path):
297
+ return job[1]
298
+ if parts.path == APPLY_PATH and (pid := parse_qs(parts.query).get("pid")):
299
+ return pid[0] if pid[0].isdigit() else None
300
+ return None
301
+
302
+
303
+ def _base(url: str) -> str:
304
+ return f"https://{urlsplit(url).hostname}"
305
+
306
+
307
+ def _query(path: str, url: str) -> str:
308
+ """The path with the parameters of `url` that pick the employer's site."""
309
+ kept = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items() if k in KEPT}
310
+ return f"{path}&{urlencode(kept)}" if kept else path
311
+
312
+
313
+ def _apply_url(url: str, pid: str) -> str:
314
+ return _base(url) + _query(f"{APPLY_PATH}?pid={pid}", url)
315
+
316
+
317
+ def _get(opener, request) -> str:
318
+ with opener.open(request, timeout=HTTP_TIMEOUT_S) as response:
319
+ return response.read().decode()
320
+
321
+
322
+ def _applies(position: dict[str, Any]) -> None:
323
+ """Raises when the posting is not applied to on this page."""
324
+ data = position.get("data") or {}
325
+ action = ((data.get("positionUserActions") or {}).get("applyAction")) or {}
326
+ match action.get("status"):
327
+ case "link_off":
328
+ host = urlsplit(action.get("applyUrl") or "").hostname
329
+ raise Unsupported(f"external_step: the application is on {host}")
330
+ case "log_in":
331
+ raise Blocked("login_required: the employer's career site asks to sign in to apply")
332
+ case "allowed" | None:
333
+ return
334
+ case other:
335
+ raise Unsupported(f"posting_gone: the apply action is {other}")
336
+
337
+
338
+ def _norm(qid: str) -> str:
339
+ return re.sub(r"[^A-Za-z0-9_]", "_", qid)
340
+
341
+
342
+ def _plain(label: str) -> str:
343
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", label)).strip()
344
+
345
+
346
+ def _label(q: dict[str, Any], by_id: dict[str, dict[str, Any]], depth: int = 0) -> str:
347
+ """The question's text; one some of a parent's answers render says so, naming the first
348
+ parent by its own label, so that a parent asked for other answers names it apart."""
349
+ label = _plain(q.get("label") or "")
350
+ if (q.get("textOptions") or {}).get("type") == "number": # a number input takes no text
351
+ label += " (a number)"
352
+ first = (q.get("parentDependencies") or [{}])[0]
353
+ if not first.get("values") or depth > MAX_DEPTH:
354
+ return label
355
+ parent = by_id.get(str(first["questionId"]), {})
356
+ labels = {str(c["value"]): c["label"] for c in _choices(parent)}
357
+ shown = " or ".join(labels.get(str(v), str(v)) for v in first["values"])
358
+ named = _label(parent, by_id, depth + 1)
359
+ quote = '"' if "'" in named else "'" # never escaped: the outermost is the last
360
+ return f"{label} (asked when {quote}{named}{quote} is {shown})"
361
+
362
+
363
+ def _choices(q: dict[str, Any]) -> list[dict[str, Any]]:
364
+ for key in ("selectOptions", "radioOptions", "checkboxOptions"):
365
+ if (options := q.get(key)) and options.get("choices"):
366
+ return options["choices"]
367
+ return []
368
+
369
+
370
+ def _form(data: dict[str, Any]) -> list[_Control]:
371
+ questions = [q for s in data["fields"] for q in s["questions"]]
372
+ by_id = {str(q["questionId"]): q for q in questions}
373
+ controls = []
374
+ for q in questions:
375
+ if q.get("type") in NOT_FIELDS:
376
+ continue
377
+ controls.append(_control(q, by_id))
378
+ return controls
379
+
380
+
381
+ def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
382
+ qid, label = str(q["questionId"]), _label(q, by_id)
383
+ kind = KINDS.get(q.get("type", ""))
384
+ choices = _choices(q)
385
+ if kind == "select" and not choices and (q.get("selectOptions") or {}).get("apiEndpoint"):
386
+ kind = "lookup" # options load once the question it depends on is answered
387
+ required = bool(q.get("required"))
388
+ parents = []
389
+ for dependency in q.get("parentDependencies") or []:
390
+ parent = by_id.get(str(dependency["questionId"]), {})
391
+ labels = {str(c["value"]): c["label"] for c in _choices(parent)}
392
+ values = [labels.get(str(v), str(v)) for v in dependency.get("values") or []]
393
+ parents.append((str(dependency["questionId"]), values))
394
+ if parents and parents[0][1]: # asked for some of its parent's answers only
395
+ required = False
396
+ ends = list(dict.fromkeys([_norm(qid), qid]))
397
+ if kind is None:
398
+ reason = f"unhandled_rendering: {qid} is a question of type {q.get('type')}"
399
+ return _Control(Field(qid, label, FieldType.TEXT, required, unfillable=reason), "", ends)
400
+ options = [c["label"] for c in choices] or None
401
+ accept = None
402
+ if kind == "file":
403
+ files = q.get("resumeOptions") or q.get("fileUploadOptions") or {}
404
+ types = files.get("acceptedFileTypes") or (files.get("fileFormats") or "").split(",")
405
+ accept = [t.strip() for t in types if t.strip()] or None
406
+ today = bool((q.get("dateOptions") or {}).get("today")) # today's date, read-only
407
+ question = Field(qid, label, TYPES[kind], required, options, accept=accept, prefilled=today)
408
+ return _Control(question, kind, ends, parents, q.get("type") == "resumeUpload")
409
+
410
+
411
+ def _decline_cookies(page: Page, wait_ms: int = COOKIE_TIMEOUT_MS) -> None:
412
+ """Declines the employer's cookie banner, which covers the form's lower half."""
413
+ decline = page.locator(", ".join(COOKIES)).or_(
414
+ page.locator(BANNERS).get_by_role("button", name=DECLINE)
415
+ )
416
+ if wait_ms: # a timeout of 0 waits for ever
417
+ with suppress(Timeout):
418
+ decline.first.wait_for(timeout=wait_ms)
419
+ for button in decline.all():
420
+ if button.is_visible():
421
+ button.click()
422
+ return
423
+
424
+
425
+ def _find(page: Page, control: _Control) -> str | None:
426
+ return page.evaluate(_DOM_IDS, [[control.field.id, control.ends]]).get(control.field.id)
427
+
428
+
429
+ def _box_of(page: Page, control: _Control):
430
+ ends = " , ".join(f'[id$="_{end}_error"]' for end in control.ends if '"' not in end)
431
+ return page.locator(ends or "#none").first
432
+
433
+
434
+ def _box(page: Page, control: _Control):
435
+ return page.locator(f"[id={json.dumps(f'{control.dom}_error')}]").locator("xpath=..")
436
+
437
+
438
+ def _prefilled(page: Page, control: _Control) -> bool:
439
+ if control.kind in ("text", "select", "date"):
440
+ el = _box(page, control).locator("input:not([type=file]), textarea").first
441
+ return bool(el.count() and el.input_value())
442
+ if control.kind == "pills":
443
+ return _box(page, control).locator("[id^=selectPilllist]").count() > 0
444
+ return False
445
+
446
+
447
+ def _fill(page: Page, control: _Control, answer: Answer) -> None:
448
+ box = _box(page, control)
449
+ value = answer if isinstance(answer, str) else (answer[0] if answer else "")
450
+ match control.kind:
451
+ case "text":
452
+ box.locator("input:not([type=file]), textarea").first.fill(value)
453
+ case "select":
454
+ _pick(page, box.locator("input[role=combobox]").first, value, exact=True)
455
+ case "lookup":
456
+ _pick(page, box.locator("input[role=combobox]").first, value, exact=False)
457
+ case "radio" | "checkbox": # the input lies over its label
458
+ picks = [answer] if isinstance(answer, str) else answer
459
+ labels = box.locator("label[for]").all()
460
+ if missing := set(picks) - {label.inner_text().strip() for label in labels}:
461
+ raise NotTaken(f"no option {sorted(missing)[0]!r}")
462
+ for label in labels:
463
+ choice = box.locator(f"[id={json.dumps(label.get_attribute('for'))}]")
464
+ if choice.is_checked() != (label.inner_text().strip() in picks):
465
+ _click(choice)
466
+ case "pills":
467
+ picks = [answer] if isinstance(answer, str) else answer
468
+ pills = box.locator("[id^=selectPilllist]")
469
+ for pill in pills.all():
470
+ if pill.locator("span").first.inner_text().strip() not in picks:
471
+ _click(pill.locator("button"))
472
+ shown = [p.locator("span").first.inner_text().strip() for p in pills.all()]
473
+ for pick in picks:
474
+ if pick not in shown:
475
+ _pick(page, box.locator("input[role=combobox]").first, pick, exact=True)
476
+ case "date":
477
+ _date(page, box.locator("input[role=combobox]").first, date.fromisoformat(value))
478
+ case "phone":
479
+ _phone(page, box, value)
480
+ case "file":
481
+ _upload(page, box, value, control.resume)
482
+
483
+
484
+ def _pick(page: Page, combo, value: str, exact: bool) -> None:
485
+ """Opens a dropdown and clicks the option with the value; a lookup's options are those its
486
+ API gives for the answers so far."""
487
+ _click(combo)
488
+ options = page.locator(f"[id={json.dumps(combo.get_attribute('aria-controls'))}] [role=option]")
489
+ with suppress(Timeout):
490
+ options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
491
+ pattern = _exact(value) if exact else re.compile(rf"^\s*{re.escape(value)}", re.IGNORECASE)
492
+ option = options.filter(has_text=pattern).first
493
+ if not option.count():
494
+ page.keyboard.press("Escape")
495
+ raise NotTaken(f"no option {value!r}")
496
+ option.click()
497
+
498
+
499
+ def _phone(page: Page, box, value: str) -> None:
500
+ """Picks the country code the number starts with, the longest that fits, then types the
501
+ rest of the number. Where several countries share it, its main country."""
502
+ digits = re.sub(r"\D", "", value)
503
+ combo = box.locator("input[role=combobox]").first
504
+ number = box.locator("input:not([role=combobox])").first
505
+ if not value.startswith("+"):
506
+ number.fill(value)
507
+ return
508
+ _click(combo)
509
+ options = page.locator(f"[id={json.dumps(combo.get_attribute('aria-controls'))}] [role=option]")
510
+ options.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
511
+ texts = options.all_inner_texts()
512
+ fits = []
513
+ for i, text in enumerate(texts):
514
+ if (dial := DIAL.search(text)) and digits.startswith(code := dial[1].replace(" ", "")):
515
+ fits.append((len(code), _country(text) == PRIMARY.get(code), -i, code))
516
+ if not fits:
517
+ page.keyboard.press("Escape")
518
+ number.fill(digits)
519
+ return
520
+ _, _, i, code = max(fits)
521
+ options.nth(-i).click()
522
+ number.fill(digits[len(code) :])
523
+
524
+
525
+ def _country(text: str) -> str:
526
+ letters = [chr(ord(c) - FLAG + ord("A")) for c in text[:2] if FLAG <= ord(c) < FLAG + 26]
527
+ return "".join(letters)
528
+
529
+
530
+ def _date(page: Page, combo, day: date) -> None:
531
+ """Opens the date picker and pages to the day's month, then clicks it."""
532
+ _click(combo)
533
+ picker = page.locator(PICKER).last
534
+ picker.wait_for(timeout=OPTIONS_TIMEOUT_MS)
535
+ cell = picker.locator(f"td[title='{day.isoformat()}'] [role=button]")
536
+ for _ in range(PICKER_MONTHS):
537
+ if cell.count():
538
+ break
539
+ shown = picker.locator("td[title]").first.get_attribute("title") or ""
540
+ step = "next" if shown < day.isoformat() else "prev"
541
+ picker.locator(f"[data-testid=picker-header-{step}-btn]").click()
542
+ cell.click()
543
+
544
+
545
+ def _upload(page: Page, box, path: str, resume: bool) -> None:
546
+ """Attaches a file; a resume first asks to agree to the site's privacy terms, and is then
547
+ uploaded and parsed at once."""
548
+ box.locator("input[type=file]").first.set_input_files(path)
549
+ if resume:
550
+ with suppress(Timeout):
551
+ page.locator(AGREE).wait_for(timeout=OPTIONS_TIMEOUT_MS)
552
+ if page.locator(AGREE).is_visible():
553
+ with page.expect_response(RESUME_UPLOAD, timeout=UPLOAD_TIMEOUT_MS):
554
+ _click(page.locator(AGREE))
555
+ page.wait_for_timeout(PARSE_SETTLE_MS) # what the parse finds lands after
556
+ box.locator("li span[title]").first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
557
+
558
+
559
+ def _click(target) -> None:
560
+ """Clicks the target scrolled to the middle of the window, clear of the sticky footer."""
561
+ target.evaluate("el => el.scrollIntoView({block: 'center'})")
562
+ browser.click(target)
563
+
564
+
565
+ def _iso(shown: str) -> str:
566
+ """A date as the picker shows it, in the site's locale, back to ISO."""
567
+ for pattern in DATE_FORMATS:
568
+ with suppress(ValueError):
569
+ return datetime.strptime(shown, pattern).date().isoformat()
570
+ return shown
571
+
572
+
573
+ def _shows(answer: Answer | None, values: list[str]) -> bool:
574
+ """Whether a parent's answer renders its dependent: any answer when `values` is empty."""
575
+ if answer in (None, "", []):
576
+ return False
577
+ picks = [answer] if isinstance(answer, str) else answer
578
+ return not values or any(p in values for p in picks)
579
+
580
+
581
+ def _exact(value: str) -> re.Pattern[str]:
582
+ return re.compile(rf"^\s*{re.escape(value)}\s*$")
@@ -0,0 +1,457 @@
1
+ """Rippling Recruiting (`ats.rippling.com/<board>/jobs/<job>`), as docs/adapters/rippling.md
2
+ observes.
3
+
4
+ The job's apply page (`…/apply`) carries its form in the page data: the standard questions by
5
+ id and type, the employer's question groups, and whether the EEO questionnaire is asked.
6
+ `read_api` fetches the page over HTTP, `read_form` reads the data of the page it opened, and
7
+ both list the fields from it. Each field renders in an element whose `data-testid` is the
8
+ field's id; the résumé is uploaded and parsed as soon as it is attached.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ import urllib.request
14
+ from contextlib import suppress
15
+ from dataclasses import replace
16
+ from typing import Any
17
+ from urllib.parse import urlsplit
18
+
19
+ from atsfill.core import browser
20
+ from atsfill.core.adapter import Gap, NotTaken, Unsupported, out_of_scope
21
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
22
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
23
+
24
+ HOST = "ats.rippling.com"
25
+ JOB = re.compile(r"^(?:/[a-z]{2}-[A-Z]{2})?/([^/]+)/jobs/([0-9a-f-]{36})(?:/|$)")
26
+ POSTING = "https://ats.rippling.com/{board}/jobs/{job}"
27
+ APPLY = POSTING + "/apply"
28
+ APPLIED = re.compile(r"^https://ats\.rippling\.com/api/.*/apply")
29
+ PARSED = re.compile(r"/parse_resume$")
30
+ PARSED_NOTICE = re.compile(r"^Résumé parsing was (successful|unsuccessful)")
31
+ DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
32
+ FORM_TIMEOUT_MS = 30_000
33
+ PARSE_TIMEOUT_MS = 30_000
34
+ UPLOAD_TIMEOUT_MS = 60_000
35
+ NOTICE_TIMEOUT_MS = 15_000
36
+ OPTIONS_TIMEOUT_MS = 10_000
37
+ PLACES_QUIET_MS = 500 # how long the place suggestions stay unchanged before one is picked
38
+ SUBMIT_TIMEOUT_MS = 90_000
39
+ DIAL_CODES = (3, 2, 1) # the lengths a dial code can have, longest tried first
40
+ FILE_TYPES = [".doc", ".docx", ".pdf"]
41
+ OTHER = "Other"
42
+ COOKIES = "#transcend-consent-manager"
43
+ TESTIDS = {"pronouns": "pronouns_strategy"} # a field whose element is not named by its id
44
+
45
+ # A standard question's type, by its `fieldType`; the location is a place typeahead.
46
+ BASIC = {
47
+ "SHORT_ANSWER": FieldType.TEXT,
48
+ "PHONE_NUMBER": FieldType.PHONE,
49
+ "FILE": FieldType.FILE,
50
+ "PRONOUN": FieldType.CHOICE,
51
+ }
52
+ # An employer's question's type, by its `questionType`.
53
+ CUSTOM = {
54
+ "SHORT_ANSWER": FieldType.TEXT,
55
+ "LONG_ANSWER": FieldType.TEXT,
56
+ "DATE": FieldType.DATE,
57
+ "DOCUMENT_UPLOAD": FieldType.FILE,
58
+ "SINGLE_SELECT_RADIO": FieldType.CHOICE,
59
+ "SINGLE_SELECT_DROPDOWN": FieldType.CHOICE,
60
+ "KNOCKOUT": FieldType.CHOICE,
61
+ "YES_NO_SCALE_4": FieldType.CHOICE,
62
+ "MULTI_SELECT_CHECKBOX": FieldType.MULTI_CHOICE,
63
+ }
64
+ SCALE = ["Strong No", "No", "Yes", "Strong Yes"]
65
+ # "Enter my pronouns" opens a text box instead of picking.
66
+ PRONOUNS = [
67
+ "She/her/hers",
68
+ "He/him/his",
69
+ "They/them/theirs",
70
+ "She/her/hers, they/them/theirs",
71
+ "He/him/his, they/them/theirs",
72
+ "Just use my name",
73
+ ]
74
+ # The EEO questionnaire, the same on every form that asks it; every answer optional.
75
+ EEOC = [
76
+ ("eeoc.gender", "Gender", ["Male", "Female", "Non-binary", "Choose not to disclose"]),
77
+ (
78
+ "eeoc.race",
79
+ "Please identify your race",
80
+ [
81
+ "American Indian or Alaskan Native",
82
+ "Asian",
83
+ "Black or African American",
84
+ "Middle Eastern or North African",
85
+ "White",
86
+ "Native Hawaiian or other Pacific Islander",
87
+ "Two or more races",
88
+ "Choose not to disclose",
89
+ ],
90
+ ),
91
+ ("eeoc.hispanicOrLatino", "Are you Hispanic/Latino?", ["Yes", "No", "Choose not to disclose"]),
92
+ (
93
+ "eeoc.veteranStatus",
94
+ "Veteran Status",
95
+ [
96
+ "I am not a protected veteran",
97
+ "I identify as one or more of the classifications of a protected veteran",
98
+ "Choose not to disclose",
99
+ ],
100
+ ),
101
+ ]
102
+ SMS = (
103
+ "sms_opt_in",
104
+ "Text message updates about this application",
105
+ [
106
+ "Yes - I consent to receiving text messages",
107
+ "No - I do not consent to receiving text messages",
108
+ ],
109
+ )
110
+
111
+ # Resolves true once no suggestion list has changed for `quiet` ms, false after `limit` ms.
112
+ _QUIET = """([quiet, limit]) => new Promise(done => {
113
+ let timer;
114
+ const end = (settled) => { observer.disconnect(); clearTimeout(timer); clearTimeout(cap);
115
+ done(settled); };
116
+ const wait = () => { clearTimeout(timer); timer = setTimeout(() => end(true), quiet); };
117
+ const list = n => n.nodeType === 1
118
+ && (n.matches("[role=listbox]") || n.querySelector("[role=listbox]"));
119
+ const listed = m => (m.target.nodeType === 1 ? m.target : m.target.parentElement)
120
+ ?.closest("[role=listbox]") || [...m.addedNodes, ...m.removedNodes].some(list);
121
+ const observer = new MutationObserver(ms => { if (ms.some(listed)) wait(); });
122
+ observer.observe(document.body, {childList: true, subtree: true, characterData: true});
123
+ const cap = setTimeout(() => end(false), limit);
124
+ wait();
125
+ })"""
126
+
127
+ # The field's control and what it shows, by its rendering: the first element carrying the
128
+ # field's `data-testid`, or the second for an "Other" answer's text box.
129
+ _SHOWN = """(root, [id, nth, testids]) => {
130
+ const el = [...root.querySelectorAll("[data-testid]")]
131
+ .filter(e => e.dataset.testid === (testids[id] ?? id))[nth];
132
+ if (!el) return {kind: "missing"};
133
+ const text = e => e.innerText.trim();
134
+ if (el.getAttribute("role") === "radiogroup") {
135
+ const on = el.querySelector("[role=radio][aria-checked=true]");
136
+ return {kind: "radio", value: on ? (on.getAttribute("aria-label") || text(on)) : null};
137
+ }
138
+ if (el.getAttribute("role") === "group")
139
+ return {kind: "checkbox", value: [...el.querySelectorAll("[role=checkbox]")]
140
+ .filter(e => e.getAttribute("aria-checked") === "true").map(text)};
141
+ if (el.querySelector("input[type=file]"))
142
+ return {kind: "file", value: [...el.querySelectorAll("[data-testid=chip] [id$=-content]")]
143
+ .map(text)[0] ?? null};
144
+ if (el.querySelector("[data-input=month]")) {
145
+ const part = p => el.querySelector(`[data-input=${p}]`).value;
146
+ const [y, m, d] = [part("year"), part("month"), part("day")];
147
+ return {kind: "date",
148
+ value: y && m && d ? `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}` : null};
149
+ }
150
+ const box = el.querySelector("[role=combobox]");
151
+ if (box && box.tagName !== "INPUT")
152
+ return {kind: "dropdown", value: box.querySelector("p") ? null : text(box)};
153
+ if (box) return {kind: "search", value: box.value || null};
154
+ const input = el.matches("input, textarea") ? el : el.querySelector("input, textarea");
155
+ if (input) return {kind: "text", value: input.value};
156
+ return {kind: "unknown"};
157
+ }"""
158
+
159
+
160
+ class Rippling:
161
+ name = "rippling"
162
+ known_gaps: dict[str, Gap] = {}
163
+ side_effects = [
164
+ SideEffect("upload", "hiring_system", "an attached file is uploaded, a résumé parsed"),
165
+ SideEffect("submit", "employer", "the application reaches the employer"),
166
+ ]
167
+
168
+ def __init__(self) -> None:
169
+ self._fields: dict[str, Field] = {}
170
+
171
+ def detect(self, url: str, page: Page | None) -> bool:
172
+ return _job(url) is not None
173
+
174
+ def posting(self, url: str) -> str:
175
+ job = _job(url)
176
+ return POSTING.format(board=job[0], job=job[1]) if job else url
177
+
178
+ def read_api(self, url: str) -> list[Field] | None:
179
+ if (job := _job(url)) is None:
180
+ return None
181
+ request = urllib.request.Request(
182
+ APPLY.format(board=job[0], job=job[1]), headers={"User-Agent": "Mozilla/5.0"}
183
+ )
184
+ try:
185
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
186
+ html = response.read().decode()
187
+ match = DATA.search(html)
188
+ return _form(_api(json.loads(match[1]))) if match else None
189
+ except OSError, ValueError, KeyError, TypeError, Unsupported:
190
+ return None
191
+
192
+ def read_form(self, page: Page) -> list[Field]:
193
+ job = _job(page.url)
194
+ assert job is not None
195
+ apply = APPLY.format(board=job[0], job=job[1])
196
+ if urlsplit(page.url).path != urlsplit(apply).path:
197
+ page.goto(apply)
198
+ try:
199
+ page.locator("[data-testid=Apply]").wait_for(timeout=FORM_TIMEOUT_MS)
200
+ except Timeout:
201
+ raise Unsupported("posting_gone: no application form on the page") from None
202
+ _hide_cookies(page)
203
+ data = page.locator("#__NEXT_DATA__").text_content() or "{}"
204
+ out = []
205
+ for field in _form(_api(json.loads(data))):
206
+ if not field.unfillable and not field.id.endswith(("_other", "_comment")):
207
+ kind = page.locator("body").evaluate(_SHOWN, [field.id, 0, TESTIDS])["kind"]
208
+ if kind in ("missing", "unknown"):
209
+ reason = f"unhandled_rendering: {field.id} is {kind} on the page"
210
+ field = replace(field, unfillable=reason)
211
+ out.append(field)
212
+ self._fields = {f.id: f for f in out if not f.unfillable}
213
+ return out
214
+
215
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
216
+ if "resume" in answers: # its parse fills the fields it finds: first, so they are ours
217
+ with page.expect_response(
218
+ lambda r: PARSED.search(urlsplit(r.url).path) is not None,
219
+ timeout=PARSE_TIMEOUT_MS,
220
+ ):
221
+ _attach(page, "resume", answers["resume"])
222
+ with suppress(Timeout): # its notice covers the fields below it while it shows
223
+ page.get_by_text(PARSED_NOTICE).wait_for(state="hidden", timeout=NOTICE_TIMEOUT_MS)
224
+ for field_id, field in self._fields.items():
225
+ if field_id == "resume" or field_id not in answers:
226
+ continue
227
+ answer = answers[field_id]
228
+ value = answer if isinstance(answer, str) else answer[0]
229
+ if field_id.endswith("_comment"):
230
+ _comment(page, field_id.removesuffix("_comment"), value)
231
+ elif field_id.endswith("_other"):
232
+ page.locator(f"[data-testid='{field_id.removesuffix('_other')}']").nth(1).locator(
233
+ "input"
234
+ ).fill(value)
235
+ elif field_id == "phone_number":
236
+ _phone(page, value)
237
+ elif field_id == "location":
238
+ _place(page, value)
239
+ elif field.type is FieldType.FILE:
240
+ _attach(page, field_id, value)
241
+ else:
242
+ _fill(page, field_id, answer)
243
+
244
+ def read_back(self, page: Page) -> dict[str, Answer]:
245
+ body, out = page.locator("body"), {}
246
+ for field_id in self._fields:
247
+ if field_id.endswith("_other"):
248
+ shown = body.evaluate(_SHOWN, [field_id.removesuffix("_other"), 1, TESTIDS])
249
+ elif field_id == "phone_number":
250
+ code = page.locator("[data-testid='phone_number-code'] input").input_value()
251
+ number = page.locator("[data-testid='input-phone_number']").input_value()
252
+ shown = {"value": f"{code.split()[0]} {number}" if number else None}
253
+ elif field_id == "location":
254
+ shown = {"value": page.locator("[data-testid=location] input").input_value()}
255
+ else:
256
+ shown = body.evaluate(_SHOWN, [field_id, 0, TESTIDS])
257
+ if (value := shown.get("value")) not in (None, "", []):
258
+ out[field_id] = value
259
+ return out
260
+
261
+ def submit(self, page: Page) -> str | None:
262
+ """Clicks Apply; the evidence is the answer to the application's POST."""
263
+ with page.expect_response(
264
+ lambda r: r.request.method == "POST" and APPLIED.match(r.url) is not None,
265
+ timeout=SUBMIT_TIMEOUT_MS,
266
+ ) as applied:
267
+ _click(page.locator("[data-testid=Apply]"))
268
+ response = applied.value
269
+ if not response.ok:
270
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
271
+ return f"{response.url} {response.status}"
272
+
273
+
274
+ def _job(url: str) -> tuple[str, str] | None:
275
+ parts = urlsplit(url)
276
+ if parts.hostname != HOST or not (match := JOB.match(parts.path)):
277
+ return None
278
+ return match[1], match[2]
279
+
280
+
281
+ def _api(data: dict[str, Any]) -> dict[str, Any]:
282
+ api = data["props"]["pageProps"].get("apiData") or {}
283
+ if not (api.get("jobPost") or {}).get("activeJobApplication"):
284
+ raise Unsupported("posting_gone: the page has no application form")
285
+ return api
286
+
287
+
288
+ def _form(api: dict[str, Any]) -> list[Field]:
289
+ """Every field of the apply page's data, in the page's order."""
290
+ post = api["jobPost"]
291
+ application = post["activeJobApplication"]
292
+ out = [_basic(q) for q in application["basicQuestions"]]
293
+ for group in application.get("additionalQuestions") or []:
294
+ for q in group["form"]["questions"]:
295
+ out += _custom(f"customQuestions.{group['id']}.{q['uniqueKey']}", q)
296
+ if post.get("eeocQuestionnaireEnabledForJobPost"):
297
+ out += [Field(i, label, FieldType.CHOICE, False, options) for i, label, options in EEOC]
298
+ if api.get("showCc305Form"):
299
+ reason = "unhandled_rendering: cc305 is the voluntary disability form"
300
+ out.append(Field("cc305", "Voluntary self-identification of disability", FieldType.CHOICE,
301
+ False, unfillable=reason)) # fmt: skip
302
+ for n, question in enumerate(api.get("videoScreeningQuestions") or []):
303
+ field_id = f"video.{question.get('uniqueKey') or question.get('id') or n}"
304
+ label = question.get("title") or question.get("question") or "Video question"
305
+ out.append(Field(field_id, label, FieldType.FILE, True,
306
+ unfillable=out_of_scope(field_id, "a video recorded live"))) # fmt: skip
307
+ out.append(Field(SMS[0], SMS[1], FieldType.CHOICE, False, SMS[2]))
308
+ return out
309
+
310
+
311
+ def _basic(q: dict[str, Any]) -> Field:
312
+ field_id, label, required = q["oid"], q["title"], bool(q.get("required"))
313
+ if (kind := BASIC.get(q["fieldType"])) is None:
314
+ reason = f"unhandled_rendering: {field_id} is a {q['fieldType']}"
315
+ return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
316
+ if field_id == "location":
317
+ kind = FieldType.TYPEAHEAD
318
+ options = PRONOUNS if kind is FieldType.CHOICE else None
319
+ accept = FILE_TYPES if kind is FieldType.FILE else None
320
+ return Field(field_id, label, kind, required, options, accept=accept)
321
+
322
+
323
+ def _custom(field_id: str, q: dict[str, Any]) -> list[Field]:
324
+ label, required = q["title"].strip(), bool(q.get("isRequired"))
325
+ if (kind := CUSTOM.get(q["questionType"])) is None:
326
+ reason = f"unhandled_rendering: {field_id} is a {q['questionType']}"
327
+ return [Field(field_id, label, FieldType.TEXT, required, unfillable=reason)]
328
+ options = None
329
+ if kind in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
330
+ options = SCALE if q["questionType"] == "YES_NO_SCALE_4" else list(q["strChoices"])
331
+ if q.get("isOtherEnabled"):
332
+ options.append(OTHER)
333
+ max_length = q.get("maxChars") if kind is FieldType.TEXT else None
334
+ out = [Field(field_id, label, kind, required, options, max_length)]
335
+ if q.get("isOtherEnabled"):
336
+ asked = f"{label}: {OTHER} (asked when {label!r} is {OTHER})"
337
+ out.append(Field(f"{field_id}_other", asked, FieldType.TEXT, False))
338
+ if q.get("allowComments"):
339
+ out.append(Field(f"{field_id}_comment", f"{label}: comment", FieldType.TEXT, False))
340
+ return out
341
+
342
+
343
+ def _hide_cookies(page: Page) -> None:
344
+ """Hides the cookie banner, whose layer takes the clicks on the lower part of the page: it
345
+ renders in a closed shadow root, out of reach of a click, and shows a moment after the
346
+ form. Nothing is answered to it."""
347
+ page.add_style_tag(content=f"{COOKIES} {{display: none !important}}")
348
+
349
+
350
+ def _click(target: Locator) -> None:
351
+ """Clicks the target scrolled to the middle of the window, clear of the Apply bar fixed
352
+ at its bottom."""
353
+ target.evaluate("e => e.scrollIntoView({block: 'center'})")
354
+ browser.click(target)
355
+
356
+
357
+ def _box(page: Page, field_id: str) -> Locator:
358
+ return page.locator(f"[data-testid='{TESTIDS.get(field_id, field_id)}']").first
359
+
360
+
361
+ def _option(page: Page, field_id: str, value: str) -> None:
362
+ """Picks the open list's option whose text is `value`."""
363
+ option = page.locator("[role=listbox] [role=option]").filter(has_text=_exact(value))
364
+ try:
365
+ option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
366
+ except Timeout:
367
+ page.keyboard.press("Escape")
368
+ raise NotTaken(f"{field_id}: no option {value!r}") from None
369
+ _click(option.first)
370
+
371
+
372
+ def _fill(page: Page, field_id: str, answer: Answer) -> None:
373
+ box = _box(page, field_id)
374
+ values = [answer] if isinstance(answer, str) else answer
375
+ match page.locator("body").evaluate(_SHOWN, [field_id, 0, TESTIDS])["kind"]:
376
+ case "radio":
377
+ radio = box.locator("[role=radio]").filter(has_text=_exact(values[0]))
378
+ if not radio.count():
379
+ radio = box.locator(f"[role=radio][aria-label='{values[0]}']")
380
+ _click(radio.first)
381
+ case "checkbox":
382
+ for item in box.locator("[role=checkbox]").all():
383
+ if (item.get_attribute("aria-checked") == "true") != (
384
+ item.inner_text().strip() in values
385
+ ):
386
+ _click(item)
387
+ case "date":
388
+ year, month, day = values[0].split("-")
389
+ for part, text in (("month", month), ("day", day), ("year", year)):
390
+ box.locator(f"[data-input={part}]").fill(text)
391
+ case "dropdown" | "search":
392
+ _click(box.locator("[role=combobox]"))
393
+ _option(page, field_id, values[0])
394
+ case _:
395
+ own = box.evaluate("e => e.matches('input, textarea')")
396
+ (box if own else box.locator("input, textarea").first).fill(values[0])
397
+
398
+
399
+ def _attach(page: Page, field_id: str, path: Answer) -> None:
400
+ box = _box(page, field_id)
401
+ box.locator("input[type=file]").set_input_files(path if isinstance(path, str) else path[0])
402
+ box.locator("[data-testid=chip]").first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
403
+
404
+
405
+ def _comment(page: Page, field_id: str, value: str) -> None:
406
+ comment = page.locator(f"[data-testid='{field_id}_comment'] textarea")
407
+ if not comment.count():
408
+ block = _box(page, field_id).locator("xpath=ancestor::*[@data-testid='field'][1]/..")
409
+ _click(block.locator("button[data-testid='Add comment']").first)
410
+ comment.fill(value)
411
+
412
+
413
+ def _phone(page: Page, value: str) -> None:
414
+ """Picks the dial code the number starts with, unless the shown one fits, then types the
415
+ rest."""
416
+ digits = re.sub(r"\D", "", value)
417
+ code = page.locator("[data-testid='phone_number-code'] input")
418
+ shown = re.sub(r"\D", "", code.input_value().split()[0] if code.input_value() else "")
419
+ if value.startswith("+") and not (shown and digits.startswith(shown)):
420
+ for length in DIAL_CODES:
421
+ _click(code)
422
+ code.fill(f"+{digits[:length]}")
423
+ option = page.locator("[role=listbox] [role=option]").filter(
424
+ has_text=re.compile(rf"^\s*\+{digits[:length]} ")
425
+ )
426
+ try:
427
+ option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS // len(DIAL_CODES))
428
+ except Timeout:
429
+ continue
430
+ _click(option.first)
431
+ shown = digits[:length]
432
+ break
433
+ else:
434
+ page.keyboard.press("Escape")
435
+ raise NotTaken(f"phone_number: no dial code for {value!r}")
436
+ national = digits[len(shown) :] if value.startswith("+") else digits
437
+ page.locator("[data-testid='input-phone_number']").fill(national)
438
+
439
+
440
+ def _place(page: Page, value: str) -> None:
441
+ """Types the place and picks the first suggestion once the list has settled: each key
442
+ typed renders its own suggestions a moment later, replacing the ones shown."""
443
+ box = page.locator("[data-testid=location] input")
444
+ box.fill("")
445
+ box.press_sequentially(value)
446
+ option = page.locator("[role=listbox] [role=option]")
447
+ try:
448
+ option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
449
+ except Timeout:
450
+ raise NotTaken(f"location: no place suggested for {value!r}") from None
451
+ if not page.evaluate(_QUIET, [PLACES_QUIET_MS, OPTIONS_TIMEOUT_MS]):
452
+ raise NotTaken(f"location: the places suggested for {value!r} kept changing")
453
+ _click(option.first)
454
+
455
+
456
+ def _exact(value: str) -> re.Pattern[str]:
457
+ return re.compile(rf"^\s*{re.escape(value)}\s*$")
@@ -307,7 +307,9 @@ kind = "ats"
307
307
  share = 0.0050
308
308
  account = "none"
309
309
  hosting = ["hosted"]
310
+ adapter = "rippling"
310
311
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
312
+ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "e8b7a0a" }
311
313
 
312
314
  [isolvedhire]
313
315
  kind = "ats"
@@ -350,7 +352,9 @@ kind = "ats"
350
352
  share = 0.0040
351
353
  account = "none"
352
354
  hosting = ["hosted"]
355
+ adapter = "eightfold"
353
356
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
357
+ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "24e163f" }
354
358
 
355
359
  [startupjobs]
356
360
  kind = "aggregator"
File without changes