Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions providers/ashby.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,55 @@
// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */

// Ashby provider — hits the public posting-api endpoint.
// Auto-detects from careers_url pattern `https://jobs.ashbyhq.com/<slug>`.
// 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 {object} job - Ashby job object
* @returns {{min: number, max: number, currency: string}|null}
*/
function parseCompensation(job) {
const comp = job?.compensation;
if (!comp) return null;

const interval = comp.interval || '1 YEAR';
const multiplier = INTERVAL_MULTIPLIERS[interval];
if (!multiplier) return null;

const minValue = comp.minValue;
const maxValue = comp.maxValue;
const currency = comp.currency || '';

// 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;

return {
min: min ?? max,
max: max ?? min,
currency: currency.toUpperCase(),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

function resolveApiUrl(entry) {
const url = entry.careers_url || '';
Expand Down Expand Up @@ -30,6 +77,7 @@ export default {
url: j.jobUrl || '',
company: entry.name,
location: j.location || '',
salary: parseCompensation(j),
}));
},
};
75 changes: 75 additions & 0 deletions scan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ mkdirSync('data', { recursive: true });

const CONCURRENCY = 10;

// ── Annualization multipliers ──────────────────────────────────────────
// Used to normalize different compensation intervals to annual salary.
// This constant is also used by providers (e.g., ashby.mjs) for salary parsing.

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,
};

// ── Provider loading ────────────────────────────────────────────────

async function loadProviders(dir) {
Expand Down Expand Up @@ -138,6 +155,57 @@ function buildLocationFilter(locationFilter) {
};
}

// ── Salary filter ───────────────────────────────────────────────────
// Optional. If `salary_filter` is absent from portals.yml, all salaries pass.
// Semantics:
// - min/max are yearly compensation filters (before conversion to annual)
// - max: 0 means "no upper limit"
// - If no salary data exists on a job, it passes (conservative behavior)
// - If currency 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.

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

const min = salaryFilter.min ?? 0;
const max = salaryFilter.max ?? 0;
const filterCurrency = (salaryFilter.currency || '').toUpperCase();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 || '').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;
};
}

// ── Dedup ───────────────────────────────────────────────────────────

function loadSeenUrls() {
Expand Down Expand Up @@ -278,6 +346,7 @@ async function main() {
const companies = config.tracked_companies || [];
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
const targets = [];
Expand Down Expand Up @@ -308,6 +377,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 All @@ -331,6 +401,10 @@ async function main() {
totalFilteredLocation++;
continue;
}
if (!salaryFilter(job.salary)) {
totalFilteredSalary++;
continue;
}
if (seenUrls.has(job.url)) {
totalDupes++;
continue;
Expand Down Expand Up @@ -368,6 +442,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`);
console.log(`New offers added: ${newOffers.length}`);

Expand Down
18 changes: 18 additions & 0 deletions templates/portals.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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 yearly compensation filters (before conversion to annual)
# - max: 0 means "no upper limit"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# - If no salary data exists on a job, it passes (conservative behavior)
# - If currency mismatch (e.g., USD filter, EUR job), it fails
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# - 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