A saved job can disappear from a URL check because your own salary filter rejected it.
You're about to connect requested - returned to your monitoring agent's archive action. The small dread: the next notification could confidently announce that an open role has closed.
Lab note 028: can a URL lookup justify that transition? The catch arrives early: our job Actor applies salary and category filters during byJobUrls resolution. A successfully resolved listing can still be skipped.
Can a missing record prove that a role closed?
A missing record from filtered byJobUrls results does not establish that a specific job closed.
The operation combines finding a listing with deciding whether to emit it. Those are different questions, even when they share an input payload.
For an LLM pipeline, the dangerous step happens afterward: your application converts absence into a factual statement, saves that statement, and retrieves it as evidence later. Now the model has a citation for your inference. Splendid paperwork. Wrong conclusion.
We'd give the pipeline separate lanes:
saved URL -> resolution check -> resolved / unresolved
|
v
preference check
|
v
matched / unresolved match
independent closure evidence -> archive permission
An unresolved check should preserve the last known record with its freshness warning. It shouldn't manufacture a fresh “closed” chunk.
Reject malformed inputs without rejecting the job
Malformed links, nonexistent listings, and closed listings are skipped. That makes input hygiene part of lifecycle safety: a damaged saved URL must not become evidence about the employer's hiring decision.
This filter accepts a conservative subset of canonical listing URLs. It deliberately sends unfamiliar forms to review rather than attempting clever repairs.
Example saved input for the checks below, without any claim that this listing is currently open:
import re
from urllib.parse import urlsplit
saved_urls = [
"https://himalayas.app/companies/sola-payments/jobs/senior-data-platform-engineer"
]
def canonical_saved_url(value):
if not isinstance(value, str):
return None
try:
parts = urlsplit(value)
except ValueError:
return None
path_ok = re.fullmatch(
r"/companies/[a-z0-9_-]+/jobs/[a-z0-9_-]+",
parts.path,
)
canonical = f"https://himalayas.app{parts.path}"
if (
not path_ok
or parts.query
or parts.fragment
or value != canonical
):
return None
return canonical
checked = [(value, canonical_saved_url(value)) for value in saved_urls]
accepted_urls = list(dict.fromkeys(url for _, url in checked if url))
rejected_urls = [value for value, url in checked if url is None]
Passing this check establishes structure, not availability. A rejected tracking link or unfamiliar slug belongs in rejected_urls, where it can't trigger archival.
Deduplicating accepted inputs also prevents repeated checks from producing competing lifecycle decisions for the same saved URL.
Keep preferences out of the resolution request
A salary floor, ceiling, or currency filter excludes listings without published pay. Category filtering can also suppress a resolved listing that doesn't match.
That creates a particularly unpleasant retrieval bug: changing a user's preferences can delete the underlying job evidence. Their preferences changed; the employer's listing didn't necessarily change with them.
The resolution payload should therefore be constructed from scratch. Copying your search payload and changing mode carries too much baggage.
This builder omits preferences and makes the emission allowance cover its batch:
EMIT_LIMIT = 5000
def lookup_input(batch):
if not 1 <= len(batch) <= EMIT_LIMIT:
raise ValueError("Batch must fit within the emitted-record limit.")
run_input = {
"mode": "byJobUrls",
"jobUrls": batch,
"maxItems": len(batch),
}
return run_input
This removes a source of ambiguity. It doesn't turn a missing result into a closure certificate.
We'd also keep preference decisions in application metadata rather than embedding them into the job description. Otherwise, retrieval can confuse “outside your salary preferences” with a property of the listing's availability.
Give every batch room to return
The default maxItems is 20. It limits emitted job records, so a larger saved set can outgrow the allowance before you know anything about its unmatched members.
A batch difference then becomes a mixture of lifecycle uncertainty and your own output budget. Asking an LLM to explain that difference only gives the uncertainty better grammar.
This collector splits the accepted URLs and uses the builder above. Preference keys are permitted only for a separate matching pass:
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
PREFERENCE_KEYS = {"minSalary", "maxSalary", "salaryCurrency", "categories"}
def fetch_jobs(urls, preferences=None):
preferences = {} if preferences is None else preferences
if set(preferences) - PREFERENCE_KEYS:
raise ValueError("Unexpected preference key.")
rows = []
for offset in range(0, len(urls), EMIT_LIMIT):
batch = urls[offset:offset + EMIT_LIMIT]
run_input = lookup_input(batch)
run_input.update(preferences)
run = client.actor(
"vexaloom-labs/himalayas-remote-job-finder-with-salary-and-tech"
).call(run_input=run_input)
if run["status"] != "SUCCEEDED":
continue
rows.extend(
client.dataset(run["defaultDatasetId"]).iterate_items()
)
return rows
An unsuccessful batch contributes no positive resolution evidence. An exception stops this program before it constructs archive candidates.
Streaming dataset retrieval also avoids making a dataset page boundary look like another missing-job event.
Keep aggregate diagnostics away from individual records
The status message reports how many URLs were closed or invalid. It doesn't establish a closure reason for every missing URL.
That distinction matters when an agent writes explanations. An aggregate diagnostic can't support “your saved role closed” unless you've evidence connecting that reason to that identity.
| Evidence available | Unsafe conclusion | Safe application action |
|---|---|---|
| Locally rejected URL | The role closed | Review the saved address |
| Missing from preference results | The role closed | Leave lifecycle unchanged |
| Missing from a capped batch | The role closed | Correct the batch allowance |
| Aggregate closed-or-invalid count | This particular role closed | Keep its reason unresolved |
For reconciliation, use jobGuid, the canonical listing URL, rather than trying to identify jobs by title or slug.
This filter accepts only an unambiguous requested identity whose jobUrl agrees with it:
def index_rows(rows, requested):
buckets = {}
for row in rows:
guid = canonical_saved_url(row.get("jobGuid"))
if guid in requested:
buckets.setdefault(guid, []).append(row)
return {
guid: matches[0]
for guid, matches in buckets.items()
if len(matches) == 1
and canonical_saved_url(matches[0].get("jobUrl")) == guid
}
resolution_rows = fetch_jobs(accepted_urls)
resolved = index_rows(resolution_rows, set(accepted_urls))
unresolved_urls = set(accepted_urls) - set(resolved)
Duplicate identities stay unresolved here instead of silently choosing whichever record arrived last. Unexpected identities never enter the saved-job index.
Neither condition deserves a closure story.
Join preference evidence without changing lifecycle
Reimplementing salary and category matching locally has its own traps. Annualized pay still needs currency context, and category matching includes parent categories and tech tags.
For exact Actor matching, we'd use a separate filtered pass and reconcile it locally. This costs another lookup pass, but avoids inventing an equivalent classifier from partial fields.
The combined filter below creates application-owned state keys. Positive preference results establish a match; absence remains unresolved because a later lookup can fail independently.
Example preferences for that separate pass:
preference_filters = {
"minSalary": 120000,
"salaryCurrency": "USD",
"categories": ["python"],
}
preference_rows = fetch_jobs(list(resolved), preference_filters)
matched = index_rows(preference_rows, set(resolved))
monitor = {}
for url in accepted_urls:
preference_state = "not_checked"
if url in resolved:
preference_state = "matched" if url in matched else "unresolved"
monitor[url] = {
"lifecycle_state": "resolved" if url in resolved else "unresolved",
"preference_state": preference_state,
}
That distinction lets your agent say, “The listing resolved, but this check didn't establish a preference match.”
It also gives retrieval a useful boundary: preference uncertainty can affect recommendations without erasing the source record. Keep check metadata separate from description chunks so it doesn't get copied across every chunk or outlive its own freshness.
The archive button needs evidence
We'd use this operation to resolve saved listings. A missing row can't authorize closure.
The worked example is our Himalayas Remote Job Finder with Salary & Tech Stack. Collection supplies evidence; your application owns the lifecycle transition.
This final guard requires a separate source of confirmed closure evidence and blocks archival when the current resolution returned the job:
confirmed_closed_urls = frozenset()
def can_archive(url, closure_evidence):
return url in closure_evidence and url not in resolved
archive_candidates = {
url for url in accepted_urls
if can_archive(url, confirmed_closed_urls)
}
Populate that evidence set through an independently verified closure process. Neither the model nor a batch-difference handler should fill it by inference.
Repeated absence can justify retry or review. Repeating an ambiguous check doesn't make its explanation specific.
Reproduce the ambiguity on a saved listing
- Choose a saved listing you can independently confirm is open.
- Replace the URL below and choose salary or category preferences that the listing fails.
- Submit the filtered input, then repeat after removing
minSalary,salaryCurrency, andcategories. - Reconcile returned
jobGuidvalues. Keep unmatched inputs unresolved in both checks.
Example filtered input, with a placeholder listing whose current availability and preference match aren't asserted:
{
"mode": "byJobUrls",
"jobUrls": [
"https://himalayas.app/companies/sola-payments/jobs/senior-data-platform-engineer"
],
"minSalary": 120000,
"salaryCurrency": "USD",
"categories": ["python"],
"maxItems": 1
}
Top comments (0)