# Ghost Job Detector — Stale, Reposted & Evergreen Job Check (`northbell/ghost-job-detector`) Actor

Check job data you already have for ghost jobs: open too long, reposted, re-dated, evergreen wording, closed but still listed, no pay. Every job gets a 0-100 score and the reason for each point, quoted from the posting. Works on any job scraper's rows. Nothing is scraped.

- **URL**: https://apify.com/northbell/ghost-job-detector.md
- **Developed by:** [Northbell](https://apify.com/northbell) (community)
- **Categories:** Jobs, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.80 / 1,000 job scoreds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Ghost Job Detector

**Some job postings are not a seat someone is hiring for right now: they have been open for months, they come back every few weeks with a fresh date, or the text itself says it is collecting a "talent pipeline".** Give this Actor the jobs you already have — from any job scraper, an ATS export, your own board — and every job gets a **ghost score from 0 to 100** and **the reason for each point**, quoted from the posting or counted from your data. Turn on *Remember jobs between runs* and a job that comes back with a newer posting date is also flagged as re-dated. Nothing is scraped: the Actor only reads what you send.

**$1 per 1,000 jobs returned**, plus $0.01 per run.

### Example

Input: three rows (two of them the same role listed twice on LinkedIn).

Output (a real run of the Actor's example input on 11 October 2026):

| title | ghostScore | signals |
|---|---|---|
| Customer Success Manager (Acme Corp) | 100 · high | **evergreen**: the posting says "We are always accepting applications and building a talent pipeline for future opportunities on our customer team." · **age**: open for 101 days (posted 2026-07-01) · **reposted**: listed 2 times on linkedin.com under the same title and place (posted 2026-07-01, 2026-09-30) · **noPay** · **thin**: the description is only 114 characters |
| Customer Success Manager (Acme) | 25 · low | **reposted** (same as above). Pay and description not checked: the row has neither |
| Payments Engineer (Example Pay) | 0 · low | — |

### Run it again and again

- **On a schedule** — save your input as a task and add a **Schedule**: after your job scraper's scheduled run. Send each run to Google Sheets, Slack or email with an integration on the task.
- **Watch for changes** — Turn on **Remember jobs between runs**: a job that comes back with a newer posting date is flagged `redated`, and its age counts from the first time it was seen.
- **In bulk** — Pick every dataset in **Datasets to check** in one run.

### The signals

| Signal | Points | What it means |
|---|---|---|
| `closed` | 50 | The source marks the job closed (`closed: true`, `acceptingApplications: false`) or the text says "no longer accepting applications" — but it is still in your list |
| `evergreen` | 35 | The text says it is not one current seat: "always accepting applications", "talent pool/community/pipeline", "future opportunities", "general application", "not a current opening". The sentence is quoted |
| `redated` | 30 | With memory on: the job was first seen with one posting date and now shows a date at least 7 days newer — the date was refreshed, the job was not |
| `reposted` | 25 | The same company, title and place is listed more than once **on the same source** with different links. The same job on LinkedIn and on the career page is not a repost |
| `age` | 15 / 30 / 40 | Open for 30 / 60 / 90 days or more — from the posting date, or from the first time it was seen (memory), whichever is older |
| `applicants` | 10 | 500 or more applicants and still open after 30 days (from rows that carry an applicant count) |
| `noPay` | 10 | No pay range in the pay fields or in the text |
| `thin` | 10 | The description is under 300 characters |

**60 or more is `high`, 30 to 59 `medium`, under 30 `low`.** The score is the order in which to check jobs before you apply, count or contact them — **not proof that a job is fake.** A role can be open for 100 days because it is hard to fill.

**What cannot be read is not scored.** A row without a description gets no `noPay` or `thin` points; it lists `"pay"` and `"description"` in `notChecked` instead. "Applications are reviewed on an ongoing basis until the position is filled" is a real seat and is **not** counted as evergreen.

### Measured on real data

On 11 October 2026, with rows from two of our own scrapers:

- **60 LinkedIn "customer success manager" jobs in the United States** (with descriptions and applicant counts): 7 medium, 0 high. 5 had been open 90 days or more, 24 stated no pay. None used evergreen wording — the "pipeline" in their texts was the sales pipeline, which the Actor does not count.
- **1,238 Stripe postings from LinkedIn and Stripe's Greenhouse board**: 3 high, 276 medium. 178 open 90 days or more; 32 listed more than once on the same source — for example one sales role listed on 2025-10-06 and again on 2026-09-22.

### A scheduled check: re-dated jobs

Turn on **Remember jobs between runs** and run it after your scraper on a schedule. The Actor keeps, for each job, the first day it saw it and the first posting date (in your key-value store `ghost-job-history`, for 180 days). From the second run, a job that shows a newer posting date gets `redated`, and its age counts from the first day it was seen — a refreshed date no longer makes an old job look new.

### What you get

Your row as it was, plus:

| Field | |
|---|---|
| `ghostScore`, `ghostLevel` | 0-100, and high / medium / low |
| `signals` | `[{ signal, points, evidence }]`, highest first |
| `notChecked` | signals that could not be read from the row (`age`, `pay`, `description`) |
| `daysOpen`, `repostCount` | the numbers behind `age` and `reposted` |

**Return jobs scoring at least** (`minScore`) keeps only the jobs worth a look: 30 for medium and high, 60 for high only.

### Input

- `jobs`: rows from any scraper, as they are. Read from `title`/`jobTitle`/`positionName`, `company`/`companyName`, `location`, `url`/`jobUrl`, `postedAt`/`datePosted`/`postedOn` (also `30+ days ago`), `description`, and when present `applicants`, `closed`, `daysOpen` and pay fields (`salaryMin`, `salaryText`, `annualSalaryMin`…).
- `datasetIds`: datasets from your other runs, read in full.
- Up to 50,000 rows per run.

The text rules are written for English postings.

### Pricing

**$1 per 1,000 jobs returned**, plus $0.01 per run. Jobs below your minimum score are not returned and not charged.

### For AI agents

Call with `{"jobs": [...rows from any job scraper...], "minScore": 30}` — seconds for a few thousand rows. Each row has `type: "job"`, `ghostScore` and `signals` with the evidence as text.

### Related

Rows with applicant counts and days open, the richest input for this check: [LinkedIn Jobs Scraper with Applicant Counts](https://apify.com/northbell/linkedin-jobs-applicants-scraper).

# Actor input Schema

## `jobs` (type: `array`):

Rows from any job scraper, as they are. Read from the usual field names: title / jobTitle / positionName, company / companyName, location, url / jobUrl, postedAt / datePosted / postedOn (also "30+ days ago"), description, and when present applicants, closed, daysOpen and pay fields. Summary and error rows are skipped. Up to 50,000 rows per run, together with the datasets below.

## `datasetIds` (type: `array`):

Datasets from your other Actor runs: pick them here, or pass their IDs (defaultDatasetId) through the API. The Actor gets read access to the datasets you pick and nothing else. Each is read in full and checked with the rows above.

## `minScore` (type: `integer`):

0 returns every job with its score. 30 returns medium and high only, 60 high only. Jobs below are not returned and not charged.

## `rememberAcrossRuns` (type: `boolean`):

Keep the first date each job was seen and its first posting date (in your key-value store ghost-job-history, for 180 days). Then a job that comes back with a newer posting date is flagged as re-dated, and age counts from the first time it was seen. Turn on for a scheduled run.

## `historyName` (type: `string`):

Separate memories for separate feeds. Letters, numbers and dashes.

## Actor input object example

```json
{
  "jobs": [
    {
      "title": "Customer Success Manager",
      "company": "Acme Corp",
      "location": "Remote, US",
      "postedAt": "2026-07-01",
      "url": "https://www.linkedin.com/jobs/view/4000000001/",
      "description": "We are always accepting applications and building a talent pipeline for future opportunities on our customer team."
    },
    {
      "title": "Customer Success Manager",
      "company": "Acme",
      "location": "Remote, US",
      "postedAt": "2026-09-30",
      "url": "https://www.linkedin.com/jobs/view/4000000002/"
    },
    {
      "title": "Payments Engineer",
      "company": "Example Pay",
      "location": "Austin, TX",
      "postedAt": "2026-10-08",
      "url": "https://boards.greenhouse.io/examplepay/jobs/1",
      "description": "You will own the payments ledger, work with finance on daily reconciliation and lead the move to real-time settlement across our card and bank rails. You will design the double-entry model, set the on-call standards for money movement and mentor two engineers. We work in Go and Postgres on AWS. The base salary range for this role is $150,000 - $180,000 per year, plus equity and benefits. We hire for this seat now and will close the posting when it is filled."
    }
  ],
  "minScore": 0,
  "rememberAcrossRuns": false,
  "historyName": "default"
}
```

# Actor output Schema

## `jobs` (type: `string`):

Each job with its ghost score and reasons.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "jobs": [
        {
            "title": "Customer Success Manager",
            "company": "Acme Corp",
            "location": "Remote, US",
            "postedAt": "2026-07-01",
            "url": "https://www.linkedin.com/jobs/view/4000000001/",
            "description": "We are always accepting applications and building a talent pipeline for future opportunities on our customer team."
        },
        {
            "title": "Customer Success Manager",
            "company": "Acme",
            "location": "Remote, US",
            "postedAt": "2026-09-30",
            "url": "https://www.linkedin.com/jobs/view/4000000002/"
        },
        {
            "title": "Payments Engineer",
            "company": "Example Pay",
            "location": "Austin, TX",
            "postedAt": "2026-10-08",
            "url": "https://boards.greenhouse.io/examplepay/jobs/1",
            "description": "You will own the payments ledger, work with finance on daily reconciliation and lead the move to real-time settlement across our card and bank rails. You will design the double-entry model, set the on-call standards for money movement and mentor two engineers. We work in Go and Postgres on AWS. The base salary range for this role is $150,000 - $180,000 per year, plus equity and benefits. We hire for this seat now and will close the posting when it is filled."
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("northbell/ghost-job-detector").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "jobs": [
        {
            "title": "Customer Success Manager",
            "company": "Acme Corp",
            "location": "Remote, US",
            "postedAt": "2026-07-01",
            "url": "https://www.linkedin.com/jobs/view/4000000001/",
            "description": "We are always accepting applications and building a talent pipeline for future opportunities on our customer team.",
        },
        {
            "title": "Customer Success Manager",
            "company": "Acme",
            "location": "Remote, US",
            "postedAt": "2026-09-30",
            "url": "https://www.linkedin.com/jobs/view/4000000002/",
        },
        {
            "title": "Payments Engineer",
            "company": "Example Pay",
            "location": "Austin, TX",
            "postedAt": "2026-10-08",
            "url": "https://boards.greenhouse.io/examplepay/jobs/1",
            "description": "You will own the payments ledger, work with finance on daily reconciliation and lead the move to real-time settlement across our card and bank rails. You will design the double-entry model, set the on-call standards for money movement and mentor two engineers. We work in Go and Postgres on AWS. The base salary range for this role is $150,000 - $180,000 per year, plus equity and benefits. We hire for this seat now and will close the posting when it is filled.",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("northbell/ghost-job-detector").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "jobs": [
    {
      "title": "Customer Success Manager",
      "company": "Acme Corp",
      "location": "Remote, US",
      "postedAt": "2026-07-01",
      "url": "https://www.linkedin.com/jobs/view/4000000001/",
      "description": "We are always accepting applications and building a talent pipeline for future opportunities on our customer team."
    },
    {
      "title": "Customer Success Manager",
      "company": "Acme",
      "location": "Remote, US",
      "postedAt": "2026-09-30",
      "url": "https://www.linkedin.com/jobs/view/4000000002/"
    },
    {
      "title": "Payments Engineer",
      "company": "Example Pay",
      "location": "Austin, TX",
      "postedAt": "2026-10-08",
      "url": "https://boards.greenhouse.io/examplepay/jobs/1",
      "description": "You will own the payments ledger, work with finance on daily reconciliation and lead the move to real-time settlement across our card and bank rails. You will design the double-entry model, set the on-call standards for money movement and mentor two engineers. We work in Go and Postgres on AWS. The base salary range for this role is $150,000 - $180,000 per year, plus equity and benefits. We hire for this seat now and will close the posting when it is filled."
    }
  ]
}' |
apify call northbell/ghost-job-detector --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,northbell/ghost-job-detector"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/UxDftEop4oFPmVRW1/builds/BSd9c0uYd3T50f9Kj/openapi.json
