Noria: a hexagonal pipeline that scores job and scholarship links
An agentic extractor for messaging platforms. Noria receives messages & URLs via chat; scrapes web data to evaluate the content against custom scoring matrices (e.g., jobs, scholarships); and sends a structured summary with a calculated match score directly to the assigned messenger number.
At a glance
- What is it?
- Noria is a JavaScript and Python system that receives links over WhatsApp, scrapes them, scores them against a profile with an LLM, and sends a formatted verdict back to the chat. It suits a person tracking opportunities, and it is a single user tool that trusts the pages it is asked to read.
- Who is it for?
- Adopt Noria if you are personally tracking job or scholarship listings and want the triage done for you, because the scoring matrix and the message template live in a domain module you can edit without touching the plumbing, and the LLM fallback chain means a single provider outage does not stop the pipeline.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 93 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A link arrives in a chat, and a verdict goes back to it
Noria is an agentic extractor for messaging platforms. The description is plain about the loop: it receives messages and URLs via chat, scrapes web data to evaluate the content against custom scoring matrices such as jobs and scholarships, and sends a structured summary with a calculated match score directly to the assigned messenger number.
The intent behind that loop is stated in the vision section. Opportunities define careers, yet discovery remains a chaotic manual process, and the aim is to crawl raw web pages, run AI evaluations against a human profile, and send real-time alerts. The example use cases given are helping students secure fully funded academic scholarships and matching developers with remote job postings.
The control surface is a Streamlit app called the Control Center, with views for profiles, scans and settings, and a dark theme stylesheet. The screenshots show an applicant profiles directory, an active scanning operations screen with predefined pipelines and a live opportunity feed, a profile editor where the LLM provider chain, API keys, applicant parameters and notification targets are configured, and a settings page with a Windows startup toggle, local data retention and a full application reset.
So the operator experience is a desktop control panel, and the automation is a chat listener. That split is the design: a Streamlit app for setup, a WhatsApp connection for the actual work.
The pipeline is one sequence with a cache in front of it
The request flow diagram is the clearest description of what the system does, and it is worth reading step by step because the order encodes the design decisions.
A target chat, a WhatsApp group or a direct chat, shares a raw URL message. The listener emits a link_extracted event carrying the URL, and the core orchestrator checks a memory cache for processing duplicates. On a cache hit the URL evaluation is skipped entirely. On a cache miss the scraper adapter runs, using Jina or Puppeteer, and returns extracted web text.
The orchestrator then validates the size and contents of that text, emits a scraper:success event, and builds the prompt through the domain promptBuilder. The LLM adapter is called through a fallback chain, Gemini to Groq, and returns match results and a verdict.
The branch on the score is the whole point of the system. A match score of 50 or more is high alignment, and the orchestrator emits analyzer:match_found, formats the message with the domain templateBuilder, and calls sendMessage on the WhatsApp sender, which delivers a markdown message alert to the target chat and emits notifier:send. A score below 50 emits analyzer:no_match and stops there.
The threshold is a hardcoded 50 in the flow, and it is a number you would want to change, which is exactly what the domain layout is arranged to let you do.
Hexagonal boundaries, enforced by a registry at boot
Noria is built on hexagonal architecture with dependencies flowing strictly inward, and the composition root is a single file, src/config/plugins.registry.js, which acts as a central registry bootstrapper.
The layout separates four concerns. src/domains/ holds pure business logic, currently the scholarships and jobs domains, each of which owns an evaluation prompt and a layout template. src/core/ holds the orchestrator and the contracts, with pipeline.js as the sequential orchestrator, registry.js as the functional registry, and events.js for domain events. src/infrastructure/ holds stateless adapters, the scraper set of Jina, Puppeteer and a resilient fetch, the LLM adapters for Gemini and Groq with the fallback chain, and the messaging adapters for the WhatsApp sender and listener. The UI layer is app.py plus ui/views/ and ui/styles.py.
What makes this more than a folder layout is that the registry validates modules functionally at boot rather than trusting that they were wired correctly. The enforced interface constraints are a table of required signatures: a domain object must provide buildPrompt, resolveProfile, buildTemplate and schema, a scraper must provide scrape, an LLM adapter must provide generateStructuredData, a sender must provide sendMessage, and a listener must be a constructor class with initialize(), an on(event, cb) method and close().
A module that does not match fails at startup rather than at the first message, which is the difference between a registry that documents intent and one that enforces it. The composition root wires the registry to the domains and the infrastructure, and the pipeline talks to the registry and to events, never to an adapter directly.
Running it: a JavaScript core, a Streamlit front end, a Makefile
The repository is a hybrid project, JavaScript for the core and Python for the control centre, and the build tooling reflects that.
The npm side has a small set of scripts, and the Makefile wraps them:
npm installnpm startnpm testThe Makefile adds a setup target that installs dependencies, runs the prepare script and copies the example environment file to .env, then prints a message telling you to configure .env and run make dev. There are separate targets for lint, format, test with coverage, building for production, and a build-exe target that produces a standalone Noria.exe on Windows by rendering the logo, converting it to an ico and embedding it.
The Docker path builds an image with docker build -f docker/Dockerfile -t noria:latest and runs it with docker-compose up -d, with a dev profile variant. The repository has a docker/ directory, a .devcontainer/, a .streamlit/ directory for the front end configuration, and run_noria.sh and run_noria.bat launchers for the two platforms.
The Python side is smaller: requirements.txt lists streamlit, qrcode, pillow, phonenumbers, pycountry and emoji-country-flag, which is the control centre and the country flag rendering for phone numbers. The JavaScript dependencies are where the real weight sits, with puppeteer, puppeteer-extra, puppeteer-extra-plugin-stealth, whatsapp-web.js, the Google generative AI SDK, js-yaml, dotenv and qrcode-terminal.
The one release is v1.0.0, published on 2026-06-05, and the last push to the main branch was on 2026-07-22. The badge counts 232 passing tests.
Configuration that decides who hears about what
The example environment file is where the system's real assumptions live, and it is worth reading closely because it contains the settings that control delivery and cost.
For the model providers, there is a Gemini key and a Groq key, and the comment notes that Gemini is the default fallback. A third key is for Jina AI, described as optional and for faster content extraction. There is also a LLM_CHAIN variable, a JSON array that overrides the single key configuration, where each entry names a provider of gemini or groq, an apiKey and a model, with the value defaulting to Auto. The fallback chain is therefore configurable rather than fixed in code.
The notification section is where the privacy questions live. NOTIFICATION_TARGET takes a WhatsApp number in international format, WHATSAPP_SOURCE_MODE sets whether the listener reads chats, channels or both, and WHATSAPP_ALLOWED_CHANNELS takes a comma-separated list of channel IDs or display names that acts as a whitelist. The example shows the format with a newsletter style ID and a readable name.
The resilience block is a set of tuning knobs that reveal the failure modes the author was worried about: MAX_RETRIES of 3 for transient failures, RETRY_BACKOFF_MS of 1000, CACHE_MAX_SIZE of 500 cached URLs, CACHE_TTL_MS of 86400000 which is 24 hours, HEALTH_CHECK_INTERVAL_MS of 60000, QUEUE_SCAN_INTERVAL_MS of 300000 for the dead-letter queue, and a flag for enabling automatic queue retry of failed items.
The cache settings deserve attention. A 24 hour TTL means a URL you have already sent will be skipped for a day, which is the right behaviour for a feed of repeated postings and the wrong behaviour when you have just edited your profile and resent the same link.
Editorial conclusion
Adopt Noria if you are personally tracking job or scholarship listings and want the triage done for you, because the scoring matrix and the message template live in a domain module you can edit without touching the plumbing, and the LLM fallback chain means a single provider outage does not stop the pipeline. Do not adopt it as a shared service, since the delivery model is a WhatsApp chat and the configuration carries a notification number and API keys in plain .env files, and do not point it at pages you are not authorised to fetch. Before you start, fill in the .env file with a provider key and the target number, and check the cache settings, because a 24 hour cache TTL will silently skip a link you resent after editing your profile.
Frequently asked questions
What does Noria actually do when a link is shared?
The WhatsApp listener emits a link_extracted event, the orchestrator checks a memory cache and skips the URL if it was already processed, the scraper adapter extracts the web text through Jina or Puppeteer, the domain promptBuilder builds a prompt, and the LLM is called through the Gemini to Groq fallback chain. A match score of 50 or more produces a formatted markdown alert back to the chat, anything lower emits analyzer:no_match.
How do I configure Noria's LLM providers and notifications?
Copy .env.example to .env and fill in a Gemini or Groq API key, optionally a Jina key for faster extraction, and the LLM_CHAIN JSON array if you want to override the single key configuration. The notification target is set with NOTIFICATION_TARGET, and WHATSAPP_SOURCE_MODE plus WHATSAPP_ALLOWED_CHANNELS control which chats and channels the listener reads.
What does the Noria registry validate at startup?
The registry in src/core/registry.js checks registered modules functionally when it is bootstrapped by the composition root. A domain object must provide buildPrompt, resolveProfile, buildTemplate and schema, a scraper adapter must provide scrape, an LLM adapter must provide generateStructuredData, a sender must provide sendMessage, and a listener must be a class with initialize(), an on(event, cb) method and close().
How do I run and test Noria?
The Makefile wraps the npm scripts, with npm install to install dependencies, npm start to run the entry point, npm test to run the jest suite and a coverage variant. There are also make setup, make dev, make lint, make format, and Docker targets that build the image with docker/Dockerfile and start it with docker-compose up -d.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/ahmadhassan-bted-noria)Was this useful?