Skip to content
3 changes: 3 additions & 0 deletions reference/ats/_core.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ it. One file per vendor makes that impossible to write, because there is nowhere
| Rippling | `rippling.md` |
| Sage HR | `sage-hr.md` |
| SmartRecruiters, both the classic form and `oneclick-ui` | `smartrecruiters.md` |
| Talentics (`<co>.talentics.app/jobs/<uuid>`) | `talentics.md` |
| Teamtailor | `teamtailor.md` |
| Traffit | `traffit.md` |
| Trakstar Hire | `trakstar.md` |
Expand Down Expand Up @@ -79,10 +80,12 @@ Section = vendor name; BambooHR, Revolut and account walls → Hand off; Viterbi
| `opportunities.<co>.<tld>/jobs/<slug>` with a "Bizneo" tab title | Bizneo |
| `live.peoplise.com/<co>/Application/Landing/<uuid>` | Peoplise |
| `careers.mantu.com/brands/<brand>/jobs/<id>` (Amaris and the other Mantu brands) | Mantu careers (`other-forms.md`) |
| `app.recruitcrafts.com/job-detail/<id>` | Recruitcrafts (`other-forms.md`) |
| `*.homerun.co` | Homerun |
| `api.dueto.io/Redirect/<id>`, `employer.dueto.io/position/<n>/<co>` | Dueto (`other-forms.md`) |
| Spanish UI, `s-rall-bn` cookie button | Viterbit |
| Taleo, SuccessFactors, iCIMS, Worldline, Scalis, haystack.cv | account walls |
| `kariyer.garantibbva.com.tr/ilan/<req>/<id>/<slug>` | account wall: "Başvur" opens an email and password sign-in (measured 23 Oct 2026); the other button applies through LinkedIn, which Seekter never uses |

## Universal rules

Expand Down
6 changes: 6 additions & 0 deletions reference/ats/other-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ First measured 23 Oct 2026. No account. The LinkedIn apply link is a redirect th
- Three steps. Step 1 is only the resume (`input[type=file]`, `file_upload` works); Next parses it and step 2 shows first name, last name, email, phone and LinkedIn already filled from the PDF. Read them back, then Next.
- Step 3 holds the employer's own questions as plain text inputs with ids `positionPage_question-<n>-<m>` and **no `name` attribute**, so select by id. `form_input` by ref sets them; the native setter renders the value too, but use `form_input` so the framework's state follows.
- The required consent box (`#positionPage_isUserPrivacyAgreementAccepted`) reads "I accept the Privacy Policy and the Terms of Service", which bundles the platform's terms of service with the data-processing consent. Accepting terms is the candidate's, so the form becomes a hand-off: fill everything, leave the box and Submit Application to them.

## Recruitcrafts (`app.recruitcrafts.com/job-detail/<id>`)

- A small Turkish agency ATS; LinkedIn's offsite apply URL points straight at the job page. The page's own "Easy Apply" button (no relation to LinkedIn's) opens `?tab=upload_cv` on the same page: first name, last name, phone (`+90` picker, national number, rendered spaced), email, a dropzone with a hidden `input[type=file]` (expose it and upload through `file_upload`), and one required data-processing consent for the application. Step 2 is "Application Questions". No account is asked for up front. Measured 22 Oct 2026.
- **The email is checked only on Continue.** If the address already has a Recruitcrafts account (the candidate applied to another posting of the same agency before), Continue answers "This email is already registered in the system. Please log in and apply again." and the questions never open. That is a sign-in wall: hand off, and tell the candidate which PDF to upload after signing in. Check the tracker for earlier postings from the same employer on this ATS before filling the form, because the hand-off is likely.
- The ref click on "Easy Apply" did nothing; a coordinate click opened the form.
1 change: 1 addition & 0 deletions reference/ats/peoplise.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Turkish recruiting platform used by banks and large employers. Postings at `live
- **The landing page carries the first step of the form**: first name, surname, email, three `chkLegalDocuments` checkboxes and a "Şimdi Başvur" submit, under an invisible reCAPTCHA v3 (`GoogleCaptchaToken`, `.grecaptcha-badge`). No CV upload on this step; what follows was not reached.
- **All three checkboxes are mandatory, although none carries `required`.** Submitting with one unticked shows "Bu alanı işaretleyiniz" and stays on the page. On one bank tenant the three were: the privacy notice (aydınlatma metni), the consent text (rıza metni), and **consent to share the data with the parent holding company**. The last is not the application's own consent; Seekter does not tick it for the user, so the posting becomes a hand-off with the first two fields and boxes filled.
- `form_input` sets the name, email and checkbox values and they read back correctly.
- **The first step differs per tenant.** Measured 23 Oct 2026 on an airline tenant (`live.peoplise.com/hq/…`): name, surname, email, a phone code `<select>` plus `PhoneNumber`, a `CvFile` upload, and only **two** `chkLegalDocuments` boxes (the privacy notice and the employer's own processing consent), both the application's own. No holding-sharing box, so nothing forces a hand-off there. Read each box's label before deciding.
9 changes: 9 additions & 0 deletions reference/ats/talentics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Talentics

Talentics careers pages: `<co>.talentics.app/` lists the jobs, each at `/jobs/<uuid>`; "APPLY FOR THIS JOB" opens `/jobs/<uuid>/apply` in place. No account, no CAPTCHA seen. The confirmation is the same URL with "Application submitted!" in the page. First measured 14 Oct 2026, on a Turkish employer whose own site linked it from its HR page, after an aggregator listing carried no apply link.

- **The form renders a few seconds after the URL changes.** A `querySelectorAll('input')` straight after the click returned nothing while the screenshot already showed the fields; wait about 4 s and read again.
- **Upload the CV first; it autofills everything.** `input[type=file][name=file]`; `file_upload` through the `read_page` ref works. The parse takes 10-20 s: wait until the hidden `file_id` and `parsed_resume_uuid` have values. It then fills `linkedin_profile`, `name`, `email`, `phone`, `current_company`, the hidden `total_years_experience` and its visible `<select>`.
- **The parser also builds a work-experience and education list, and gets the months wrong.** Every date came back as January (`01/2025 - 01/2026` for April to March) and an end date it could not read stays `MM/YYYY`. The list is plain spans (`.additional-info-item-placeholder`): clicking a date span opens four `<select>`s (month, year, month, year) inside that item, and setting them with `.value` plus `change` sticks; clicking the title turns it into a text input. Correct the dates to the profile's before submitting: this list is what the recruiter reads.
- **Native setter works** on the text inputs and the `cover_letter` textarea. Custom questions are `linkQuestion_<id>` text inputs; read each label from its parent, since the `*` on the label is not reflected in `required`.
- **Consent boxes are `checkbox_<id>`, labelled by the employer.** On the measured tenant: one acknowledges the candidate privacy notice, the other gives explicit consent to process and transfer the application data to domestic and foreign service providers for candidate selection. Both are the application's own data consents, not marketing: tick them only when the profile consents to application data processing.
2 changes: 1 addition & 1 deletion reference/sources/freehire.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Response shape: `{ data: [...], meta: { limit, offset, total } }`.
Filter keys: `category`, `countries`, `regions`, `work_mode`, `posting_language`, `domains`, `seniority`, `employment_type`, `english_level`, `salary_currency`, `salary_period`, `company_size`, `company_type`, `relocation`, `requires_clearance`, `role_type`, `skills`, `source`, `visa_sponsorship`, `is_tech`, `cities`, `collections`, `ai_interview`, `auto_apply_available`, `reality`.
- `regions`: there is **no `worldwide`**; use `global`. Others: `eu`, `uk`, `mena`, `emea`, `europe`, `turkey`, `north_america`, `latam`, `apac`, `africa`, `cis`.
- **`regions=turkey` returned 0** (6 Oct 2026) while `countries=TR` returned 314 for a two-word backend title. For a home-country-only candidate, leave `regions` empty and set `home_country`; the sweep then runs only the `countries` pass.
- **Turkish postings are mostly `source: whatjobs-tr`** and their `url` is a `tr.whatjobs.com/pub_api__cpl__…` link that redirects to a JobLeads sign-up wall. The record carries no original URL. Treat each as a lead: resolve the employer's own posting (LinkedIn, the company careers page, Kariyer.net) or skip it.
- **Turkish postings are mostly `source: whatjobs-tr`** and their `url` is a `tr.whatjobs.com/pub_api__cpl__…` link that redirects to a JobLeads sign-up wall. The record carries no original URL. Treat each as a lead: resolve the employer's own posting (LinkedIn, the company careers page, Kariyer.net) or skip it. Measured 17 Oct 2026: `tr.whatjobs.com` stopped resolving at all (DNS NXDOMAIN), so the link cannot even be opened; the title and company in the freehire record are the only lead.
- Design work is spread across `category` = `design`, `frontend`, `engineering_design`, `product`. Query each.

Per-record fields worth using:
Expand Down
4 changes: 3 additions & 1 deletion reference/sources/kariyer-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Turkey's largest general job board. First measured 6 Oct 2026, logged out.

## Applying (logged in), measured 7 Oct 2026

- "Başvur" on the posting opens `/basvuru-tamamlama/<jobCode>`: three steps. **Özgeçmiş** uses the CV stored in the user's Kariyer.net profile (no upload). **Ön Yazı** is optional and defaults to "Ön yazı eklemek istemiyorum". **Şirket Soruları** are the employer's questions.
- "Başvur" on the posting opens `/basvuru-tamamlama/<jobCode>`: three steps. **Özgeçmiş** uses the CV stored in the user's Kariyer.net profile (no upload). **Ön Yazı** is optional and defaults to "Ön yazı eklemek istemiyorum". Measured 13 Oct 2026: "Yeni Ekle" opens "Ön Yazı Ekle" with a name field (`input[name=name]`, 50 characters) and a Quill editor (5000 characters; `.ql-container.__quill.setText()` fills it). The name needs real typing after a coordinate click on the field; measured 24 Oct 2026, JS `focus()` followed by typed text left the value empty, while a click at the field then typing filled it. "Kaydet" saves the letter to the account's list, dated, and selects it for this application (`input[name=selected-cover-letter]` checked); the account keeps it afterwards, so name it after the company and role. **Şirket Soruları** are the employer's questions; an employer without questions gets a two-step form (`(1/2)`, `(2/2)`) and "Başvurunu Tamamla" sits under the cover letter list.
- **Answers are pre-filled from the account's saved answers** (`Şirket Sorularım`), including ones from earlier applications and ones the user saved long ago. Read every pre-filled value against the profile before submitting; a value the profile does not cover (travel restriction, shift/weekend work) is not verified just because the site filled it.
- Dropdowns are custom widgets: coordinate-click the field, then the option. English scale: Başlangıç / Orta / İyi / İleri / Ana Dil. Years scale: 0-1 / 1-3 / 3-5 / 5-7 / 7+.
- **Some employers add a consent box under their questions** (measured 9-10 Oct 2026): "Şirkete verilerimi ve ilettiği sorulara yanıtlarımı işlemesi için açık rıza veriyorum", with the note that the employer only sees the answers when it is ticked. It is the application's own data consent: tick it only when the candidate's profile gives consent for application data, otherwise hand off. Group-company accounts (one Kariyer.net employer posting for several subsidiaries) also say the data may be shared within the group, so treat every subsidiary posted under one group account as the same company for the one-application-per-window rule.
Expand All @@ -25,4 +25,6 @@ Turkey's largest general job board. First measured 6 Oct 2026, logged out.
- **A Chrome window in the background breaks coordinate clicks here** (measured 9-10 Oct 2026). When `document.visibilityState` is `hidden`, screenshots time out every other call, coordinate clicks land nowhere, and "Devam Et" does not open the questions. Retake the screenshot until one succeeds and click right after it. `button.click()` from JS on "Başvurunu Tamamla" submits even while hidden, so read the answers first, then submit by JS and check for `/basvuru-onay`.
- Clicking outside the form can open "Yarım kalan başvurunu kaydedelim mi?" (save the unfinished application). Neither "Sil" nor "Daha Sonra Başvur" is wanted: reload `/basvuru-tamamlama/<jobCode>` instead.
- **The salary field ("Aylık net ücret beklentiniz nedir?") can already hold the account's saved figure, and typing appends to it.** Measured 11 Oct 2026: a click plus typed digits turned a saved value into one three orders of magnitude larger. Read `.value` first; to change it, `select()` the input, press Delete, type, then read `.value` again before submitting.
- **Replies arrive on the site, not in the inbox.** Measured 12 Oct 2026: six days and 20 Kariyer.net applications produced no mail from kariyer.net at all. The status lives at `/tum-basvurular` (`/basvurularim` is a 404): each card reads "Başvurun <date> tarihinde iletildi" until the employer acts, then e.g. "Özgeçmişin Görüntülendi". An inbox sweep (`/seekter-log`) has to read that page too, or every Kariyer.net application looks unanswered.
- **A mandatory question can have no truthful option.** Measured 23 Oct 2026: "Üniversite kaçıncı sınıfsınız?" (which university year are you in) offered only 1-4, no graduate choice, on a senior full-stack posting. Picking a year would be false: leave the tab with everything else filled and hand off. The options are a custom list (`input.kds-input-field`) that renders only after a click on the field itself, not on its "Seç" placeholder.
- Measured 9-10 Oct 2026: some employers ask "Şehir Seçiniz" (preferred city) twice, pre-filled from saved answers; check them against the profile's location rules.
3 changes: 2 additions & 1 deletion scripts/seekter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,8 @@ export function argsFromJson(file, spec) {
let raw;
try { raw = file === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(file, "utf8"); } catch (e) { die(`--json: cannot read ${file} (${e.code || e.message})`, 2); }
let obj;
try { obj = JSON.parse(raw); } catch (e) { die(`--json: ${file} is not valid JSON (${e.message})`, 2); }
// Windows PowerShell 5.1 writes UTF-8 with a byte-order mark, which JSON.parse rejects.
try { obj = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch (e) { die(`--json: ${file} is not valid JSON (${e.message})`, 2); }
if (!obj || typeof obj !== "object" || Array.isArray(obj)) die(`--json: ${file} must hold one JSON object`, 2);
const names = spec.positional || [], options = spec.options || {};
const pos = [], opts = [];
Expand Down
8 changes: 8 additions & 0 deletions tests/seekter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,14 @@ describe("tracker", () => {
assert.equal(cli("move", "somefile.md", "--json", f).rc, 2);
});

test("--json reads a file that starts with a UTF-8 byte-order mark", () => {
// Windows PowerShell 5.1's Set-Content -Encoding utf8 writes one.
const f = path.join(tmp, "bom.json");
fs.writeFileSync(f, "\uFEFF" + JSON.stringify({ company: "Acme", role: "Dev", status: "skipped", url: "https://jobs.example.com/acme/777777", no_index: true }));
const r = cli("add", "--json", f);
assert.equal(r.rc, 0, r.err);
});

test("add takes a fit score from 1 to 5 and refuses anything else", () => {
add({ company: "A", role: "D", url: "https://a.com/careers/1111111", status: "applied", fit: "4" });
for (const bad of ["8", "0", "3.5", "good fit"]) {
Expand Down
Loading