GoodJob is a static technical job dashboard backed by employer-direct applicant tracking system feeds. It gives job seekers one place to search, compare, age, sort, and save listings without creating an account or sending personal activity to a server.
The project combines a scheduled Node.js data pipeline with a dependency-free browser application. Greenhouse, Lever, and Ashby listings are normalized into one public contract, committed to the repository, and served through GitHub Pages.
Job listings from employer career sites use different field names, date formats, salary structures, and location conventions. A useful dashboard needs consistent records without replacing the employer as the source of truth.
GoodJob is designed to:
- collect jobs from public employer ATS endpoints
- preserve canonical employer application links and source metadata
- present one predictable schema to the browser
- continue refreshing when one employer feed is unavailable
- keep filters, saved jobs, and browsing state on the user's device
- run as a static GitHub Pages project with no database or application server
The dashboard is aimed at software engineering, data, infrastructure, IT support, technical operations, business systems, product, security, and QA roles. The starter source list favors employers with relevant US, New York, hybrid, or remote opportunities.
flowchart LR
A["config/sources.json"] --> B["scripts/fetch-jobs.mjs"]
C["Greenhouse API"] --> D["Greenhouse adapter"]
E["Lever API"] --> F["Lever adapter"]
G["Ashby API"] --> H["Ashby adapter"]
D --> B
F --> B
H --> B
B --> I["Normalize and classify"]
I --> J["Deduplicate and sort"]
J --> K["Validate public schema"]
K --> L["public/data/jobs.json"]
K --> M["public/data/sources.json"]
K --> N["public/data/meta.json"]
L --> O["GoodJob browser UI"]
M --> O
N --> O
O --> P["localStorage"]
Q["GitHub Actions schedule"] --> B
The architecture has two boundaries:
- The data pipeline converts provider-specific responses into versioned public files.
- The browser reads those files and manages user state locally.
The browser never calls employer APIs directly. This keeps the user experience independent from cross-origin rules, provider latency, and temporary feed failures.
config/sources.json is the pipeline's source of truth. Each record identifies the employer, adapter, public endpoint, source tags, and career homepage.
{
"id": "company_greenhouse",
"name": "Company Name",
"adapter": "greenhouse",
"url": "https://boards-api.greenhouse.io/v1/boards/company/jobs?content=true",
"enabled": true,
"tags": ["Software", "Data"],
"homepage": "https://company.com/careers"
}The committed starter configuration contains 12 reviewed employers across all three supported adapters.
scripts/fetch-jobs.mjs fetches enabled sources concurrently with a 30-second request timeout and retry handling. Each adapter translates its provider's response:
| Adapter | Public API | Primary provider fields |
|---|---|---|
| Greenhouse | Job Board API | id, title, location, offices, departments, content, absolute_url, updated_at |
| Lever | Postings API | id, text, categories, workplaceType, descriptionPlain, hostedUrl, createdAt |
| Ashby | Job Posting API | id, title, location, department, employmentType, descriptionHtml, jobUrl, publishedAt |
Adapter failures are contained per source. A failed source produces an error record in public/data/sources.json; successful sources continue through the pipeline.
Every accepted listing is converted to the GoodJob contract:
{
"id": "job-0123456789abcdefabcd",
"externalId": "1234567",
"title": "Business Systems Analyst",
"company": "Company Name",
"location": "New York, NY",
"workMode": "Hybrid",
"employmentType": "Full-time",
"seniority": "Mid-Level",
"salaryMin": 95000,
"salaryMax": 118000,
"salaryText": "$95,000 - $118,000",
"postedDate": "2026-06-30",
"source": "Greenhouse",
"sourceId": "company_greenhouse",
"sourceAdapter": "greenhouse",
"category": "Business Systems",
"tags": ["SQL", "Systems", "Analytics"],
"description": "Short plain-text role summary.",
"applyUrl": "https://company.com/careers/job/1234567",
"fetchedAt": "2026-06-30T13:00:00.000Z"
}Normalization applies the following rules:
- IDs use a deterministic SHA-256 digest of the source ID plus ATS job ID or canonical URL.
- HTML is removed from descriptions and whitespace is normalized.
- Card descriptions are shortened to a fixed readable length.
- Tracking parameters are removed from application URLs when safe.
- Dates become
YYYY-MM-DD; missing or invalid dates becomenull. - Salary values remain numbers or
null; the original matching salary text is retained. - Hourly-looking values are not promoted into annual salaries.
- Work mode, employment type, seniority, category, and technical tags are inferred from provider fields and job text.
- Unknown values remain explicit rather than being invented.
The pipeline checks three identities in order:
- canonical application URL
- source ID plus external ATS job ID
- normalized title plus company plus location
The first accepted record owns those identities. Final output is sorted newest first. Records without a usable date appear after dated records.
The pipeline validates the complete dataset before replacing public output. Validation covers:
- required strings
- stable and unique IDs
- valid source references
- allowed classification values
- valid application URLs
- date shape
- numeric or
nullsalary fields - tag arrays
The run fails when every enabled source fails or when the final dataset violates the contract. Existing committed public data remains available if a failed run cannot produce valid replacement files.
The pipeline writes three files consumed by the site and refresh workflow.
The normalized job array used by the dashboard.
Operational status for every configured source:
{
"id": "company_greenhouse",
"name": "Company Name",
"adapter": "greenhouse",
"enabled": true,
"tags": ["Software", "Data"],
"homepage": "https://company.com/careers",
"ok": true,
"jobsFetched": 42,
"lastFetchedAt": "2026-06-30T13:00:00.000Z",
"error": null
}Dataset-level refresh information:
{
"generatedAt": "2026-06-30T13:00:00.000Z",
"jobCount": 1234,
"sourceCount": 12,
"okSourceCount": 11,
"failedSourceCount": 1
}Raw API responses and local snapshots are written under data/ for inspection during development. JSON files in those directories are ignored by Git. Only normalized public data is committed.
GoodJob keeps the existing static dashboard in index.html, script.js, and styles.css. There is no frontend build step and no runtime package dependency.
The application loads data in this order:
public/data/jobs.jsonpublic/data/meta.json- root
jobs.jsononly when live generated jobs cannot load
Root jobs.json is a demo recovery dataset, not the primary source.
The dashboard supports:
- keyword and location search
- work mode, employment type, seniority, category, source, and tag filters
- minimum salary and maximum age filters
- newest, oldest, salary, company, and title sorting
- expandable job details
- copied links to stable job card IDs
- saved jobs and restored controls through
localStorage - loading, empty, live-data error, and demo-fallback states
- a visible generated-data timestamp
| Status | Age |
|---|---|
| New | 0 to 3 days |
| Fresh | 4 to 7 days |
| Aging | 8 to 21 days |
| Old | 22 or more days |
| Unknown | No usable provider date |
Unknown dates do not enter the average-age calculation. Date and salary sorts place unknown values after known values.
GoodJob stores these keys in the current browser:
| Key | Purpose |
|---|---|
goodjob.filters.v1 |
active filters and search values |
goodjob.sort.v1 |
selected sort |
goodjob.saved.v1 |
stable IDs of saved jobs |
This state is not committed, uploaded, or shared between devices. Clearing site data removes it.
GoodJob sends lightweight custom events through the existing GA4 tag. analytics.js owns the trackEvent(eventName, params = {}) adapter. Calls safely stop when GA4 is blocked or unavailable. Every event includes app_name: "GoodJob" and the current app_version.
| Event | Trigger | Parameters |
|---|---|---|
app_loaded |
Live data, demo fallback, or failed load completes | total_jobs, data_updated, load_status |
job_search |
Debounced keyword search change | search_length, results_count |
filter_applied |
Location, work mode, employment type, seniority, category, minimum salary, job age, source, or tag changes | filter_type, filter_value, active_filter_count, results_count |
filters_cleared |
Any clear-filters control is used | previous_filter_count, results_count |
sort_changed |
Sort order changes | sort_value, results_count |
job_card_opened |
A job card expands or a linked card is selected | job_id, company, source, work_mode, job_age_days, available salary bounds |
job_saved |
A job is saved or unsaved | job_id, company, source, saved_state, saved_jobs_count |
apply_click |
An outbound employer link is clicked | job_id, company, source, work_mode, job_age_days |
Search text is never sent. Location input is reported only as set or cleared; typed location text is not sent. Notes, resumes, email addresses, and other personal input are outside the event contract. apply_click can be marked as a GA4 key event later.
- Run GoodJob through a local web server.
- In browser console, run
localStorage.setItem("DEBUG_ANALYTICS", "true"), then reload. - Use search, filters, sort, card, save, and apply controls.
- Confirm
[GoodJob analytics] sententries in browser console. - Open GA4 Admin, then DebugView, and confirm event names and parameters.
- Open GA4 Reports, then Realtime, and confirm events arrive from the active page.
- Disable local debug mode with
localStorage.removeItem("DEBUG_ANALYTICS"), then reload.
Ad blockers may stop network delivery. App behavior should remain unchanged when delivery is blocked.
GoodJob accepts employer-direct public ATS APIs only. It does not scrape Indeed, LinkedIn, Google Jobs, ZipRecruiter, Glassdoor, or another aggregator.
Each public job retains its source adapter, configured source ID, and employer-controlled application URL when supplied by the feed.
Public source data is committed under public/data/. Personal browsing state stays in localStorage.
Candidate scoring and private profile criteria are outside the public application. Local criteria belong in the ignored config/profile.local.json; automation secrets belong in GitHub Actions secrets. The public fetch script does not read or publish a candidate profile.
.github/workflows/refresh-jobs.yml runs four times per day:
- cron: "12 5,11,17,23 * * *"The workflow can also run manually from the Actions tab. It:
- checks out the repository
- installs Node.js
- runs
npm run fetch:jobs - runs
npm test - commits changed files under
public/data/ - pushes the refresh commit to
main
One employer outage does not fail the workflow. An all-source outage or schema failure does.
GoodJob is served from the root of main through GitHub Pages. Relative asset and data paths keep the site valid under the /GoodJob/ project path.
npm test uses Node's built-in test runner with no test framework dependency.
The suite covers:
- generated file presence and metadata counts
- required fields and enum values
- URL, date, salary, source, and unique-ID validation
- newest-first output with unknown dates last
- age-band boundaries and unknown-age behavior
- average age with missing dates
- filters against normalized live records
- salary and date sorting with
nullvalues - stable IDs as browser save keys
- adapter normalization and deduplication
.github/workflows/checks.yml runs the suite for pushes to main and pull requests.
- Confirm that the URL is an employer's public Greenhouse, Lever, or Ashby JSON endpoint.
- Confirm that returned jobs link to employer-controlled postings.
- Add a unique source record to
config/sources.json. - Run the local refresh and tests.
- Inspect the source summary and a sample of normalized records.
npm run fetch:jobs
npm test
jq '.[] | select(.id == "company_greenhouse")' public/data/sources.json
jq '.[] | select(.sourceId == "company_greenhouse")' public/data/jobs.json | headDo not add an aggregator URL, HTML scraper, private profile, API credential, or candidate targeting rule to the public source configuration.
Requirements:
- Node.js 22 or newer
- Python 3 for the example static server
npm install
npm run fetch:jobs
npm test
python3 -m http.server 8000Open http://localhost:8000.
When serving from the parent directory to reproduce the GitHub Pages project path, open:
http://localhost:8000/GoodJob/
.
├── .github/workflows/
│ ├── checks.yml
│ └── refresh-jobs.yml
├── config/
│ ├── profile.example.json
│ └── sources.json
├── data/
│ ├── raw/
│ └── snapshots/
├── public/data/
│ ├── jobs.json
│ ├── meta.json
│ └── sources.json
├── scripts/
│ └── fetch-jobs.mjs
├── tests/
│ ├── analytics.test.js
│ └── goodjob.test.js
├── analytics.js
├── index.html
├── jobs.json
├── script.js
└── styles.css
- Greenhouse exposes
updated_at, which may represent an update rather than the original publication date. GoodJob uses the provider timestamp and does not claim greater precision. - Employment type is often absent from public ATS responses. Missing values remain
Unknown. - Salary extraction is conservative and cannot interpret every compensation format.
- Classification is deterministic keyword inference, not a claim about the employer's internal job family.
- The dashboard renders the committed snapshot. Listings can change or close between refreshes.
- Apply pages remain the authority for availability, compensation, location, and requirements.