#!/usr/bin/env bun import { buildUrl, isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE, JAVA_TITLE_RE, SOFTWARE_TITLE_RE } from "../fetch.ts"; import type { SearchConfig } from "../config.ts"; const HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", }; function clean(raw: string): string { return raw.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim(); } // Same as fetch.ts parseJobs but WITHOUT the Switzerland location guard function parseJobs(html: string, label: string) { const jobs: { id: string; title: string; company: string; link: string; location: string }[] = []; const idRe = /data-entity-urn="urn:li:jobPosting:(\d+)"/g; const positions: { id: string; start: number }[] = []; let m; while ((m = idRe.exec(html)) !== null) positions.push({ id: m[1], start: m.index }); for (let i = 0; i < positions.length; i++) { const { id, start } = positions[i]; const card = html.slice(start, Math.min(positions[i + 1]?.start ?? Infinity, start + 3000)); const titleM = card.match(/

\s*([\s\S]*?)\s*<\/h3>/); if (!titleM) continue; const companyM = card.match(/

[\s\S]{0,300}?]*>\s*([\s\S]*?)\s*<\/a>/); const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/); const jobLocation = locM ? locM[1].trim() : ""; jobs.push({ id, title: clean(titleM[1]), company: companyM ? clean(companyM[1]) : "", location: jobLocation, link: `https://www.linkedin.com/jobs/view/${id}/` }); } return jobs; } async function fetchJobsNoGeo(config: SearchConfig) { const baseUrl = buildUrl(config); const all: { id: string; title: string; company: string; location: string; link: string }[] = []; const seen = new Set(); for (let page = 0; page < 6; page++) { if (page > 0) await Bun.sleep(400); const url = `${baseUrl}&start=${page * 25}`; process.stdout.write(` page ${page + 1} … `); const res = await fetch(url, { headers: HEADERS }); if (!res.ok) { console.error(`failed (${res.status})`); break; } const html = await res.text(); const jobs = parseJobs(html, config.label) .filter(j => !NON_ENGLISH_RE.test(j.title)) .filter(j => !JUNIOR_TITLE_RE.test(j.title)) .filter(j => !JAVA_TITLE_RE.test(j.title)) .filter(j => SOFTWARE_TITLE_RE.test(j.title)); let newCount = 0; for (const job of jobs) { if (!seen.has(job.id)) { seen.add(job.id); all.push(job); newCount++; } } console.log(`${jobs.length} jobs, ${newCount} new`); if (newCount === 0) break; } return all; } const RUBY_KEYWORDS = [ "Ruby on Rails", "Rails Engineer", "Rails Developer", "Ruby Engineer", "Ruby Developer", "Tech Lead Ruby", "Lead Engineer Ruby", "Staff Ruby", "Principal Ruby", ].join(" OR "); const searches: SearchConfig[] = [ { label: "USA Remote Ruby/Rails", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], timePosted: "month" }, { label: "USA Remote Ruby/Rails mid-senior+", keywords: RUBY_KEYWORDS, location: "United States", workType: ["remote"], seniority: ["mid-senior", "director"], timePosted: "month" }, ]; type Job = { id: string; title: string; company: string; location: string; link: string; search: string }; const results: Job[] = []; const seen = new Set(); for (const config of searches) { console.log(`\n[${config.label}]`); const jobs = await fetchJobsNoGeo(config); if (jobs.length === 0) { console.log(" no jobs found"); continue; } let added = 0; for (const job of jobs) { if (seen.has(job.id)) continue; seen.add(job.id); results.push({ ...job, search: config.label }); added++; } console.log(` => ${added} new`); } console.log(`\n=== TOTAL: ${results.length} jobs ===\n`); for (const j of results) { console.log(`${j.title} | ${j.company} | ${j.location} | ${j.id} | ${j.link}`); }