Skip to content

About

Customized monitoring of all job updates (SWE, SDE, Research, etc.)

Resources

Stars

47 stars

Watchers

2 watching

Forks

Repository files navigation

job-monitor

CI job-monitor Python Dependencies

Why

I got tired of refreshing a pile of careers pages, so I wanted a job notifier that does the filtering for me. Most companies serve their open roles from a public JSON API (the same one their careers page calls). This subscribes to those feeds, keeps only the roles that match my filters, remembers what it has already seen, and pings me on Discord when a new one shows up.

Description

A job notifier I built to filter openings the way I want and get a Discord ping the moment something matches. It polls company career APIs on a schedule and only sends postings that are new and match my keywords and locations. The monitor has no third-party dependencies; Python 3.8+ standard library only. A separate /linkedin Discord command searches LinkedIn on demand (see below).

Live jobs page: all currently-open matches, refreshed on each scheduled run — capy-zhiao.github.io/Job_Monitor

Search across every source, filter by role category (Security / AI-ML / SDE / Research), country (Canada / US), work type (full-time / intern), city, and recency, and star / mark-applied any posting (kept in your browser). A one-line summary sits above the list, which is grouped by the day each role was posted (or first seen, when the source gives no date). Same data as the Discord alerts, browsable.

live jobs page

Supported platforms

Type Endpoint Example companies
greenhouse boards-api.greenhouse.io/v1/boards/<board>/jobs Stripe, Datadog, Coinbase, Okta
lever api.lever.co/v0/postings/<company> Palantir, Waabi, Magnet Forensics
ashby api.ashbyhq.com/posting-api/job-board/<board> OpenAI, Cohere, 1Password, Vanta
amazon amazon.jobs/en/search.json Amazon
workday <host>/wday/cxs/<tenant>/<site>/jobs TD, CrowdStrike, Trend Micro, Arctic Wolf
phenom <host>/widgets RBC, eBay
eightfold <host>/api/apply/v2/jobs Netflix
recruitee <board>.recruitee.com/api/offers/ Huawei Canada
google Careers batchexecute RPC (r06xKb), JSON behind the results page Google
microsoft apply.careers.microsoft.com/api/pcsx/search (Eightfold) Microsoft
apple jobs.apple.com/api/v1/search Apple
shopify Shopify Careers page (HTML scrape) Shopify
github_listings listings.json from community new-grad list repos SimplifyJobs/New-Grad-Positions, vanshb03/New-Grad-2027
github_tables markdown tables (no JSON) in community list repos speedyapply/2027-SWE-College-Jobs (international new-grad and internship lists)
workable apply.workable.com/api/v1/widget/accounts/<account> Genetec, Valsoft
bamboohr <company>.bamboohr.com/careers/list BinSentry, EUNA
rippling ats.rippling.com/api/v2/board/<board>/jobs D-Wave, nesto, Webull
icims_portal classic careers-<company>.icims.com search pages (HTML) Kinaxis
jobvite jobs.jobvite.com/<company>/jobs (HTML) Absolute Security
successfactors <host>/services/rss/job/ (jobs2web RSS) SAP
icims <host>/api/jobs (iCIMS Career Sites / Jibe) AMD
oracle Oracle Recruiting Cloud recruitingCEJobRequisitions REST Fortinet, Uber
pcsx Eightfold /api/pcsx/search Ericsson
dayforce <region>.dayforcehcm.com/Api/<client>/V1/JobFeeds eSentire
smartrecruiters api.smartrecruiters.com/v1/companies/<X>/postings ServiceNow
jazzhr <board>.applytojob.com/apply (HTML) Xanadu
ibm www-api.ibm.com/search/api/v2 (Elasticsearch proxy) IBM

Project layout

job_monitor/
├── run.py                     # entry point: python3 run.py
├── config.json                # sources, keywords, locations
├── pyproject.toml
├── jobmonitor/
│   ├── cli.py                 # argument parsing + orchestration
│   ├── fetchers.py            # one adapter per platform + FETCHERS registry
│   ├── filters.py             # title/location matching
│   ├── notify.py              # Discord / Slack webhooks
│   ├── state.py               # seen.json persistence
│   ├── http.py                # urllib helpers
│   └── models.py              # Job dataclass
├── tests/                     # unittest suite (offline, HTTP mocked)
└── .github/workflows/monitor.yml

Usage

# Preview: fetch and print matches, send nothing, write nothing
python3 run.py --dry-run

# Live run. First run seeds state silently; later runs notify on new jobs only.
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/xxxx/yyyy"
python3 run.py

# Force notifications on the first run too
python3 run.py --notify-first

Create a Discord webhook via: channel settings, Integrations, Webhooks, New Webhook, Copy URL. For Slack, create an Incoming Webhook and set SLACK_WEBHOOK_URL. If both are set, both receive notifications.

example Discord notification

Configuration (config.json)

Three global fields plus a list of sources:

  • keywords: case-insensitive substring match on the job title. A title matches if it contains any keyword. A year keyword ("2027") only counts when the title also names technical work, so "Finance Intern - Winter 2027" stays out.
  • exclude_keywords: if the title contains any of these, the job is dropped even when a keyword matched. Independently of this list, a level above entry anywhere in the title ("Software Engineer II - Backend", "Cloud Engineer 2") drops the job unless the title also says new grad, graduate, university or early career, or a keyword names that level itself.
  • locations: a job is kept only if its location contains one of these terms. An empty list disables location filtering.
  • sources: one entry per feed. Any source can override keywords, exclude_keywords, or locations just for itself.

Customizing it for your own search

Add a company. Find which platform it uses by opening its careers page and watching the network requests (F12). A request to boards.greenhouse.io/embed/job_board?for=<x> means Greenhouse (board <x>), jobs.lever.co/<x> means Lever, <x>.myworkdayjobs.com means Workday, and jobs.ashbyhq.com/<x> means Ashby. Then add one entry to sources. For most platforms it is a single line:

{ "type": "greenhouse", "name": "Stripe", "board": "stripe" }

Workday needs a bit more (host, tenant, site), and you should give it a country plus a client-side location filter:

{
  "type": "workday",
  "name": "TD",
  "host": "td.wd3.myworkdayjobs.com",
  "tenant": "td",
  "site": "TD_Bank_Careers",
  "search": "software engineer",
  "country": "CAN",
  "pages": 3
}

Change what counts as a match. Edit keywords to describe the roles you want (for example "new grad", "software engineer i,", "security analyst") and exclude_keywords to cut the noise (for example "senior", "staff", "manager", "intern"). Matching is on the title only.

Change where. Edit locations to the cities or countries you care about (for example "toronto", "vancouver", "canada"). Leave it empty to allow anywhere.

Filter a single company differently. Put keywords or locations inside a source to override the global lists there. This is handy when one company uses odd titles, for example a security-only search at Amazon:

{
  "type": "amazon",
  "name": "Amazon Security",
  "query": "security engineer",
  "countries": ["CAN"],
  "keywords": ["security engineer i,", "new grad", "early career"]
}

Subscribe to the GitHub new-grad lists. The github_listings type reads the structured listings.json that the community-maintained new-grad repos generate their README tables from, so every posting the crowd tracks lands in the same Discord channel as the company feeds. One source entry covers any number of list repos; postings appearing in several lists are de-duplicated by apply URL, listings marked as requiring U.S. citizenship are dropped at the source, and max_age_days keeps the initial backfill sane. Roles marked "Offers Sponsorship" get a visa tag on the page, but the lists rarely fill this field in: of about 19,000 SimplifyJobs entries in October 2026, only 12 said they sponsor and 15 that they do not.

{
  "type": "github_listings",
  "name": "GitHub New-Grad Lists",
  "urls": [
    "https://raw.githubusercontent.com/SimplifyJobs/New-Grad-Positions/dev/.github/scripts/listings.json",
    "https://raw.githubusercontent.com/vanshb03/New-Grad-2027/main/.github/scripts/listings.json"
  ],
  "max_age_days": 30
}

Some lists publish only a markdown table. The github_tables type parses the | Company | Position | Location | Posting | Age | rows of speedyapply/2027-SWE-College-Jobs (NEW_GRAD_INTL.md and INTERN_INTL.md, filtered to Canada, plus NEW_GRAD_USA.md, filtered to the same US tech cities as the other list source). Rows that don't parse are skipped, as are roles that require a security clearance or U.S. citizenship, and max_age_days is applied to the age column. The tables have no sponsorship field, so unlike github_listings this source cannot drop U.S. roles that offer no sponsorship.

Note the branch in each raw URL (Simplify uses dev, not main). If a list starts returning 404, check whether the repo moved the file or renamed its default branch.

Big-tech direct sources. Microsoft, Apple, and Google don't use a standard ATS, so each has a dedicated fetcher (verified 2026-08). Quirks worth knowing when they break:

  • microsoft — Eightfold PCSX API, GET with query/location params. Page size is pinned to 10 (num below 10 is ignored). The pre-2026 gcsservices.careers.microsoft.com endpoint is dead (stale TLS cert).
  • apple — POST api/v1/search; the body must include the sort and format keys or the API silently returns zero results. Multi-location postings repeat per location; the fetcher dedupes on positionId.
  • google — the careers page is a JS app; jobs come from a batchexecute RPC with a double-encoded f.req form field and a )]}' response prefix. The old target_level=EARLY server-side filter is defunct; entry-level filtering is client-side on titles like every other source.
  • Workday tenants with custom facet GUIDs (e.g. NVIDIA's locationHierarchy1) can pass them via a raw facets object on the source entry.
  • Workday lists a multi-location posting as "2 Locations". For postings whose title matched, resolve_workday asks the job's detail endpoint for the real places (and exact posting date) before the location filter runs.

Overlapping queries (e.g. "Microsoft" + "Microsoft (Security)") may return the same posting; collect_matches dedupes globally on uid. The same posting also arrives from its company's feed and from community lists under different uids, so the run then dedupes on the posting URL (tracking parameters dropped), keeping the company's copy, and records seen URLs as well as uids. A repost gets a new URL and alerts again.

Posting dates come from each API where it has one (Greenhouse first_published, Lever createdAt, Ashby publishedAt, Workday's "Posted 3 Days Ago", the lists' date_posted, and so on; about nine in ten postings have one). The rest fall back to the day the monitor first saw them.

A good loop is: run python3 run.py --dry-run, look at how many jobs each source returns and how many match, then tighten or loosen the keywords until the matches are the ones you would actually apply to. Only then schedule it.

How to test if it is still running: tail -3 /tmp/job_monitor.log

Tests

The suite uses the standard-library unittest and mocks the HTTP layer, so it runs offline with no dependencies. Run it from the project root:

cd job_monitor          # the directory containing run.py and tests/
python3 -m unittest discover -s tests -v

It covers the title/location filters, the seen.json state round-trip, each platform's response parsing and posting dates, URL dedupe, and the source-health rules.

Scheduling

Local cron (requires the machine to be awake):

*/15 * * * * DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/xxxx/yyyy" /usr/bin/python3 /path/to/job_monitor/run.py >> /tmp/job_monitor.log 2>&1

GitHub Actions (recommended, runs without your machine): push this repo to your own GitHub repository, add DISCORD_WEBHOOK_URL as a repository secret, and the included .github/workflows/monitor.yml runs on a 15-minute cron and commits seen.json back to persist state. GitHub delays scheduled runs on busy days; this repo's runs usually land a few hours apart. The commit step rebases onto main before pushing, so a push made during a run does not lose that run's state.

Source health. Each run records every source's result in health.json. A source that fails three runs in a row, or returns no postings at all for three days, is reported once to the Discord channel, and again when it recovers. The jobs page footer shows how many sources responded on the last run.

Public jobs page

The Actions run also emits every currently-matched posting to docs/jobs.json (via run.py --emit-json), which docs/index.html renders as a static, searchable page served by GitHub Pages: search box, per-company filter, and postings grouped by the day they were posted (first seen, when the source has no date). Discord is where new matches arrive; the page is the browsable full picture. A first_seen.json sidecar keeps stable discovery dates across runs.

Each row shows the company's icon. Most postings link to an applicant tracking system rather than the company's site, so company_domains.json maps company names to their domains by hand; a company missing from it shows its initial instead of a guessed (possibly wrong) logo. The run fetches each mapped domain's favicon once into docs/logos/, so the page serves the icons itself.

On-demand LinkedIn search

LinkedIn isn't one of the scheduled sources: it blocks datacenter IPs like GitHub's runners, and its terms don't allow automated scraping. Instead, a small Discord bot answers /linkedin in the alerts channel by searching LinkedIn on the spot from a home connection and posting the entry-level results the scheduled monitor isn't already reporting. It keeps no state and never writes to the repo.

/linkedin                                   # 11 preset searches (security, software, research), Canada, last 24 hours, 3 pages each
/linkedin keywords:"backend engineer"       # any search

/linkedin results in Discord

The reply header counts what was found: new results, ones the scheduled monitor already reports, and ones dropped as senior or posted by a staffing agency. Setup is in discord_bot/.

How state works

seen.json is a flat list of job identifiers and posting URLs (url: entries). Both are only ever added, so a posting that disappears from a feed and later returns will not notify twice. Delete seen.json to reset (the next run becomes a fresh first run).

About

Customized monitoring of all job updates (SWE, SDE, Research, etc.)

Resources

Stars

47 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages