atsfill 0.0.4.dev79__tar.gz → 0.0.4.dev80__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 (44) hide show
  1. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/README.md +1 -1
  3. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev80/src/atsfill/adapters/hiringthing.py +335 -0
  7. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/eightfold.py +0 -0
  15. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/greenhouse.py +0 -0
  16. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/hireology.py +0 -0
  17. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/jazzhr.py +0 -0
  18. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/lever.py +0 -0
  19. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/manatal.py +0 -0
  20. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/oracle.py +0 -0
  21. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/paycor.py +0 -0
  22. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/paylocity.py +0 -0
  23. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/personio.py +0 -0
  24. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/phenom.py +0 -0
  25. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/pinpoint.py +0 -0
  26. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/recruitee.py +0 -0
  27. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/rippling.py +0 -0
  28. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/smartrecruiters.py +0 -0
  29. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/teamtailor.py +0 -0
  30. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/workable.py +0 -0
  31. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/wttj.py +0 -0
  32. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/zohorecruit.py +0 -0
  33. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/cli.py +0 -0
  34. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/__init__.py +0 -0
  35. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/accounts.py +0 -0
  36. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/adapter.py +0 -0
  37. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/attempts.py +0 -0
  38. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/browser.py +0 -0
  39. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/commands.py +0 -0
  40. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/matching.py +0 -0
  41. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/paths.py +0 -0
  42. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/registry.py +0 -0
  43. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/results.py +0 -0
  44. {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/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.dev79
3
+ Version: 0.0.4.dev80
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) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
57
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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.dev79"
3
+ version = "0.0.4.dev80"
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.dev79"
3
+ version = "0.0.4.dev80"
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"
@@ -5,6 +5,7 @@ from atsfill.adapters.dayforce import Dayforce
5
5
  from atsfill.adapters.eightfold import Eightfold
6
6
  from atsfill.adapters.greenhouse import Greenhouse
7
7
  from atsfill.adapters.hireology import Hireology
8
+ from atsfill.adapters.hiringthing import HiringThing
8
9
  from atsfill.adapters.jazzhr import JazzHR
9
10
  from atsfill.adapters.lever import Lever
10
11
  from atsfill.adapters.manatal import Manatal
@@ -48,6 +49,7 @@ def adapters() -> list[Adapter]:
48
49
  Personio(),
49
50
  Rippling(),
50
51
  Pinpoint(),
52
+ HiringThing(),
51
53
  Eightfold(),
52
54
  Phenom(), # last: it claims a URL by its path alone
53
55
  ]
@@ -0,0 +1,335 @@
1
+ """HiringThing (`<employer>.<white-label domain>/job/<id>/<slug>`), as docs/adapters/hiringthing.md
2
+ observes.
3
+
4
+ The job page carries the posting's configuration in a React component's props, and its Apply
5
+ button renders the form from them in the page: MUI inputs named by field id, radios and boxes,
6
+ react-select comboboxes for the country and state, Dropzone areas that upload a file as soon as
7
+ it is attached. `read_form` lists the rendered controls; there is no `read_api`, since which
8
+ controls render is the page's logic over the props, not the props alone.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ from contextlib import suppress
14
+ from datetime import datetime
15
+ from urllib.parse import urlsplit
16
+
17
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
18
+ from atsfill.core.browser import Locator, Page, Timeout
19
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
20
+
21
+ # The white-label domains HiringThing serves its career sites on, as freehire indexes them.
22
+ DOMAINS = (
23
+ "hiringthing.com",
24
+ "oasisrecruit.com",
25
+ "prismhr-hire.com",
26
+ "gnahiring.com",
27
+ "primepay-recruit.com",
28
+ "checkwritersrecruit.com",
29
+ "exponentats.com",
30
+ "rippling-ats.com",
31
+ "verahr-hiring.com",
32
+ "alphastaff-hiring.com",
33
+ "elevate-ats.com",
34
+ "iconnecthire.com",
35
+ "atsmodule.com",
36
+ "viewpointhr-ats.com",
37
+ "ezhiregov.com",
38
+ "lumberhiring.com",
39
+ "topdoghrrecruiting.com",
40
+ "teammemberhire-recruit.com",
41
+ "edistoats.com",
42
+ "topgradinghire.com",
43
+ )
44
+ JOB = re.compile(r"^/job/(\d+)(?:/|$)")
45
+ PROPS = "[data-react-class='HiringThing.Components.ApplyButtonGroup']"
46
+ APPLY = "button.job-apply-button"
47
+ FORM = "#job-application-form"
48
+ SUBMIT = f"{FORM} button[type=submit]"
49
+ BANNER = ".cookie-banner"
50
+ UPLOAD = "/client_api/add_file"
51
+ FORM_TIMEOUT_MS = 30_000
52
+ PAGE_TIMEOUT_MS = 30_000
53
+ MENU_TIMEOUT_MS = 5_000
54
+ REGIONS_TIMEOUT_MS = 10_000
55
+ UPLOAD_TIMEOUT_MS = 30_000
56
+ SUBMIT_TIMEOUT_MS = 90_000
57
+ REQUIRED = re.compile(r"\s*\(required\)\s*$")
58
+ DATE = "%m/%d/%Y"
59
+ YES_NO = ["Yes", "No"]
60
+ TYPES = {
61
+ "text": FieldType.TEXT,
62
+ "date": FieldType.DATE,
63
+ "phone": FieldType.PHONE,
64
+ "radio": FieldType.CHOICE,
65
+ "box": FieldType.CHOICE,
66
+ "boxes": FieldType.MULTI_CHOICE,
67
+ "select": FieldType.CHOICE,
68
+ "region": FieldType.TYPEAHEAD,
69
+ "file": FieldType.FILE,
70
+ }
71
+
72
+ # Each control of the form by its name: its kind, label, options; a Dropzone area by its id.
73
+ _CONTROLS = """form => {
74
+ const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
75
+ const option = e => text(e.closest(".multi-option") ?? e.closest("label"));
76
+ const labelOf = id => document.getElementById(`${id}_label`);
77
+ const TEXTS = {text: "text", email: "text", textarea: "text", tel: "phone"};
78
+ const out = new Map();
79
+ for (const el of form.querySelectorAll("input, textarea, select")) {
80
+ const name = el.getAttribute("name") || "";
81
+ if (el.getAttribute("role") === "combobox") {
82
+ const id = el.id;
83
+ const kind = id.endsWith(".state") ? "region" : "select";
84
+ out.set(id, {id, kind, label: text(labelOf(id))});
85
+ continue;
86
+ }
87
+ if (!name || el.type === "hidden" || el.type === "file" || name.endsWith("phoneCountry"))
88
+ continue;
89
+ const label = labelOf(name);
90
+ if (el.type === "radio" || el.type === "checkbox") {
91
+ const own = el.type === "radio" ? el.value : option(el);
92
+ const entry = out.get(name) ?? {id: name, kind: el.type, label: text(label), options: []};
93
+ entry.options.push(own);
94
+ if (el.type === "checkbox") {
95
+ entry.kind = label?.classList.contains("multi-field-label") ? "boxes" : "box";
96
+ if (entry.kind === "box") entry.label = own;
97
+ }
98
+ out.set(name, entry);
99
+ continue;
100
+ }
101
+ const typed = el.tagName === "INPUT" ? el.type : el.tagName.toLowerCase();
102
+ let kind = TEXTS[typed] ?? "unhandled";
103
+ if (label?.classList.contains("datePicker-field-label")) kind = "date";
104
+ const maxLength = el.maxLength > 0 ? el.maxLength : null;
105
+ out.set(name, {id: name, kind, label: text(label), maxLength, typed});
106
+ }
107
+ for (const zone of form.querySelectorAll(".file-field-input[id]"))
108
+ out.set(zone.id, {id: zone.id, kind: "file", label: text(labelOf(`${zone.id}.file`))});
109
+ return [...out.values()];
110
+ }"""
111
+
112
+ # What each field holds: a value, the checked option, a combobox's shown value, a file's name.
113
+ _READ_BACK = """(form, fields) => {
114
+ const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
115
+ const option = e => text(e.closest(".multi-option") ?? e.closest("label"));
116
+ const out = {};
117
+ for (const [id, kind] of fields) {
118
+ const named = [...form.querySelectorAll(`[name="${id}"]`)];
119
+ if (kind === "radio") out[id] = named.find(e => e.checked)?.value ?? null;
120
+ else if (kind === "boxes")
121
+ out[id] = named.filter(e => e.checked).map(option);
122
+ else if (kind === "box") out[id] = named[0]?.checked ? "Yes" : "No";
123
+ else if (kind === "select" || kind === "region") {
124
+ const select = document.getElementById(id)?.closest(".Select");
125
+ out[id] = text(select?.querySelector(".Select-value-label")) || null;
126
+ } else if (kind === "file") {
127
+ const zone = document.getElementById(id);
128
+ const names = [...(zone?.querySelectorAll(".dz-complete .dz-filename") ?? [])];
129
+ out[id] = names.length ? text(names[names.length - 1]) : null;
130
+ } else out[id] = named[0]?.value ?? null;
131
+ }
132
+ return out;
133
+ }"""
134
+
135
+
136
+ class HiringThing:
137
+ name = "hiringthing"
138
+ known_gaps: dict[str, Gap] = {}
139
+ side_effects = [
140
+ SideEffect("upload", "hiring_system", "a file is uploaded to HiringThing once attached"),
141
+ SideEffect("submit", "employer", "the application reaches the employer"),
142
+ ]
143
+
144
+ def __init__(self) -> None:
145
+ self._kinds: dict[str, str] = {}
146
+
147
+ def detect(self, url: str, page: Page | None) -> bool:
148
+ parts = urlsplit(url)
149
+ if not JOB.match(parts.path):
150
+ return False
151
+ host = parts.hostname or ""
152
+ if any(host.endswith(f".{d}") for d in DOMAINS):
153
+ return True
154
+ return page is not None and page.locator(PROPS).count() > 0
155
+
156
+ def posting(self, url: str) -> str:
157
+ parts = urlsplit(url)
158
+ job = JOB.match(parts.path)
159
+ return f"https://{parts.hostname}/job/{job[1]}" if job else url
160
+
161
+ def read_form(self, page: Page) -> list[Field]:
162
+ props = page.locator(PROPS).first
163
+ try:
164
+ props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
165
+ except Timeout:
166
+ raise Unsupported("posting_gone: no application on the page") from None
167
+ _hide_banner(page)
168
+ form = page.locator(FORM)
169
+ if not form.count():
170
+ page.locator(APPLY).filter(visible=True).first.click()
171
+ try:
172
+ page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
173
+ except Timeout:
174
+ raise Unsupported("posting_gone: Apply showed no form") from None
175
+ if form.locator("[id='user.state']:not([role=combobox])").count():
176
+ with suppress(Timeout): # the state list loads after the form, and replaces its input
177
+ _combobox(page, "user.state").wait_for(state="attached", timeout=REGIONS_TIMEOUT_MS)
178
+ questions = _questions(props.get_attribute("data-react-props") or "{}")
179
+ controls = form.evaluate(_CONTROLS)
180
+ self._kinds = {c["id"]: c["kind"] for c in controls if c["kind"] != "unhandled"}
181
+ return [
182
+ _field(c, questions, _menu(page, c["id"]) if c["kind"] == "select" else None)
183
+ for c in controls
184
+ ]
185
+
186
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
187
+ _hide_banner(page)
188
+ form = page.locator(FORM)
189
+ # the state last: its options are the chosen country's
190
+ for field_id, kind in sorted(self._kinds.items(), key=lambda k: k[1] == "region"):
191
+ if field_id not in answers:
192
+ continue
193
+ answer = answers[field_id]
194
+ value = answer if isinstance(answer, str) else answer[0]
195
+ named = form.locator(f"[name='{field_id}']")
196
+ match kind:
197
+ case "text" | "phone":
198
+ named.fill(value)
199
+ case "date":
200
+ named.fill("")
201
+ named.press_sequentially(datetime.fromisoformat(value).strftime(DATE))
202
+ named.press("Tab")
203
+ case "radio":
204
+ form.locator(
205
+ f"input[type=radio][name='{field_id}'][value={json.dumps(value)}]"
206
+ ).check()
207
+ case "box":
208
+ named.set_checked(value == "Yes")
209
+ case "boxes":
210
+ for option in [answer] if isinstance(answer, str) else answer:
211
+ exact = re.compile(f"^\\s*{re.escape(option)}\\s*$")
212
+ box = form.locator(
213
+ ".multi-option", has=page.locator(f"[name='{field_id}']")
214
+ )
215
+ box = box.filter(has_text=exact)
216
+ box.locator("input").first.check()
217
+ case "select" | "region":
218
+ _pick(page, field_id, value, exact=kind == "select")
219
+ case "file":
220
+ _attach(page, field_id, value)
221
+
222
+ def read_back(self, page: Page) -> dict[str, Answer]:
223
+ values = page.locator(FORM).evaluate(_READ_BACK, list(self._kinds.items()))
224
+ out: dict[str, Answer] = {}
225
+ for field_id, value in values.items():
226
+ if self._kinds[field_id] == "date" and value:
227
+ with suppress(ValueError):
228
+ value = datetime.strptime(value, DATE).date().isoformat()
229
+ if value not in (None, "", []):
230
+ out[field_id] = value
231
+ return out
232
+
233
+ def submit(self, page: Page) -> str | None:
234
+ """Clicks Submit Application; the evidence is the answer to the application's POST."""
235
+ host = urlsplit(page.url).hostname
236
+ with page.expect_response(
237
+ lambda r: (
238
+ r.request.method == "POST"
239
+ and urlsplit(r.url).hostname == host
240
+ and not r.url.endswith(UPLOAD)
241
+ ),
242
+ timeout=SUBMIT_TIMEOUT_MS,
243
+ ) as applied:
244
+ page.locator(SUBMIT).click()
245
+ response = applied.value
246
+ if page.locator("iframe[src*='recaptcha'][title*='challenge']").count():
247
+ raise Blocked("captcha: a reCAPTCHA challenge showed on submit")
248
+ if not response.ok:
249
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
250
+ return f"{response.url} {response.status}"
251
+
252
+
253
+ def _questions(props: str) -> dict[str, str]:
254
+ """Each question's text by its id, from the job's props."""
255
+ with suppress(ValueError, KeyError, TypeError):
256
+ table = json.loads(props)["jobObj"]["table"]
257
+ return {str(q["table"]["id"]): q["table"]["question"] for q in table.get("questions") or []}
258
+ return {}
259
+
260
+
261
+ def _field(control: dict, questions: dict[str, str], options: list[str] | None) -> Field:
262
+ """A field from a rendered control; a history entry's field labelled with its question."""
263
+ field_id, kind = control["id"], control["kind"]
264
+ label = control["label"] or field_id
265
+ required = bool(REQUIRED.search(label))
266
+ label = REQUIRED.sub("", label)
267
+ if entry := re.match(r"job_assessment\.question_(\d+)\.response\.\d+\.", field_id):
268
+ label = f"{questions.get(entry[1], 'History')} {label}"
269
+ if kind == "unhandled":
270
+ reason = f"unhandled_rendering: {field_id} is a {control['typed']}"
271
+ return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
272
+ if kind == "box":
273
+ options = YES_NO
274
+ required = "disclosure_agree" in field_id # an agreement, not a preference
275
+ elif kind in ("radio", "boxes"):
276
+ options = control["options"]
277
+ max_length = control.get("maxLength") if kind == "text" else None
278
+ return Field(field_id, label, TYPES[kind], required, options, max_length)
279
+
280
+
281
+ def _hide_banner(page: Page) -> None:
282
+ """Hides the cookie banner, which covers the form's bottom, without answering it."""
283
+ with suppress(Exception):
284
+ page.locator(BANNER).evaluate_all("es => es.forEach(e => e.style.display = 'none')")
285
+
286
+
287
+ def _combobox(page: Page, field_id: str) -> Locator:
288
+ return page.locator(f"{FORM} input[role=combobox][id='{field_id}']")
289
+
290
+
291
+ def _menu(page: Page, field_id: str) -> list[str]:
292
+ """A react-select's options, read by opening its menu and closing it again."""
293
+ box = _combobox(page, field_id)
294
+ box.focus()
295
+ box.press("ArrowDown")
296
+ options = page.locator(".Select-menu-outer .Select-option")
297
+ with suppress(Timeout):
298
+ options.first.wait_for(timeout=MENU_TIMEOUT_MS)
299
+ texts = [" ".join(t.split()) for t in options.all_inner_texts()]
300
+ box.press("Escape")
301
+ return texts
302
+
303
+
304
+ def _pick(page: Page, field_id: str, value: str, exact: bool) -> None:
305
+ """Types into a react-select and clicks the option that is the answer, or for a typeahead
306
+ the first that starts with it."""
307
+ box = _combobox(page, field_id)
308
+ box.focus()
309
+ box.press_sequentially(value)
310
+ options = page.locator(".Select-menu-outer .Select-option")
311
+ try:
312
+ options.first.wait_for(timeout=MENU_TIMEOUT_MS)
313
+ except Timeout:
314
+ box.press("Escape")
315
+ raise NotTaken(f"no option {value!r} in {field_id}") from None
316
+ texts = [" ".join(t.split()) for t in options.all_inner_texts()]
317
+ hit = next((i for i, t in enumerate(texts) if t == value), None)
318
+ if hit is None and not exact:
319
+ hit = next((i for i, t in enumerate(texts) if t.startswith(value)), None)
320
+ if hit is None:
321
+ box.press("Escape")
322
+ raise NotTaken(f"no option {value!r} in {field_id}")
323
+ options.nth(hit).click()
324
+
325
+
326
+ def _attach(page: Page, field_id: str, path: str) -> None:
327
+ """Attaches a file through its Dropzone area's file chooser, and waits for its upload."""
328
+ zone = page.locator(f"{FORM} [id='{field_id}'] .dropzone")
329
+ with page.expect_file_chooser() as chooser:
330
+ zone.click()
331
+ chooser.value.set_files(path)
332
+ name = path.rsplit("/", 1)[-1]
333
+ done = zone.locator(".dz-complete .dz-filename", has_text=name)
334
+ with suppress(Timeout):
335
+ done.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
@@ -398,7 +398,9 @@ kind = "ats"
398
398
  share = 0.0028
399
399
  account = "none"
400
400
  hosting = ["hosted"]
401
+ adapter = "hiringthing"
401
402
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
403
+ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "cce148b" }
402
404
 
403
405
  [jibe]
404
406
  kind = "aggregator"
File without changes