From 72e106499a76499810ddaf4f166455004ba3732c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20=C3=87ak=C4=B1ro=C4=9Flu?= Date: Thu, 12 Nov 2026 13:35:33 +0300 Subject: [PATCH 1/8] A Ukrainian posting read as English, so Cyrillic gets its own word lists Measured 5 Nov 2026: a posting written in Ukrainian on an English Ashby page came back CLEAR from posting.mjs, because the language guess had no Cyrillic stop words at all. Ukrainian and Russian now each have a list of the words the two languages spell differently, so the guess tells them apart and a Ukrainian-only posting closes the door. Co-Authored-By: Claude Opus 5.5 --- scripts/posting.mjs | 3 +++ tests/seekter.test.mjs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/scripts/posting.mjs b/scripts/posting.mjs index ea10af9..27d690c 100644 --- a/scripts/posting.mjs +++ b/scripts/posting.mjs @@ -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) { diff --git a/tests/seekter.test.mjs b/tests/seekter.test.mjs index c956451..8f97901 100644 --- a/tests/seekter.test.mjs +++ b/tests/seekter.test.mjs @@ -1123,6 +1123,12 @@ describe("posting read", () => { // under the board's English menu (4 Nov 2026). const pl = "Offers Salaries Companies Dołącz do nas jako Product Designer w zespole produktowym. Odpowiadasz za design produktu i jego design system, od rozmowy z użytkownikiem do widoku w Figmie. AI jest dla Ciebie narzędziem pracy, a my dajemy Ci przestrzeń. Jesteśmy firmą, która się rozwija w e-mail marketingu i pracujemy z markami na rynku."; assert.equal(textLanguage(pl), "pl"); + // A Ukrainian posting on an English Ashby page came back CLEAR (5 Nov 2026): no Cyrillic list. + const uk = "RiseGuide – це застосунок для тих, хто хоче стати найкращою версією себе. Ми будуємо продукт, і ти будеш вести великі продуктові ставки та запускати A/B-тести. Також ти працюватимеш з PM та інженерами, які формують гіпотези, або допомагатимеш команді як дизайнер."; + assert.equal(textLanguage(uk), "uk"); + assert.equal(analyze(uk, opts).verdict, "CLOSER"); + const ru = "Мы ищем дизайнера, который будет работать с командой. Это продукт, и ты будешь отвечать за интерфейс, что очень важно. Также вы будете общаться с инженерами, которые строят систему, или с менеджерами, как и мы."; + assert.equal(textLanguage(ru), "ru"); }); test("a required language outside the profile closes it, a nice-to-have only asks for a read", () => { From 688abda6163e16840355d2d69add1b6f59eaa61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20=C3=87ak=C4=B1ro=C4=9Flu?= Date: Thu, 12 Nov 2026 13:35:33 +0300 Subject: [PATCH 2/8] An expired Teamtailor ad still answers 200 Measured 6 Nov 2026: live.mjs called a Teamtailor posting OPEN because the job page answered 200, while the page itself said "This position is no longer active". Only a removed ad answers 404. The check now reads the page text on a 200. Co-Authored-By: Claude Opus 5.5 --- scripts/providers/teamtailor.mjs | 10 ++++++---- tests/seekter.test.mjs | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/providers/teamtailor.mjs b/scripts/providers/teamtailor.mjs index b479e55..96477c5 100644 --- a/scripts/providers/teamtailor.mjs +++ b/scripts/providers/teamtailor.mjs @@ -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 (!/ { ["jobs.ashbyhq.com/acme/22222222-2222-2222-2222-222222222222", { status: 200, body: 'x "posting":null x' }], ["jobs.acme.example/jobs.rss", ''], ["jobs.acme.example/jobs/9-gone", { status: 404, body: "" }], + ["jobs.acme.example/jobs/8-expired", { status: 200, body: "

This position is no longer active

" }], + ["jobs.acme.example/jobs/7-live", { status: 200, body: "

Senior Product Designer

Apply for this job" }], ["careers.other.example/jobs/9-gone", { status: 404, body: "" }], ], { status: 500, body: "" }); }); @@ -1020,6 +1022,7 @@ describe("live check", () => { "https://job-boards.greenhouse.io/acme/jobs/111", "https://job-boards.greenhouse.io/acme/jobs/222", "https://jobs.ashbyhq.com/acme/11111111-1111-1111-1111-111111111111", "https://jobs.ashbyhq.com/acme/22222222-2222-2222-2222-222222222222", "https://jobs.acme.example/jobs/9-gone", "https://careers.other.example/jobs/9-gone", + "https://jobs.acme.example/jobs/8-expired", "https://jobs.acme.example/jobs/7-live", "https://www.linkedin.com/jobs/view/4001234567/"], { env: http.env }); assert.equal(r.rc, 0, r.err); assert.match(r.out, /^OPEN +https:\/\/job-boards\.greenhouse\.io\/acme\/jobs\/111 +\(greenhouse\)/m); @@ -1027,6 +1030,9 @@ describe("live check", () => { assert.match(r.out, /^OPEN +https:\/\/jobs\.ashbyhq\.com\/acme\/1111/m); assert.match(r.out, /^CLOSED +https:\/\/jobs\.ashbyhq\.com\/acme\/2222/m); assert.match(r.out, /^CLOSED +https:\/\/jobs\.acme\.example\/jobs\/9-gone +\(teamtailor\)/m); + // An expired Teamtailor ad still answers 200; the page text is what says so (6 Nov 2026). + assert.match(r.out, /^CLOSED +https:\/\/jobs\.acme\.example\/jobs\/8-expired/m); + assert.match(r.out, /^OPEN +https:\/\/jobs\.acme\.example\/jobs\/7-live/m); // A 404 on a site that is not Teamtailor says nothing, so it stays unknown. assert.match(r.out, /^UNKNOWN +https:\/\/careers\.other\.example/m); assert.match(r.out, /^UNKNOWN +https:\/\/www\.linkedin\.com.*never called/m); From e6feee38fb05e4d8a0267923433a2fd8bd6d1090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20=C3=87ak=C4=B1ro=C4=9Flu?= Date: Thu, 12 Nov 2026 13:35:33 +0300 Subject: [PATCH 3/8] Ashby's form query comes back null when it also asks for the location Measured 12 Nov 2026 on three tenants: adding locationName and workplaceType to the ApiJobPosting selection made jobPosting null with HTTP 200, which reads exactly like a wrong slug or a closed posting. The example now asks for title and applicationForm only; the location comes from the public board API. An organization name with spaces works as the slug. Co-Authored-By: Claude Opus 5.5 --- reference/ats/ashby.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reference/ats/ashby.md b/reference/ats/ashby.md index a30c020..86cfa1e 100644 --- a/reference/ats/ashby.md +++ b/reference/ats/ashby.md @@ -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":"","jobPostingId":""},"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":"","jobPostingId":""},"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:** From b7a708885dad38739391f32b8512910c3a2bb520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20=C3=87ak=C4=B1ro=C4=9Flu?= Date: Thu, 12 Nov 2026 13:35:33 +0300 Subject: [PATCH 4/8] Four form fields that look filled and are not, from one week of forms - Ashby (Spruce, 9 Nov 2026): a "When can you start?" text input is a calendar; a sentence typed into it fails as a missing entry. - Greenhouse (ESW 5 Nov, Financial Times 6 Nov): a select picked with PICKOPT shows its value and submits empty; picking it by keyboard registers it. - Rippling (Multiplayer Studio, 9 Nov): the resume parse pre-answers the SMS consent and Current company and cuts the first name. - Teamtailor (ENSEK, 9 Nov): a phone picker opened on the employer's country turns a national number into a foreign one; typing the full international number switches the flag. Co-Authored-By: Claude Opus 5.5 --- reference/ats/ashby.md | 1 + reference/ats/greenhouse.md | 1 + reference/ats/rippling.md | 1 + reference/ats/teamtailor.md | 2 ++ 4 files changed, 5 insertions(+) diff --git a/reference/ats/ashby.md b/reference/ats/ashby.md index 86cfa1e..469f5ca 100644 --- a/reference/ats/ashby.md +++ b/reference/ats/ashby.md @@ -76,3 +76,4 @@ - **Company careers pages often embed Ashby.** `api.ashbyhq.com/posting-api/job-board/` answers from the company's own page (CORS allows it) and returns every posting with its UUID, which gives `jobs.ashbyhq.com///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. diff --git a/reference/ats/greenhouse.md b/reference/ats/greenhouse.md index 3a8e604..8cfc836 100644 --- a/reference/ats/greenhouse.md +++ b/reference/ats/greenhouse.md @@ -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 ``. - "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/` is not a Greenhouse board: it is Drupal hosting the Greenhouse questions, so the field ids are `edit-*` and `edit-question-` rather than Greenhouse's own. Plain `` value leaves the visible control empty; `jQuery('[name=jobCountry]').selectpicker('val','