atsfill 0.0.3.dev13__tar.gz → 0.0.3.dev14__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 (24) hide show
  1. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/PKG-INFO +2 -2
  2. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/README.md +1 -1
  3. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/wttj.py +32 -15
  6. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/matching.py +1 -1
  7. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/results.py +1 -0
  8. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/registry.toml +1 -0
  9. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/LICENSE +0 -0
  10. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/__init__.py +0 -0
  12. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/greenhouse.py +0 -0
  14. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/teamtailor.py +0 -0
  15. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/cli.py +0 -0
  16. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/__init__.py +0 -0
  17. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/accounts.py +0 -0
  18. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/adapter.py +0 -0
  19. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/attempts.py +0 -0
  20. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/browser.py +0 -0
  21. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/commands.py +0 -0
  22. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/paths.py +0 -0
  23. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/registry.py +0 -0
  24. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.3.dev13
3
+ Version: 0.0.3.dev14
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
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
53
53
  ## Supported hiring systems
54
54
 
55
55
  <!-- badges -->
56
- ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
56
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![wttj: dry run verified 2026-10-19](https://img.shields.io/badge/wttj-dry%20run%20verified%202026--10--19-7aa2f7)
57
57
  <!-- /badges -->
58
58
 
59
59
  | System | Account needed | `read` without a browser |
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
32
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![wttj: dry run verified 2026-10-19](https://img.shields.io/badge/wttj-dry%20run%20verified%202026--10--19-7aa2f7)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev13"
3
+ version = "0.0.3.dev14"
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.3.dev13"
3
+ version = "0.0.3.dev14"
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"
@@ -17,11 +17,14 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
17
 
18
18
  HOST = "www.welcometothejungle.com"
19
19
  JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/jobs/([^/?#]+)")
20
- FORM = "form:has([name=cover_letter])"
20
+ FORM = "form:has([data-testid=apply-form-submit])"
21
21
  ME = "https://api.welcometothejungle.com/api/v2/users/me"
22
22
  FORM_TIMEOUT_MS = 20_000
23
23
  SIGNED_IN_TIMEOUT_S = 10
24
24
  SUBMIT_TIMEOUT_MS = 90_000
25
+ SUGGEST_TRIES = 3
26
+ LOCATION_TIMEOUT_S = 3
27
+ SUGGEST_TIMEOUT_S = 5
25
28
  YES_NO = ["Yes", "No"]
26
29
 
27
30
  # The fillable fields: an input's name is its id. Email comes from the account, disabled.
@@ -33,7 +36,9 @@ _READ = """form => [...form.querySelectorAll("input[name], textarea[name]")]
33
36
  label: (label ?? e.closest("fieldset")?.querySelector("legend"))?.textContent
34
37
  .replace(/\\s+/g, " ").trim() ?? "",
35
38
  required: e.required || label?.hasAttribute("required"),
36
- accept: e.accept ? e.accept.split(",") : null};
39
+ accept: e.accept ? e.accept.split(",") : null,
40
+ prefilled: e.type === "checkbox" ? e.checked
41
+ : e.type === "file" ? !!e.parentElement.querySelector("[data-id]") : !!e.value};
37
42
  })"""
38
43
 
39
44
  _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
@@ -98,6 +103,10 @@ class WelcomeToTheJungle:
98
103
  form.wait_for(timeout=FORM_TIMEOUT_MS)
99
104
  except Timeout:
100
105
  raise Unsupported("form_shape: the apply button opened no form") from None
106
+ location = form.locator("[name=location]")
107
+ deadline = time.monotonic() + LOCATION_TIMEOUT_S
108
+ while location.count() and not location.input_value() and time.monotonic() < deadline:
109
+ page.wait_for_timeout(200) # the profile's location comes last, a second later
101
110
  fields = []
102
111
  for raw in form.evaluate(_READ):
103
112
  field = _field(raw)
@@ -114,11 +123,12 @@ class WelcomeToTheJungle:
114
123
  target = form.locator(f'[name="{field_id}"]')
115
124
  match self._fields[field_id].type, answer:
116
125
  case FieldType.FILE, str():
117
- remove = target.locator("xpath=..").locator("button[title=Remove]")
126
+ box = target.locator("xpath=..")
127
+ remove = box.locator("button[title=Remove]")
118
128
  if remove.count():
119
129
  remove.click() # the profile's CV; never made the default
120
130
  target.set_input_files(answer)
121
- form.locator(f'[data-id="{answer.rsplit("/", 1)[-1]}"]').wait_for()
131
+ box.locator(f'[data-id="{answer.rsplit("/", 1)[-1]}"]').wait_for()
122
132
  case FieldType.CHOICE, str():
123
133
  if target.is_checked() != (answer == "Yes"):
124
134
  browser.click(form.locator(f'label[for="{target.get_attribute("id")}"]'))
@@ -149,17 +159,21 @@ class WelcomeToTheJungle:
149
159
 
150
160
  def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
151
161
  """Retypes the answer key by key, which loads the suggestions, and picks the first that
152
- matches it, never "current position" (the item with an icon, which geolocates)."""
153
- box.click()
154
- box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
155
- box.press("Backspace")
156
- box.press_sequentially(answer, delay=30)
162
+ matches it, never "current position" (the item with an icon, which geolocates). Typed too
163
+ early, the answer loads no suggestions at all: it is typed again."""
157
164
  options = page.locator("[role=option]:visible").filter(has_not=page.locator("svg"))
158
- options.first.wait_for()
159
- for i in range(options.count()):
160
- if matching.matches(field, answer, options.nth(i).inner_text().strip()):
161
- options.nth(i).click()
162
- return
165
+ for _ in range(SUGGEST_TRIES):
166
+ box.click()
167
+ box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
168
+ box.press("Backspace")
169
+ box.press_sequentially(answer, delay=30)
170
+ deadline = time.monotonic() + SUGGEST_TIMEOUT_S
171
+ while time.monotonic() < deadline:
172
+ for option in options.all():
173
+ if matching.matches(field, answer, option.inner_text().strip()):
174
+ option.click()
175
+ return
176
+ page.wait_for_timeout(250)
163
177
  raise Unsupported(f"form_shape: no suggestion for {answer!r} in {field.id}")
164
178
 
165
179
 
@@ -177,4 +191,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
177
191
  )
178
192
  if kind is None:
179
193
  return None
180
- return Field(name, raw["label"], kind, bool(raw["required"]), options, None, raw["accept"])
194
+ return Field(
195
+ name, raw["label"], kind, bool(raw["required"]), options, None, raw["accept"],
196
+ raw["prefilled"],
197
+ ) # fmt: skip
@@ -91,7 +91,7 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
91
91
  out += [
92
92
  f"no answer for required {f.id} ({f.label})"
93
93
  for f in fields
94
- if f.required and f.id not in answers
94
+ if f.required and not f.prefilled and f.id not in answers
95
95
  ]
96
96
  out += [
97
97
  f"{i}: {problem}"
@@ -34,6 +34,7 @@ class Field:
34
34
  options: list[str] | None = None
35
35
  max_length: int | None = None
36
36
  accept: list[str] | None = None
37
+ prefilled: bool = False # holds a value before any answer, from the account's profile
37
38
 
38
39
 
39
40
  @dataclass(frozen=True)
@@ -41,3 +41,4 @@ kind = "board"
41
41
  account = "to_send"
42
42
  hosting = ["hosted"]
43
43
  adapter = "wttj"
44
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "ad9f478" }
File without changes