Skip to content
4 changes: 3 additions & 1 deletion reference/ats/ashby.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
- **Read the form's questions before opening it.** The public board API carries the posting but not the form; the job page's own GraphQL endpoint carries both and needs no session:
```bash
curl -s -X POST -H 'content-type: application/json' 'https://jobs.ashbyhq.com/api/non-user-graphql?op=ApiJobPosting' \
-d '{"operationName":"ApiJobPosting","variables":{"organizationHostedJobsPageName":"<co>","jobPostingId":"<uuid>"},"query":"query ApiJobPosting($organizationHostedJobsPageName: String!, $jobPostingId: String!) { jobPosting(organizationHostedJobsPageName: $organizationHostedJobsPageName, jobPostingId: $jobPostingId) { title locationName workplaceType applicationForm { sections { fieldEntries { ... on FormFieldEntry { isRequired field } } } } } }"}'
-d '{"operationName":"ApiJobPosting","variables":{"organizationHostedJobsPageName":"<co>","jobPostingId":"<uuid>"},"query":"query ApiJobPosting($organizationHostedJobsPageName: String!, $jobPostingId: String!) { jobPosting(organizationHostedJobsPageName: $organizationHostedJobsPageName, jobPostingId: $jobPostingId) { title applicationForm { sections { fieldEntries { ... on FormFieldEntry { isRequired field } } } } } }"}'
```
**Ask for `title` and `applicationForm` only.** Measured 12 Nov 2026 on three tenants: adding `locationName workplaceType` to the selection made `jobPosting` come back `null` with HTTP 200, which reads exactly like a wrong slug or a closed posting. Take the location from the public board API instead. An organization name with spaces (`Superhuman Platform Inc`) works as the slug as it stands, URL-encoded in links.
Each `field` has `title`, `type` and `selectableValues`. Measured 6 Oct on five tenants: it showed which relocation forms carry a visa-sponsorship option (one did, two did not), a hard requirement written into a Boolean question, and team-size questions that are never-guess, all before a tab was opened.
- **Core problem:** DOM value and React state diverge unpredictably → "Missing entry for required field: X" on fields that look filled.
- **Set values (text/textarea) — default:**
Expand Down Expand Up @@ -75,3 +76,4 @@
- **Company careers pages often embed Ashby.** `api.ashbyhq.com/posting-api/job-board/<org>` answers from the company's own page (CORS allows it) and returns every posting with its UUID, which gives `jobs.ashbyhq.com/<org>/<uuid>/application` directly. The org slug is the path of the embed iframe's `src`.
- **The order matters: `insertText` first, then `form_input` on the same field.** `form_input` alone on an empty Name or Email still came back "Missing entry" (ElevenLabs, 2 Oct); `form_input` over a value that `insertText` had already put there passed every time it was tried that day (five tenants). Do both for Name, Email and every textarea before the first submit, and the round trip usually disappears.
- **Not always.** Measured 12 Oct (Molecule): after `insertText` and `form_input` on every field, two submits still named `_systemfield_name` and a plain URL-type text question ("link to your LinkedIn profile"). `form_input` was repeated on both and the second submit named the same two. `focus()` + `select()` + real `Delete` + real `computer type` committed both at once and the next submit returned Success. When the same field survives one `form_input` round, go straight to real typing rather than repeating it.
- **A free-looking "When can you start?" question can be a calendar on a plain text input.** Measured 9 Nov 2026 (Spruce): the label reads like free text and the DOM says `type=text`, so a setter accepted a sentence, and the submit reported "Missing entry for required field" for it; a real space to commit made no difference. Read `placeholder` ("Pick date...") before writing to any start-date field, then use the calendar steps above. The first submit of that form sat on a spinner for over a minute with no request sent; reloading and refilling is safe when no `ApiSubmitSingleApplicationFormAction` appears in the resource timings.
1 change: 1 addition & 0 deletions reference/ats/greenhouse.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
- Company forms built on Greenhouse (e.g. Miro) may have server-side char limits with no counter (900) and strict phone format: the field's own placeholder shows the shape it wants, which is E.164 with no spaces and no punctuation, so send `<PHONE_E164>`.
- "How did you hear" with only company channels, no "other" → hand over.
- `find` may return options unnamed — list texts via JS first.
- **A select picked with `PICKOPT` can show its value and still be empty.** Measured 5 Nov 2026 (ESW, a one-option "Candidate Privacy Notice") and again 6 Nov 2026 (Financial Times, a 17-option currency list): `PICKOPT` rendered the choice in `singleValue`, yet submit failed with "This field is required." on that field alone. Focusing the input, typing the start of the option and pressing `Return` registered it, and the next submit went through. It is not limited to one-option lists, and it hit one select out of seven on the same form. When a submit fails on a select that reads as filled, re-pick it by keyboard before anything else; for a required consent select, pick it by keyboard from the start.
- **Submit:** if only stale phone errors remain, submit again. Confirm the thank-you page.

`okta.com/company/careers/<slug>` is not a Greenhouse board: it is Drupal hosting the Greenhouse questions, so the field ids are `edit-*` and `edit-question-<id>` rather than Greenhouse's own. Plain `<select>` elements with option values `1`/`0` for Yes/No, so the native setter plus a `change` event works; no react-select anywhere.
Expand Down
13 changes: 13 additions & 0 deletions reference/ats/other-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,16 @@ First measured 23 Oct 2026. No account. The LinkedIn apply link is a redirect th
- A recruitment agency's job page with the form underneath, no account. Measured 4 Nov 2026: first name, last name, email, phone (`name` attributes `firstName`, `lastName`, `email`, `phoneNumber`; the native setter takes them and the phone is reformatted to spaced international format), a LinkedIn field that keeps a fixed `linkedin.com/in/` prefix (type only the handle), an optional Location button that opens a modal with a place search (pick the suggestion, then press the modal's confirm button), résumé and cover-letter file inputs (`find` + `file_upload`).
- **The form ends with a required "I certify that all information provided is accurate" checkbox.** It is a declaration, so it goes to the candidate with Submit, the same way the other required declarations do.


## Recooty (`careerspage.io/<company>/<job-slug>`, "Apply for this role" → `/apply`)

- No account. Measured 11 Nov 2026 on one employer: the form opens with only a Resume dropzone. Uploading the CV reveals First Name, Last Name, Email and Mobile Number already filled from the parse ("Details prefilled — please review below"); read them back before going on.
- **A visible reCAPTCHA v2 checkbox sits above "Submit application"**, so the form always ends with the candidate. Fill it, leave the tab, hand it over.

## GlobalLogic (`globallogic.com/careers/<slug>/`)

- The form is inline at the bottom of the posting, no account. Measured 11 Nov 2026: First Name, Last Name, Email, Phone, Gender (required native select), Country of Residence, City, "How did you hear about this job?", a resume file input (hidden, `.doc,.docx,.pdf,.rtf`) and LinkedIn URL.
- **Country of Residence is a Bootstrap selectpicker.** Setting the native `<select>` value leaves the visible control empty; `jQuery('[name=jobCountry]').selectpicker('val','<option text>')` updates both. The home country may be spelled natively in the list ("Turkiye").
- **A visible reCAPTCHA v2 checkbox comes before the submit arrow**, so this form is always a hand-off.
- **The first submit can fail even after the candidate ticks the reCAPTCHA; a second try went through.** Measured 11 Nov 2026: the first submit left the form emptied with no message; the candidate filled and submitted again and landed on `/career-thank-you/`. Tell the candidate to retry before giving up on it.
- A posting's page carries the company's office list in its navigation, so a script that greps the page for the home country finds it there. Read the posting's own "Location" field (a single country) instead.
1 change: 1 addition & 0 deletions reference/ats/rippling.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
- **Count the text inputs, don't guess their order.** `Pronouns` sits between `Email` and `Current company` and is a plain text input, so "the first empty text field" is Pronouns, not the question you are looking for. Measured 25 Sept: a required free-text question was typed into Pronouns and the submit still failed. Locate a field by walking up from it to the nearest ancestor with text: `e.closest('div')` upwards until `innerText.length > 25`, then match the question wording.
- **`input[type=text]` as a selector has missed on this ATS**; `querySelectorAll('input')` and filtering on `e.type === 'text'` works. Same for the submit: the form validates silently and only paints "This field is required" next to the offender, so read visible error text after every failed submit instead of re-clicking.
- **Rippling's own posting can disagree with the boards.** Measured 25 Sept: intodesignsystems listed a role as "Remote · North America and Europe" and designsystems.jobs listed the same role as "United States, United Kingdom, Canada", while the posting itself named no country and the form asked no residence question. Read the ATS page, not the board card.
- **The resume parse can pre-answer things.** Measured 9 Nov 2026 (Multiplayer Studio): after the upload the SMS radio read "Yes - I consent", `Current company` held the last employer on the resume, and the first name was cut to its first word. Read every field back after the upload and correct them.
2 changes: 2 additions & 0 deletions reference/ats/teamtailor.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@
a red required-consent message. Before refilling, reload the posting and look for "You already applied
for this job", which is Teamtailor's own applied marker.
- **A required "Address" can be a geocoder, `candidate[location][query]`, placeholder "Start typing your address".** A native setter on it leaves it empty and submit fails with "Address can't be blank" and no other sign. Click it, type the city, wait for the suggestion list, click "<CITY>, <COUNTRY>". Measured 2 Oct (C Teleport).

- **When the phone picker opens on the wrong country, type the whole number.** Measured 9 Nov 2026 (ENSEK): dispatching a click on the `.iti__country` row left the flag on the employer's country, and a national number then became a wrong foreign number (`+44 50 …`). Focus and select the phone input from JS, press real `Delete`, and type `+<code><number>`: the widget switched the flag and formatted the number. Read `.iti__selected-country` `title` before submitting.
1 change: 1 addition & 0 deletions reference/sources/freehire.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Per-record fields worth using:
- `closed_at` → drop if set.
- `reality` → `{class: fresh|stale, age_days, repost_count, mass_posting_count, fake_freshness}`. `mass_posting_count ≥ 3` is the "one text republished under many titles" pattern (EWOR: 10+ variants, every apply URL 404). Push these to the end or skip; a legitimate multi-team hire can also trip it, so check before discarding.
- `url` → the apply link. It can be the employer's ATS, **another aggregator (Adzuna seen as the top result)**, or a Telegram post (`t.me/...`, skip: no messaging on the user's behalf). Resolve to the real employer before applying.
- **`arc.dev/remote-jobs/j/…` records are LinkedIn postings.** Measured 5 Nov 2026: 17 of a day's 37 candidates came through arc.dev, and every one resolved to a LinkedIn job. The page carries the source in its embedded JSON as `"url":"https://<cc>.linkedin.com/jobs/view/<slug>-<id>"` (fetch with a browser user agent and take the first `"url"` that is not arc.dev). Dedup on `https://www.linkedin.com/jobs/view/<id>/`, not on the arc.dev link, and read them in the LinkedIn step: through a detail request in `read` mode, or as unresolved leads in `email` mode. Five of the 17 were already in the tracker under their LinkedIn id.

Pitfalls:
- The API returns **403 to Python's default user agent**. curl and Node `fetch` pass. Always send an explicit `User-Agent`.
Expand Down
2 changes: 2 additions & 0 deletions reference/sources/jobicy.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
**2 Oct: eight tags, zero rows.** `product-designer`, `ux-designer`, `ui-designer`, `design-system`, `product-design`, `design-engineer`, `design-technologist` and `ux-engineer` each returned `jobCount: 0`. The API itself is healthy: HTTP 200, `apiVersion 2.2.19`, and a tagless call answers normally, so this is not the 403 user-agent trap and not a transport failure. Even the three stale rows that had been returned identically on 26, 27, 28 and 30 Sept were gone. One zero is not a verdict, but a source that has produced one new candidate in five measured runs is close to the line in `dead-and-low-value.md`.

**The four measured tags were too narrow, and the fix is one more curl.** Measured 30 Sept: running six tags instead of four returned the same three stale rows plus one the previous measurements could never have seen, a Vercel Design Engineer, under `design-engineer`. `ui-designer` returned nothing, so the cost of the two extra tags was two curls for one new candidate. **Derive the tag list from the candidate's target titles rather than from what was tried first**, and re-derive it when a title is added to the profile. The standing verdict on freshness is unchanged: three of the four rows on 30 Sept were the same ones returned on 26, 27 and 28 Sept.

**9 Nov 2026: tags with a space work and return a lot.** `tag=product designer` and five siblings returned 267 rows, 148 unique, mostly unrelated roles (the matching is semantic, so filter on `jobTitle`). The on-discipline rows were DuckDuckGo (posting already gone from its Ashby board), Canonical (all tracked), CodePath (tracked) and Grafana's country-by-country listings: no new application.
2 changes: 2 additions & 0 deletions reference/sources/linkedin.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,5 @@ const url=(am['com.linkedin.voyager.jobs.OffsiteApply']||{}).companyApplyUrl||''
## Easy Apply

Listed, never filled, in both modes. The report gives each one's LinkedIn URL and the CV to pick, and the user sends them.

**Before listing an Easy Apply posting, look for the same role on the employer's own board.** An empty `companyApplyUrl` only says how LinkedIn takes the application, not that the employer has no form of its own. When the job page's URL carries `applicantTrackingSystemName=Greenhouse` (or another ATS), LinkedIn is feeding that ATS, and the employer usually publishes the same posting there. Measured 5 Nov 2026: ESW's "Senior Product Design Engineer" was listed as Easy Apply at fit 3 from the alert card alone; `boards-api.greenhouse.io/v1/boards/esw/jobs` held the identical role, whose full posting read as fit 5 and which was submitted as a company form the same day. Probe the board API (tenant = the company's slug) before writing the Easy Apply list, and fill the company form instead when it exists.
3 changes: 3 additions & 0 deletions scripts/posting.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ const STOP = {
fi: ["ja", "on", "että", "se", "ovat", "kanssa", "tai", "meidän", "sinä"],
cs: ["se", "na", "je", "pro", "že", "ve", "jsme", "nebo", "práce"],
hu: ["és", "az", "hogy", "egy", "nem", "van", "csapat", "valamint", "számára"],
// Cyrillic: only the words the two languages spell differently, so each one counts for one.
uk: ["і", "та", "що", "це", "як", "або", "також", "ти", "ми", "які", "твої", "наш"],
ru: ["и", "что", "это", "как", "или", "также", "ты", "мы", "которые", "твои", "наш", "вы"],
};

export function textLanguage(text) {
Expand Down
10 changes: 6 additions & 4 deletions scripts/providers/teamtailor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export default async function board(slug, net) {
});
}

// Is one posting still open? The job page answers 404 once it closes. Any host can be a
// Teamtailor site, so the host has to serve /jobs.rss first; on any other site a 404 would say
// nothing about Teamtailor.
// Is one posting still open? The job page answers 404 once it is removed, but an expired ad
// keeps answering 200 with "This position is no longer active" in the page (measured 6 Nov 2026).
// Any host can be a Teamtailor site, so the host has to serve /jobs.rss first; on any other
// site a 404 would say nothing about Teamtailor.
export async function live(url, net) {
const m = url.match(/\/\/([^/]+)\/jobs\/(\d+)-[^?#]*/i);
if (!m) return null;
if (!/<rss/.test(await net.text(`https://${m[1]}/jobs.rss`))) return null;
const r = await net.status(`https://${m[0].slice(2)}`);
return r.status === 200 ? "open" : r.status === 404 ? "closed" : null;
if (r.status === 200) return /position is no longer active/i.test(r.body) ? "closed" : "open";
return r.status === 404 ? "closed" : null;
}
Loading
Loading