atsfill 0.0.4.dev96__tar.gz → 0.0.4.dev99__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.
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/PKG-INFO +3 -2
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/README.md +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/pyproject.toml +2 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/pyproject.toml.orig +2 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/ashby.py +4 -4
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/bamboohr.py +4 -4
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/breezy.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/dayforce.py +2 -2
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/eightfold.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/greenhouse.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/hireology.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/hiringthing.py +2 -2
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/jazzhr.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/lever.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/manatal.py +2 -2
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/oracle.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/paycor.py +2 -2
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/paylocity.py +4 -4
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/personio.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/phenom.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/pinpoint.py +4 -4
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/recruitee.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/rippling.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/smartrecruiters.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/teamtailor.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/workable.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/workday.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/wttj.py +1 -1
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/zohorecruit.py +3 -3
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/adapter.py +8 -8
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/commands.py +11 -10
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/LICENSE +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev96 → atsfill-0.0.4.dev99}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev99
|
|
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
|
|
@@ -10,6 +10,7 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
10
10
|
Classifier: Environment :: Console
|
|
11
11
|
Classifier: Intended Audience :: End Users/Desktop
|
|
12
12
|
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
14
|
Classifier: Operating System :: POSIX :: Linux
|
|
14
15
|
Classifier: Programming Language :: Python :: 3
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
@@ -83,7 +84,7 @@ atsfill moves the quirks into one adapter per system and makes the check code: a
|
|
|
83
84
|
|
|
84
85
|
## Install
|
|
85
86
|
|
|
86
|
-
atsfill runs on Linux and
|
|
87
|
+
atsfill runs on Linux, macOS and Windows with Python 3.14, which `uvx` fetches if needed.
|
|
87
88
|
|
|
88
89
|
```sh
|
|
89
90
|
uvx atsfill install-browser
|
|
@@ -56,7 +56,7 @@ atsfill moves the quirks into one adapter per system and makes the check code: a
|
|
|
56
56
|
|
|
57
57
|
## Install
|
|
58
58
|
|
|
59
|
-
atsfill runs on Linux and
|
|
59
|
+
atsfill runs on Linux, macOS and Windows with Python 3.14, which `uvx` fetches if needed.
|
|
60
60
|
|
|
61
61
|
```sh
|
|
62
62
|
uvx atsfill install-browser
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev99"
|
|
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"
|
|
@@ -20,6 +20,7 @@ classifiers = [
|
|
|
20
20
|
"Environment :: Console",
|
|
21
21
|
"Intended Audience :: End Users/Desktop",
|
|
22
22
|
"Operating System :: MacOS",
|
|
23
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
24
|
"Operating System :: POSIX :: Linux",
|
|
24
25
|
"Programming Language :: Python :: 3",
|
|
25
26
|
"Programming Language :: Python :: 3.14",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev99"
|
|
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"
|
|
@@ -13,6 +13,7 @@ classifiers = [
|
|
|
13
13
|
"Environment :: Console",
|
|
14
14
|
"Intended Audience :: End Users/Desktop",
|
|
15
15
|
"Operating System :: MacOS",
|
|
16
|
+
"Operating System :: Microsoft :: Windows",
|
|
16
17
|
"Operating System :: POSIX :: Linux",
|
|
17
18
|
"Programming Language :: Python :: 3",
|
|
18
19
|
"Programming Language :: Python :: 3.14",
|
|
@@ -19,7 +19,7 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
|
19
19
|
|
|
20
20
|
HOST = "jobs.ashbyhq.com"
|
|
21
21
|
GRAPHQL = f"https://{HOST}/api/non-user-graphql?op=ApiJobPosting"
|
|
22
|
-
# The fields of the page's own ApiJobPosting query that `
|
|
22
|
+
# The fields of the page's own ApiJobPosting query that `browser_read` uses.
|
|
23
23
|
JOB_POSTING = """query ApiJobPosting($org: String!, $job: String!) {
|
|
24
24
|
jobPosting(organizationHostedJobsPageName: $org, jobPostingId: $job) {
|
|
25
25
|
applicationForm { ...Form }
|
|
@@ -78,7 +78,7 @@ class Ashby:
|
|
|
78
78
|
|
|
79
79
|
def __init__(self) -> None:
|
|
80
80
|
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
81
|
-
self._pending: set[Any] = set() # GraphQL requests in flight, since `
|
|
81
|
+
self._pending: set[Any] = set() # GraphQL requests in flight, since `browser_read`
|
|
82
82
|
self._last_change = 0.0
|
|
83
83
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
84
84
|
|
|
@@ -100,7 +100,7 @@ class Ashby:
|
|
|
100
100
|
def posting(self, url: str) -> str:
|
|
101
101
|
return _hosted(url) if urlsplit(url).hostname == HOST else self._hosted or url
|
|
102
102
|
|
|
103
|
-
def
|
|
103
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
104
104
|
"""The fields from the GraphQL query the job page itself sends, for a hosted URL."""
|
|
105
105
|
if urlsplit(url).hostname != HOST:
|
|
106
106
|
return None
|
|
@@ -125,7 +125,7 @@ class Ashby:
|
|
|
125
125
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
126
126
|
return None
|
|
127
127
|
|
|
128
|
-
def
|
|
128
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
129
129
|
page.on("request", self._on_request)
|
|
130
130
|
page.on("requestfinished", self._on_finished)
|
|
131
131
|
page.on("requestfailed", self._on_finished)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""BambooHR (`<employer>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
|
|
2
2
|
|
|
3
3
|
The job page renders its application form, behind Apply for This Job, from a public JSON
|
|
4
|
-
answer that lists every field with its label, requirement and options. `
|
|
5
|
-
over HTTP, `
|
|
4
|
+
answer that lists every field with its label, requirement and options. `request_read` fetches it
|
|
5
|
+
over HTTP, `browser_read` through the page's own browser context, and both list the fields from
|
|
6
6
|
it. Dropdowns are Fabric menus over a hidden `select`; a file is uploaded once attached.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -159,7 +159,7 @@ class BambooHR:
|
|
|
159
159
|
job = _job(url)
|
|
160
160
|
return f"https://{job[0]}/careers/{job[1]}" if job else url
|
|
161
161
|
|
|
162
|
-
def
|
|
162
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
163
163
|
if (job := _job(url)) is None:
|
|
164
164
|
return None
|
|
165
165
|
try:
|
|
@@ -169,7 +169,7 @@ class BambooHR:
|
|
|
169
169
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
170
170
|
return None
|
|
171
171
|
|
|
172
|
-
def
|
|
172
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
173
173
|
host, job = _job(page.url) or ("", "")
|
|
174
174
|
form = page.locator(FORM)
|
|
175
175
|
try:
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
docs/adapters/breezy.md observes.
|
|
3
3
|
|
|
4
4
|
Two page engines, an AngularJS one and a React one, render the same markup: named controls
|
|
5
|
-
under `h3` headings, the questionnaire's as `section_<section>_question_<n>`.
|
|
6
|
-
read
|
|
5
|
+
under `h3` headings, the questionnaire's as `section_<section>_question_<n>`. Only a browser
|
|
6
|
+
read lists the fields. Files upload to Breezy when attached; the rest reaches it on submit.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import re
|
|
@@ -236,7 +236,7 @@ class Breezy:
|
|
|
236
236
|
match = JOB.match(parts.path)
|
|
237
237
|
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
238
238
|
|
|
239
|
-
def
|
|
239
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
240
240
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
241
241
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
242
242
|
form = page.locator(FORM)
|
|
@@ -197,7 +197,7 @@ class Dayforce:
|
|
|
197
197
|
found = _job(url)
|
|
198
198
|
return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
|
|
199
199
|
|
|
200
|
-
def
|
|
200
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
201
201
|
found = _job(page.url)
|
|
202
202
|
if found is None:
|
|
203
203
|
raise Unsupported("posting_gone: not a Dayforce job page")
|
|
@@ -481,7 +481,7 @@ class Dayforce:
|
|
|
481
481
|
option = box_id.removeprefix(f"{base}answerFollowup").removeprefix("s_")
|
|
482
482
|
followup = f"{field_id}.followup" + (f".{option}" if option else "")
|
|
483
483
|
if box.is_visible() and followup not in self._fields:
|
|
484
|
-
self._shown.append(box_id) # a comment box `
|
|
484
|
+
self._shown.append(box_id) # a comment box `browser_read` did not list
|
|
485
485
|
|
|
486
486
|
def _set_question(
|
|
487
487
|
self, page: Page, target: Locator, field: Field, question: dict[str, Any], answer: Answer
|
|
@@ -3,7 +3,7 @@ the employer's own domain), as docs/adapters/eightfold.md observes.
|
|
|
3
3
|
|
|
4
4
|
A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
|
|
5
5
|
sections of questions by id, type, requirement and options, and the questions whose answer
|
|
6
|
-
shows each follow-up. `
|
|
6
|
+
shows each follow-up. `request_read` fetches it over HTTP, `browser_read` through the page's browser
|
|
7
7
|
context, and both list the fields from it. Each question renders as a control whose element id
|
|
8
8
|
is its section's title and its id, with every other character an underscore.
|
|
9
9
|
"""
|
|
@@ -162,7 +162,7 @@ class Eightfold:
|
|
|
162
162
|
parts, pid = urlsplit(url), _pid(url)
|
|
163
163
|
return f"https://{parts.hostname}/careers/job/{pid}" if pid else url
|
|
164
164
|
|
|
165
|
-
def
|
|
165
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
166
166
|
if (pid := _pid(url)) is None:
|
|
167
167
|
return None
|
|
168
168
|
try:
|
|
@@ -185,7 +185,7 @@ class Eightfold:
|
|
|
185
185
|
except OSError, ValueError, KeyError, TypeError, Unsupported, Blocked:
|
|
186
186
|
return None
|
|
187
187
|
|
|
188
|
-
def
|
|
188
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
189
189
|
pid = _pid(page.url)
|
|
190
190
|
if pid is None:
|
|
191
191
|
raise Unsupported(f"posting_gone: {page.url} is not a job page")
|
|
@@ -44,7 +44,7 @@ API_TYPES = {
|
|
|
44
44
|
}
|
|
45
45
|
YES_NO = ["Yes", "No"]
|
|
46
46
|
# Sections the page adds or names its own way, or shows a question of only on another answer
|
|
47
|
-
# (EEOC race after Hispanic/Latino): `
|
|
47
|
+
# (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
|
|
48
48
|
UNMAPPED = ("education", "compliance", "demographic_questions")
|
|
49
49
|
|
|
50
50
|
# The fields in page order. A select's options are read by opening it, from Python.
|
|
@@ -167,7 +167,7 @@ class Greenhouse:
|
|
|
167
167
|
board, job = _board_job(url) or _board_job(self._embed or "") or ("", url)
|
|
168
168
|
return f"https://job-boards.greenhouse.io/{board}/jobs/{job}"
|
|
169
169
|
|
|
170
|
-
def
|
|
170
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
171
171
|
"""The fields from the Job Board API, unless the page holds some the API leaves out."""
|
|
172
172
|
found = _board_job(url)
|
|
173
173
|
if found is None:
|
|
@@ -177,7 +177,7 @@ class Greenhouse:
|
|
|
177
177
|
except OSError, ValueError, KeyError, TypeError:
|
|
178
178
|
return None
|
|
179
179
|
|
|
180
|
-
def
|
|
180
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
181
181
|
if self._embed:
|
|
182
182
|
page.goto(self._embed)
|
|
183
183
|
form = page.locator(FORM)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
docs/adapters/hireology.md observes.
|
|
3
3
|
|
|
4
4
|
The job page renders its application form from a public form API, which lists every field by
|
|
5
|
-
id, type and requirement. `
|
|
5
|
+
id, type and requirement. `request_read` fetches it over HTTP, `browser_read` through the page's own
|
|
6
6
|
browser context, and both list the fields from it. Each field renders as one control whose id is
|
|
7
7
|
`<field id>-<n>`; nothing but the submit reaches Hireology.
|
|
8
8
|
"""
|
|
@@ -202,7 +202,7 @@ class Hireology:
|
|
|
202
202
|
job = _job(url)
|
|
203
203
|
return API.format(job=job) if job else url
|
|
204
204
|
|
|
205
|
-
def
|
|
205
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
206
206
|
if (job := _job(url)) is None:
|
|
207
207
|
return None
|
|
208
208
|
request = urllib.request.Request(API.format(job=job), headers={"User-Agent": "atsfill"})
|
|
@@ -212,7 +212,7 @@ class Hireology:
|
|
|
212
212
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
213
213
|
return None
|
|
214
214
|
|
|
215
|
-
def
|
|
215
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
216
216
|
job = _job(page.url)
|
|
217
217
|
try:
|
|
218
218
|
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -4,7 +4,7 @@ observes.
|
|
|
4
4
|
The job page carries the posting's configuration in a React component's props, and its Apply
|
|
5
5
|
button renders the form from them in the page: MUI inputs named by field id, radios and boxes,
|
|
6
6
|
react-select comboboxes for the country and state, Dropzone areas that upload a file as soon as
|
|
7
|
-
it is attached. `
|
|
7
|
+
it is attached. `browser_read` lists the rendered controls; there is no `request_read`, since which
|
|
8
8
|
controls render is the page's logic over the props, not the props alone.
|
|
9
9
|
"""
|
|
10
10
|
|
|
@@ -157,7 +157,7 @@ class HiringThing:
|
|
|
157
157
|
job = JOB.match(parts.path)
|
|
158
158
|
return f"https://{parts.hostname}/job/{job[1]}" if job else url
|
|
159
159
|
|
|
160
|
-
def
|
|
160
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
161
161
|
props = page.locator(PROPS).first
|
|
162
162
|
try:
|
|
163
163
|
props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
|
|
@@ -140,7 +140,7 @@ class JazzHR:
|
|
|
140
140
|
job = _job(url)
|
|
141
141
|
return f"https://{job[0]}/apply/{job[1]}" if job else url
|
|
142
142
|
|
|
143
|
-
def
|
|
143
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
144
144
|
form = page.locator(FORM)
|
|
145
145
|
try:
|
|
146
146
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -133,7 +133,7 @@ class Lever:
|
|
|
133
133
|
parts, job = urlsplit(url), _job(url)
|
|
134
134
|
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
135
135
|
|
|
136
|
-
def
|
|
136
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
137
137
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
138
138
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
139
139
|
form = page.locator(FORM)
|
|
@@ -191,7 +191,7 @@ class Manatal:
|
|
|
191
191
|
def posting(self, url: str) -> str:
|
|
192
192
|
return _posting(url)
|
|
193
193
|
|
|
194
|
-
def
|
|
194
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
195
195
|
"""The fields from the endpoint the apply page fetches, its job id from that page."""
|
|
196
196
|
client = urlsplit(url).path.strip("/").split("/")[0]
|
|
197
197
|
try:
|
|
@@ -210,7 +210,7 @@ class Manatal:
|
|
|
210
210
|
except OSError, ValueError, KeyError, TypeError:
|
|
211
211
|
return None
|
|
212
212
|
|
|
213
|
-
def
|
|
213
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
214
214
|
with page.expect_response(
|
|
215
215
|
lambda r: r.request.method == "GET" and "/application-form/" in r.url,
|
|
216
216
|
timeout=LOAD_TIMEOUT_MS,
|
|
@@ -266,7 +266,7 @@ class Oracle:
|
|
|
266
266
|
assert match, url
|
|
267
267
|
return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
|
|
268
268
|
|
|
269
|
-
def
|
|
269
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
270
270
|
parts = urlsplit(page.url)
|
|
271
271
|
match = JOB.search(parts.path)
|
|
272
272
|
assert match, page.url # detected on a job URL
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The apply page is server-rendered. Before the form it may show pages of their own: the
|
|
4
4
|
federal contractor's self-identification (VEVRAA) and the Yes/No qualifying questions. Each
|
|
5
5
|
posts its answers to the next, which carries them as hidden inputs; the form posts everything
|
|
6
|
-
to `ApplyJob.action` on Submit. `
|
|
6
|
+
to `ApplyJob.action` on Submit. `browser_read` walks those pages to the form, `fill` walks them
|
|
7
7
|
again with the answers.
|
|
8
8
|
"""
|
|
9
9
|
|
|
@@ -171,7 +171,7 @@ class Paycor:
|
|
|
171
171
|
job = (query.get("id") or query.get("gni") or [url])[0]
|
|
172
172
|
return f"{CAREER}JobIntroduction.action?id={job}"
|
|
173
173
|
|
|
174
|
-
def
|
|
174
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
175
175
|
self._before, self._codes = [], {}
|
|
176
176
|
self._walk(page, None)
|
|
177
177
|
controls = [(_field(c), c["kind"]) for c in page.evaluate(_CONTROLS)]
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
docs/adapters/paylocity.md observes.
|
|
3
3
|
|
|
4
4
|
The apply page carries the whole form's configuration in `window.pageData`: the sections and
|
|
5
|
-
their fields, the screener questions and the option lists. `
|
|
6
|
-
`
|
|
5
|
+
their fields, the screener questions and the option lists. `request_read` fetches it over HTTP,
|
|
6
|
+
`browser_read` takes it from the page, and both list the fields from it. The form is one page or
|
|
7
7
|
a wizard of steps that render one at a time, so `fill` keeps each step's read-back before
|
|
8
8
|
moving on; nothing reaches the employer before Submit.
|
|
9
9
|
"""
|
|
@@ -326,7 +326,7 @@ class Paylocity:
|
|
|
326
326
|
job = _job(url)
|
|
327
327
|
return f"https://{HOST}/Recruiting/Jobs/Details/{job}" if job else url
|
|
328
328
|
|
|
329
|
-
def
|
|
329
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
330
330
|
if (job := _job(url)) is None:
|
|
331
331
|
return None
|
|
332
332
|
request = urllib.request.Request(APPLY + job, headers={"User-Agent": "atsfill"})
|
|
@@ -337,7 +337,7 @@ class Paylocity:
|
|
|
337
337
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
338
338
|
return None
|
|
339
339
|
|
|
340
|
-
def
|
|
340
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
341
341
|
job = _job(page.url)
|
|
342
342
|
if job and "/apply/" not in page.url.lower():
|
|
343
343
|
page.goto(APPLY + job)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The apply page is rendered on the server, its form whole in the HTML: every field is one native
|
|
4
4
|
control named by its id, labelled by a `label[for]` or its group's label, a star marking it
|
|
5
|
-
required. `
|
|
5
|
+
required. `request_read` fetches that HTML over HTTP, `browser_read` takes the page's form, and both
|
|
6
6
|
list the fields from it with one parser. A document is uploaded to Personio once attached.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -72,7 +72,7 @@ class Personio:
|
|
|
72
72
|
job = _job(url)
|
|
73
73
|
return f"https://{job[0]}.jobs.personio.com/job/{job[1]}" if job else url
|
|
74
74
|
|
|
75
|
-
def
|
|
75
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
76
76
|
if _job(url) is None:
|
|
77
77
|
return None
|
|
78
78
|
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
@@ -84,7 +84,7 @@ class Personio:
|
|
|
84
84
|
form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
|
|
85
85
|
return [f for f, _ in _form(form)] if form else None
|
|
86
86
|
|
|
87
|
-
def
|
|
87
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
88
88
|
if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
89
89
|
page.goto(_apply(page.url))
|
|
90
90
|
try:
|
|
@@ -4,7 +4,7 @@ as docs/adapters/phenom.md observes.
|
|
|
4
4
|
The job page's Apply link leads to Phenom's own wizard (`/apply?jobSeqNo=`) or to another
|
|
5
5
|
system. The wizard renders one step at a time from the employer's configuration, a form schema
|
|
6
6
|
on Phenom's CDN, and the parts its loaders serve (the job's questionnaires, the country's
|
|
7
|
-
fields, dependent lists):
|
|
7
|
+
fields, dependent lists): a request read or a browser read interprets them without passing a
|
|
8
8
|
step. Filling walks the wizard on a fresh session, each step's answers read back before Next.
|
|
9
9
|
"""
|
|
10
10
|
|
|
@@ -260,7 +260,7 @@ class Phenom:
|
|
|
260
260
|
seq = _seq(url)
|
|
261
261
|
return f"https://{parts.hostname}/apply?jobSeqNo={seq}" if seq else url
|
|
262
262
|
|
|
263
|
-
def
|
|
263
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
264
264
|
"""The fields from the job's apply page and the configuration it names, over HTTP;
|
|
265
265
|
None for a job applied to on another system, or a page it does not understand."""
|
|
266
266
|
parts = urlsplit(url)
|
|
@@ -297,7 +297,7 @@ class Phenom:
|
|
|
297
297
|
except OSError, ValueError, KeyError, TypeError, RuntimeError, Unsupported:
|
|
298
298
|
return None
|
|
299
299
|
|
|
300
|
-
def
|
|
300
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
301
301
|
_check_open(page)
|
|
302
302
|
self._apply = _apply_url(page)
|
|
303
303
|
self._open(page)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
docs/adapters/pinpoint.md observes.
|
|
3
3
|
|
|
4
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. `
|
|
6
|
-
HTML over HTTP, `
|
|
5
|
+
field as a React component whose props sit in a JSON script next to it. `request_read` fetches that
|
|
6
|
+
HTML over HTTP, `browser_read` takes the page's form, and both list the fields from it with one
|
|
7
7
|
parser. A document is uploaded to Pinpoint once attached.
|
|
8
8
|
"""
|
|
9
9
|
|
|
@@ -125,7 +125,7 @@ class Pinpoint:
|
|
|
125
125
|
job = _job(url)
|
|
126
126
|
return f"https://{urlsplit(url).hostname}/en/postings/{job[1]}" if job else url
|
|
127
127
|
|
|
128
|
-
def
|
|
128
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
129
129
|
if _job(url) is None:
|
|
130
130
|
return None
|
|
131
131
|
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
@@ -139,7 +139,7 @@ class Pinpoint:
|
|
|
139
139
|
)
|
|
140
140
|
return [f for f, _ in _form(form, self)] if form else None
|
|
141
141
|
|
|
142
|
-
def
|
|
142
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
143
143
|
if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/new"):
|
|
144
144
|
page.goto(_apply(page.url))
|
|
145
145
|
try:
|
|
@@ -143,7 +143,7 @@ class Recruitee:
|
|
|
143
143
|
match = JOB.match(parts.path)
|
|
144
144
|
return f"https://{parts.hostname}/o/{match[1]}" if match else url
|
|
145
145
|
|
|
146
|
-
def
|
|
146
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
147
147
|
if not urlsplit(page.url).path.rstrip("/").endswith("/c/new"):
|
|
148
148
|
page.goto(f"{self.posting(page.url)}/c/new")
|
|
149
149
|
form = page.locator(FORM)
|
|
@@ -3,7 +3,7 @@ observes.
|
|
|
3
3
|
|
|
4
4
|
The job's apply page (`…/apply`) carries its form in the page data: the standard questions by
|
|
5
5
|
id and type, the employer's question groups, and whether the EEO questionnaire is asked.
|
|
6
|
-
`
|
|
6
|
+
`request_read` fetches the page over HTTP, `browser_read` reads the data of the page it opened, and
|
|
7
7
|
both list the fields from it. Each field renders in an element whose `data-testid` is the
|
|
8
8
|
field's id; the résumé is uploaded and parsed as soon as it is attached.
|
|
9
9
|
"""
|
|
@@ -175,7 +175,7 @@ class Rippling:
|
|
|
175
175
|
job = _job(url)
|
|
176
176
|
return POSTING.format(board=job[0], job=job[1]) if job else url
|
|
177
177
|
|
|
178
|
-
def
|
|
178
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
179
179
|
if (job := _job(url)) is None:
|
|
180
180
|
return None
|
|
181
181
|
request = urllib.request.Request(
|
|
@@ -189,7 +189,7 @@ class Rippling:
|
|
|
189
189
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
190
190
|
return None
|
|
191
191
|
|
|
192
|
-
def
|
|
192
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
193
193
|
job = _job(page.url)
|
|
194
194
|
assert job is not None
|
|
195
195
|
apply = APPLY.format(board=job[0], job=job[1])
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
docs/adapters/smartrecruiters.md observes.
|
|
3
3
|
|
|
4
4
|
The form is two pages: the profile, then the screening questions and consents. The second
|
|
5
|
-
page's questions come from the page's own `__OC_CONTEXT__`, so `
|
|
5
|
+
page's questions come from the page's own `__OC_CONTEXT__`, so `browser_read` lists them without
|
|
6
6
|
leaving the first page. Nothing but the resume reaches SmartRecruiters before submit, so the
|
|
7
7
|
read-back is the page: the profile as it stood when `fill` left it, then the second page.
|
|
8
8
|
"""
|
|
@@ -136,7 +136,7 @@ class SmartRecruiters:
|
|
|
136
136
|
def posting(self, url: str) -> str:
|
|
137
137
|
return _key(url) or url
|
|
138
138
|
|
|
139
|
-
def
|
|
139
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
140
140
|
if not ONECLICK.match(urlsplit(page.url).path):
|
|
141
141
|
link = page.locator(APPLY_LINK).first
|
|
142
142
|
try:
|
|
@@ -252,7 +252,7 @@ class SmartRecruiters:
|
|
|
252
252
|
return fields
|
|
253
253
|
|
|
254
254
|
def _question_answers(self, question_id: str) -> list[tuple[str, dict[str, Any]]]:
|
|
255
|
-
"""(field id, the question's field) for each field `
|
|
255
|
+
"""(field id, the question's field) for each field `browser_read` listed."""
|
|
256
256
|
entries = self._questions[question_id]["fields"]
|
|
257
257
|
ids = [f"{question_id}.{e['id']}" if len(entries) > 1 else question_id for e in entries]
|
|
258
258
|
return [
|
|
@@ -198,7 +198,7 @@ class Teamtailor:
|
|
|
198
198
|
assert match, url
|
|
199
199
|
return f"https://{parts.hostname}/jobs/{match[1]}"
|
|
200
200
|
|
|
201
|
-
def
|
|
201
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
202
202
|
"""Opens the form's own page, never the overlay nor "Apply with LinkedIn"."""
|
|
203
203
|
parts = urlsplit(page.url)
|
|
204
204
|
job = JOB.search(parts.path)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
docs/adapters/workable.md observes.
|
|
3
3
|
|
|
4
4
|
Every field sits under an element whose `data-ui` is its id in Workable's form API, which
|
|
5
|
-
`
|
|
5
|
+
`request_read` reads. Nothing but an attached file reaches Workable before submit, so the
|
|
6
6
|
read-back is the page.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -159,7 +159,7 @@ class Workable:
|
|
|
159
159
|
def posting(self, url: str) -> str:
|
|
160
160
|
return f"https://apply.workable.com/j/{_code(url) or url}"
|
|
161
161
|
|
|
162
|
-
def
|
|
162
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
163
163
|
"""The fields from the form API the page itself loads, unless it has a type it does not
|
|
164
164
|
know."""
|
|
165
165
|
code = _code(url)
|
|
@@ -174,7 +174,7 @@ class Workable:
|
|
|
174
174
|
except OSError, ValueError, KeyError, TypeError:
|
|
175
175
|
return None
|
|
176
176
|
|
|
177
|
-
def
|
|
177
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
178
178
|
if "/apply" not in urlsplit(page.url).path:
|
|
179
179
|
page.goto(f"{self.posting(page.url)}/apply/")
|
|
180
180
|
form = page.locator(FORM)
|
|
@@ -253,7 +253,7 @@ class Workday:
|
|
|
253
253
|
consent.check()
|
|
254
254
|
_submit(page, "createAccountSubmitButton", email, password)
|
|
255
255
|
|
|
256
|
-
def
|
|
256
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
257
257
|
"""Walks the wizard as far as its pages save with what they hold, reading each page
|
|
258
258
|
it reaches: from the page, else My Experience from the apply flow's sections and the
|
|
259
259
|
questions from the questionnaires, which also stand for those pages when they are not
|
|
@@ -106,7 +106,7 @@ class WelcomeToTheJungle:
|
|
|
106
106
|
_account_form(page, "sign-up-form", values, "/registrations")
|
|
107
107
|
raise Blocked("email_link: the sign-up sent a confirmation link")
|
|
108
108
|
|
|
109
|
-
def
|
|
109
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
110
110
|
"""Opens the apply modal; a job that applies on another site has no form here."""
|
|
111
111
|
if not JOB.match(urlsplit(page.url).path):
|
|
112
112
|
raise RuntimeError(f"{page.url} is not a Welcome to the Jungle job")
|
|
@@ -3,7 +3,7 @@ docs/adapters/zohorecruit.md observes.
|
|
|
3
3
|
|
|
4
4
|
The job page names its application form, which "I'm interested" renders from a public form
|
|
5
5
|
API: sections of fields by id, type and requirement, and the pre-screening questions a form
|
|
6
|
-
may ask with them. `
|
|
6
|
+
may ask with them. `request_read` fetches it over HTTP, `browser_read` through the page's browser
|
|
7
7
|
context, and both list the fields from it. Each field renders as a Lyte component whose id
|
|
8
8
|
derives from the field's; nothing but the submit reaches Zoho.
|
|
9
9
|
"""
|
|
@@ -143,7 +143,7 @@ class ZohoRecruit:
|
|
|
143
143
|
parts, job = urlsplit(url), _job(url)
|
|
144
144
|
return POSTING.format(host=parts.hostname, job=job[2]) if job else url
|
|
145
145
|
|
|
146
|
-
def
|
|
146
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
147
147
|
if _job(url) is None:
|
|
148
148
|
return None
|
|
149
149
|
try:
|
|
@@ -155,7 +155,7 @@ class ZohoRecruit:
|
|
|
155
155
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
156
156
|
return None
|
|
157
157
|
|
|
158
|
-
def
|
|
158
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
159
159
|
job = _job(page.url)
|
|
160
160
|
if job is None:
|
|
161
161
|
raise Unsupported(f"posting_gone: {page.url} is not a job page")
|
|
@@ -17,7 +17,7 @@ class Unsupported(Exception):
|
|
|
17
17
|
"""What stops the form before any fill: a flow the adapter does not handle, or a fact about
|
|
18
18
|
the posting (`posting_gone`, `external_step`). The message is `code: detail`.
|
|
19
19
|
|
|
20
|
-
A field atsfill cannot fill is not raised: `
|
|
20
|
+
A field atsfill cannot fill is not raised: `browser_read` returns it with `unfillable` set.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
|
|
@@ -68,7 +68,7 @@ class Adapter(Protocol):
|
|
|
68
68
|
"""The posting's key for the double-send guard: every URL of one posting gives the same."""
|
|
69
69
|
...
|
|
70
70
|
|
|
71
|
-
def
|
|
71
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
72
72
|
"""Every field, one atsfill cannot fill included with its reason in `unfillable`."""
|
|
73
73
|
...
|
|
74
74
|
|
|
@@ -137,19 +137,19 @@ class CreatesAccount(SignsIn, Protocol):
|
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
@runtime_checkable
|
|
140
|
-
class
|
|
141
|
-
"""An adapter
|
|
140
|
+
class ReadsByRequest(Adapter, Protocol):
|
|
141
|
+
"""An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
|
|
142
142
|
|
|
143
|
-
def
|
|
144
|
-
"""The same fields as `
|
|
143
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
144
|
+
"""The same fields as `browser_read`; None when it cannot for this URL."""
|
|
145
145
|
...
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
@runtime_checkable
|
|
149
149
|
class ListsShownFields(Adapter, Protocol):
|
|
150
150
|
"""An adapter whose form can show fields after an answer: once filled, it says which the
|
|
151
|
-
page shows, so that one `
|
|
151
|
+
page shows, so that one `browser_read` did not list stops the fill (ADR 0005)."""
|
|
152
152
|
|
|
153
153
|
def shown_fields(self, page: Page) -> list[str]:
|
|
154
|
-
"""The ids of the fields the filled page shows, each as `
|
|
154
|
+
"""The ids of the fields the filled page shows, each as `browser_read` would name it."""
|
|
155
155
|
...
|
|
@@ -22,7 +22,7 @@ from atsfill.core.adapter import (
|
|
|
22
22
|
ListsShownFields,
|
|
23
23
|
NeedsAccount,
|
|
24
24
|
NotTaken,
|
|
25
|
-
|
|
25
|
+
ReadsByRequest,
|
|
26
26
|
Refused,
|
|
27
27
|
SignsIn,
|
|
28
28
|
Unsupported,
|
|
@@ -196,7 +196,7 @@ def _unfillable(result: Result, answers: dict[str, Answer] | None = None) -> boo
|
|
|
196
196
|
|
|
197
197
|
|
|
198
198
|
def _listed(result: Result, fields: list[Field]) -> None:
|
|
199
|
-
"""Sets the fields `
|
|
199
|
+
"""Sets the fields `browser_read` or `request_read` listed; their conditions must name listed
|
|
200
200
|
fields, never a loop."""
|
|
201
201
|
if faults := conditions.faults(fields):
|
|
202
202
|
raise ValueError("; ".join(faults))
|
|
@@ -206,7 +206,7 @@ def _listed(result: Result, fields: list[Field]) -> None:
|
|
|
206
206
|
def _shown_as_listed(
|
|
207
207
|
page: Page, adapter: Adapter, result: Result, answers: dict[str, Answer]
|
|
208
208
|
) -> None:
|
|
209
|
-
"""Stops a fill that showed a field `
|
|
209
|
+
"""Stops a fill that showed a field `browser_read` did not list, or a follow-up whose
|
|
210
210
|
condition the answers leave unmet: that condition was read wrong (ADR 0005)."""
|
|
211
211
|
if not isinstance(adapter, ListsShownFields):
|
|
212
212
|
return
|
|
@@ -259,7 +259,7 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
|
|
|
259
259
|
"""Fills and reads back; `ready` when every answer reads back as given."""
|
|
260
260
|
result = _result(adapter, url, user)
|
|
261
261
|
with _stops(result):
|
|
262
|
-
_listed(result, adapter.
|
|
262
|
+
_listed(result, adapter.browser_read(page))
|
|
263
263
|
if _unfillable(result, answers):
|
|
264
264
|
return result
|
|
265
265
|
if problems := matching.problems(result.fields, answers):
|
|
@@ -275,7 +275,7 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
|
|
|
275
275
|
return result
|
|
276
276
|
|
|
277
277
|
|
|
278
|
-
def
|
|
278
|
+
def _browser_read(
|
|
279
279
|
data: Path, user: str, url: str, sessions: Sessions | None, detected: list[str]
|
|
280
280
|
) -> Result:
|
|
281
281
|
with _opened(data, user, url, sessions, detected) as opened:
|
|
@@ -284,7 +284,7 @@ def _read_page(
|
|
|
284
284
|
page, adapter = opened
|
|
285
285
|
result = _result(adapter, url, user)
|
|
286
286
|
with _stops(result):
|
|
287
|
-
_listed(result, adapter.
|
|
287
|
+
_listed(result, adapter.browser_read(page))
|
|
288
288
|
_unfillable(result)
|
|
289
289
|
return result
|
|
290
290
|
|
|
@@ -347,11 +347,12 @@ def read(
|
|
|
347
347
|
data_dir: Path | None = None,
|
|
348
348
|
sessions: Sessions | None = None,
|
|
349
349
|
) -> Result:
|
|
350
|
-
"""Lists the form's fields
|
|
350
|
+
"""Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
|
|
351
|
+
browser read."""
|
|
351
352
|
for adapter in adapters():
|
|
352
|
-
if not isinstance(adapter,
|
|
353
|
+
if not isinstance(adapter, ReadsByRequest):
|
|
353
354
|
continue
|
|
354
|
-
if (fields := adapter.
|
|
355
|
+
if (fields := adapter.request_read(url)) is not None:
|
|
355
356
|
result = _result(adapter, url, user)
|
|
356
357
|
try:
|
|
357
358
|
_listed(result, fields)
|
|
@@ -360,7 +361,7 @@ def read(
|
|
|
360
361
|
_unfillable(result)
|
|
361
362
|
return result
|
|
362
363
|
data = paths.data_dir(data_dir)
|
|
363
|
-
return _in_budget(url, lambda seen:
|
|
364
|
+
return _in_budget(url, lambda seen: _browser_read(data, user, url, sessions, seen))
|
|
364
365
|
|
|
365
366
|
|
|
366
367
|
def fill(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|