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.
Files changed (69) hide show
  1. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/PKG-INFO +2 -1
  2. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/README.md +1 -0
  3. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/pyproject.toml +1 -1
  4. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.6.dev199/src/atsfill/adapters/wellfound.py +339 -0
  7. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/registry.toml +8 -0
  8. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/LICENSE +0 -0
  9. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/adp.py +0 -0
  11. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/adpmyjobs.py +0 -0
  12. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/bamboohr.py +0 -0
  14. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/dayforce.py +0 -0
  16. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/eightfold.py +0 -0
  17. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/greenhouse.py +0 -0
  18. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/gupy.py +0 -0
  19. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/hireology.py +0 -0
  20. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/hiringthing.py +0 -0
  21. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/hrmdirect.py +0 -0
  22. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/isolvedhire.py +0 -0
  23. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/jazzhr.py +0 -0
  24. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/jobaffinity.py +0 -0
  25. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/jobstreet.py +0 -0
  26. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/join.py +0 -0
  27. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/lever.py +0 -0
  28. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/manatal.py +0 -0
  29. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/oracle.py +0 -0
  30. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/paycor.py +0 -0
  31. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/paylocity.py +0 -0
  32. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/personio.py +0 -0
  33. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/phenom.py +0 -0
  34. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/pinpoint.py +0 -0
  35. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/recruitee.py +0 -0
  36. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/recruiterflow.py +0 -0
  37. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/rippling.py +0 -0
  38. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/smartrecruiters.py +0 -0
  39. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/successfactors.js +0 -0
  40. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/successfactors.py +0 -0
  41. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/taleo.py +0 -0
  42. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/welcomekit.py +0 -0
  45. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/werecruit.py +0 -0
  46. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/workable.py +0 -0
  47. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/workatastartup.py +0 -0
  48. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/workday.py +0 -0
  49. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/wttj.py +0 -0
  50. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/adapters/zohorecruit.py +0 -0
  51. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/cli.py +0 -0
  52. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/__init__.py +0 -0
  53. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/accounts.py +0 -0
  54. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/adapter.py +0 -0
  55. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/attempts.py +0 -0
  56. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/browser.py +0 -0
  57. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/commands.py +0 -0
  58. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/conditions.py +0 -0
  59. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/countries.py +0 -0
  60. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/html.py +0 -0
  61. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/matching.py +0 -0
  62. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/otp.py +0 -0
  63. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/passwords.py +0 -0
  64. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/paths.py +0 -0
  65. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/registry.py +0 -0
  66. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/results.py +0 -0
  67. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/snapshot.py +0 -0
  68. {atsfill-0.0.6.dev197 → atsfill-0.0.6.dev199}/src/atsfill/core/telemetry.py +0 -0
  69. {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.dev197
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
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
89
89
  [![workatastartup run history](https://shbernal.github.io/atsfill/run-history/workatastartup.svg)](https://shbernal.github.io/atsfill/run-history/)
90
90
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
91
+ [![wellfound run history](https://shbernal.github.io/atsfill/run-history/wellfound.svg)](https://shbernal.github.io/atsfill/run-history/)
91
92
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
92
93
  [![successfactors run history](https://shbernal.github.io/atsfill/run-history/successfactors.svg)](https://shbernal.github.io/atsfill/run-history/)
93
94
  [![adp run history](https://shbernal.github.io/atsfill/run-history/adp.svg)](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
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
60
60
  [![workatastartup run history](https://shbernal.github.io/atsfill/run-history/workatastartup.svg)](https://shbernal.github.io/atsfill/run-history/)
61
61
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
62
+ [![wellfound run history](https://shbernal.github.io/atsfill/run-history/wellfound.svg)](https://shbernal.github.io/atsfill/run-history/)
62
63
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
63
64
  [![successfactors run history](https://shbernal.github.io/atsfill/run-history/successfactors.svg)](https://shbernal.github.io/atsfill/run-history/)
64
65
  [![adp run history](https://shbernal.github.io/atsfill/run-history/adp.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.6.dev197"
3
+ version = "0.0.6.dev199"
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.6.dev197"
3
+ version = "0.0.6.dev199"
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"
@@ -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