If you’re looking for remote work or freelance contracts, you probably have a routine: open three or four job boards every morning, scroll past 50 listings that say “Senior” when you’re mid-level, “US only” when you live in Jakarta, or “React” when you do Python, and maybe find two worth reading. That’s 20–40 minutes a day, every day, mostly spent rejecting things.
Keyword alerts don’t fix it. “Python” matches a data-engineering role you’d love and a “must be authorized to work in the US, on-site Fridays” role you can’t take. The useful filter is the one you do in your head: can I actually apply for this, and do I want it?
A cheap language model handles that judgement well enough to be useful, as long as it only sorts and never decides for you. In this guide you’ll build a Python script that:
- pulls new listings from We Work Remotely (RSS), Remotive (public API) and the monthly Hacker News “Who is hiring?” thread (Algolia API)
- throws out obvious misses with a free keyword pre-filter, so you don’t pay to score them
- asks Gemini 3.5 Flash-Lite to score each remaining listing 0–10 against a short profile you write, and to extract location restrictions and salary if stated
- sends listings scoring 7+ to your Telegram, with a one-line reason and the link
- remembers what it has already seen in a SQLite file, so you never get the same job twice
AI cost: roughly $0.01–0.03 per day, under $1 a month. Build time: about 45 minutes.
What you need
| Item | Cost | Notes |
|---|---|---|
| Python 3.10+ | $0 | Laptop, Raspberry Pi, or any small VPS |
| Gemini API key | $0 to start | aistudio.google.com → “Get API key” |
google-genai, pydantic, feedparser, requests | $0 | pip install |
| Telegram account + a bot | $0 | Created in 2 minutes via @BotFather |
| Something that runs on a schedule | $0 | cron, Windows Task Scheduler, or a $4–6/month VPS |
Pricing as of October 2026 (check ai.google.dev/pricing, Google changes models and prices often):
- Gemini 3.5 Flash-Lite (
gemini-3.5-flash-lite, paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens. Default in the script. - Free tier: available for this model, rate-limited, but enough for this job’s volume. Free-tier prompts may be used by Google to improve its products. Job listings are public data, so that matters little here, but your profile text gets sent too, so keep personal details (phone, address) out of it.
Older tutorials use gemini-2.5-flash. Google now limits the 2.5 models to accounts that already used them, and recommends 3.5 Flash-Lite for new projects, so stick to a 3.x model.
Why these three sources
They all offer a documented, public way to fetch listings without scraping HTML or logging in:
- We Work Remotely publishes RSS feeds per category, e.g.
https://weworkremotely.com/categories/remote-programming-jobs.rss. The full list of feeds is linked from their site. - Remotive has a free JSON API at
https://remotive.com/api/remote-jobs. The API’s own legal notice asks you to link back to Remotive and mention it as the source, and to call it at most 4 times a day. Jobs in the free API are delayed by 24 hours, and in our October 2026 test it returned only about 15 listings per request, so treat it as a small extra source, not the main one. Read their current terms before you rely on it. - Hacker News “Ask HN: Who is hiring?” is posted on the first weekday of each month by the account
whoishiring. Every top-level comment is one job. The Algolia HN Search API (hn.algolia.com/api) returns them as JSON, no key needed.
What’s deliberately not here: LinkedIn and Indeed (their terms forbid automated scraping, and they block it), and Upwork (it shut down its job RSS feeds in 2024). If a tutorial tells you to scrape those, expect a banned account or a script that breaks next week.
How the cost works out
Outside the monthly HN thread, the sources below bring in a few dozen new listings a day (in our test the two WWR feeds held 47 listings in total and the free Remotive API returned 15). The keyword pre-filter (Step 3) removed about half to two thirds of them before any AI call. To be generous, say 60 listings get scored on a busy day.
Each listing is cut to 2,500 characters of description. With your profile and the prompt, that’s about 1,000 input tokens per listing, plus about 100 output tokens of JSON.
- Input: 60 × 1,000 × $0.30/1M = $0.018
- Output: 60 × 100 × $2.50/1M = $0.015
- Total: about $0.03 per day, around $1 a month at the busy end
The first run after a new HN thread costs more, because a few hundred job posts arrive at once (the October 2026 thread had about 270 top-level posts when we tested, and about 100 passed the pre-filter). That one run is roughly $0.05–0.15. Gemini 3.x models “think” before answering and thinking tokens are billed as output, so the script sets thinking_level="minimal". It also prints actual token usage on every run, so you don’t have to trust these estimates.
Step 1: Create the Telegram bot
- In Telegram, open a chat with @BotFather, send
/newbot, pick a name and a username ending inbot. BotFather replies with a token like7412345678:AAF.... - Open a chat with your new bot and send it any message (bots can’t message you first).
- Get your chat ID by opening this URL in a browser, with your token pasted in:
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
Look for "chat":{"id":123456789,...}. That number is your chat ID.
Step 2: Set up the project
mkdir jobradar && cd jobradar
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install google-genai pydantic feedparser requests
export GEMINI_API_KEY="your-gemini-key"
export TG_TOKEN="7412345678:AAF..."
export TG_CHAT="123456789"
Now write profile.txt. This is the most important file in the project. Be specific and honest, and write the deal-breakers explicitly, because that’s what the model uses to reject things:
I am a mid-level Python developer (4 years), based in Indonesia (UTC+7).
Strong: Python, Django, FastAPI, PostgreSQL, scraping, automation, REST APIs.
Some experience: React, Docker, AWS basics. No Java, no mobile.
I want: fully remote, contract or full-time, open to candidates in Asia or worldwide.
Deal-breakers: US/EU-only or "must reside in" a country I don't live in,
on-site or hybrid, senior/staff/lead titles requiring 7+ years, unpaid trials,
commission-only sales roles.
Nice to have: async teams, overlap with Asian or European hours, salary stated.
Step 3: The script
Save this as jobradar.py:
import html, os, re, sqlite3, sys, time
import feedparser, requests
from google import genai
from google.genai import errors, types
from pydantic import BaseModel
MODEL = os.environ.get("JOB_MODEL", "gemini-3.5-flash-lite")
PRICE_IN, PRICE_OUT = 0.30, 2.50 # USD per 1M tokens, Gemini 3.5 Flash-Lite paid tier
MIN_SCORE = 7
MAX_DESC = 2500 # characters of description sent per job
WWR_FEEDS = [
"https://weworkremotely.com/categories/remote-programming-jobs.rss",
"https://weworkremotely.com/categories/remote-devops-sysadmin-jobs.rss",
]
REMOTIVE_CATEGORY = "software-dev"
# Free pre-filter: a job must contain one of MUST_ANY and none of SKIP_ANY.
MUST_ANY = ["python", "django", "fastapi", "automation", "scraping", "backend"]
SKIP_ANY = ["us citizens only", "security clearance", "on-site only", "staff engineer"]
class Verdict(BaseModel):
score: int # 0-10
reason: str # one short sentence
location_ok: bool
salary: str # "" if not stated
client = genai.Client()
db = sqlite3.connect("seen.db")
db.execute("CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, ts INTEGER)")
def clean(text):
text = re.sub(r"<[^>]+>", " ", text or "")
return re.sub(r"\s+", " ", html.unescape(text)).strip()
def fetch_wwr():
for url in WWR_FEEDS:
for e in feedparser.parse(url).entries:
yield {"id": "wwr:" + e.link, "source": "We Work Remotely",
"title": e.title, "url": e.link,
"text": clean(e.get("summary", "")) + " Region: " + e.get("region", "")}
def fetch_remotive():
r = requests.get("https://remotive.com/api/remote-jobs",
params={"category": REMOTIVE_CATEGORY}, timeout=30)
r.raise_for_status()
for j in r.json().get("jobs", []):
yield {"id": f"remotive:{j['id']}", "source": "Remotive",
"title": f"{j['company_name']}: {j['title']}", "url": j["url"],
"text": f"Location: {j.get('candidate_required_location', '')}. "
f"Salary: {j.get('salary', '')}. {clean(j.get('description'))}"}
def fetch_hn():
api = "https://hn.algolia.com/api/v1/search_by_date"
r = requests.get(api, params={"tags": "story,author_whoishiring",
"query": "who is hiring", "hitsPerPage": 1}, timeout=30)
r.raise_for_status()
hits = r.json()["hits"]
if not hits:
return
story = hits[0]["objectID"]
r = requests.get(api, params={"tags": f"comment,story_{story}",
"hitsPerPage": 1000}, timeout=30)
r.raise_for_status()
for c in r.json()["hits"]:
if str(c.get("parent_id")) != story: # top-level comments only
continue
text = clean(c.get("comment_text"))
yield {"id": f"hn:{c['objectID']}", "source": "HN Who is hiring",
"title": text[:90], "url": f"https://news.ycombinator.com/item?id={c['objectID']}",
"text": text}
def is_new(job):
return db.execute("SELECT 1 FROM seen WHERE id=?", (job["id"],)).fetchone() is None
def mark_seen(job):
db.execute("INSERT OR IGNORE INTO seen VALUES (?, ?)", (job["id"], int(time.time())))
def prefilter(job):
blob = (job["title"] + " " + job["text"]).lower()
return any(k in blob for k in MUST_ANY) and not any(k in blob for k in SKIP_ANY)
def score(job, profile):
prompt = f"""You screen job listings for one candidate. Be strict and literal.
Candidate profile:
{profile}
Job listing ({job['source']}):
Title: {job['title']}
{job['text'][:MAX_DESC]}
Return JSON:
- score: 0-10 fit. 0-3 clear mismatch or a deal-breaker, 4-6 partial fit, 7-8 good fit, 9-10 excellent.
Any deal-breaker in the profile means score 3 or lower.
- reason: one short sentence, mention the main match or the main problem.
- location_ok: false if the listing restricts where the candidate must live or work
and the candidate does not qualify. true if open or not stated.
- salary: the stated pay exactly as written, or "" if not stated. Never guess."""
for attempt in range(3):
try:
resp = client.models.generate_content(
model=MODEL,
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=Verdict,
thinking_config=types.ThinkingConfig(thinking_level="minimal"),
),
)
if resp.parsed:
return resp.parsed, resp.usage_metadata
except errors.APIError as e:
print(f" API error {e.code} (attempt {attempt + 1})", file=sys.stderr)
time.sleep(5 * (attempt + 1))
return None, None
def send(job, v):
msg = (f"<b>{v.score}/10</b> · {html.escape(job['source'])}\n"
f"<b>{html.escape(job['title'])}</b>\n"
f"{html.escape(v.reason)}\n"
+ (f"💰 {html.escape(v.salary)}\n" if v.salary else "")
+ job["url"])
requests.post(f"https://api.telegram.org/bot{os.environ['TG_TOKEN']}/sendMessage",
json={"chat_id": os.environ["TG_CHAT"], "text": msg, "parse_mode": "HTML",
"link_preview_options": {"is_disabled": True}}, timeout=30)
def main():
profile = open("profile.txt", encoding="utf-8").read()
jobs, tok_in, tok_out, sent, scored = [], 0, 0, 0, 0
for fetch in (fetch_wwr, fetch_remotive, fetch_hn):
try:
jobs += [j for j in fetch() if is_new(j)]
except Exception as e: # one broken source shouldn't stop the rest
print(f"{fetch.__name__} failed: {e}", file=sys.stderr)
for job in jobs:
if prefilter(job):
v, usage = score(job, profile)
if v is None:
continue # not marked seen, retried next run
scored += 1
tok_in += usage.prompt_token_count or 0
tok_out += (usage.candidates_token_count or 0) + (usage.thoughts_token_count or 0)
if v.score >= MIN_SCORE and v.location_ok:
send(job, v)
sent += 1
mark_seen(job)
db.commit()
cost = tok_in / 1e6 * PRICE_IN + tok_out / 1e6 * PRICE_OUT
print(f"{len(jobs)} new, {scored} scored, {sent} sent. "
f"Tokens {tok_in} in / {tok_out} out, about ${cost:.4f}")
if __name__ == "__main__":
main()
A few design choices worth knowing:
- Jobs that fail the pre-filter are still marked as seen. Otherwise they’d be re-checked every run forever.
- Jobs where the AI call fails are not marked as seen, so the next run retries them.
location_okis a separate field, not just part of the score. Location restrictions are the most common reason a “perfect” listing is useless to someone outside the US or EU, and keeping it separate makes the rule easy to audit.- Salary is copied, never estimated. The prompt says “never guess” and the field is empty when nothing is stated.
Step 4: First run, then calibrate
The first run sees every listing currently in the feeds, so it will score more than usual. Run it once by hand:
python jobradar.py
The last line of output looks like this (your numbers will differ):
329 new, 134 scored, 9 sent. Tokens 138410 in / 13205 out, about $0.0745
Now spend 15 minutes on calibration. This is the step people skip, and it’s the step that makes the tool worth using:
- Open the messages in Telegram. Are they actually good? If several are senior roles you can’t get, add a firmer line to
profile.txt(“Reject any title containing Senior, Staff, Lead, Principal”). - Temporarily set
MIN_SCORE = 5, deleteseen.db, run again, and look at the 5s and 6s. If good jobs are landing there, your profile is too strict or the pre-filter keywords are too narrow. - Check the pre-filter: add
print(job["title"])in anelse:branch for jobs that failprefilter, and skim the list once. If real matches are being thrown away for free, widenMUST_ANY.
Two or three rounds is usually enough. Then put MIN_SCORE back to 7.
Step 5: Run it on a schedule
Every 6 hours is plenty. Remotive asks for a low request rate, and job listings don’t vanish within hours.
Linux / macOS (cron): run crontab -e and add:
0 */6 * * * cd /home/you/jobradar && GEMINI_API_KEY=... TG_TOKEN=... TG_CHAT=... .venv/bin/python jobradar.py >> radar.log 2>&1
Windows: in Task Scheduler, create a task that runs C:\path\to\jobradar\.venv\Scripts\python.exe with argument jobradar.py, “Start in” set to the project folder, triggered daily and repeated every 6 hours. Set the three variables as user environment variables first.
If your laptop is off at night, a small VPS (Hetzner, DigitalOcean and Vultr all have plans around $4–6/month) runs it reliably and can host your other scripts too. A Raspberry Pi at home works just as well.
What this realistically saves
Time your current routine for a week before you trust any before-and-after numbers. A common pattern looks like this:
| Before | After | |
|---|---|---|
| Daily time scanning boards | 20–40 min | 5–10 min reading Telegram |
| Listings looked at per day | 100+ titles, ~10 full posts | 3–10 pre-screened posts |
| Monthly cost | $0 | ~$1 AI + optional $4–6 VPS |
That’s roughly 8–15 hours a month back, which you can spend on what actually gets interviews: tailored applications, a portfolio piece, or a follow-up message.
What it doesn’t do is get you hired. It doesn’t increase the number of good jobs that exist, and it doesn’t write your application. Being early may help on boards where a listing gets hundreds of applicants, but there’s no solid public data showing that applying first changes your odds, so don’t count on it.
Can you sell this?
Some people run this for others: recruiters and small agencies tracking roles for several candidates, or bootcamps sending alerts to graduates. Technically it’s one profile.txt and one Telegram chat ID per person. If you do this, check each source’s terms for commercial use first. Remotive’s API notice forbids pushing its jobs to third-party sites and using them to collect sign-ups, and points commercial users to a paid private API (starting around $5k/month). Republishing listings in a paid product is a different thing from personal alerts. For most readers this is a personal tool, and that’s fine.
Reality check: what does NOT work
Trusting the score as a decision. The model reads a 2,500-character excerpt and makes a quick call. It will sometimes rate a great job a 6 because the listing is vague, or a bad one an 8 because it’s full of the right keywords. Use it to sort what you read, not to decide what you skip forever. Once a week, skim a board manually for 10 minutes as a sanity check.
A vague profile. “I’m a developer looking for remote work” gets you vague scores. The profile is the whole product. Deal-breakers written as explicit rules work much better than preferences.
Auto-applying. Don’t wire this up to send applications. Mass-sent generic applications get ignored at best, and several platforms ban accounts for automated submissions.
Scraping sites that forbid it. LinkedIn, Indeed and Glassdoor block bots and prohibit scraping in their terms. Stick to sources with RSS or a public API, and respect their rate limits and attribution rules.
Ignoring scams. Remote job boards attract fake listings: “data entry, $40/hour, no experience”, requests to pay for training or equipment, or interviews held only over chat. The AI doesn’t verify employers. If a listing asks you to pay anything, or moves to a messaging app before any video call, walk away.
Never checking the log. Feeds change URLs, APIs change fields, and Google renames models. If radar.log shows fetch_remotive failed or “0 new” for days, something moved. Check the log weekly. When the model changes, set the JOB_MODEL environment variable and redo the calibration step.
Expecting full coverage. Three sources is a slice of the market. Many companies only post on their own careers page or on LinkedIn. You can add more sources that offer RSS or a public API (many company career pages run on Greenhouse or Lever, which expose public job board APIs), but this will never see everything.
Final checklist
- Telegram bot created, test message received
-
profile.txtlists skills, location, time zone and explicit deal-breakers - First run done, scores calibrated against your own judgement at least twice
- Pre-filter checked so it isn’t silently dropping good matches
- Scheduled every 6 hours,
radar.logchecked after the first week - Weekly 10-minute manual skim of one board as a sanity check
Run it for two weeks alongside your normal routine. If the Telegram alerts contain every job you would have picked by hand, drop the manual scrolling and spend that time on applications instead.