atsfill 0.0.6.dev197__tar.gz → 0.0.6.dev199__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.6.dev197 → atsfill-0.0.6.dev199}/PKG-INFO +2 -1
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/README.md +1 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/pyproject.toml +1 -1
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/pyproject.toml.orig +1 -1
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.6.dev199/src/atsfill/adapters/wellfound.py +339 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/registry.toml +8 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/LICENSE +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/werecruit.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.6.
|
|
3
|
+
Version: 0.0.6.dev199
|
|
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
|
|
@@ -88,6 +88,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
88
88
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
89
89
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
90
90
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
91
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
91
92
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
92
93
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
93
94
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -59,6 +59,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
59
59
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
60
60
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
61
61
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
62
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
62
63
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
63
64
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
64
65
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -30,6 +30,7 @@ from atsfill.adapters.successfactors import SuccessFactors
|
|
|
30
30
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
31
31
|
from atsfill.adapters.ukg import UKG
|
|
32
32
|
from atsfill.adapters.welcomekit import WelcomeKit
|
|
33
|
+
from atsfill.adapters.wellfound import Wellfound
|
|
33
34
|
from atsfill.adapters.werecruit import WeRecruit
|
|
34
35
|
from atsfill.adapters.workable import Workable
|
|
35
36
|
from atsfill.adapters.workatastartup import WorkAtAStartup
|
|
@@ -47,6 +48,7 @@ def adapters() -> list[Adapter]:
|
|
|
47
48
|
Greenhouse(),
|
|
48
49
|
WelcomeToTheJungle(),
|
|
49
50
|
WorkAtAStartup(),
|
|
51
|
+
Wellfound(),
|
|
50
52
|
Join(),
|
|
51
53
|
JobAffinity(),
|
|
52
54
|
WelcomeKit(),
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Wellfound, a startup job board (`wellfound.com/jobs/<id>-<slug>`), as docs/adapters/wellfound.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
Applying needs a Wellfound candidate account whose account profile is onboarded: Apply on the
|
|
5
|
+
posting then opens a dialog, the employer's questions or, without any, a note. The dialog's
|
|
6
|
+
GraphQL read, `JobApplicationModal`, lists the questions with their kind and options, and says
|
|
7
|
+
whether the account may apply. Nothing is sent before Send, so the read-back is the dialog's
|
|
8
|
+
state.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from atsfill.core import browser, otp
|
|
19
|
+
from atsfill.core.adapter import (
|
|
20
|
+
Blocked,
|
|
21
|
+
Gap,
|
|
22
|
+
NotTaken,
|
|
23
|
+
Refused,
|
|
24
|
+
SignedOut,
|
|
25
|
+
SignUpDetails,
|
|
26
|
+
Unsupported,
|
|
27
|
+
known_gap,
|
|
28
|
+
)
|
|
29
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
30
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
31
|
+
|
|
32
|
+
HOST = "wellfound.com"
|
|
33
|
+
JOB = re.compile(r"^/(?:company/[^/]+/)?jobs/(\d+)(?:-[^/]*)?/?$")
|
|
34
|
+
LOGIN = f"https://{HOST}/login"
|
|
35
|
+
SIGN_UP = f"https://{HOST}/jobs/signup"
|
|
36
|
+
ONBOARDING = f"https://{HOST}/jobs/onboarding" # a visitor is sent to /login
|
|
37
|
+
GRAPHQL = "/graphql"
|
|
38
|
+
MODAL = "JobApplicationModal"
|
|
39
|
+
CREATE = "CreateJobApplication"
|
|
40
|
+
FORM = 'form:has([data-test="JobApplicationModal--SubmitButton"])'
|
|
41
|
+
SEND = '[data-test="JobApplicationModal--SubmitButton"]'
|
|
42
|
+
NOTE = "note"
|
|
43
|
+
GONE = "no longer available"
|
|
44
|
+
WRONG_PASSWORD = "Email or password is incorrect"
|
|
45
|
+
PASSWORD_LENGTH = (8, 128)
|
|
46
|
+
NUMBER = re.compile(r"-?\d+(\.\d+)?")
|
|
47
|
+
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
|
48
|
+
# a qualification error the profile can fix; the others are the employer's rules
|
|
49
|
+
MISSING = {"MISSING_SKILLS": "skills", "MISSING_WORK_EXPERIENCE": "work experience"}
|
|
50
|
+
OPEN_TIMEOUT_S = 20
|
|
51
|
+
OPEN_POLL_MS = 250
|
|
52
|
+
ACCOUNT_TIMEOUT_MS = 20_000
|
|
53
|
+
CODE_TIMEOUT_MS = 20_000
|
|
54
|
+
SUBMIT_TIMEOUT_MS = 60_000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Wellfound:
|
|
58
|
+
name = "wellfound"
|
|
59
|
+
per_employer = False
|
|
60
|
+
asks = {"name"}
|
|
61
|
+
known_gaps = {
|
|
62
|
+
"profile_incomplete": Gap.FLOW,
|
|
63
|
+
"ai_assessment": Gap.FLOW,
|
|
64
|
+
"question_sets": Gap.RENDERING,
|
|
65
|
+
}
|
|
66
|
+
side_effects = [
|
|
67
|
+
SideEffect("sign_up", "hiring_system", "login --create creates a Wellfound account"),
|
|
68
|
+
SideEffect("load", "hiring_system", "Apply records an apply attempt on the job"),
|
|
69
|
+
SideEffect("submit", "employer", "the answers reach the employer with the profile"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._job: dict[str, Any] = {}
|
|
74
|
+
|
|
75
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
76
|
+
parts = urlsplit(url)
|
|
77
|
+
return parts.hostname in (HOST, f"www.{HOST}") and bool(JOB.match(parts.path))
|
|
78
|
+
|
|
79
|
+
def posting(self, url: str) -> str:
|
|
80
|
+
match = JOB.match(urlsplit(url).path)
|
|
81
|
+
return f"https://{HOST}/jobs/{match[1]}" if match else url
|
|
82
|
+
|
|
83
|
+
def account_scope(self, url: str) -> None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def login_url(self, scope: str | None) -> str:
|
|
87
|
+
return LOGIN
|
|
88
|
+
|
|
89
|
+
def signed_in(self, request: Request, scope: str | None) -> bool | None:
|
|
90
|
+
"""The onboarding sends a visitor to the sign-in, an account to one of its steps."""
|
|
91
|
+
response = request.get(ONBOARDING, max_redirects=0)
|
|
92
|
+
if response.headers.get("cf-mitigated"):
|
|
93
|
+
return None
|
|
94
|
+
return urlsplit(response.headers.get("location") or "").path != "/login"
|
|
95
|
+
|
|
96
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
97
|
+
"""`POST /login` redirects an account, and shows the form again with an error
|
|
98
|
+
otherwise."""
|
|
99
|
+
page.locator("#user_email").fill(email)
|
|
100
|
+
page.locator("#user_password").fill(password)
|
|
101
|
+
page.locator("input[type=submit][value='Log in']").click()
|
|
102
|
+
deadline = time.monotonic() + ACCOUNT_TIMEOUT_MS / 1000
|
|
103
|
+
while time.monotonic() < deadline:
|
|
104
|
+
if urlsplit(page.url).path != "/login":
|
|
105
|
+
return
|
|
106
|
+
with contextlib.suppress(browser.BrowserError): # the page is navigating
|
|
107
|
+
if WRONG_PASSWORD in page.locator("body").inner_text():
|
|
108
|
+
raise Refused("wrong_password")
|
|
109
|
+
page.wait_for_timeout(OPEN_POLL_MS)
|
|
110
|
+
raise Unsupported("unhandled_rendering: the sign-in stays on /login without an error")
|
|
111
|
+
|
|
112
|
+
def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
113
|
+
"""The job seeker sign-up: name, email and password, then the six-digit OTP it emails;
|
|
114
|
+
Create account signs in and starts the onboarding."""
|
|
115
|
+
low, high = PASSWORD_LENGTH
|
|
116
|
+
if not low <= len(password) <= high:
|
|
117
|
+
raise Refused(f"password_rule: {low} to {high} characters")
|
|
118
|
+
page.goto(SIGN_UP)
|
|
119
|
+
page.locator("[data-signup-field=name]").fill(details.name or "")
|
|
120
|
+
page.locator("[data-signup-field=email]").fill(email)
|
|
121
|
+
page.locator("[data-signup-field=password]").fill(password)
|
|
122
|
+
response = _posted(
|
|
123
|
+
page, "/jobs/signup/email-verification-code", page.locator("input[value=Continue]")
|
|
124
|
+
)
|
|
125
|
+
if response.status == 429:
|
|
126
|
+
raise Blocked("rate_limited: the sign-up's OTP was asked too often")
|
|
127
|
+
if response.status == 422:
|
|
128
|
+
errors = _json(response).get("field_errors") or {}
|
|
129
|
+
if "email" in errors:
|
|
130
|
+
raise Refused("email_in_use")
|
|
131
|
+
if "password" in errors:
|
|
132
|
+
raise Refused(f"password_rule: {'; '.join(errors['password'])}")
|
|
133
|
+
raise Unsupported(f"unhandled_rendering: the sign-up refused {errors}")
|
|
134
|
+
if not response.ok:
|
|
135
|
+
raise RuntimeError(f"the sign-up's OTP request answered {response.status}")
|
|
136
|
+
code = page.locator("#email_verification_code")
|
|
137
|
+
with page.expect_response(
|
|
138
|
+
lambda r: urlsplit(r.url).path == "/jobs/signup/email-verification-code/verify",
|
|
139
|
+
timeout=CODE_TIMEOUT_MS,
|
|
140
|
+
) as verified:
|
|
141
|
+
otp.enter(code, email, "the sign-up")
|
|
142
|
+
if not verified.value.ok:
|
|
143
|
+
raise Blocked("otp_rejected: the sign-up did not take the OTP")
|
|
144
|
+
create = page.locator("input[type=submit][value='Create account']")
|
|
145
|
+
create.wait_for(timeout=ACCOUNT_TIMEOUT_MS)
|
|
146
|
+
page.wait_for_function("e => !e.disabled", arg=create.element_handle())
|
|
147
|
+
response = _posted(page, "/jobs/create-user", create)
|
|
148
|
+
if not response.ok:
|
|
149
|
+
raise RuntimeError(f"POST /jobs/create-user answered {response.status}")
|
|
150
|
+
page.wait_for_url(lambda url: urlsplit(url).path != "/jobs/signup")
|
|
151
|
+
|
|
152
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
153
|
+
"""Clicks Apply and lists the dialog's questions from its GraphQL read."""
|
|
154
|
+
if GONE in page.locator("body").inner_text():
|
|
155
|
+
raise Unsupported("posting_gone: the job listing is no longer available")
|
|
156
|
+
apply = page.get_by_role("button", name="Apply", exact=True)
|
|
157
|
+
if not apply.count():
|
|
158
|
+
raise Unsupported("posting_gone: the posting shows no Apply")
|
|
159
|
+
read: list[dict[str, Any]] = []
|
|
160
|
+
|
|
161
|
+
def seen(response: Response) -> None:
|
|
162
|
+
if _operation(response) == MODAL:
|
|
163
|
+
read.append(_json(response))
|
|
164
|
+
|
|
165
|
+
page.on("response", seen)
|
|
166
|
+
try:
|
|
167
|
+
browser.click(apply.first)
|
|
168
|
+
self._job = _opened(page, read)
|
|
169
|
+
finally:
|
|
170
|
+
page.remove_listener("response", seen)
|
|
171
|
+
job = self._job
|
|
172
|
+
if job.get("currentUserApplied"):
|
|
173
|
+
raise Unsupported("already_sent: the account applied to this posting")
|
|
174
|
+
if job.get("offsiteListingUrl"):
|
|
175
|
+
raise Unsupported(f"external_step: Apply opens {job['offsiteListingUrl']}")
|
|
176
|
+
errors = (job.get("currentUserQualificationReport") or {}).get("errors") or []
|
|
177
|
+
if missing := [MISSING[e] for e in errors if e in MISSING]:
|
|
178
|
+
reason = f"the job needs the profile's {' and '.join(missing)}"
|
|
179
|
+
raise Unsupported(known_gap(self, "profile_incomplete", reason))
|
|
180
|
+
if errors:
|
|
181
|
+
raise Unsupported(f"not_qualified: {', '.join(errors)}")
|
|
182
|
+
if job.get("aiAssessmentRequired"):
|
|
183
|
+
reason = "the job asks for an AI interview after the send"
|
|
184
|
+
raise Unsupported(known_gap(self, "ai_assessment", reason))
|
|
185
|
+
if job.get("applicationQuestionSets"):
|
|
186
|
+
reason = "the dialog shows question sets"
|
|
187
|
+
raise Unsupported(known_gap(self, "question_sets", reason))
|
|
188
|
+
form = page.locator(FORM)
|
|
189
|
+
fields = [_field(q) for q in job.get("questions") or []]
|
|
190
|
+
if form.locator("textarea[name=userNote]").count():
|
|
191
|
+
label = form.locator("textarea[name=userNote]").get_attribute("placeholder") or "Note"
|
|
192
|
+
fields.append(Field(NOTE, label, FieldType.TEXT, bool(job["candidateNoteRequired"])))
|
|
193
|
+
return fields
|
|
194
|
+
|
|
195
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
196
|
+
form = page.locator(FORM)
|
|
197
|
+
questions = {f"question_{q['id']}": q for q in self._job.get("questions") or []}
|
|
198
|
+
for field_id, answer in answers.items():
|
|
199
|
+
if field_id == NOTE:
|
|
200
|
+
form.locator("textarea[name=userNote]").fill(str(answer))
|
|
201
|
+
continue
|
|
202
|
+
question = questions[field_id]
|
|
203
|
+
name = f"customQuestionAnswers[{question['id']}]"
|
|
204
|
+
match question["kind"], answer:
|
|
205
|
+
case "SHORT" | "LONG", str():
|
|
206
|
+
form.locator(f'[name="{name}[answer]"]').fill(answer)
|
|
207
|
+
case "NUMBER", str() if NUMBER.fullmatch(answer.strip()):
|
|
208
|
+
form.locator(f'[name="{name}[answer]"]').fill(answer.strip())
|
|
209
|
+
case "DATE", str() if DATE.fullmatch(answer):
|
|
210
|
+
form.locator(f'[name="{name}[answer]"]').fill(answer)
|
|
211
|
+
case "NUMBER" | "DATE", str():
|
|
212
|
+
raise NotTaken(f"{answer!r} is no {question['kind'].lower()} for {field_id}")
|
|
213
|
+
case "SINGLE", str():
|
|
214
|
+
_tick(form, _option(form, question, answer), True)
|
|
215
|
+
case "MULTIPLE", list():
|
|
216
|
+
for option in question["options"]:
|
|
217
|
+
box = _option(form, question, option["option"])
|
|
218
|
+
_tick(form, box, option["option"] in answer)
|
|
219
|
+
case kind, _:
|
|
220
|
+
said = f"cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
221
|
+
raise Unsupported(f"unhandled_rendering: {said}")
|
|
222
|
+
|
|
223
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
224
|
+
form = page.locator(FORM)
|
|
225
|
+
values: dict[str, Answer] = {}
|
|
226
|
+
note = form.locator("textarea[name=userNote]")
|
|
227
|
+
if note.count():
|
|
228
|
+
values[NOTE] = note.input_value()
|
|
229
|
+
for q in self._job.get("questions") or []:
|
|
230
|
+
name, field_id = f"customQuestionAnswers[{q['id']}]", f"question_{q['id']}"
|
|
231
|
+
if q["kind"] not in ("SINGLE", "MULTIPLE"):
|
|
232
|
+
values[field_id] = form.locator(f'[name="{name}[answer]"]').input_value()
|
|
233
|
+
continue
|
|
234
|
+
labels = {o["id"]: o["option"] for o in q["options"]}
|
|
235
|
+
picked = form.locator(f'input[name="{name}[jobListingQuestionOptionId]"]:checked')
|
|
236
|
+
chosen = [labels.get(v, v) for v in picked.evaluate_all("es => es.map(e => e.value)")]
|
|
237
|
+
single = chosen[0] if chosen else ""
|
|
238
|
+
values[field_id] = chosen if q["kind"] == "MULTIPLE" else single
|
|
239
|
+
return {k: v for k, v in values.items() if v not in ("", [])}
|
|
240
|
+
|
|
241
|
+
def submit(self, page: Page) -> str | None:
|
|
242
|
+
"""Send posts the `CreateJobApplication` mutation, answered with the job and whether
|
|
243
|
+
the account applied to it. A request Cloudflare challenges shows Turnstile, whose pass
|
|
244
|
+
reloads the page."""
|
|
245
|
+
try:
|
|
246
|
+
with page.expect_response(
|
|
247
|
+
lambda r: _operation(r) == CREATE, timeout=SUBMIT_TIMEOUT_MS
|
|
248
|
+
) as sent:
|
|
249
|
+
browser.click(page.locator(FORM).locator(SEND))
|
|
250
|
+
except Timeout:
|
|
251
|
+
raise RuntimeError(f"Send posted no {CREATE}") from None
|
|
252
|
+
response = sent.value
|
|
253
|
+
if response.headers.get("cf-mitigated") == "challenge":
|
|
254
|
+
raise Blocked("captcha: Cloudflare Turnstile challenged the send")
|
|
255
|
+
answer = _json(response)
|
|
256
|
+
if errors := answer.get("errors"):
|
|
257
|
+
said = "; ".join(e.get("message", "") for e in errors)
|
|
258
|
+
raise Blocked(f"answer_refused: {said}")
|
|
259
|
+
job = (answer.get("data") or {}).get("jobListing") or {}
|
|
260
|
+
if job.get("currentUserApplied") and str(job.get("id")) == str(self._job.get("id")):
|
|
261
|
+
return f"{CREATE} {response.status}: job {job['id']} currentUserApplied"
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _opened(page: Page, read: list[dict[str, Any]]) -> dict[str, Any]:
|
|
266
|
+
"""Waits for what Apply leads to: the dialog's read, the profile's edit page where the
|
|
267
|
+
onboarding is unfinished, or the visitor's dialog where the session ended."""
|
|
268
|
+
deadline = time.monotonic() + OPEN_TIMEOUT_S
|
|
269
|
+
while time.monotonic() < deadline:
|
|
270
|
+
if urlsplit(page.url).path == "/profile/edit":
|
|
271
|
+
reason = "Apply sends the account to its profile: the onboarding is unfinished"
|
|
272
|
+
raise Unsupported(known_gap(Wellfound(), "profile_incomplete", reason))
|
|
273
|
+
if page.locator("input[name=passwordConfirmation]").count():
|
|
274
|
+
raise SignedOut("login_required: Apply shows the visitor's sign-up dialog")
|
|
275
|
+
if read and page.locator(FORM).count():
|
|
276
|
+
job = ((read[-1].get("data") or {}).get("jobListing")) or {}
|
|
277
|
+
if not job:
|
|
278
|
+
raise Unsupported("posting_gone: the dialog's read has no job")
|
|
279
|
+
return job
|
|
280
|
+
page.wait_for_timeout(OPEN_POLL_MS)
|
|
281
|
+
raise RuntimeError("Apply opened no dialog")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _field(question: dict[str, Any]) -> Field:
|
|
285
|
+
field_id, label = f"question_{question['id']}", question["question"].strip()
|
|
286
|
+
required, options = question["required"], [o["option"] for o in question["options"]]
|
|
287
|
+
match question["kind"]:
|
|
288
|
+
case "SHORT" | "LONG" | "NUMBER":
|
|
289
|
+
return Field(field_id, label, FieldType.TEXT, required)
|
|
290
|
+
case "DATE":
|
|
291
|
+
return Field(field_id, label, FieldType.DATE, required)
|
|
292
|
+
case "SINGLE":
|
|
293
|
+
return Field(field_id, label, FieldType.CHOICE, required, options)
|
|
294
|
+
case "MULTIPLE":
|
|
295
|
+
return Field(field_id, label, FieldType.MULTI_CHOICE, required, options)
|
|
296
|
+
reason = f"unhandled_rendering: {label!r} is a {question['kind']} question"
|
|
297
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _option(form: Locator, question: dict[str, Any], label: str) -> Locator:
|
|
301
|
+
ids = [o["id"] for o in question["options"] if o["option"] == label]
|
|
302
|
+
if not ids:
|
|
303
|
+
raise NotTaken(f"{label!r} is no option of question_{question['id']}")
|
|
304
|
+
name = f"customQuestionAnswers[{question['id']}][jobListingQuestionOptionId]"
|
|
305
|
+
return form.locator(f'input[name="{name}"][value="{ids[0]}"]')
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _tick(form: Locator, box: Locator, checked: bool) -> None:
|
|
309
|
+
"""Clicks the option's label: the input itself is moved out of view."""
|
|
310
|
+
if box.is_checked() != checked:
|
|
311
|
+
form.locator(f'label[for="{box.get_attribute("id")}"]').click()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _operation(response: Response) -> str | None:
|
|
315
|
+
"""The GraphQL operation a response answers, None for another request."""
|
|
316
|
+
request = response.request
|
|
317
|
+
if request.method != "POST" or urlsplit(response.url).path != GRAPHQL:
|
|
318
|
+
return None
|
|
319
|
+
try:
|
|
320
|
+
return json.loads(request.post_data or "{}").get("operationName")
|
|
321
|
+
except ValueError:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _json(response: Response) -> dict[str, Any]:
|
|
326
|
+
try:
|
|
327
|
+
answer = response.json()
|
|
328
|
+
except ValueError:
|
|
329
|
+
return {}
|
|
330
|
+
return answer if isinstance(answer, dict) else {}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _posted(page: Page, path: str, button: Locator) -> Response:
|
|
334
|
+
with page.expect_response(
|
|
335
|
+
lambda r: r.request.method == "POST" and urlsplit(r.url).path == path,
|
|
336
|
+
timeout=ACCOUNT_TIMEOUT_MS,
|
|
337
|
+
) as sent:
|
|
338
|
+
button.click()
|
|
339
|
+
return sent.value
|
|
@@ -47,6 +47,14 @@ hosting = ["hosted"]
|
|
|
47
47
|
adapter = "wttj"
|
|
48
48
|
scope = { is = "supported", date = 2026-10-24, commit = "90c58c3" }
|
|
49
49
|
|
|
50
|
+
[wellfound]
|
|
51
|
+
kind = "board"
|
|
52
|
+
share = 0.0003
|
|
53
|
+
account = "to_send"
|
|
54
|
+
hosting = ["hosted"]
|
|
55
|
+
adapter = "wellfound"
|
|
56
|
+
scope = { is = "supported", date = 2026-10-30, commit = "c6349e6" }
|
|
57
|
+
|
|
50
58
|
[workatastartup]
|
|
51
59
|
kind = "board"
|
|
52
60
|
share = 0.0008
|
|
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
|
|
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
|
|
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
|
|
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
|