Most remote job listings are ghosts — already filled, never opened, or posted just to farm résumés. As a developer, that annoyed me enough to solve it with code instead of complaining about it on X.
So I built Remoty.work, which grades every listing A–F on how likely it is to be real. Here's how the detection actually works under the hood.
The problem, from an engineering angle
Job boards are an endless scrape loop. Board A scrapes Board B, which scraped Board C. The same dead listing propagates across a dozen sites, and none of them verify anything. There's no signal for "is this real," so the noise compounds until every board looks identical.
I didn't want to build board number thirteen in that loop. I wanted a layer on top that answers one question every listing should have to: is anyone actually going to read my application?
The architecture
Everything runs on a single VPS — Postgres, scrapers, and the scoring jobs, supervised on a schedule. Deliberately boring. High level:
-
Ingestion: scheduled scrapers pull from source boards and company ATS feeds into Postgres. Each raw listing is deduplicated by a fingerprint (title + company + normalized URL) so the same job reposted across five boards collapses into one row with a
repost_count. - Scoring engine: every listing gets a ghost-risk score from a handful of signals, then mapped to an A–F grade so it's human-readable, not a black-box number.
- The "rant" signal: I cross-reference what people say about companies in places like r/recruitinghell and hiring threads. That's where the truth about a company's hiring leaks out, and it turns out to be a strong predictor.
- Agents: I use DeepSeek to classify and summarize the messy text job descriptions, company chatter. DeepSeek because running this over thousands of listings every night on GPT-4-class models would have killed the unit economics before I had a single user.
One infra detail I didn't expect to spend a weekend on: the frontend is on Cloudflare's edge, but the edge can't open a raw TCP connection to my Postgres (it's firewalled to localhost on the VPS). So the dynamic pages talk to a tiny authenticated read-only HTTP bridge on the box instead of the database directly. Obvious in hindsight; not obvious at 1am.
Here's the shape of the scoring (simplified from the real thing):
type Signals = {
repostCount: number; // same job seen across N boards
ageDays: number; // how long it's been "open"
hasSalary: boolean; // real openings tend to disclose
descriptionQuality: number; // 0–1 from the LLM pass
companySentiment: number; // -1..1 from the "rant" signal
};
function ghostRisk(s: Signals): number {
let risk = 0;
if (s.repostCount > 6) risk += 0.30; // sprayed everywhere = funnel, not a role
if (s.ageDays > 45) risk += 0.25; // "open" for 6 weeks = probably isn't
if (!s.hasSalary) risk += 0.15;
risk += (1 - s.descriptionQuality) * 0.15;
risk += Math.max(0, -s.companySentiment) * 0.15;
return Math.min(risk, 1);
}
const grade = (risk: number) =>
risk < 0.15 ? "A" : risk < 0.35 ? "B" : risk < 0.55 ? "C" : risk < 0.75 ? "D" : "F";
The signal I didn't see coming
The best predictor wasn't age or repost count. It was realizing some listings aren't jobs at all — they're ads.
A couple of well-known "vetted talent network" platforms post hundreds of roles that look like openings. When I applied to test them, the rejection emails said the quiet part out loud: they have an excess of candidates for each posted role. The listing isn't a job. It's a lead-gen ad to grow their bench, wearing a job's clothes.
So those don't get an F — they get their own label: "Ad — Not Hiring."
Nothing in the description tells you that. You only learn it by applying, which is exactly the thing the product is supposed to save you from.
The human in the loop
The AI isn't the final judge. When the system flags something abnormal, I actually apply to the job and watch what happens. Right? Good. Wrong? I correct the label and feed it the why, so the next pass is sharper.
It's slower than a fully automated board. That's the point. The whole product is "we checked, so you don't have to waste your time."
What I got wrong
- A trailing slash cost me half my traffic silently. My static pages auto-redirect a missing trailing slash; my server-rendered job pages don't. So every click on a job fell through to the homepage. I only caught it because analytics showed a specific /jobs/12345 URL as my #2 page — serving the homepage's title. Users clicked a job and got the front page. For days.
- Double-encoding hell. Scrapers stored raw HTML entities (&), then my templating engine escaped them again, so titles rendered Q&A. Classic, embarrassing, took too long to spot.
- More content made SEO worse. I generated thousands of programmatic keyword pages. Google "discovered" ~9k of them and indexed almost none — crawl budget, not content volume, was the ceiling. Publishing more actively hurt. I killed the page-generation treadmill. ‹confirm the ~9k number›
- My own automation hallucinated. An outreach agent cheerfully reported sending six sponsor emails. Zero had actually been sent. The irony of a product built to detect fake signals shipping a fake signal internally was not lost on me. Now every agent action gets verified against a real side effect before it's trusted — the same "did it actually happen?" rule the job scoring runs on.
The stack
- Astro 5 (static + on-demand hybrid) on Cloudflare Pages.
- Postgres on a single VPS.
- DeepSeek for the LLM passes.
- Pagefind for search.
- Self-hosted GitHub Actions runner (the box builds itself)
- Whop for checkout. ‹confirm payments›
Happy to go deeper on any part in the comments.
Try it / tell me I'm wrong
It's live: Remoty.work. If you've been burned by ghost jobs, or if you think my scoring is naive, I genuinely want to hear it — that feedback is what trains the next pass.
(And if you find this useful — I just launched on Product Hunt today. An upvote would mean a lot: here)
Top comments (0)