atsfill 0.0.4.dev75__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.dev75 → atsfill-0.0.4.dev78}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/README.md +1 -1
  3. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/__init__.py +4 -0
  6. atsfill-0.0.4.dev78/src/atsfill/adapters/pinpoint.py +551 -0
  7. atsfill-0.0.4.dev78/src/atsfill/adapters/rippling.py +457 -0
  8. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/registry.toml +4 -0
  9. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/LICENSE +0 -0
  10. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/jazzhr.py +0 -0
  18. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/lever.py +0 -0
  19. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/manatal.py +0 -0
  20. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/oracle.py +0 -0
  21. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/paycor.py +0 -0
  22. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/paylocity.py +0 -0
  23. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/personio.py +0 -0
  24. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/phenom.py +0 -0
  25. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/recruitee.py +0 -0
  26. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/smartrecruiters.py +0 -0
  27. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/teamtailor.py +0 -0
  28. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/workable.py +0 -0
  29. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/wttj.py +0 -0
  30. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/adapters/zohorecruit.py +0 -0
  31. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/cli.py +0 -0
  32. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/__init__.py +0 -0
  33. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/accounts.py +0 -0
  34. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/adapter.py +0 -0
  35. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/attempts.py +0 -0
  36. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/browser.py +0 -0
  37. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/commands.py +0 -0
  38. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/matching.py +0 -0
  39. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/paths.py +0 -0
  40. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/registry.py +0 -0
  41. {atsfill-0.0.4.dev75 → atsfill-0.0.4.dev78}/src/atsfill/core/results.py +0 -0
  42. {atsfill-0.0.4.dev75 → 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.dev75
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) ![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) ![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.dev75"
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.dev75"
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"
@@ -12,7 +12,9 @@ from atsfill.adapters.paycor import Paycor
12
12
  from atsfill.adapters.paylocity import Paylocity
13
13
  from atsfill.adapters.personio import Personio
14
14
  from atsfill.adapters.phenom import Phenom
15
+ from atsfill.adapters.pinpoint import Pinpoint
15
16
  from atsfill.adapters.recruitee import Recruitee
17
+ from atsfill.adapters.rippling import Rippling
16
18
  from atsfill.adapters.smartrecruiters import SmartRecruiters
17
19
  from atsfill.adapters.teamtailor import Teamtailor
18
20
  from atsfill.adapters.workable import Workable
@@ -43,5 +45,7 @@ def adapters() -> list[Adapter]:
43
45
  BambooHR(),
44
46
  ZohoRecruit(),
45
47
  Personio(),
48
+ Rippling(),
49
+ Pinpoint(),
46
50
  Phenom(), # last: it claims a URL by its path alone
47
51
  ]
@@ -0,0 +1,551 @@
1
+ """Pinpoint (`<employer>.pinpointhq.com` or a career domain, `/<lang>/postings/<uuid>`), as
2
+ docs/adapters/pinpoint.md observes.
3
+
4
+ The apply page is rendered on the server: the standard fields as native controls, every other
5
+ field as a React component whose props sit in a JSON script next to it. `read_api` fetches that
6
+ HTML over HTTP, `read_form` takes the page's form, and both list the fields from it with one
7
+ parser. A document is uploaded to Pinpoint once attached.
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import urllib.request
13
+ from contextlib import suppress
14
+ from dataclasses import dataclass, field
15
+ from datetime import date
16
+ from html.parser import HTMLParser
17
+ from typing import Any
18
+ from urllib.parse import urlsplit
19
+
20
+ from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
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 = re.compile(r"(?:^|\.)pinpointhq\.com$")
25
+ JOB = re.compile(
26
+ r"^/([a-z]{2}(?:-[a-z]{2})?)/postings/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-"
27
+ r"[0-9a-f]{12})(?:/applications/new)?/?$",
28
+ re.IGNORECASE,
29
+ )
30
+ FORM = "#application-form"
31
+ SUBMIT = f"{FORM} button[type=submit]"
32
+ COMPONENT = "js-react-on-rails-component"
33
+ FORM_TIMEOUT_MS = 30_000
34
+ HYDRATE_TIMEOUT_MS = 30_000
35
+ OPTION_TIMEOUT_MS = 5_000
36
+ SHOWN_TIMEOUT_MS = 2_000
37
+ COOKIE_TIMEOUT_MS = 5_000
38
+ ONETRUST = "#onetrust-consent-sdk"
39
+ US_STATE = ".react-select:has(input[type=hidden][name$='[town]'])" # the town's place in the US
40
+ UPLOAD_TIMEOUT_MS = 30_000
41
+ SUBMIT_TIMEOUT_MS = 90_000
42
+ YES_NO = ["Yes", "No"]
43
+ US = "United States" # the country whose address asks for a city and a state
44
+ JOB_PREFERENCES = "job_preferences"
45
+ TEXT_QUESTIONS = {"Shorttext", "Longtext", "Url", "Numberinput"}
46
+ FILE_QUESTIONS = {"Document", "Multipledocuments"}
47
+ NOT_FIELDS = {"New", "Chatbot", "Stagescarousel"} # components without a field, by last name
48
+ VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
49
+
50
+ # What each field holds, by how it renders: a control's value, a react-select's shown value(s),
51
+ # a radio's label, a date's three selects, a document's uploaded names.
52
+ _READ_BACK = r"""(form, specs) => {
53
+ const text = e => e ? e.textContent.replace(/\s+/g, " ").trim() : null;
54
+ const root = s => s.root ? form.querySelector(`[id="${s.root}"]`) : form;
55
+ const control = s => root(s)?.querySelector(`[name="${s.name}"]:not([type=hidden])`);
56
+ const shown = box => {
57
+ const multi = [...(box?.querySelectorAll(".react-select__multi-value__label") ?? [])];
58
+ if (multi.length) return multi.map(text);
59
+ return text(box?.querySelector(".react-select__single-value"));
60
+ };
61
+ const out = {};
62
+ for (const s of specs) {
63
+ const us = !!root(s)?.querySelector(`.react-select input[type=hidden][name$="[town]"]`);
64
+ if (s.kind === "text" || s.kind === "town") out[s.id] = control(s)?.value ?? null;
65
+ else if (s.kind === "line2" || s.kind === "city")
66
+ out[s.id] = us === (s.kind === "city") ? control(s)?.value ?? null : null;
67
+ else if (s.kind === "check")
68
+ out[s.id] = control(s) ? (control(s).checked ? "Yes" : "No") : null;
69
+ else if (s.kind === "radio") {
70
+ const on = root(s)?.querySelector(`input[name="${s.name}"]:checked`);
71
+ out[s.id] = on ? text(form.querySelector(`label[for="${on.id}"]`)) : null;
72
+ } else if (s.kind === "react" || s.kind === "state") {
73
+ const hidden = s.name && root(s)?.querySelector(`input[type=hidden][name="${s.name}"]`);
74
+ out[s.id] = shown(s.name ? hidden?.closest(".react-select") : root(s));
75
+ } else if (s.kind === "date") {
76
+ const part = n => root(s)?.querySelector(`[name="${s.name}(${n}i)]"]`)?.value;
77
+ const [y, m, d] = [part(1), part(2), part(3)];
78
+ out[s.id] = y && m && d ? `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}` : null;
79
+ } else if (s.kind === "file") {
80
+ const names = [...(root(s)?.querySelectorAll(".bp3-tag a.link") ?? [])];
81
+ out[s.id] = text(names[names.length - 1]);
82
+ }
83
+ }
84
+ return out;
85
+ }"""
86
+
87
+
88
+ @dataclass
89
+ class _Spec:
90
+ """Where a field's control is: the component's root id (random per page load), the control's
91
+ name, and how it renders."""
92
+
93
+ kind: (
94
+ str # text | check | radio | react | date | file; the address's line2 | city | town | state
95
+ )
96
+ name: str | None = None
97
+ root: str | None = None
98
+ options: dict[str, str] | None = None # a radio's value by its label
99
+
100
+
101
+ class Pinpoint:
102
+ name = "pinpoint"
103
+ known_gaps: dict[str, Gap] = {JOB_PREFERENCES: Gap.RENDERING}
104
+ side_effects = [
105
+ SideEffect("upload", "hiring_system", "a document is uploaded to Pinpoint at once"),
106
+ SideEffect("submit", "employer", "the application reaches the employer"),
107
+ ]
108
+
109
+ def __init__(self) -> None:
110
+ self._fields: list[Field] = []
111
+ self._specs: dict[str, _Spec] = {}
112
+
113
+ def detect(self, url: str, page: Page | None) -> bool:
114
+ return _job(url) is not None
115
+
116
+ def posting(self, url: str) -> str:
117
+ job = _job(url)
118
+ return f"https://{urlsplit(url).hostname}/en/postings/{job[1]}" if job else url
119
+
120
+ def read_api(self, url: str) -> list[Field] | None:
121
+ if _job(url) is None:
122
+ return None
123
+ request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
124
+ try:
125
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
126
+ html = response.read().decode()
127
+ except OSError, ValueError:
128
+ return None
129
+ form = next(
130
+ (n for n in _parse(html).walk() if n.attrs.get("id") == "application-form"), None
131
+ )
132
+ return [f for f, _ in _form(form, self)] if form else None
133
+
134
+ def read_form(self, page: Page) -> list[Field]:
135
+ if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/new"):
136
+ page.goto(_apply(page.url))
137
+ try:
138
+ page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
139
+ except Timeout:
140
+ raise Unsupported("posting_gone: no application form on the page") from None
141
+ _hydrated(page)
142
+ listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
143
+ self._specs = {f.id: s for f, s in listed if not f.unfillable}
144
+ self._fields = [f for f, _ in listed if not f.unfillable]
145
+ return [f for f, _ in listed]
146
+
147
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
148
+ _decline_cookies(page)
149
+ form = page.locator(FORM)
150
+ for f in self._fields:
151
+ if f.id not in answers:
152
+ continue
153
+ answer, spec = answers[f.id], self._specs[f.id]
154
+ values = [answer] if isinstance(answer, str) else answer
155
+ root = form.locator(f'[id="{spec.root}"]') if spec.root else form
156
+ if spec.root and not _shown(root):
157
+ continue # a question its parent's answer keeps hidden
158
+ control = root.locator(f'[name="{spec.name}"]:not([type=hidden])')
159
+ us = root.locator(US_STATE)
160
+ match spec.kind:
161
+ case "text":
162
+ control.fill(values[0])
163
+ case "line2" | "town" if not us.count():
164
+ control.fill(values[0])
165
+ case "city" if us.count():
166
+ control.fill(values[0])
167
+ case "state" if us.count():
168
+ _pick(us, values[0], typed=True)
169
+ case "check":
170
+ control.set_checked(values[0] == YES_NO[0])
171
+ case "radio":
172
+ value = (spec.options or {}).get(values[0], values[0])
173
+ radio = root.locator(f'input[name="{spec.name}"][value="{value}"]')
174
+ form.locator(f'label[for="{radio.get_attribute("id")}"]').click()
175
+ case "react":
176
+ box = root.locator(".react-select").first
177
+ if spec.name:
178
+ box = root.locator(f'.react-select:has(input[name="{spec.name}"])')
179
+ for value in values:
180
+ _pick(box, value)
181
+ if box.locator(".react-select__option").count(): # a multi-select's menu
182
+ box.locator("input[role=combobox]").press("Escape")
183
+ case "date":
184
+ day = date.fromisoformat(values[0])
185
+ for n, part in ((1, day.year), (2, day.month), (3, day.day)):
186
+ _choose(root.locator(f'select[name="{spec.name}({n}i)]"]'), str(part))
187
+ case "file":
188
+ _attach(root, values[0])
189
+
190
+ def read_back(self, page: Page) -> dict[str, Answer]:
191
+ specs = [
192
+ {"id": i, "kind": s.kind, "name": s.name, "root": s.root}
193
+ for i, s in self._specs.items()
194
+ ]
195
+ values = page.locator(FORM).evaluate(_READ_BACK, specs)
196
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
197
+
198
+ def submit(self, page: Page) -> str | None:
199
+ """Clicks submit; the evidence is the page the form's POST leads to, unless it is the
200
+ form again (`/applications`, a rejected answer)."""
201
+ page.locator(SUBMIT).click()
202
+ page.wait_for_url(
203
+ lambda u: not urlsplit(u).path.endswith("/applications/new"), timeout=SUBMIT_TIMEOUT_MS
204
+ )
205
+ if urlsplit(page.url).path.rstrip("/").endswith("/applications"):
206
+ raise RuntimeError("the form came back after the submit")
207
+ return page.url
208
+
209
+
210
+ def _job(url: str) -> tuple[str, str] | None:
211
+ """The language and posting uuid, from `/<lang>/postings/<uuid>[/applications/new]`."""
212
+ match = JOB.match(urlsplit(url).path)
213
+ return (match[1], match[2].lower()) if match else None
214
+
215
+
216
+ def _apply(url: str) -> str:
217
+ """The posting's apply page."""
218
+ job = _job(url)
219
+ assert job is not None
220
+ return f"https://{urlsplit(url).hostname}/{job[0]}/postings/{job[1]}/applications/new"
221
+
222
+
223
+ def _hydrated(page: Page) -> None:
224
+ """Waits for React to render the components with a field; a question's may stay empty,
225
+ kept hidden by an earlier answer."""
226
+ with suppress(Timeout):
227
+ page.wait_for_function(
228
+ """skip => [...document.querySelectorAll("#application-form script.COMPONENT")]
229
+ .filter(s => !s.dataset.componentName.includes("Questions")
230
+ && !skip.includes(s.dataset.componentName.split("::").pop()))
231
+ .every(s => document.getElementById(s.dataset.domId)?.childElementCount)""".replace(
232
+ "COMPONENT", COMPONENT
233
+ ),
234
+ arg=sorted(NOT_FIELDS),
235
+ timeout=HYDRATE_TIMEOUT_MS,
236
+ )
237
+
238
+
239
+ def _decline_cookies(page: Page) -> None:
240
+ """Declines an employer's OneTrust banner, whose backdrop covers the form."""
241
+ if not page.locator(ONETRUST).count():
242
+ return
243
+ reject = page.locator(f"{ONETRUST} #onetrust-reject-all-handler")
244
+ with suppress(Timeout):
245
+ reject.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
246
+ reject.click()
247
+
248
+
249
+ def _shown(root: Locator) -> bool:
250
+ """Whether a component shows a control, waiting a moment for one an earlier answer opens."""
251
+ try:
252
+ root.locator("input, select, textarea").first.wait_for(
253
+ state="attached", timeout=SHOWN_TIMEOUT_MS
254
+ )
255
+ except Timeout:
256
+ return False
257
+ return True
258
+
259
+
260
+ def _pick(box: Locator, value: str, typed: bool = False) -> None:
261
+ """Opens a react-select, `typed` types the answer to narrow a long list, and clicks the
262
+ option that is the answer."""
263
+ options = box.locator(".react-select__option")
264
+ if not options.count(): # a multi-select's menu stays open between picks
265
+ box.locator(".react-select__control").click()
266
+ if typed:
267
+ box.locator("input.react-select__input").press_sequentially(value)
268
+ words = r"\s+".join(re.escape(w) for w in value.split())
269
+ option = options.filter(has_text=re.compile(rf"^\s*{words}\s*$")).first
270
+ try:
271
+ option.wait_for(timeout=OPTION_TIMEOUT_MS)
272
+ except Timeout:
273
+ box.locator(".react-select__control").press("Escape")
274
+ raise NotTaken(f"no option {value!r}") from None
275
+ option.click()
276
+
277
+
278
+ def _choose(select: Locator, value: str) -> None:
279
+ """Picks a date part's option, or says the select has none for it."""
280
+ if not select.locator(f'option[value="{value}"]').count():
281
+ raise NotTaken(f"no option {value!r} in {select.get_attribute('name')}")
282
+ select.select_option(value)
283
+
284
+
285
+ def _attach(root: Locator, path: str) -> None:
286
+ """Attaches a document and waits for Pinpoint's upload to show its name."""
287
+ name = path.rsplit("/", 1)[-1]
288
+ root.locator("input[type=file]").first.set_input_files(path)
289
+ with suppress(Timeout):
290
+ root.locator(".bp3-tag a.link", has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
291
+
292
+
293
+ @dataclass
294
+ class _Node:
295
+ tag: str
296
+ attrs: dict[str, str]
297
+ parent: _Node | None = None
298
+ children: list[_Node | str] = field(default_factory=list)
299
+
300
+ def walk(self) -> list[_Node]:
301
+ out = []
302
+ for child in self.children:
303
+ if isinstance(child, _Node):
304
+ out += [child, *child.walk()]
305
+ return out
306
+
307
+ def text(self) -> str:
308
+ return " ".join(
309
+ "".join(c if isinstance(c, str) else c.text() for c in self.children).split()
310
+ )
311
+
312
+ def classes(self) -> list[str]:
313
+ return self.attrs.get("class", "").split()
314
+
315
+ def in_component(self) -> bool:
316
+ node = self.parent
317
+ while node is not None and "-react-component-" not in node.attrs.get("id", ""):
318
+ node = node.parent
319
+ return node is not None
320
+
321
+
322
+ class _Tree(HTMLParser):
323
+ def __init__(self) -> None:
324
+ super().__init__(convert_charrefs=True)
325
+ self.root = self.open = _Node("", {})
326
+
327
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
328
+ node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
329
+ self.open.children.append(node)
330
+ if tag not in VOID:
331
+ self.open = node
332
+
333
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
334
+ self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
335
+
336
+ def handle_endtag(self, tag: str) -> None:
337
+ node: _Node | None = self.open
338
+ while node is not None and node.tag != tag:
339
+ node = node.parent
340
+ if node is not None and node.parent is not None:
341
+ self.open = node.parent
342
+
343
+ def handle_data(self, data: str) -> None:
344
+ self.open.children.append(data)
345
+
346
+
347
+ def _parse(html: str) -> _Node:
348
+ tree = _Tree()
349
+ tree.feed(html)
350
+ return tree.root
351
+
352
+
353
+ def _key(name: str) -> str:
354
+ """A control's attribute: `first_name` for `application_form[application][first_name]`."""
355
+ return re.findall(r"\w+", re.sub(r"\(\di\)", "", name))[-1]
356
+
357
+
358
+ def _text(label: object) -> str:
359
+ """A label with its runs of whitespace as one space, trimmed."""
360
+ return " ".join(str(label).split())
361
+
362
+
363
+ def _accept(extensions: list[str] | None) -> list[str] | None:
364
+ return [f".{e}" for e in extensions] if extensions else None
365
+
366
+
367
+ def _form(form: _Node, adapter: Pinpoint) -> list[tuple[Field, _Spec]]:
368
+ """Each field of the form, in its order, with where its control is."""
369
+ nodes = form.walk()
370
+ labels = {n.attrs["for"]: n.text() for n in nodes if n.tag == "label" and "for" in n.attrs}
371
+ out: list[tuple[Field, _Spec]] = []
372
+ titles: dict[int, tuple[str, dict[str, str]]] = {} # a question's label, its values' words
373
+ for node in nodes:
374
+ if node.tag == "script" and COMPONENT in node.classes():
375
+ props = json.loads(node.text() or "{}")
376
+ name = node.attrs.get("data-component-name", "")
377
+ out += _component(name, props, node, labels, titles, adapter)
378
+ elif node.tag in ("input", "select", "textarea") and not node.in_component():
379
+ out += _plain(node, labels, out)
380
+ return out
381
+
382
+
383
+ def _plain(node: _Node, labels: dict[str, str], listed: list) -> list[tuple[Field, _Spec]]:
384
+ """A server-rendered control: a name, the email, the summary, a date of birth, the consent."""
385
+ name, kind = node.attrs.get("name", ""), node.attrs.get("type", node.tag)
386
+ if not name or kind in ("hidden", "submit", "button") or "g-recaptcha" in name:
387
+ return []
388
+ field_id, required = _key(name), "required" in node.attrs
389
+ label = labels.get(node.attrs.get("id", ""), "").rstrip(" *") or field_id
390
+ if re.search(r"\(\di\)\]$", name): # a date's day, month and year selects
391
+ if any(f.id == field_id for f, _ in listed):
392
+ return []
393
+ label = labels.get(node.attrs.get("id", "")[: -len("_3i")], label)
394
+ prefix = name[: name.rindex("(")] # the selects' names are `<prefix>(<n>i)]`
395
+ return [(Field(field_id, label, FieldType.DATE, required), _Spec("date", prefix))]
396
+ if kind == "checkbox":
397
+ return [(Field(field_id, label, FieldType.CHOICE, required, YES_NO), _Spec("check", name))]
398
+ if kind in ("text", "email", "textarea"):
399
+ length = int(node.attrs.get("maxlength", 0)) or None
400
+ return [
401
+ (
402
+ Field(field_id, label, FieldType.TEXT, required, max_length=length),
403
+ _Spec("text", name),
404
+ )
405
+ ]
406
+ reason = f"unhandled_rendering: {field_id} is a {kind}"
407
+ return [(Field(field_id, label, FieldType.TEXT, required, unfillable=reason), _Spec(kind))]
408
+
409
+
410
+ def _component(
411
+ name: str,
412
+ props: dict[str, Any],
413
+ script: _Node,
414
+ labels: dict[str, str],
415
+ titles: dict[int, tuple[str, dict[str, str]]],
416
+ adapter: Pinpoint,
417
+ ) -> list[tuple[Field, _Spec]]:
418
+ """The fields a React component renders, from its props."""
419
+ root = script.attrs.get("data-dom-id")
420
+ short = name.removeprefix("Shared::Form::").removeprefix("Questions::")
421
+ if name.endswith("Phonenumberinput"):
422
+ label = labels.get(props.get("id", ""), "Phone")
423
+ spec = _Spec("text", props["name"], root)
424
+ return [
425
+ (Field(_key(props["name"]), label, FieldType.PHONE, bool(props.get("required"))), spec)
426
+ ]
427
+ if name.endswith("::Address"):
428
+ return _address(props, root)
429
+ if name.endswith("::Filefield"):
430
+ label, required = _file_label(script, props)
431
+ accept = _accept(props.get("accept"))
432
+ return [
433
+ (
434
+ Field(_key(props["name"]), label, FieldType.FILE, required, accept=accept),
435
+ _Spec("file", props["name"], root),
436
+ )
437
+ ]
438
+ if name.endswith("Equalitymonitoring::Tsselect"):
439
+ field_id = props["inputId"].removeprefix("application_form_")
440
+ options = [_text(o["label"]) for o in props.get("options", [])]
441
+ label = labels.get(props["inputId"], field_id)
442
+ spec = _Spec("react", None, root)
443
+ return [
444
+ (Field(field_id, label, FieldType.CHOICE, bool(props.get("required")), options), spec)
445
+ ]
446
+ if name.endswith("::Jobpreferences"):
447
+ field_id = props.get("jobPreferenceName", JOB_PREFERENCES)
448
+ reason = known_gap(adapter, JOB_PREFERENCES, f"{field_id} is a further job to apply for")
449
+ label = f"{props.get('ordinalizedNumber', '')} preference".strip()
450
+ return [
451
+ (Field(field_id, label, FieldType.CHOICE, False, unfillable=reason), _Spec("react"))
452
+ ]
453
+ details = props.get("questionDetails")
454
+ if not details:
455
+ if name.split("::")[-1] in NOT_FIELDS or "name" not in props:
456
+ return []
457
+ reason = f"unhandled_rendering: {name}"
458
+ return [
459
+ (
460
+ Field(_key(props["name"]), name, FieldType.TEXT, False, unfillable=reason),
461
+ _Spec(name),
462
+ )
463
+ ]
464
+ qid = details["questionId"]
465
+ field_id = f"question_{qid}"
466
+ label = " ".join(
467
+ (details.get("title") or details.get("additionalInformation") or field_id).split()
468
+ )
469
+ required = bool(details.get("required"))
470
+ options = None
471
+ prefix = f"application_form[application][answers_attributes][{details['index']}]"
472
+ yes_no = props.get("translations", {})
473
+ if short in TEXT_QUESTIONS:
474
+ field_type, spec = FieldType.TEXT, _Spec("text", props["name"], root)
475
+ if short == "Numberinput":
476
+ label += " (a number)"
477
+ elif short == "Boolean":
478
+ options = [yes_no.get("yes", "Yes"), yes_no.get("no", "No")]
479
+ field_type = FieldType.CHOICE
480
+ spec = _Spec(
481
+ "radio",
482
+ f"{prefix}[boolean_answer]",
483
+ root,
484
+ dict(zip(options, ("true", "false"), strict=True)),
485
+ )
486
+ elif short in ("Singleselect", "Multiselect"):
487
+ options = [_text(o["label"]) for o in props.get("options", [])]
488
+ field_type = FieldType.CHOICE if short == "Singleselect" else FieldType.MULTI_CHOICE
489
+ spec = _Spec("react", None, root)
490
+ elif short == "Dateselect":
491
+ field_type, spec = FieldType.DATE, _Spec("date", f"{prefix}[date_answer", root)
492
+ elif short in FILE_QUESTIONS:
493
+ field_type, spec = FieldType.FILE, _Spec("file", None, root)
494
+ else:
495
+ reason = f"unhandled_rendering: {field_id} is a {short}"
496
+ titles[qid] = (label, {})
497
+ return [(Field(field_id, label, FieldType.TEXT, required, unfillable=reason), _Spec(short))]
498
+ if parent := titles.get(props.get("conditionalQuestionParentQuestionId") or -1):
499
+ label += _asked_when(parent, props.get("conditionalQuestionConditions") or [])
500
+ required = False
501
+ words = dict(zip(("true", "false"), options or [], strict=False)) if short == "Boolean" else {}
502
+ titles[qid] = (label, words)
503
+ accept = _accept(props.get("accept")) if field_type is FieldType.FILE else None
504
+ return [(Field(field_id, label, field_type, required, options, accept=accept), spec)]
505
+
506
+
507
+ def _asked_when(parent: tuple[str, dict[str, str]], conditions: list[dict]) -> str:
508
+ """` (asked when "<parent>" is <value>)`, a boolean's `true` in its words."""
509
+ label, words = parent
510
+ values = [words.get(str(c["value"]), str(c["value"])) for c in conditions]
511
+ negated = "not " if any(c.get("operator") == "not_equals" for c in conditions) else ""
512
+ return f' (asked when "{label}" is {negated}{" or ".join(values)})'
513
+
514
+
515
+ def _file_label(script: _Node, props: dict) -> tuple[str, bool]:
516
+ """A document's label, from the `external-form__label` block before its component."""
517
+ parent = script.parent
518
+ siblings = [c for c in parent.children if isinstance(c, _Node)] if parent else []
519
+ before = siblings[: siblings.index(script)] if script in siblings else []
520
+ block = next((n for n in reversed(before) if "external-form__label" in n.classes()), None)
521
+ if block is None:
522
+ return props.get("labelText") or _key(props["name"]), False
523
+ title = next((n for n in block.walk() if n.tag == "div"), block)
524
+ required = any("external-form__label--required" in n.classes() for n in [block, *block.walk()])
525
+ return title.text(), required
526
+
527
+
528
+ def _address(props: dict, root: str | None) -> list[tuple[Field, _Spec]]:
529
+ """The address: a country, then lines whose labels and kinds follow it. The United States
530
+ asks for a city in the second line and a state (a select) in the town's place."""
531
+ names, words = props["fieldNames"], props.get("translations", {})
532
+ country = words.get("country", "Country")
533
+ us, not_us = (f' (asked when "{country}" is {n}{US})' for n in ("", "not "))
534
+ countries = [_text(o["label"]) for o in props.get("countryOptions", [])]
535
+ states = [_text(o["label"]) for o in props.get("stateOptions", [])]
536
+ line1 = f"{words.get('addressOne', 'Address Line 1')} ({words.get('streetAddress')})"
537
+ postcode = f"{words.get('postcode', 'Postcode')} ({words.get('zipcode')})"
538
+ rows = [ # id, label, type, required, options, kind, name
539
+ ("country", country, FieldType.CHOICE, True, countries, "react", "country"),
540
+ ("address1", line1, FieldType.TEXT, True, None, "text", "addressOne"),
541
+ ("address2", f"{words.get('addressTwo')}{not_us}", FieldType.TEXT, False, None, "line2",
542
+ "addressTwo"),
543
+ ("city", f"{words.get('city')}{us}", FieldType.TEXT, False, None, "city", "addressTwo"),
544
+ ("town", f"{words.get('town')}{not_us}", FieldType.TEXT, False, None, "town", "town"),
545
+ ("state", f"{words.get('state')}{us}", FieldType.CHOICE, False, states, "state", "town"),
546
+ ("postcode", postcode, FieldType.TEXT, True, None, "text", "postcode"),
547
+ ] # fmt: skip
548
+ return [
549
+ (Field(i, label, t, required, options), _Spec(kind, names[name], root))
550
+ for i, label, t, required, options, kind, name in rows
551
+ ]
@@ -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"
@@ -364,7 +366,9 @@ kind = "ats"
364
366
  share = 0.0035
365
367
  account = "none"
366
368
  hosting = ["hosted"]
369
+ adapter = "pinpoint"
367
370
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
371
+ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "58b3972" }
368
372
 
369
373
  [radancy]
370
374
  kind = "aggregator"
File without changes