atsfill 0.0.4.dev76__tar.gz → 0.0.4.dev78__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 (42) hide show
  1. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/README.md +1 -1
  3. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev78/src/atsfill/adapters/rippling.py +457 -0
  7. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/hireology.py +0 -0
  16. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/jazzhr.py +0 -0
  17. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/lever.py +0 -0
  18. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/manatal.py +0 -0
  19. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/oracle.py +0 -0
  20. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/paycor.py +0 -0
  21. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/paylocity.py +0 -0
  22. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/personio.py +0 -0
  23. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/phenom.py +0 -0
  24. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/pinpoint.py +0 -0
  25. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/recruitee.py +0 -0
  26. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/smartrecruiters.py +0 -0
  27. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/teamtailor.py +0 -0
  28. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/workable.py +0 -0
  29. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/wttj.py +0 -0
  30. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/adapters/zohorecruit.py +0 -0
  31. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/cli.py +0 -0
  32. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/__init__.py +0 -0
  33. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/accounts.py +0 -0
  34. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/adapter.py +0 -0
  35. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/attempts.py +0 -0
  36. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/browser.py +0 -0
  37. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/commands.py +0 -0
  38. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/matching.py +0 -0
  39. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/paths.py +0 -0
  40. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/registry.py +0 -0
  41. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/src/atsfill/core/results.py +0 -0
  42. {atsfill-0.0.4.dev76 → atsfill-0.0.4.dev78}/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.dev78
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) ![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) ![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.dev78"
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.dev78"
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"
@@ -14,6 +14,7 @@ from atsfill.adapters.personio import Personio
14
14
  from atsfill.adapters.phenom import Phenom
15
15
  from atsfill.adapters.pinpoint import Pinpoint
16
16
  from atsfill.adapters.recruitee import Recruitee
17
+ from atsfill.adapters.rippling import Rippling
17
18
  from atsfill.adapters.smartrecruiters import SmartRecruiters
18
19
  from atsfill.adapters.teamtailor import Teamtailor
19
20
  from atsfill.adapters.workable import Workable
@@ -44,6 +45,7 @@ def adapters() -> list[Adapter]:
44
45
  BambooHR(),
45
46
  ZohoRecruit(),
46
47
  Personio(),
48
+ Rippling(),
47
49
  Pinpoint(),
48
50
  Phenom(), # last: it claims a URL by its path alone
49
51
  ]
@@ -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"
File without changes