chore: update search scripts, extra sources and post drafts
Adds the one-off scripts accumulated across recent sessions, removes temp scripts whose searches now live in config.ts as role-family sweeps, and updates fetch-other.ts plus the LinkedIn post drafts.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { JUNIOR_TITLE_RE, JAVA_TITLE_RE } from "../fetch.ts";
|
||||
|
||||
const TECH_LEAD_KEYWORDS = [
|
||||
"Tech Lead", "Technical Lead", "Lead Engineer", "Lead Software Engineer",
|
||||
"Lead Developer", "Staff Engineer", "Principal Engineer", "Engineering Manager",
|
||||
"Lead Fullstack", "Lead Backend", "Lead Platform",
|
||||
].join(" OR ");
|
||||
|
||||
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(/–/g, "–").replace(/—/g, "—")
|
||||
.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
title: string;
|
||||
company: string;
|
||||
location: string;
|
||||
postedAt: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
function parseJobs(html: string): Job[] {
|
||||
const jobs: Job[] = [];
|
||||
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(/<h3 class="base-search-card__title">\s*([\s\S]*?)\s*<\/h3>/);
|
||||
if (!titleM) continue;
|
||||
|
||||
const companyM = card.match(/<h4 class="base-search-card__subtitle">[\s\S]{0,300}?<a[^>]*>\s*([\s\S]*?)\s*<\/a>/);
|
||||
const timeM = card.match(/<time[^>]+datetime="([^"]+)"/);
|
||||
const locM = card.match(/job-search-card__location[^>]*>\s*([^<]+)/);
|
||||
const jobLocation = locM ? locM[1].trim() : "";
|
||||
|
||||
const loc = jobLocation.toLowerCase();
|
||||
const isBrazil = loc.includes("brazil") || loc.includes("brasil") || loc === "" || loc.includes("remote");
|
||||
const isOtherCountry = /switzerland|germany|united states|france|uk\b|united kingdom|netherlands|spain|italy/i.test(jobLocation);
|
||||
if (!isBrazil || isOtherCountry) continue;
|
||||
|
||||
jobs.push({
|
||||
id,
|
||||
title: clean(titleM[1]),
|
||||
company: companyM ? clean(companyM[1]) : "",
|
||||
location: jobLocation || "Remote",
|
||||
postedAt: timeM ? timeM[1] : "",
|
||||
link: `https://www.linkedin.com/jobs/view/${id}/`,
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
const EN_RE = /\b(english|inglês|inglés)\b/i;
|
||||
const ES_RE = /\b(spanish|espanhol|español)\b/i;
|
||||
|
||||
async function requiresEnglishAndSpanish(id: string): Promise<boolean> {
|
||||
await Bun.sleep(400);
|
||||
try {
|
||||
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
|
||||
const html = await res.text();
|
||||
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
|
||||
const text = m ? m[1].replace(/<[^>]+>/g, " ") : html.replace(/<[^>]+>/g, " ");
|
||||
return EN_RE.test(text) && ES_RE.test(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const MAX_PAGES = 6;
|
||||
const baseUrl = `https://www.linkedin.com/jobs/search/?keywords=${encodeURIComponent(TECH_LEAD_KEYWORDS)}&location=Brazil&f_WT=2&f_TPR=r2592000`;
|
||||
|
||||
const allJobs: Job[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
if (page > 0) await Bun.sleep(500);
|
||||
const url = `${baseUrl}&start=${page * PAGE_SIZE}`;
|
||||
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)
|
||||
.filter((j) => !JUNIOR_TITLE_RE.test(j.title))
|
||||
.filter((j) => !JAVA_TITLE_RE.test(j.title));
|
||||
|
||||
let newOnPage = 0;
|
||||
for (const job of jobs) {
|
||||
if (!seen.has(job.id)) { seen.add(job.id); allJobs.push(job); newOnPage++; }
|
||||
}
|
||||
|
||||
console.log(`${jobs.length} found, ${newOnPage} new`);
|
||||
if (newOnPage === 0) break;
|
||||
}
|
||||
|
||||
console.log(`\n${allJobs.length} vagas Tech Lead/Lead. Checando descrições por EN+ES…\n`);
|
||||
|
||||
const matches: Job[] = [];
|
||||
for (const job of allJobs) {
|
||||
process.stdout.write(` checking ${job.id}… `);
|
||||
const ok = await requiresEnglishAndSpanish(job.id);
|
||||
console.log(ok ? "✓ EN+ES" : "—");
|
||||
if (ok) matches.push(job);
|
||||
}
|
||||
|
||||
console.log(`\n✓ ${matches.length} vagas com inglês + espanhol\n`);
|
||||
for (const j of matches) {
|
||||
console.log(`${j.title} — ${j.company} (${j.location})`);
|
||||
console.log(` ${j.link}`);
|
||||
}
|
||||
Reference in New Issue
Block a user