Aggregates scattered scholarship/hackathon/exam announcements into a searchable dashboard with one-click Google Calendar deadline sync.
student-tracker/
├── backend/
│ ├── src/
│ │ ├── models/
│ │ │ ├── Opportunity.js # unified schema for all scraped listings
│ │ │ └── ScrapeLog.js # per-run logging for debugging silent scrape failures
│ │ ├── scrapers/
│ │ │ ├── sources/ # ONE FILE PER SOURCE — isolated, swappable
│ │ │ │ ├── example-rss-source.js # still a placeholder template
│ │ │ │ └── devpost-hackathons.js # REAL — live hackathon data, no key needed
│ │ │ ├── normalizer.js # maps any adapter's raw output -> unified schema, upserts
│ │ │ └── runner.js # node-cron entrypoint, orchestrates all adapters
│ │ ├── routes/
│ │ │ └── opportunities.js # GET /api/opportunities, /search, /:id
│ │ ├── utils/
│ │ │ └── normalize.js # dedup key generation, chrono-node date parsing
│ │ └── server.js # Express app entrypoint
│ ├── scripts/
│ │ ├── sample-raw-data.js # messy realistic sample data (incl. deliberate dupes)
│ │ └── dry-run-seed.js # validates normalize/dedup logic w/o DB or network
│ ├── package.json
│ └── .env.example
└── frontend/
├── src/
│ ├── components/
│ │ ├── OpportunityCard.jsx # card w/ category badge + Calendar button
│ │ └── FilterBar.jsx # search + category filter controls
│ ├── hooks/
│ │ ├── useOpportunities.js # fetches active listings once
│ │ └── useFilteredOpportunities.js # pure client-side filter/sort
│ ├── utils/
│ │ └── googleCalendar.js # generates "quick add" GCal links (no OAuth needed)
│ ├── App.jsx
│ └── main.jsx
├── package.json
└── .env.example
cd backend
cp .env.example .env # then edit MONGO_URI if not using local default
npm install
npm run dev # starts Express API on :5000Run a one-off scrape (useful while developing adapters):
npm run scrape -- --onceOr let it run on the daily cron schedule (default 3 AM):
npm run scrapecd frontend
cp .env.example .env
npm install
npm run dev # starts Vite dev server on :5173Before wiring up a real MongoDB instance, you can sanity-check the normalize → dedup → save logic against realistic messy sample data:
cd backend
node scripts/dry-run-seed.jsThis exercises the same dedup-key and date-parsing logic as production code against sample data that includes a duplicate posting (same scholarship from two "sources") and a deliberately garbled date, and prints what would be saved, merged, or skipped. It's a fast way to confirm the pipeline behaves correctly before you touch a real database.
Note: this script uses a small inline fallback date parser instead of
chrono-node(since that requiresnpm install). Production code (src/utils/normalize.js) uses the realchrono-nodeparser, which handles a much wider range of messy real-world date formats — don't copy the fallback into production.
Replace the example adapters with real sources— done:devpost-hackathons.jspulls real, live hackathon listings with no API key needed.example-rss-source.jsis still a placeholder — swap it for a real scholarship/exam source next (e.g. a government portal's RSS feed, once you find one that's actually live).- Run
npm run scrape -- --onceand check Compass — you should now see real hackathon documents in theopportunitiescollection. - Add more adapters incrementally — one file + one line in
runner.jseach time. - Once you have real users, consider upgrading Calendar sync from the
current "quick add" link (no auth) to full OAuth + Calendar API
(
calendar.events.insert) for true one-click sync without a redirect tab. - If your active listing count grows past what's comfortable to ship to the
browser (~5–10k documents), switch the frontend to use the
/api/opportunities/searchendpoint (MongoDB text index) instead of client-side filtering on the full dataset. - Consider adding a PDF-text-extraction step (
pdf-parse) to the ingestion pipeline — many government scholarship notices are PDF-only, not HTML/RSS.
- Adapters stay dumb. Each source file in
scrapers/sources/only fetches and returns raw data — it never touches the database.normalizer.jsis the single place that maps raw shapes to the unified schema and upserts. This means a broken adapter never corrupts the DB layer, and you can unit-test adapters in isolation. - Dedup, don't just insert. The same scholarship often gets reposted
across multiple aggregator sites. Every listing gets a
dedupKey(hash of normalized title+organization+deadline) and upserts use that key. - Don't delete expired listings.
isActiveflips tofalseonce the deadline passes (seedeactivateExpired()innormalizer.js). This keeps history for future features (e.g. "here's what was posted around this time last year") without needing separate archival logic. - Calendar sync ships without OAuth for MVP. The Google Calendar "quick
add" URL scheme (
calendar.google.com/calendar/render?...) requires zero backend auth setup and satisfies the one-click requirement. Swap to the real Calendar API only once you have a concrete reason (e.g. users wanting sync without leaving the page).