Skip to content
Merged
1 change: 1 addition & 0 deletions providers/_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* @property {string} name User-facing label; appears in logs and placeholders.
* @property {boolean} [enabled] Default: true.
* @property {string} [careers_url] Public listing URL; consumed by detect().
* @property {string} [api] JSON API URL; used directly by greenhouse/ashby providers.
* @property {string} [provider] Explicit provider id — bypasses detect().
* @property {('http')} [transport] Default: 'http'. Reserved for future transports.
*/
Expand Down
68 changes: 65 additions & 3 deletions providers/ashby.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,68 @@
const ASHBY_TIMEOUT_MS = 30_000;
const ASHBY_RETRIES = 2;

// Annualization multipliers for different compensation intervals
const INTERVAL_MULTIPLIERS = {
'1 HOUR': 2080,
'1 DAY': 260,
'1 WEEK': 52,
'2 WEEK': 26,
'0.5 MONTH': 24,
'1 MONTH': 12,
'2 MONTH': 6,
'3 MONTH': 4,
'6 MONTH': 2,
'1 YEAR': 1,
};

/**
* Parse compensation data from Ashby job object.
* Returns structured salary object with min, max, and currency,
* or null if no valid compensation data exists.
* @param {any} job - Ashby job object
* @returns {{min: number, max: number, currency: string}|null}
*/
export function parseCompensation(job) {
const comp = job?.compensation;
if (!comp) return null;

const interval = /** @type {keyof typeof INTERVAL_MULTIPLIERS} */ (comp.interval || '1 YEAR');
const multiplier = INTERVAL_MULTIPLIERS[interval];
if (!multiplier) return null;

// Coerce and validate numeric fields — malformed API payloads must not propagate
/** @param {any} v */
const normalizeNum = (v) => {
if (v == null) return null;
if (typeof v === 'string' && v.trim() === '') return null;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : null;
};
const minValue = normalizeNum(comp.minValue);
const maxValue = normalizeNum(comp.maxValue);
const currency = typeof comp.currency === 'string' ? comp.currency.trim() : '';

// If neither min nor max is provided, no valid compensation
if (minValue == null && maxValue == null) return null;

// Annualize the values
const min = minValue != null ? minValue * multiplier : null;
const max = maxValue != null ? maxValue * multiplier : null;

// Must have at least one valid annual value
if (min == null && max == null) return null;

// Ensure correct ordering (min <= max)
const resolvedMin = /** @type {number} */ (min ?? max);
const resolvedMax = /** @type {number} */ (max ?? min);
return {
min: Math.min(resolvedMin, resolvedMax),
max: Math.max(resolvedMin, resolvedMax),
currency: currency.toUpperCase(),
};
}

/** @param {import('./_types.js').PortalEntry} entry */
function resolveApiUrl(entry) {
const url = entry.careers_url || '';
const match = url.match(/jobs\.ashbyhq\.com\/([^/?#]+)/);
Expand Down Expand Up @@ -42,7 +104,6 @@ export default {
async fetch(entry, ctx) {
const apiUrl = resolveApiUrl(entry);
if (!apiUrl) throw new Error(`ashby: cannot derive API URL for ${entry.name}`);

let lastErr;
for (let attempt = 0; attempt <= ASHBY_RETRIES; attempt++) {
if (attempt > 0) {
Expand All @@ -51,13 +112,14 @@ export default {
await sleep(backoff);
}
try {
const json = await ctx.fetchJson(apiUrl, { timeoutMs: ASHBY_TIMEOUT_MS });
const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { timeoutMs: ASHBY_TIMEOUT_MS }));
const jobs = Array.isArray(json?.jobs) ? json.jobs : [];
return jobs.map((j) => ({
return jobs.map(/** @param {any} j */ (j) => ({
title: j.title || '',
url: j.jobUrl || '',
company: entry.name,
location: j.location || '',
salary: parseCompensation(j),
postedAt: toEpochMs(j.publishedAt),
}));
} catch (e) {
Expand Down
6 changes: 4 additions & 2 deletions providers/greenhouse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const ALLOWED_GREENHOUSE_HOSTS = new Set([
'job-boards.eu.greenhouse.io',
]);

/** @param {string} url */
function assertGreenhouseUrl(url) {
let parsed;
try {
Expand All @@ -24,6 +25,7 @@ function assertGreenhouseUrl(url) {
return url;
}

/** @param {import('./_types.js').PortalEntry} entry */
function resolveApiUrl(entry) {
if (entry.api) {
assertGreenhouseUrl(entry.api);
Expand Down Expand Up @@ -61,9 +63,9 @@ export default {
assertGreenhouseUrl(apiUrl);
// redirect:'error' prevents SSRF via server-side redirects; combined with
// assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist.
const json = await ctx.fetchJson(apiUrl, { redirect: 'error' });
const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' }));
const jobs = Array.isArray(json?.jobs) ? json.jobs : [];
return jobs.filter(j => j.absolute_url).map(j => ({
return jobs.filter(/** @param {any} j */ j => j.absolute_url).map(/** @param {any} j */ j => ({
title: j.title || '',
url: j.absolute_url,
company: entry.name,
Expand Down
68 changes: 68 additions & 0 deletions scan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,67 @@ export function buildLocationFilter(locationFilter) {
};
}

// ── Salary filter ───────────────────────────────────────────────────
// Optional. If `salary_filter` is absent from portals.yml, all salaries pass.
// Semantics:
// - min/max are annual compensation filters (use annualized values)
// - max: 0 means "no upper limit"
// - If no salary data exists on a job, it passes (conservative behavior)
// - If both currencies are known and mismatch (e.g., USD filter, EUR job), it fails
// - Partial ranges (min only or max only) work correctly via overlap logic
// Uses null-safe checks (!= null, ??) to preserve 0 values correctly.

export function buildSalaryFilter(salaryFilter) {
if (!salaryFilter) return () => true;

// Coerce and validate bounds — malformed YAML must not silently mis-filter
const min = Number(salaryFilter.min ?? 0);
const max = Number(salaryFilter.max ?? 0);
const filterCurrency = (salaryFilter.currency || '').trim().toUpperCase();

if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < 0) {
console.error('Warning: salary_filter.min/max must be non-negative numbers — salary filter disabled');
return () => true;
}
if (max > 0 && min > max) {
console.error('Warning: salary_filter.min cannot exceed salary_filter.max — salary filter disabled');
return () => true;
}

// If both min and max are 0, no filtering applied
if (min === 0 && max === 0) return () => true;

return (salary) => {
// If no salary data exists, pass (conservative - many providers don't expose salary)
if (!salary) return true;

const jobMin = salary.min ?? salary.max ?? null;
const jobMax = salary.max ?? salary.min ?? null;

// If we have no usable salary values, pass conservatively
if (jobMin == null && jobMax == null) return true;

// Currency handling - reject only if BOTH currencies exist and mismatch
const jobCurrency = (salary.currency || '').trim().toUpperCase();
if (filterCurrency && jobCurrency && filterCurrency !== jobCurrency) {
return false;
}

// Range overlap logic - reject ONLY if job is completely outside filter range
// Job entirely below user minimum
if (min > 0 && jobMax != null && jobMax < min) {
return false;
}
// Job entirely above user maximum
if (max > 0 && jobMin != null && jobMin > max) {
return false;
}

// Otherwise pass (overlap exists or no valid range to compare)
return true;
};
}

// ── URL rediscovery (--rediscover-404) ──────────────────────────────
// When a tracked company's job URL returns 404/410, the role may have just
// moved to a new URL (Workday/Greenhouse rotate URLs without closing roles).
Expand Down Expand Up @@ -580,6 +641,7 @@ async function main() {
const boards = Array.isArray(config.job_boards) ? config.job_boards : [];
const titleFilter = buildTitleFilter(config.title_filter);
const locationFilter = buildLocationFilter(config.location_filter);
const salaryFilter = buildSalaryFilter(config.salary_filter);

// 3. Resolve a provider for each enabled company / board
const targets = [];
Expand Down Expand Up @@ -650,6 +712,7 @@ async function main() {
let totalFound = 0;
let totalFilteredTitle = 0;
let totalFilteredLocation = 0;
let totalFilteredSalary = 0;
let totalDupes = 0;
const newOffers = [];
const errors = [...resolveErrors];
Expand Down Expand Up @@ -688,6 +751,10 @@ async function main() {
totalFilteredLocation++;
continue;
}
if (!salaryFilter(job.salary)) {
totalFilteredSalary++;
continue;
}
if (seenUrls.has(job.url)) {
totalDupes++;
continue;
Expand Down Expand Up @@ -784,6 +851,7 @@ async function main() {
console.log(`Total jobs found: ${totalFound}`);
console.log(`Filtered by title: ${totalFilteredTitle} removed`);
console.log(`Filtered by location: ${totalFilteredLocation} removed`);
console.log(`Filtered by salary: ${totalFilteredSalary} removed`);
console.log(`Duplicates: ${totalDupes} skipped`);
if (historyPolicy.recheckAfterDays != null) {
console.log(`Recheck eligible: ${seenUrlState.recheckEligible} old scan-history URL(s)`);
Expand Down
18 changes: 18 additions & 0 deletions templates/portals.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@
# - "Brazil"
# - "Australia"

# -- Salary filter (optional) --
# Filter scanned jobs by annual compensation range. Applied AFTER location
# filter, BEFORE dedup. Only applies when structured salary data exists.
#
# Semantics:
# - min/max are annual compensation filters (use annualized values)
# - max: 0 means "no upper limit"
# - If no salary data exists on a job, it passes (conservative behavior)
# - If both currencies are known and differ, the job is rejected by the salary filter
# - Partial ranges (min only or max only) work correctly
# - If this entire block is absent, salary is not filtered (default behavior)
#
# Example: target roles paying at least $100k, no upper limit
# salary_filter:
# min: 100000
# max: 0
# currency: "USD"

# -- Title filter --
# The scanner uses these keywords to decide if a title is relevant.
# At least 1 positive must match AND 0 negatives must match (case-insensitive).
Expand Down
Loading
Loading