Skip to main content
Glama
README.md
# Bolha

[![CI](https://github.com/vsterle20/bolha.com-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/vsterle20/bolha.com-mcp/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/bolha.svg)](https://www.npmjs.com/package/bolha)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)

A client for **[bolha.com](https://www.bolha.com)**, Slovenia's largest classifieds marketplace —
as a reusable **library**, a **CLI**, and an **MCP server**, so that humans and AI agents can use
Bolha reliably without the web UI.

```text
Bolha website  →  bolha core client  →  ┌────────────┐
   (HTML/JSON)     (src/core)           │  CLI + MCP │
                                        └────────────┘
```

One core library, two front-ends. No duplicated scraping logic.

> **Unofficial.** Not affiliated with or endorsed by Styria digital marketplaces d.o.o.,
> the operator of bolha.com. Read-only by design: it never writes to the site.

> **Publishing this?** See [PREPARE.md](PREPARE.md) — three things need your input first.


---

## Why this exists

Bolha has **no public API**. `api.bolha.com` 301-redirects to the homepage, and there is no
Bolha MCP server (both verified — see [`research/EXISTING_PROJECTS.md`](research/EXISTING_PROJECTS.md)).
The site is nonetheless fully machine-readable: every page embeds a complete JSON payload in
`window.__INITIAL_STATE__`. This project reads **that**, rather than scraping CSS selectors,
which makes it both simpler and far more resistant to layout changes.

> **Research-first.** Nothing here was written from memory of the site. Every URL, parameter,
> id and field was verified against the live site — see [`AGENTS.md`](AGENTS.md) §3 and the
> [`research/`](research/) knowledge base.

---

## Features

| Area | Supported |
|---|---|
| **Search** | Text search, category restriction, price range, condition, image-only, online-payment, sort, pagination |
| **Categories** | Top-level tree, category detail with subcategories, category-specific filters, listings counts |
| **Filters** | Discovered dynamically from the site, so vehicle/real-estate filters work without special-casing |
| **Locations** | 238 distinct locality slugs from Bolha's public sitemap: 11 Slovenian regions + 184 towns/settlements |
| **Region filter** | Works in **every** category — exact locality path in real estate, measured radius everywhere else |
| **Facets** | Category facet pages (e.g. all 60 vehicle makes under `/avto-oglasi`) are listed and browsable |
| **Listings** | Full detail: description, all image sizes, seller, category path, Bolha's attribute table, schema.org data |
| **Sorting** | `relevance` (search only), `new`, `old`, `cheap`, `expensive`, `distance` |
| **Pagination** | Multi-page walking, de-duplicated, bounded for safety |
| **Sellers** | Private sellers (`/uporabnik/`) and business stores (`/trgovina/`) with their current listings |
| **Diagnostics** | `bolha doctor` — nine live checks so silent breakage is visible |
| **MCP** | Six tools over stdio with validated structured output |

**Not supported (deliberately):** posting, editing, deleting, favouriting, messaging, or any
account change. Those require authentication and are intentionally out of scope — see
[Authentication](research/Authentication.md).

---

## Requirements

- **Node.js ≥ 20** (the MCP SDK requires it)

## Installation

```bash
git clone <this-repo> bolha
cd bolha
npm install
npm run build
```

Run the CLI directly, or link it onto your `PATH`:

```bash
node dist/cli/index.js --help

# or, globally:
npm link
bolha --help
```

---

## CLI usage

```bash
bolha search <query>            # search listings
bolha browse <category>         # browse a category by slug or URL
bolha listing <url>             # full detail for one listing
bolha categories                # top-level categories
bolha category <slug>           # category detail + its filters
bolha facets <category>         # facet sub-pages (makes, brands, …)
bolha locations [category]      # localities: regions and towns
bolha regions [category]        # the eleven Slovenian regions
bolha towns [category]          # towns and settlements
bolha location-categories       # which categories support locality filtering
bolha filters [category]        # filters the site advertises
bolha sort [category]           # sort orders available
bolha seller <slug|url>         # a private seller or business store
bolha doctor                    # live diagnostics
```

### Examples

```bash
# Plain search
bolha search "RTX 5090"

# Machine-readable output — nothing but JSON on stdout
bolha search "iPhone" --json | jq '.listings[] | {id, title, price: .price.formatted}'

# Filtered + sorted
bolha search "iPhone" --condition used --min-price 100 --max-price 900 --sort cheap

# Restrict to a category
bolha search "kolo" --category rekreacija-sport --limit 50

# Browse a category by price
bolha browse avto-oglasi --min-price 5000 --max-price 20000 --sort cheap

# Radius search around a city
bolha browse nepremicnine --lat 46.0511 --lng 14.5051 --radius 20

# Category-specific filter (vehicles) via the escape hatch
bolha browse avto-oglasi --param "yearManufactured[min]=2020" --param "condition[used]=1"

# Narrow a category by make: /avto-oglasi links every make as a facet page
bolha facets avto-oglasi
bolha browse avto-oglasi/audi --sort cheap

# Filter by place. Localities are ordinary category path segments.
bolha locations prodaja-hise --query maribor
bolha browse prodaja-hise/maribor --sort cheap

# Region filter — works in any category, not just property.
bolha regions                          # the 11 Slovenian regions
bolha towns prodaja-hise               # 184 towns and settlements
bolha browse avto-oglasi --region koroska          # vehicles in Koroška
bolha browse racunalnistvo --region gorenjska      # computers in Gorenjska
bolha search "hiša" --region koroska --category prodaja-hise

# Inspect one listing (copy the URL from any search)
bolha listing /apple-iphone/prodam-apple-iphone-14-oglas-16445305

# Who is this seller?
bolha seller _borut_007
bolha seller https://www.bolha.com/trgovina/primer-trgovina

# Check everything still works
bolha doctor --verbose
```

### Options

| Option | Meaning |
|---|---|
| `--limit <n>` | Max listings (default 25, cap 500) |
| `--page <n>` | 1-based page (max 100) |
| `--sort <order>` | `relevance \| new \| old \| cheap \| expensive \| distance` |
| `--category <slug>` / `--category-id <n>` | Restrict to a category |
| `--min-price` / `--max-price` | Price range in EUR |
| `--condition <c>` | Repeatable: `new \| used \| defective` |
| `--lat` `--lng` `--radius` | Radius search |
| `--location <id>` | Repeatable Bolha location id |
| `--ads-with-images` | Only listings with a photo |
| `--param <k=v>` | Any other Bolha URL parameter (repeatable) |
| `--json` | Pure JSON on stdout |
| `--verbose` / `-v` | Log every HTTP request to stderr |
| `--timeout` `--concurrency` `--retries` | Network tuning |
| `--no-cache` / `--cache-dir` | Cache control |
| `--help`, `--version` | |

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Success |
| `2` | Usage error (bad argument, limit above cap) |
| `3` | Not found / no results / removed listing / parent-category page |
| `4` | Network, HTTP or parse failure |
| `130` | Interrupted |

---

## Library

```ts
import { BolhaClient } from 'bolha';

const bolha = new BolhaClient();

const page = await bolha.search({ query: 'iPhone', maxPrice: 900, limit: 10 });
console.log(page.totalCount, page.listings[0]?.title);

const listing = await bolha.getListing(page.listings[0]!.url);
console.log(listing.price.formatted, listing.images.length, listing.attributes);
```

Main methods:

| Method | Returns |
|---|---|
| `search(options)` | `ListingPage` |
| `browseCategory(slug, options)` | `ListingPage` |
| `getListing(urlOrPath)` | `ListingDetail` |
| `getTopCategories()` | `Category[]` |
| `getCategory(slug)` | `CategoryDetail` |
| `getFilters({ categorySlug })` | `FilterDefinition[]` |
| `getSortOrders({ categorySlug })` | `SortOption[]` |
| `getSeller(ref, options)` | `SellerProfile` |
| `resolveSellerFromListing(url)` | `string \| null` |

Every method returns typed, structured data — never raw HTML. Errors are `BolhaError`s with a
stable `code` (`NOT_FOUND`, `PARENT_CATEGORY`, `SITE_CHANGED`, `LIMIT_EXCEEDED`, …).

---

## MCP server

Six tools, each with a strict input schema and validated structured output:

| Tool | Purpose |
|---|---|
| `bolha_search` | Text search with filters and sorting |
| `bolha_browse_category` | Listings in one category, no query needed |
| `bolha_get_categories` | Discover category slugs |
| `bolha_get_category` | One category's subcategories, filters and sort orders |
| `bolha_get_facets` | A category's facet sub-pages (vehicle makes, brand tags) |
| `bolha_get_locations` | Slovenian localities (regions and towns) for a category or free-text search |
| `bolha_get_listing` | Full detail of one listing |
| `bolha_get_regions` | The eleven Slovenian statistical regions |
| `bolha_get_location_categories` | Which categories support locality filtering |
| `bolha_search_in_location` | Text search scoped to a region or town |
| `bolha_get_seller` | Seller or store profile |

### Configuration

**Codex / Claude Desktop / any stdio MCP client:**

```json
{
  "mcpServers": {
    "bolha": {
      "command": "node",
      "args": ["/absolute/path/to/bolha/dist/mcp/server.js"]
    }
  }
}
```

**Environment variables** (all optional):

| Variable | Effect |
|---|---|
| `BOLHA_CACHE_DIR` | Cache location (default `~/.bolha/cache`) |
| `BOLHA_NO_CACHE=1` | Disable the cache |
| `BOLHA_USER_AGENT` | Override the `User-Agent` |
| `BOLHA_VERBOSE=1` | Log requests to **stderr** |

### Context discipline

Search results are **compact summaries** — id, title, price, location, date, seller, URL — with
no descriptions. An agent can choose which listings matter, then call `bolha_get_listing` for the
full text. Listings default to at most 100 per call and descriptions are truncated at 4000
characters, so a search cannot flood a model's context.

The tools speak human terms (`new`/`used`/`defective`, `cheap`/`new`), never Bolha's internal
numeric codes.

---

## Configuration

No configuration is required. Optional environment variables:

| Variable | Default | Purpose |
|---|---|---|
| `BOLHA_USER_AGENT` | descriptive default | Identify your client to Bolha |
| `BOLHA_CACHE_DIR` | `~/.bolha/cache` | Cache location |
| `BOLHA_NO_CACHE` | unset | Disable caching |
| `BOLHA_VERBOSE` | unset | Request logging to stderr |

The cache stores only public page data, is keyed by URL, honours per-kind TTLs (categories 6 h,
searches 1 min) and is invalidated automatically when the library's output shape changes. Session
state and credentials live under `~/.bolha/` and are **never** written to the repository.

---

---

## Setup: Claude Code and Claude Desktop

The MCP server is `dist/mcp/server.js`. It speaks stdio, needs **no API key**, and
reads only public bolha.com pages.

Build it once first (it is already built in this checkout, but you need it after
any `git pull` or code change):

```bash
cd D:\marketplace\bolha
npm install
npm run build
```

Quick check that it works before wiring it into anything:

```bash
npm run doctor
```

### Claude Code (terminal agent)

Run this once:

```bash
claude mcp add bolha --scope user -- node D:\marketplace\bolha\dist\mcp\server.js
```

Then verify:

```bash
claude mcp list          # should show: bolha: node D:\marketplace\bolha\dist\mcp\server.js - √ Connected
```

Or add it interactively from inside a `claude` session with `/mcp`.

**Scopes**

| Scope | Flag | Where it is stored |
|---|---|---|
| user (all your projects) | `--scope user` | `~/.claude.json` |
| project (this repo only) | `--scope project` | `.mcp.json` in the repo |
| local (this one project, you only) | `--scope local` | `~/.claude.json`, project-scoped |

Prefer `user` so it follows you across directories. Use `project` if you want the
repo to declare the dependency for your whole team — but then commit `.mcp.json`.

**Windows note.** If `claude` is not on your PATH in the terminal Claude Code runs
from, replace `node` with the absolute interpreter path:

```bash
claude mcp add bolha --scope user -- "C:\Program Files\nodejs\node.exe" D:\marketplace\bolha\dist\mcp\server.js
```

### Claude Desktop (GUI app)

Desktop only reads MCP servers from its own JSON file — the `claude mcp add`
command above does **not** configure it.

Edit:

```
%APPDATA%\Claude\claude_desktop_config.json
```

(on your machine: `C:\Users\vilis\AppData\Roaming\Claude\claude_desktop_config.json`)

Add a `bolha` entry alongside the servers already there, keeping the existing
ones intact:

```json
{
  "mcpServers": {
    "davinci-resolve": { "...": "keep what is already there" },
    "FaceMCP":         { "...": "keep what is already there" },
    "secondhand-facebook": { "...": "keep what is already there" },
    "bolha": {
      "command": "node",
      "args": ["D:\\marketplace\\bolha\\dist\\mcp\\server.js"]
    }
  }
}
```

Then **fully quit and restart Claude Desktop** (close the tray icon too — closing
the window is not enough on Windows). The server appears in the tools menu
(🔨 icon) as `bolha_search`, `bolha_get_listing`, and so on.

To undo: delete the `bolha` block from that file and restart Desktop.

### Optional environment variables

None are required. Add them under `"env"` in the config block, or pass
`--env KEY=value` to `claude mcp add`:

| Variable | Effect |
|---|---|
| `BOLHA_USER_AGENT` | Identify yourself to bolha.com (please do set this) |
| `BOLHA_CACHE_DIR` | Cache location (default `~/.bolha/cache`) |
| `BOLHA_NO_CACHE=1` | Disable caching |
| `BOLHA_VERBOSE=1` | Log requests to stderr |

Example with a custom user agent:

```bash
claude mcp add bolha --scope user \
  --env BOLHA_USER_AGENT="janez/my-bolha-mcp" \
  -- node D:\marketplace\bolha\dist\mcp\server.js
```

### Verifying from inside Claude

Ask it something that forces a live call, for example:

> Search bolha for "iPhone" under 500 EUR, cheapest first, and show me the
> listing URLs.

or, to check the connection specifically:

> List the bolha categories.

If the tools are not offered, the server did not start — check
`claude mcp list` for a connection error, and confirm `dist/mcp/server.js`
exists (run `npm run build`).
## Reliability

- **Polite by default** — descriptive `User-Agent`, ≤3 concurrent requests, ≥350 ms between
  requests, retries with exponential backoff, per-request timeouts.
- **Breaks loudly** — if Bolha reports listings but none can be parsed, the client raises
  `SITE_CHANGED` instead of quietly returning `[]`.
- **Bounded** — 500 listings and 100 pages per call, so an agent cannot accidentally trigger a
  crawl.
- **Honest about limits** — see [`research/Known Limitations.md`](research/Known Limitations.md).

### robots.txt and ethics

Bolha's `robots.txt` defines 39 separate user-agent groups, and the details are easy to
misread. Checked directly against the live file:

| Path | `User-agent: *` | AI crawler groups |
|---|---|---|
| `/search`, `/hitro-iskanje`, `/brza-pretraga` | **Disallowed** | mixed |
| `/uporabnik/`, `/*-oglas-`, `/objava-oglasa` | not listed | Disallowed (GPTBot-style) |

So `/search` is off-limits to *every* crawler, while listing and profile URLs are only restricted
for training-oriented AI crawlers.

**How this project treats that.** It is a **user-operated client**: it runs on your machine, at
human pace, and returns results to your own terminal or agent. It does not train models, does not
systematically crawl, and does not re-publish content. Where an equivalent route exists, the
client prefers the **allowed** surface:

- `browse`/`category`/`filters`/`seller` all use **category URLs**, which are not disallowed;
- **public sitemaps** (`/sitemap-index.xml`) are the intended discovery surface;
- requests are paced (≥350 ms apart), capped at 3 concurrent, cached, and sent with a
  descriptive `User-Agent` you can override via `BOLHA_USER_AGENT`;
- if Bolha serves a CAPTCHA or block, the client **reports it and stops** — it never tries to
  defeat it.

Please keep it that way. If you need bulk discovery, the sitemaps are the right tool.

---

## Development

```bash
npm run build            # compile TypeScript to dist/
npm test                 # unit tests (offline, 40 tests)
npm run test:mcp         # MCP tests against the live site (23 checks)
npm run test:integration # live integration tests (21 tests)
npm run test:all         # everything
```

Tests are real: unit tests run offline against sanitised fixtures, while integration and MCP
tests hit the live site read-only and are **discovery-based** (they search, then open whatever
came back) rather than depending on hardcoded listing ids that might expire.

### Project layout

```text
src/
  types/       public types
  parsing/     __INITIAL_STATE__ extraction + normalisers
  core/        BolhaClient, HTTP, URLs, cache, errors
  cli/         the bolha command
  mcp/         MCP stdio server
research/      Obsidian knowledge base (start at research/INDEX.md)
tests/         unit (offline) · integration (live) · mcp (live)
```

---

## Documentation

| Document | Purpose |
|---|---|
| [`AGENTS.md`](AGENTS.md) | Project contract: rules, confirmed findings, decisions, status |
| [`BUGS.md`](BUGS.md) | Every bug found, with cause, fix and live evidence |
| [`STATUS.md`](STATUS.md) | What works and what has actually been tested |
| [`TODO.md`](TODO.md) | Remaining work |
| [`COMPLETION_REPORT.md`](COMPLETION_REPORT.md) | Final test report: every feature with PASS/FAIL/LIMITATION |
| [`research/INDEX.md`](research/INDEX.md) | Knowledge-base entry point |
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | How to contribute, and the project's ground rules |
| [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) | Code of conduct |
| [`SECURITY.md`](SECURITY.md) | What counts as a security issue, and how to report one |
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |

---

## Licence

MIT.