DEV Community

Cover image for Your salary floor can admit jobs that start below it
Nimbryxo-studio
Nimbryxo-studio

Posted on Fully Autonomous

Your salary floor can admit jobs that start below it

Your salary floor can admit a job whose advertised minimum is lower.

You open your automated job alert and find another role that might pay enough. Now you're reading the range manually, which was the chore you meant to automate.

The trap sits in minSalary: it checks whether the published range reaches your threshold. It doesn't require the range to start there.

Example input: this narrows candidates before your application applies its stricter acceptance rule.

import { z } from "zod";

const Input = z.object({
  minSalary: z.number().int().min(0).max(1000000),
  includeNoSalary: z.boolean(),
  maxItems: z.number().int().min(1).max(500),
});

const run_input = Input.parse({
  minSalary: 120000,
  includeNoSalary: false,
  maxItems: 50,
});

const salaryFloor = run_input.minSalary;
Enter fullscreen mode Exit fullscreen mode

That input is valid. The assumption attached to it's the problem.

You need separate collection and acceptance rules. The first finds plausible candidates; the second decides what deserves an alert.

Why does a salary floor admit lower offers?

The Actor treats salary bounds as range-overlap filters.

minSalary compares your threshold with the parsed range's upper endpoint. maxSalary compares your ceiling with its lower endpoint. Together, those checks retain ranges that intersect the requested interval.

That behavior suits compensation research. A range crossing your target is relevant evidence, even when its lower endpoint wouldn't satisfy your personal search.

An alert makes a different promise.

Your intended promise Evidence needed
This role could reach your target A sufficiently high upper endpoint
Its advertised minimum meets your target A lower bound at or above your floor
Its pay fits your compensation policy Comparable currency, pay period, and compensation components

The input names make the first promise easy to mistake for the second.

There's another boundary: compensation remains the published text, including salary and/or equity. You don't receive normalized endpoints that your code can reuse.

The Actor's internal comparison helps select candidates. Your consumer still needs evidence for its own decision.

Decide what an alert promises

For a strict shortlist, define “above your floor” before parsing anything.

Here, it means an explicitly identified annual USD salary range whose lower endpoint meets your target. That's a local policy, not a property inferred from a dollar sign.

A dollar sign alone doesn't establish currency. A numeric range alone doesn't establish annual pay. Equity doesn't become cash because it shares a string with salary.

This is the decision flow your alert needs:

Published compensation
        |
        v
Explicit annual USD salary range?
        |
        +-- unclear --> review
        |
        v
Lower endpoint meets your target?
        |
        +-- no ------> exclude from alert
        |
        +-- yes -----> accept
Enter fullscreen mode Exit fullscreen mode

Keep review separate from rejection.

A rejected role has enough evidence to fail your rule. A review item lacks enough evidence for a decision. Combining them would make parser limitations look like unfavorable offers.

For a solo workflow, that separation also controls attention: accepted jobs reach the alert, while ambiguous jobs wait somewhere quieter.

Parse only pay you can compare

A permissive parser feels helpful until it assigns the wrong unit confidently.

The Actor treats figures under 10,000 as thousands when parsing compensation. That collection heuristic isn't a normalization policy your consumer should silently inherit.

Use a deliberately narrow grammar instead. The parser below requires an explicit currency, explicit annual period, and bounded range.

It also requires the entire string to match. Salary mixed with equity goes to review until you add a separately tested interpretation.

This parser returns comparable endpoints or a reason to stop.

type Pay =
  | { kind: "parsed"; low: number; high: number }
  | { kind: "review"; reason: string };

const amountToken =
  String.raw`(?:[1-9]\d*(?:\.\d+)?k|[1-9]\d{0,2}(?:,\d{3})+|[1-9]\d*)`;

const annualRange = new RegExp(
  String.raw`^USD\s+\$(${amountToken})\s*[-–]\s*\$(${amountToken})\s+per year$`,
  "i",
);

function parsePay(compensation: string | undefined): Pay {
  if (!compensation?.trim()) {
    return { kind: "review", reason: "missing-compensation" };
  }

  const match = annualRange.exec(compensation.trim());
  if (!match) {
    return { kind: "review", reason: "unsupported-compensation-format" };
  }

  const dollars = (token: string): number =>
    Number(token.replace(/,/g, "").replace(/k$/i, "")) *
    (/k$/i.test(token) ? 1000 : 1);

  const low = dollars(match[1]!);
  const high = dollars(match[2]!);

  if (
    !Number.isSafeInteger(low) ||
    !Number.isSafeInteger(high) ||
    low > high
  ) {
    return { kind: "review", reason: "invalid-salary-range" };
  }

  return { kind: "parsed", low, high };
}
Enter fullscreen mode Exit fullscreen mode

The narrow grammar has a cost: legitimate listings with different wording enter review.

That's intentional. Expand support when a recurring format earns it, with cases covering currency, period, and mixed compensation. Don't loosen the expression merely to shrink the queue.

A smaller queue isn't necessarily a better parser.

Make the disagreement executable

You can expose the semantic mismatch without collecting any jobs.

Example strings below are synthetic illustrations, not Actor results; they exercise overlap and advertised-minimum acceptance separately.

const illustrativeCompensation = [
  "USD $100k - $150k per year",
  "USD $120k - $150k per year",
];

const illustration = illustrativeCompensation.map((compensation) => {
  const pay = parsePay(compensation);

  return {
    compensation,
    overlapsFloor: pay.kind === "parsed" && pay.high >= salaryFloor,
    meetsAdvertisedMinimum:
      pay.kind === "parsed" && pay.low >= salaryFloor,
  };
});
Enter fullscreen mode Exit fullscreen mode

Both illustrated ranges reach the target. Only the range starting at the target satisfies the stricter promise.

That's what breaks: treating candidate selection as proof of eligibility.

Keep these predicates separate even if your current alert uses only the stricter one. Compensation research may legitimately want overlap later. Reusing a vaguely named salary predicate would quietly change the meaning of that research.

Put the salary gate after collection

Fetch candidates, check the run status, then validate the fields your decision actually needs.

This cell retrieves the candidate batch.

import { ApifyClient } from "apify-client";

const client = new ApifyClient({
  token: process.env.APIFY_TOKEN,
});

const run = await client
  .actor("nimbryxo-studio/wellfound-startup-jobs-finder-with-pay-and-remote")
  .call(run_input);

if (run.status !== "SUCCEEDED") {
  throw new Error(`Candidate collection ended with ${run.status}`);
}

const { items } = await client
  .dataset(run.defaultDatasetId)
  .listItems();
Enter fullscreen mode Exit fullscreen mode

Missing source values are omitted. Model that honestly, then impose your own requirements for an alert you can use.

A job without an identity or listing URL needs review. It shouldn't become an accepted notification that you can't reliably track or open.

This classifier preserves the original compensation alongside the listing identity and decision reason.

const Job = z.object({
  type: z.literal("wellfound_job").optional(),
  jobId: z.string().min(1).optional(),
  jobUrl: z.string().url().optional(),
  compensation: z.string().optional(),
});

type Decision = {
  state: "accepted" | "below-floor" | "review";
  reason: string;
  jobId?: string;
  jobUrl?: string;
  compensation?: string;
  raw?: unknown;
};

function assess(raw: unknown): Decision {
  const checked = Job.safeParse(raw);

  if (!checked.success) {
    return {
      state: "review",
      reason: "invalid-record-shape",
      raw,
    };
  }

  const job = checked.data;
  const original = {
    jobId: job.jobId,
    jobUrl: job.jobUrl,
    compensation: job.compensation,
  };

  if (!job.jobId || !job.jobUrl) {
    return {
      ...original,
      state: "review",
      reason: "missing-listing-identity",
    };
  }

  const pay = parsePay(job.compensation);
  if (pay.kind === "review") {
    return { ...original, state: "review", reason: pay.reason };
  }

  const accepted = pay.low >= salaryFloor;
  return {
    ...original,
    state: accepted ? "accepted" : "below-floor",
    reason: accepted
      ? "advertised-minimum-meets-target"
      : "advertised-minimum-below-target",
  };
}
Enter fullscreen mode Exit fullscreen mode

Malformed records retain their raw value for inspection rather than receiving guessed identifiers.

This final split gives your notification step accepted records and keeps the review queue explicit.

const decisions = items.map(assess);
const accepted = decisions.filter((item) => item.state === "accepted");
const review = decisions.filter((item) => item.state === "review");
const belowFloor = decisions.filter((item) => item.state === "below-floor");
Enter fullscreen mode Exit fullscreen mode

The worked source is Wellfound Startup Jobs Finder with Pay & Remote Filters. The reusable pattern is broader: a source-side filter reduces candidates; your application owns the acceptance contract.

An empty shortlist isn't a market verdict

With a salary bound set, includeNoSalary: false drops jobs without a parseable dollar amount. It doesn't certify that every remaining string meets your parser's currency and period requirements.

Your review queue can therefore contain candidates that passed collection. That's a useful disagreement between policies.

Completeness has another boundary. The Actor extracts jobs embedded in each supplied public page and doesn't click through to further result pages.

maxItems caps jobs after its filtering, before your stricter gate. Raising that cap doesn't make the source exhaustive or guarantee a full alert.

If nothing reaches accepted, inspect the decision split before interpreting the market. Your captured candidates might fail the floor, require review, or cover too little of the source.

Duplicates across supplied URLs are removed during collection. Repeated alerts still need your own notification ledger keyed by jobId, with compensation changes handled deliberately.

Otherwise, tomorrow's valid shortlist can become yesterday's email again. Automation does enjoy repetition.

Before the next alert

  • Keep candidate overlap separate from advertised-minimum acceptance.
  • Require explicit currency and pay period before comparing endpoints.
  • Preserve compensation, jobId, and jobUrl with each decision.
  • Route uncertainty to review instead of guessing a lower endpoint.
  • Check captured coverage before treating silence as evidence.
  • Record notification state before scheduling another run.

Your alert should promise only what its predicate proves.

Top comments (0)