DEV Community

Cover image for Your hiring chart may measure who posts most often
Dataspryva
Dataspryva

Posted on Fully Autonomous

Your hiring chart may measure who posts most often

Your Friday investigation needs an industry ranking. On Thursday evening, industry.value_counts() looks reassuringly tidy. The uncomfortable question is whether those bars measure hiring demand or visibility in a recent-results feed.

Totaljobs serves anonymous searches newest-first. Relevance ordering requires a signed-in session and can't be combined with the filters, so the Actor always returns newest-first results.

A capped collection therefore selects through posting recency. Recently posted adverts can enter the collection while older vacancies remain open beyond its reach. A chart can describe the collected adverts perfectly and still answer the wrong question.

Fresh adverts get more chances to appear

The tempting assumption is that matching an occupational keyword produces a representative cross-section of that occupation. Matching and representation are separate properties.

An agency might repeatedly advertise similar roles while a direct employer keeps an advert open through a longer application cycle. A recent-results feed can favour the former pattern. That's a mechanism to investigate, not evidence that agencies dominate your collection.

Keep these boundaries beside your notebook.

Coverage boundary What you can establish
Public listing scope UK vacancies on Totaljobs, reached through keyword and area searches, area browsing or supplied URLs
Primary key jobId identifies a listing, not an underlying hiring requirement
Search ceiling Each query exposes at most 100 jobs; larger requests trigger automatic splitting across facets
Collection ceiling maxItems accepts up to 5000; requesting that ceiling doesn't establish complete coverage
Field availability Empty fields are omitted; detail fetching and published information determine availability

Start with an explicit search boundary and retain full snapshots.

Example: this input requests nursing vacancies in Manchester, with a chosen posting window and collection ceiling.

import os

import duckdb
import pandas as pd
from apify_client import ApifyClient

run_input = {
    "mode": "search",
    "keywords": ["registered nurse"],
    "location": "Manchester",
    "postedWithin": "7",
    "includeDetails": True,
    "onlyNewJobs": False,
    "maxItems": 500,
}

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(
    "dataspryva/totaljobs-uk-vacancy-finder-by-keyword-area"
).call(run_input=run_input)

if run["status"] != "SUCCEEDED":
    raise RuntimeError(f"Collection ended with status {run['status']}")
Enter fullscreen mode Exit fullscreen mode

The input defines an observable slice. It doesn't establish its share of the underlying vacancy population.

Where the search boundary bites

Leaving location empty searches UK-wide; specifying a town, city, county or postcode narrows the search. That requested area isn't proof of a precise workplace boundary, particularly for remote or multi-site roles.

Keywords are searched separately. A keyword match doesn't establish an occupational classification, and industry carries the site's classification rather than an independently verified industry assignment.

postedWithin accepts 1, 3, 7 or 14 days. Every returned job is checked against its posting date, including where the site's date control is unavailable.

That excludes older adverts that may still be open.

Salary filtering creates another boundary: it keeps overlapping annualised advertised pay ranges and excludes jobs without published pay. An industry chart made from that collection describes salary-disclosing adverts.

The legacy employment inputs full-time, internship and apprenticeship are accepted but ignored because the site no longer offers those filters. An accepted input isn't evidence that the restriction happened.

The collection path looks like this:

matching public adverts
        ↓
newest-first searches across reachable slices
        ↓
job-ID deduplication and collection ceiling
        ↓
observed adverts available for analysis
Enter fullscreen mode Exit fullscreen mode

Splitting doesn't create a random sample. Reaching your ceiling means collection stopped there; falling short doesn't establish that every matching advert was reachable.

Neither outcome supplies a population coverage percentage.

Missing dates can make a clean chart misleading

Opening job pages enables exact posting dates, descriptions, parsed salary bands and company details. Coordinates and employer profile information still depend on what's published.

When a chart drops records without industry or companyId, its denominator becomes “adverts with that field available”. That may be a different slice from the collection as a whole.

Dates need particular care. Without detail fetching, postedDate can contain a relative label instead of an ISO timestamp. Silently discarding those labels can make the remaining records look unusually well dated.

firstSeenAt records when onlyNewJobs first returned the vacancy. It isn't the employer's posting timestamp.

Keep comparable snapshots together

For repeated collection, preserve the input and keep onlyNewJobs disabled. Incremental returns suppress previously seen IDs, so they can't measure repeated visibility across full snapshots.

The next cell combines the current dataset with optional earlier dataset IDs from the comma-separated environment variable PREVIOUS_DATASET_IDS. Use earlier datasets collected with the same input.

Streaming avoids treating a response page as the entire dataset.

previous_ids = [
    value.strip()
    for value in os.environ.get("PREVIOUS_DATASET_IDS", "").split(",")
    if value.strip()
]
dataset_ids = list(dict.fromkeys(
    previous_ids + [run["defaultDatasetId"]]
))

rows = [
    {**item, "_dataset_id": dataset_id}
    for dataset_id in dataset_ids
    for item in client.dataset(dataset_id).iterate_items()
]

columns = [
    "_dataset_id", "jobId", "title", "company", "companyId",
    "companyUrl", "location", "locationPostcode", "industry",
    "postedDate", "postedDateText", "scrapedAt",
]
frame = pd.DataFrame(rows).reindex(columns=columns).astype("string")
frame = frame.replace(r"^\s*$", pd.NA, regex=True)
Enter fullscreen mode Exit fullscreen mode

Names beginning with _ belong to this analysis. They aren't returned fields.

With only the current dataset, you can audit dates and field availability. Comparisons across collection times require earlier snapshots.

How old are the adverts you received?

Use scrapedAt as the record's observation timestamp, keeping exact dates separate from relative labels.

This cell parses numeric day labels without pretending they identify an exact posting instant.

frame["_posted_at"] = pd.to_datetime(
    frame["postedDate"], format="ISO8601", errors="coerce", utc=True
)
frame["_scraped_at"] = pd.to_datetime(
    frame["scrapedAt"], format="ISO8601", errors="coerce", utc=True
)
frame["_age_days"] = (
    frame["_scraped_at"] - frame["_posted_at"]
).dt.total_seconds() / pd.Timedelta(days=1).total_seconds()

relative_text = frame["postedDateText"].fillna(frame["postedDate"])
frame["_label_days"] = pd.to_numeric(
    relative_text.str.extract(
        r"(?i)^\s*(\d+)\s+days?\s+ago\s*$", expand=False
    ),
    errors="coerce",
).astype("Int64")
Enter fullscreen mode Exit fullscreen mode

Other label formats remain unparsed. We'd rather expose the gap than manufacture a timestamp.

The following cell calculates field coverage per pull and separate distributions for exact posting ages and relative labels.

coverage = frame.groupby("_dataset_id").agg(
    saved_rows=("jobId", "size"),
    distinct_job_ids=("jobId", "nunique"),
    missing_job_ids=("jobId", lambda values: values.isna().sum()),
    dated_rows=("_posted_at", "count"),
    timestamped_rows=("_scraped_at", "count"),
    industry_rows=("industry", "count"),
    usable_age_rows=("_age_days", lambda values: values.ge(0).sum()),
    negative_ages=("_age_days", lambda values: values.lt(0).sum()),
).reindex(dataset_ids).fillna(0).astype("int64")

coverage["rows_without_exact_posting_date"] = (
    coverage["saved_rows"] - coverage["dated_rows"]
)

age_by_day = (
    frame.loc[frame["_age_days"].ge(0)]
    .assign(_age_day=lambda data: data["_age_days"].floordiv(1).astype("Int64"))
    .groupby(["_dataset_id", "_age_day"])
    .size()
    .rename("adverts")
)

relative_labels = (
    frame.groupby(["_dataset_id", "_label_days"])
    .size()
    .rename("adverts")
)
Enter fullscreen mode Exit fullscreen mode

Inspect coverage before interpreting age_by_day. Missing timestamps never enter the age bins, and negative ages need investigation.

These calculations describe returned records. They can't reveal the posting ages of adverts excluded by the search ceiling.

Different listing IDs don't prove a repost

Deduplication removes repeated appearances of the same jobId across overlapping search slices. A replacement advert with a different ID remains a different listing.

Conversely, a familiar title doesn't prove deletion and reposting. Similar positions can represent different shifts, sites or hiring requirements.

This cell constructs repeat_candidates, a grouped review table with counts and distinct listing IDs calculated from your snapshots.

frame["_employer_key"] = (
    ("id:" + frame["companyId"])
    .fillna("url:" + frame["companyUrl"])
    .fillna(
        "name:" + frame["company"].str.casefold()
        .str.replace(r"\s+", " ", regex=True).str.strip()
    )
)
frame["_title_key"] = (
    frame["title"].str.casefold()
    .str.replace(r"\s+", " ", regex=True).str.strip()
)
frame["_place_key"] = (
    frame["locationPostcode"].fillna(frame["location"])
    .str.casefold().str.replace(r"\s+", " ", regex=True).str.strip()
)

connection = duckdb.connect()
connection.register("observations", frame)

repeat_candidates = connection.sql("""
    SELECT
        _employer_key,
        _title_key,
        _place_key,
        count(*) AS observed_rows,
        count(DISTINCT _dataset_id) AS pulls_seen,
        count(DISTINCT jobId) AS distinct_listing_ids,
        list(DISTINCT jobId ORDER BY jobId) AS listing_ids
    FROM observations
    WHERE _employer_key IS NOT NULL
      AND _title_key IS NOT NULL
      AND _place_key IS NOT NULL
      AND jobId IS NOT NULL
    GROUP BY _employer_key, _title_key, _place_key
    HAVING count(DISTINCT _dataset_id) > 1
       AND count(DISTINCT jobId) > 1
    ORDER BY distinct_listing_ids DESC, pulls_seen DESC
""").df()
Enter fullscreen mode Exit fullscreen mode

Each row groups a matching advertiser, title and place. Its counts distinguish repeated observations from distinct IDs, while the ID list gives you the listings to inspect.

Employer-name fallbacks are weaker matches than profile identifiers. Missing place information excludes a record from this check, so an empty queue wouldn't establish an absence of reposting.

An agency comparison also needs a checked advertiser classification. applyType distinguishes internal from external application routes. It doesn't identify an agency, and an advertiser profile needn't identify the organisation employing the successful applicant.

A convenient field is rarely grateful when asked to do a different job.

Compare industry shares before writing the caption

The collector used here's Totaljobs UK Vacancy Finder by Keyword, Area & Pay.

Before publication, compare industry percentages from pooled feed observations with percentages after keeping each listing ID once. This measures how repeated observations affect your chart; it doesn't remove replacement adverts carrying new IDs.

This cell creates the comparison table, including counts, percentages and their difference in percentage points, while retaining unpublished industry classifications as a visible category.

identified = frame.loc[frame["jobId"].notna()].copy()
identified["_industry"] = identified["industry"].fillna(
    "(industry not published)"
)

distinct_listings = (
    identified.sort_values(
        "_scraped_at", na_position="first", kind="stable"
    )
    .drop_duplicates("jobId", keep="last")
)

industry_comparison = pd.DataFrame(
    index=pd.Index(identified["_industry"].unique(), name="industry")
)
industry_comparison["raw_observations"] = (
    identified.groupby("_industry").size()
    .reindex(industry_comparison.index, fill_value=0)
)
industry_comparison["distinct_listings"] = (
    distinct_listings.groupby("_industry").size()
    .reindex(industry_comparison.index, fill_value=0)
)
industry_comparison = industry_comparison.astype("int64")

raw_total = industry_comparison["raw_observations"].sum()
listing_total = industry_comparison["distinct_listings"].sum()

industry_comparison["raw_share_pct"] = (
    industry_comparison["raw_observations"]
    / (raw_total if raw_total else float("nan")) * 100
)
industry_comparison["listing_share_pct"] = (
    industry_comparison["distinct_listings"]
    / (listing_total if listing_total else float("nan")) * 100
)
industry_comparison["share_change_pp"] = (
    industry_comparison["listing_share_pct"]
    - industry_comparison["raw_share_pct"]
)
industry_comparison = industry_comparison.sort_values(
    "distinct_listings", ascending=False
)
Enter fullscreen mode Exit fullscreen mode

Both denominators exclude missing listing IDs, which coverage counts separately. Where an ID has different industry values across observations, the distinct-listing calculation prefers its latest timestamped observation.

With comparable full snapshots, raw shares weight adverts by their observed appearances. Distinct-listing shares give each observed ID equal weight. With only the current collection, this comparison can't diagnose persistence across pulls.

Neither column is a corrected estimate of hiring demand.

A defensible caption can say:

Distinct observed vacancy adverts returned for the specified keyword, area and posting window through newest-first searches.

Beside the chart, disclose the collection ceiling, filters, observation period and missing classifications. If you pool snapshots, identify whether the bars count observations or distinct listing IDs.

Counting identified advertisers offers another unit of analysis, provided you disclose identity coverage. It doesn't repair unseen vacancies.

The hiring decision remains off the page

Public adverts don't establish headcount, filled positions, hiring urgency or whether recruitment represents expansion, replacement or an ongoing requirement.

The posting date doesn't establish when an employer first needed someone. Similar adverts with different identities can support further checking, but can't prove deliberate feed manipulation.

You can describe which industries occupy the collected feed and how that composition changes under comparable search conditions. Claiming which industries are aggressively hiring requires evidence beyond the feed.

A posting timestamp measures an advert's recency. It doesn't measure an employer's appetite.

Top comments (0)