PostulaDo
The problem is not learning a tool: it is learning the right one. PostulaDo takes a batch of postings, groups them by the kind of work they share, extracts the disciplines they cover with their real coverage over that batch, and returns alternatives for each so nobody is locked into one vendor. NestJS backend with Prisma over PostgreSQL, BullMQ queues on Redis and structured extraction with SGLang; a static Next.js frontend talks to the API directly.
- Role
- Sole author — backend and frontend
- Status
- Public reference implementation. Open source.
Stack
- Framework
- NestJS · Next.js · TypeScript
- Data
- Prisma · PostgreSQL · Redis
- Analysis
- SGLang (XGrammar, RadixAttention) · Cheerio
- Domain
- Taxonomía de 8 oficios con alternativas por tema
- Queues
- BullMQ · Redis
- Security
- JWT · Passport · guards por rol · ownership checks
- Testing
- e2e contra PostgreSQL y Redis reales en Docker Compose
Architecture
Batch of URLs
│
▼
┌─────────────────────────────────────────────┐
│ NestJS API │
│ ─ AuthModule (JWT + Passport + Roles) │
│ ─ UsersModule (ownership por recurso) │
│ ─ PostulacionesModule (CRUD + lote) │
│ ─ AnálisisModule │
│ ├─ BullMQ producer (URL → job) │
│ └─ BullMQ worker │
│ ├─ Scraping con Cheerio │
│ └─ Extracción estructurada SGLang │
│ ─ Taxonomía (8 oficios + alternativas) │
└─────────────────────────────────────────────┘
│ │
▼ ▼
PostgreSQL Redis + BullMQ
(Prisma) │
▼
Inferencia SGLang
┌─────────────────────────────────────────────┐
│ Frontend Next.js (estático) │
│ encolar → poll → guardar → analizar el lote │
└─────────────────────────────────────────────┘ Problem
Studying without knowing what is being asked for is expensive. A junior opens ten postings, sees ten different tool lists and cannot tell whether 10% or 70% of what they study will matter. The market information is not hard to find: it is scattered across every posting, and scattered it says nothing.
The challenge was not to summarise one posting: it was to compare a whole batch and derive the study plan from it. And to do it without lying —every percentage has to be auditable against the postings that produced it.
Approach
A NestJS API with layered architecture. The auth layer handles JWT through Passport, role guards and an ownership check on every per-user resource: roles and ownership are separate axes, and an admin is not the owner of a record.
The key is a taxonomy: 36 technologies mapped to 8 disciplines —testing, delivery, cloud, containers, data, caching, backend, frontend— and each discipline with the alternatives that cover it. The plan does not say "learn AWS": it says "cloud deployment" and offers AWS, Azure, GCP and Cloudflare. The denominator is always the batch, not the user's whole history: a percentage without the set it belongs to means nothing.
And everything that does not need to be in the request path —scraping, extraction, inference— goes to a BullMQ queue on Redis. The frontend is a static Next.js app that enqueues, waits and queries the batch analysis; there is no proxy: it talks to the API directly.
Result
The user pastes five postings and gets a plan: the kind of work they share, the disciplines that cover them with their coverage over that batch, and how to enter each. Not thirty-six tools: the few themes that actually matter, with alternatives so nobody is locked to a vendor.
The analysis runs in the background: the frontend enqueues, keeps working and polls the status until the batch is ready. And the landing is static —it ships inside this same portfolio— so it does not need its own server to exist.
Known limits
The taxonomy is a fixed table: 36 technologies mapped to 8 disciplines. It does not discover new disciplines, and a technology that is not in the table enters no coverage. That is deliberate —an auditable plan is worth more than an invented one— but it is a real limit.
The kind of work is detected by the signature of themes, not by a model: it is heuristic and can misname the discipline even when the percentages are right.
Scraping is still plain text from a public URL. With no headless browser, listings that only exist behind client-side JavaScript are not read.