#!/usr/bin/env bun import { isEnglishContent, NON_ENGLISH_RE, JUNIOR_TITLE_RE } from "../fetch.ts"; const CSV = "./jobs.csv"; const existingLinks = new Set(); try { const text = await Bun.file(CSV).text(); for (const line of text.split("\n").slice(1)) { const url = line.match(/"(https?:\/\/[^"]+)"/)?.[1]; if (url) existingLinks.add(url); } } catch {} const RUBY_KEYWORDS = [ "Ruby on Rails", "Rails Engineer", "Ruby Engineer", "Rails Developer", "Ruby Developer", ].join(" OR "); const searches = [ { label: "United States Remote (Ruby)", location: "United States", workType: "2" }, ]; const RUBY_RE = /\bruby\b|\brails\b/i; interface Job { id: string; title: string; company: string; location: string; link: string; postedAt: string } function unescape(s: string) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") .replace(/'/g, "'").replace(/"/g, '"').replace(/\s+/g, " ").trim(); } async function fetchPage(location: string, workType: string, start: number): Promise { const params = new URLSearchParams({ keywords: RUBY_KEYWORDS, location, f_TPR: "r2592000", f_WT: workType, start: String(start), }); const url = `https://www.linkedin.com/jobs/search/?${params}`; const res = await fetch(url, { 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" } }); const html = await res.text(); const jobs: Job[] = []; const cardRe = /]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn="urn:li:jobPosting:(\d+)"[^>]*>([\s\S]*?)(?=]*class="[^"]*base-search-card[^"]*"[^>]*data-entity-urn|<\/ul>)/g; let m; while ((m = cardRe.exec(html)) !== null) { const id = m[1]; const chunk = m[2]; const titleM = chunk.match(/class="[^"]*base-search-card__title[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/h3>/); const companyM = chunk.match(/class="[^"]*base-search-card__subtitle[^"]*"[\s\S]*?]*>\s*([\s\S]*?)\s*<\/a>/); const locationM = chunk.match(/class="[^"]*job-search-card__location[^"]*"[^>]*>\s*([\s\S]*?)\s*<\//); const postedM = chunk.match(/datetime="([^"]+)"/); const title = titleM ? unescape(titleM[1].replace(/<[^>]+>/g, "")) : ""; const company = companyM ? unescape(companyM[1].replace(/<[^>]+>/g, "")) : ""; const location = locationM ? unescape(locationM[1]) : ""; const postedAt = postedM ? postedM[1] : ""; if (!title || !id) continue; jobs.push({ id, title, company, location, link: `https://www.linkedin.com/jobs/view/${id}/`, postedAt }); } return jobs; } const newJobs: Array = []; const seenIds = new Set(); for (const s of searches) { console.log(`\n[${s.label}]`); let totalFound = 0, added = 0; for (let page = 0; page < 4; page++) { const jobs = await fetchPage(s.location, s.workType, page * 25); if (jobs.length === 0) { console.log(` page ${page + 1} empty, stopping`); break; } console.log(` page ${page + 1} … ${jobs.length} cards`); totalFound += jobs.length; for (const job of jobs) { if (existingLinks.has(job.link) || seenIds.has(job.id)) continue; if (NON_ENGLISH_RE.test(job.title) || JUNIOR_TITLE_RE.test(job.title)) continue; if (!RUBY_RE.test(job.title)) continue; seenIds.add(job.id); process.stdout.write(` checking: ${job.title} @ ${job.company} (${job.location}) … `); const english = await isEnglishContent(job.id); if (!english) { console.log("dropped (non-English)"); continue; } console.log("ok"); newJobs.push({ ...job, search: s.label }); added++; } } console.log(` ${totalFound} total, ${added} new`); } console.log(`\n=== ${newJobs.length} Ruby remote jobs ===\n`); for (const j of newJobs) { console.log(`${j.title} | ${j.company} | ${j.location} | ${j.link}`); }