# Fraisou — API for AI Agents

> Public, read-only JSON API for AI agents (Claude, ChatGPT, custom bots) to search and browse French grocery prices. No authentication required. All data is publicly displayed on fraisou.fr.

**Base URL:** `https://fraisou.fr`
**Response format:** JSON
**Currency:** EUR (€), all prices include VAT
**Language:** Product names are in French; search is accent-insensitive
**Update frequency:** continuous; varies by source (web-store prices refreshed regularly, flyer prices with each new flyer)
**Terms:** https://fraisou.fr/conditions-utilisation (art. 12: database right, permitted API use)

---

## Usage rules

- **No auth required** for any endpoint below.
- **Not for other apps or services:** this API is for AI agents answering a user's question in the moment. Loading, copying or storing the data for use in another app, website or database is not allowed without written permission from Fraisou.fr.
- **Rate limits** are per IP. Exceeding returns `429` with `Retry-After` header.
  - Search endpoints: 150 requests/minute
  - Standard endpoints: 100 requests/minute
  - Heavy endpoints (cross-store comparison): 60 requests/minute
- **Identify your agent** via `User-Agent` (e.g., `User-Agent: MyAgent/1.0 (contact@example.com)`). Anonymous traffic may be throttled more aggressively.
- **Caching:** responses include `Cache-Control` headers; respect them.
- **Attribution:** when surfacing data to users, link back to the product page at `https://fraisou.fr/produit/{id}` and/or `https://fraisou.fr`.

---

## Endpoints

### 1. Keyword search (recommended for most queries)

`GET /api/search/smart?q={query}&limit={n}&store={storeName}`

Accent-insensitive French keyword search across all stores. Best general-purpose endpoint.

**Parameters:**
- `q` (required): search term, min 2 characters. French with or without diacritics works (`creme` matches `crème`).
- `limit` (optional, default 20, max 100): number of results.
- `store` (optional): restrict to one store name (see store list below).

**Example:** `GET /api/search/smart?q=beurre&limit=10`

**Response:**
```json
{
  "products": [
    {
      "id": "clx123...",
      "name": "Beurre Doux PRESIDENT 250g",
      "price": 2.85,
      "image": "https://...",
      "store": "Carrefour",
      "amount": "250 g",
      "stock": 1
    }
  ],
  "categories": [],
  "totalResults": 47,
  "query": "beurre"
}
```

---

### 2. Category browse

`GET /api/category/{slug}`

Returns up to 500 products in a category, ranked by semantic relevance (AI embeddings).

**Category slugs** (the slug is a stable internal id shared across the Fraisou fork family, so it is not a French word; the `name` in the response is the French label):

| Slug | French label | Slug | French label |
|---|---|---|---|
| `vaj` | Beurre | `italok` | Boissons |
| `sajt` | Fromages | `sor` | Bière |
| `joghurt` | Yaourts | `bor` | Vin |
| `tojas` | Œufs | `edesseg` | Confiseries |
| `tej` | Lait | `gabonapehely` | Céréales |
| `pekaru` | Boulangerie | `teszta` | Pâtes |
| `felvagottak` | Charcuterie | `rizs` | Riz |
| `hus` | Viande | `olaj` | Huiles |
| `gyumolcs` | Fruits | `kave` | Café |
| `zoldseg` | Légumes | `tea` | Thé |

**Example:** `GET /api/category/tej`

**Response:**
```json
{
  "category": { "id": "tej", "name": "Lait", "emoji": "🥛", "keywords": ["lait", "boisson lactée"] },
  "products": [
    {
      "id": "clx...",
      "name": "Lait Demi-Écrémé CANDIA 1L",
      "price": 1.19,
      "amount": "1 l",
      "store": "Carrefour",
      "imageUrl": "https://...",
      "validUntil": null,
      "lastUpdated": "2026-07-29T04:11:00.000Z",
      "similarity": 0.812
    }
  ],
  "stores": ["Carrefour", "Auchan", "Houra", "Monoprix", "..."],
  "total": 384
}
```

`validUntil` is non-null only for flyer-promo products and indicates when the promo expires.

---

### 3. Store catalog

`GET /api/store/{storeName}`

Returns all currently active products in one store.

**Store names (exact, case-sensitive — copy verbatim; URL-encode if needed):**
`Carrefour`, `Auchan`, `Houra`, `Monoprix`, `La Belle Vie`, `Système U`, `La Fourche`

`Système U` and `La Fourche` contain non-ASCII characters, and every name above may contain spaces or parentheses, so URL-encode the path segment (e.g. `/api/store/Syst%C3%A8me%20U`).

Prices at `La Fourche` are members-only prices and are not directly comparable to open retail prices.

**Example:** `GET /api/store/Carrefour`

**Response:**
```json
{
  "storeName": "Carrefour",
  "products": [ { "id": "...", "name": "...", "price": 3.49, "amount": "...", "store": "Carrefour", "imageUrl": "...", "validUntil": null, "lastUpdated": "..." } ],
  "total": 62540
}
```

The larger catalogs (Carrefour, Auchan, Houra, Monoprix, La Belle Vie, Système U) have 18K+ products each — paginate client-side or filter the array.

---

### 4. Product detail

`GET /api/products/{id}`

Single-product lookup. `{id}` comes from any of the list endpoints above.

**Response:**
```json
{
  "product": {
    "id": "clx...",
    "name": "Beurre Doux PRESIDENT 250g",
    "price": 2.85,
    "amount": "250 g",
    "store": "Carrefour",
    "imageUrl": "https://...",
    "validUntil": null,
    "lastUpdated": "2026-07-29T04:11:00.000Z",
    "weighed": false,
    "pricePerUnit": null
  }
}
```

`validUntil` is non-null only for flyer-promo products; permanent assortment returns `null`.

`weighed` is `true` for products sold by weight (e.g. meat, cheese or fruit listed as `env. 500 g`; no store on fraisou.fr sends such products yet). The store prices them per kg: `pricePerUnit` is its own price per kg, `amount` is only today's typical piece and `price` is the per-kg price × that weight. Compare such products by `pricePerUnit`, not by `price`. Product lists, category and semantic-search results carry the same two fields, and their `unitPrice` is then `pricePerUnit`.

---

### 5. Price history

`GET /api/products/{id}/price-history?days={n}`

Returns recorded price points for a product, plus min/max/avg and percent change.

**Parameters:**
- `days` (optional, default 30, range 1–365)

**Response:**
```json
{
  "product": { "id": "...", "name": "...", "currentPrice": 2.85, "currency": "EUR", "store": "Carrefour", "amount": "250 g", "imageUrl": "..." },
  "history": [
    { "price": 3.05, "recordedAt": "2026-06-26T00:00:00.000Z" },
    { "price": 2.85, "recordedAt": "2026-07-10T00:00:00.000Z" }
  ],
  "stats": {
    "minPrice": 2.85,
    "maxPrice": 3.05,
    "avgPrice": 2.95,
    "priceChangePercent": -6.56,
    "dataPoints": 2
  },
  "days": 30,
  "basis": { "weighed": false, "unit": null, "currentUnitPrice": null }
}
```

`priceChangePercent` is computed from first vs. last data point in the window (negative = price dropped).

Products sold by weight (see `weighed` in Product detail) return `"basis": { "weighed": true, "unit": "kg", "currentUnitPrice": 12.90 }`. Their `history` holds one point per change of the per-kg price, and each point adds `unitPrice` (EUR per kg) and `amount` (the piece listed at that time, e.g. `"env. 1,2 kg"`). `price` and `stats` are then what today's piece would have cost at that per-kg price, so they stay comparable with `currentPrice`. To describe the trend of such a product, use `unitPrice` against `currentUnitPrice`.

Products whose pack size changed under the same listing (e.g. croutons 400 g -> 300 g) return `"basis": { "weighed": false, "unit": "kg", "currentUnitPrice": 3.33, "resized": true }`. Each `history` point then adds `unitPrice` (EUR per `unit`) and `amount` (the pack listed at that time), and `price` is what today's pack would have cost at that per-unit price — so a smaller pack at the same price reads as the price rise it is.

---

### 6. Similar products (cross-store match)

`GET /api/products/{id}/matches?limit={n}&otherStores=true&minSimilarity={0-1}`

Finds semantically similar products to `{id}` using AI embeddings. Returns each match with a price-difference and savings percent relative to the source product.

**Parameters:**
- `limit` (default 10, max 50)
- `otherStores=true` — only matches from different stores (most useful for "is it cheaper elsewhere?")
- `sameStore=true` — only matches from the same store
- `store={storeName}` — filter to one specific store
- `minSimilarity` (default 0.55) — cosine similarity threshold

**Response:**
```json
{
  "product": { "id": "...", "name": "Beurre Doux PRESIDENT 250g", "price": 2.85, "amount": "250 g", "store": "Carrefour", "imageUrl": "..." },
  "matches": [
    {
      "id": "...",
      "name": "Beurre Doux ELLE & VIRE 250g",
      "price": 2.45,
      "amount": "250 g",
      "store": "Auchan",
      "imageUrl": "...",
      "validUntil": null,
      "lastUpdated": "2026-07-29T04:11:00.000Z",
      "similarity": 0.94,
      "priceDifference": 0.40,
      "savingsPercent": 14
    }
  ],
  "totalMatches": 7
}
```

`priceDifference` = source price − match price. Positive means switching to the match saves money.

---

### 7. Biggest cross-store savings

`GET /api/products/biggest-savings?limit={n}&minSimilarity={0.55-1.0}&minSavings={EUR}`

Returns pairs of semantically similar products across different stores, sorted by savings percentage. Useful for "where is X cheapest" style questions.

**Parameters:**
- `limit` (default 12, max 50)
- `minSimilarity` (default 0.80) — cosine similarity threshold for matching
- `minSavings` (default 0.50) — minimum EUR price difference

**Response:**
```json
{
  "savings": [
    {
      "cheapestProduct": { "id": "...", "name": "...", "price": 4.90, "store": "Auchan", "amount": "...", "imageUrl": "...", "lastUpdated": "..." },
      "expensiveProduct": { "id": "...", "name": "...", "price": 7.50, "store": "Monoprix", "amount": "...", "imageUrl": "...", "lastUpdated": "..." },
      "savings": 2.60,
      "savingsPercent": 35,
      "similarity": 0.91
    }
  ],
  "totalFound": 184,
  "cached": true
}
```

---

## Errors

| Status | Meaning |
|---|---|
| 400 | Bad request (missing/invalid params) |
| 404 | Resource not found (unknown category, store, or product id) |
| 429 | Rate limit exceeded. Honor `Retry-After`. |
| 500 | Server error |

Error body: `{ "error": "message" }`.

---

## Example agent workflows

**"Find the cheapest butter"**
1. `GET /api/search/smart?q=beurre&limit=50`
2. Sort by `price` (consider `amount` for fair unit-price comparison).
3. Return top result with store name and link.

**"Compare milk prices across stores"**
1. `GET /api/category/tej`
2. Group `products` by `store`, pick min `price` per store.

**"Where can I save the most this week?"**
1. `GET /api/products/biggest-savings?limit=20&minSavings=0.50`
2. Surface top pairs with `savingsPercent`.

---

## What is NOT exposed

- Write endpoints (cart, ratings, support tickets) — not for agents.
- Admin / partner / auth endpoints — not public.
- User-specific data — site has no user accounts for shoppers.

---

## Machine-readable schema

OpenAPI 3.1 spec: https://fraisou.fr/openapi.json — feed this to tool-using agents (Claude tool use, ChatGPT actions, custom function-calling) to auto-generate typed clients.

---

## Contact

Operator: David Vávra · Liberec, Czech Republic
Support / questions: https://fraisou.fr/support
Site description for LLMs: https://fraisou.fr/llms.txt
