Skip to content

ryyos/Voice

Repository files navigation

Voice logo

Keyword-driven public opinion and sentiment tracker for Indonesian content, built as a data engineering portfolio project.


What is Voice?

Voice is a data engineering system that answers a simple question: what is the public saying about a topic right now?

Enter a keyword (e.g. makan bergizi gratis, korupsi, demo mahasiswa) and Voice:

  1. Scrapes news articles and social media comments from 6 sources (no login, no API keys for most sources)
  2. Cleans raw HTML/JSON into structured rows in a data warehouse
  3. Classifies sentiment using a hybrid local AI approach (IndoBERT + Gemini fallback)
  4. Serves the results through a REST API and an interactive dashboard

The pipeline is fully decoupled. Each stage runs independently, connected by a message queue. All raw data is preserved so re-processing is always possible without re-scraping.


Dashboard

Screenshots below show real data. Filter set to positive sentiment in Feed view.

Overview

Overview

Sentiment Trend Over Time

Trend

Sources Breakdown

Sources

Top Words by Sentiment

Words

Comment Feed (Positive Filter)

Feed


Architecture

flowchart LR
    A([REST API\nor Dashboard]) -->|keyword + date_range| B[Redpanda / Kafka]
    B --> C[Scraper Worker]
    C -->|raw HTML / JSON| D[(MongoDB\nBronze Layer)]
    D --> E[Cleaner]
    E -->|structured rows| F[(PostgreSQL\nGold Layer)]
    F --> G[AI Processor\nIndoBERT + Gemini]
    G -->|sentiment labels| F
    F --> H[FastAPI]
    H --> A

    V[(Valkey\nCache)] -. dedup by URL hash .-> C
    V -. checkpoint cursor .-> E
Loading

Data flow in 6 steps:

  1. A keyword job is published to Redpanda (Kafka-compatible), triggered via REST API
  2. The Scraper Worker consumes the job and dispatches it to all registered source plugins concurrently
  3. Each plugin fetches articles and comments; a Valkey cache keyed on URL MD5 prevents re-scraping the same content across jobs
  4. Raw HTML/JSON is stored as-is in MongoDB (bronze layer, insert-only, never mutated)
  5. The Cleaner reads MongoDB, parses raw payloads per source, and writes structured rows to PostgreSQL (gold layer)
  6. The AI Processor reads unclassified comments, runs IndoBERT locally for bulk classification, and falls back to Gemini API only for low-confidence or stance-ambiguous cases

Data Sources

Source Content Comments Method
Detik ✅ Articles ✅ (GraphQL API) SSR scraping + GraphQL
CNN Indonesia ✅ Articles ✅ (GraphQL API) JSON search API + GraphQL
Liputan6 ✅ Articles No Tag page SSR
Merdeka ✅ Articles No Google CSE API (JSONP)
YouTube ✅ Videos ✅ Comments Piped (self-hosted, no key)
Reddit ✅ Posts ✅ (PullPush archive) Public RSS + PullPush API

No login or paid API keys required for any source.


Why This Tech Stack

Each tool was chosen deliberately, not to collect buzzwords, but to fit the actual problem.

MongoDB for the Raw Layer

Raw HTML and JSON vary wildly between sources. A Detik article body is structured HTML while a Reddit post is a JSON blob with nested fields. A document store handles schema-per-source naturally. More importantly, keeping raw data immutable means you can always re-parse after fixing a bug in the cleaner, with no re-scraping needed.

Why not MinIO (object storage)? Evaluated and rejected. MongoDB makes it easy to query and inspect raw documents during development (useful when debugging a parser). MinIO would require downloading files to inspect them.

PostgreSQL for the Warehouse

The cleaned, structured data fits a relational model well: comments have a fixed schema, keywords and sources are discrete dimensions. PostgreSQL handles both the OLTP queries the API needs (filtering, pagination) and the simple aggregations the dashboard uses (sentiment percentages, word counts) without needing a separate analytics database.

Why not ClickHouse? Planned for a later phase. PostgreSQL is more than sufficient for MVP scale and avoids adding infrastructure complexity before it's needed.

Redpanda (Kafka-compatible) as the Message Queue

Decoupling the scraper from the API trigger means the pipeline is async and observable. You can inspect the queue, replay messages, and add new consumers without touching the producer. Redpanda runs as a single Docker container vs Kafka's multi-service setup, making local development much lighter, while remaining fully Kafka-API compatible for production upgrade.

Hybrid AI: IndoBERT local + Gemini fallback

Running a local model (IndoBERT via transformers) handles the bulk of classification. It is free, has no rate limit, and works offline. Gemini is invoked only for comments where the local model's confidence is below 75%, or where the text contains explicit stance markers (pro/kontra). This keeps cost at $0 for normal runs while improving accuracy on the hardest cases.

Why not Gemini-only? Rate limits (10 RPM free tier) would make classifying 5,000+ comments impractical. Why not local-only? Gemini meaningfully improves accuracy on ambiguous Indonesian slang and stance detection.

Valkey (Redis-compatible) for Cache

Two responsibilities: (1) dedup by URL MD5, which prevents re-scraping the same article across keyword runs; (2) checkpoint cursor for the cleaner, letting it resume from where it left off after a crash or restart. Valkey is a Redis fork with no license restrictions, drop-in compatible with redis-py.

Piped (self-hosted) for YouTube

YouTube's official Data API has strict quota limits. Piped proxies YouTube's internal APIs with no account required, returning video metadata, upload dates, and comments. Running it self-hosted means no shared quota and no rate limits imposed by a third-party instance.


Key Design Decisions

Raw-first pipeline: raw data is always preserved in MongoDB before any transformation. Cleaning and AI are separate idempotent passes that can be re-run without re-fetching. This is the data lakehouse pattern at small scale.

Plugin-based source registry: adding a new source is one new file with no changes to the pipeline. Each source inherits BaseSource and registers itself via a decorator. The worker discovers and runs all registered plugins automatically.

Broker flexibility: switch between Redpanda and Kafka by changing BROKER_TYPE in .env. The same kafka-python client works with both.

date_range="all": scraping and querying support "all" as a special value meaning no date restriction. Useful for initial data collection or historical analysis.


Project Structure

voice/
├── api/
│   ├── main.py                 # FastAPI app + CORS
│   ├── utils.py                # Shared query helpers
│   └── routers/
│       ├── opinions.py         # /summary, /trend, /trend-detail, /sources, /words, /feed, /prokontra
│       └── jobs.py             # /jobs/trigger (REST pipeline trigger)
├── worker/
│   └── main.py                 # Kafka consumer, scraper pipeline
├── processor/
│   ├── main.py                 # Orchestrator: cleaner + AI loop (every 30s)
│   ├── cleaner.py              # MongoDB, parse per source, PostgreSQL
│   └── ai.py                   # IndoBERT local + Gemini fallback classifier
├── sources/
│   ├── __init__.py             # BaseSource abstract class
│   ├── registry.py             # @register_source decorator + plugin registry
│   ├── news/
│   │   ├── detik.py            # SSR scraping + GraphQL comments
│   │   ├── cnn.py              # JSON search API + GraphQL comments
│   │   ├── liputan6.py         # Tag page SSR scraping
│   │   └── merdeka.py          # Google CSE API (JSONP)
│   └── socmed/
│       ├── youtube.py          # Piped API (search + comments)
│       └── reddit.py           # Public RSS + PullPush comment archive
├── shared/
│   ├── config.py               # pydantic-settings, all config from .env
│   ├── kafka/                  # Producer + Consumer wrappers
│   ├── mongodb/                # Connection + insert/find helpers
│   ├── postgresql/             # ThreadedConnectionPool + DDL + typed helpers
│   ├── redys/                  # Valkey client: URL cache, checkpoint, job lock
│   └── utils/
│       ├── logger.py           # Loguru + Rich, two-sink (console + rotating file)
│       ├── network.py          # Async HTTP with retry + backoff
│       ├── datetime.py         # Interval parsing (7d / 30d / 90d / all)
│       └── endecode.py         # MD5, URL encode, base64 utilities
├── dashboard/                  # React + Vite + Tailwind + Recharts
├── bot/                        # Telegram bot (coming soon)
├── docker-compose.yml          # Infra: MongoDB, PostgreSQL, Valkey, Redpanda/Kafka, Piped
├── requirements.txt
└── .env.example

Setup

Prerequisites

  • Docker Desktop for all infrastructure services

  • Python 3.11+ for the pipeline services

  • Node.js 18+ for the dashboard (optional)

  • Gemini API key — optional, pipeline works with IndoBERT only. To get one:

    1. Go to aistudio.google.com and sign in with a Google account
    2. Click Get API key in the left sidebar
    3. Click Create API key, select a project (or create one), and copy the key
    4. Paste it into GEMINI_API_KEY in your .env file and set USE_GEMINI=true

    The free tier includes 1,500 requests/day and 10 requests/minute, which is enough for fallback use.

1. Clone and configure

git clone https://github.com/yourusername/voice.git
cd voice
cp .env.example .env

Edit .env with your values:

Variable Description Default
BROKER_TYPE redpanda or kafka redpanda
BROKER_ADDRESS Kafka/Redpanda address localhost:19092
MONGO_URI MongoDB connection string mongodb://voice:voice@localhost:27017/voice_datalake?authSource=admin
POSTGRES_DSN PostgreSQL connection string postgresql://voice:voice@localhost:5432/voice_warehouse
VALKEY_URL Valkey/Redis URL redis://localhost:6379/0
PIPED_BASE_URL Piped backend (for YouTube) http://localhost:8888
GEMINI_API_KEY Gemini API key (optional) (empty)
USE_GEMINI Enable Gemini fallback true

2. Start infrastructure

# Redpanda + all core services (MongoDB, PostgreSQL, Valkey)
docker compose --profile redpanda up -d

# Add Piped for YouTube support
docker compose --profile redpanda --profile piped up -d

Verify everything is running:

docker compose ps

3. Install Python dependencies

pip install -r requirements.txt

4. Run the pipeline services

Open three separate terminals:

# Terminal 1: Scraper worker (listens for keyword jobs)
python -m worker.main

# Terminal 2: Processor (cleaner + AI classifier, runs every 30s)
python -m processor.main

# Terminal 3: REST API
python -m api.main

5. Trigger a keyword job

curl -X POST http://localhost:8000/api/v1/jobs/trigger \
  -H "Content-Type: application/json" \
  -d '{"keyword": "makan bergizi gratis", "date_range": "30d"}'

The job flows through: Redpanda, scraper worker, MongoDB, cleaner, PostgreSQL, AI processor, and is then available via the API.

6. (Optional) Start the dashboard

cd dashboard
npm install
npm run dev

Open http://localhost:5173, enter your keyword, and explore the results.


API Reference

Base URL: http://localhost:8000

All GET endpoints accept keyword (required), date_range (7d | 30d | 90d | all, default 30d), date_from, and date_to.

Endpoint Description
GET /api/v1/summary Overall sentiment percentages, article + comment counts
GET /api/v1/trend Daily sentiment breakdown over time
GET /api/v1/trend-detail Comments for a specific date (drill-down)
GET /api/v1/sources Per-source article and comment counts
GET /api/v1/words Top words by sentiment (positive / negative / neutral)
GET /api/v1/feed Paginated comment + article feed with filters
GET /api/v1/prokontra Pro/kontra vote breakdown (Detik and CNN only)
GET /api/v1/pipeline-stats AI classification stats (IndoBERT vs Gemini ratio)
POST /api/v1/jobs/trigger Trigger a scraping + classification job

Interactive docs available at http://localhost:8000/docs.


Source Plugin Contract

Adding a new data source requires one file, with no changes to the pipeline:

from sources import BaseSource
from sources.registry import register_source

@register_source("mysource")
class MySource(BaseSource):
    async def collect_urls(self, keyword: str, interval: str) -> list[dict]:
        """Return [{url, title, media, desc, date}, ...]"""
        ...

    async def fetch_detail(self, content: dict) -> dict:
        """Return content with content_id and raw fields added."""
        ...

    async def fetch_comments(self, content: dict) -> list[dict]:
        """Return raw comment objects (one per MongoDB document)."""
        ...

BaseSource.process() handles concurrency, dedup, MongoDB writes, and progress display automatically.


Roadmap

Phase Status Name Description
0 ✅ Done Foundation Scaffolding, tooling, local dev environment
1 ✅ Done Scraping Core BaseSource plugin registry, all 6 sources
2 ✅ Done Pipeline Redpanda, MongoDB bronze layer, Valkey dedup
3 ✅ Done Cleaning & AI Cleaner, IndoBERT + Gemini hybrid classifier
4 ✅ Done API & Dashboard FastAPI endpoints, React dashboard
5 🔄 In Progress Bot Telegram bot interface
6 ⏳ Planned Hardening Retries, observability, test coverage
7 ⏳ Planned Scheduling Airflow DAGs for periodic keyword re-runs

Ethical Notes

Coverage is partial: only public, no-login-required sources are scraped. Results represent indexed opinion, not population-representative opinion.

AI limitations: classification reflects biases in the IndoBERT training data and Gemini prompt design. Confidence scores are exposed in the API so consumers can filter by threshold.

Privacy: no user-identifying information is stored after the cleaning stage. Author handles from Reddit/YouTube are dropped before the API layer.

Responsible use: this system must not be used for mass surveillance, targeted harassment, or political manipulation. Scraping respects source rate limits.


License

MIT. See LICENSE

About

A public opinion & sentiment tracker. collect, classify, and serve what the world is saying about any topic.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors