basalam-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ["3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v10.2.0
17
+ with:
18
+ python-version: ${{ matrix.python }}
19
+ - run: uv sync --locked
20
+ - run: uv run ruff check .
21
+ - run: uv run ruff format --check .
22
+ - run: uv run pytest -q
@@ -0,0 +1,51 @@
1
+ name: Publish
2
+
3
+ # Tag a release (git tag v0.1.0 && git push --tags) after bumping the version in
4
+ # pyproject.toml and server.json.
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ pypi:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write # PyPI trusted publishing
15
+ contents: write # GitHub Release
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+ - uses: astral-sh/setup-uv@v10.2.0
19
+ - name: Check versions match the tag
20
+ run: |
21
+ TAG=${GITHUB_REF_NAME#v}
22
+ grep -q "^version = \"$TAG\"" pyproject.toml
23
+ test "$(jq -r .version server.json)" = "$TAG"
24
+ test "$(jq -r '.packages[0].version' server.json)" = "$TAG"
25
+ - run: uv sync --locked && uv run pytest -q
26
+ - run: uv build
27
+ - uses: pypa/gh-action-pypi-publish@release/v1
28
+ - run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --title "$GITHUB_REF_NAME"
29
+ env:
30
+ GH_TOKEN: ${{ github.token }}
31
+
32
+ mcp-registry:
33
+ needs: pypi
34
+ runs-on: ubuntu-latest
35
+ permissions:
36
+ id-token: write # MCP Registry login via GitHub OIDC
37
+ contents: read
38
+ steps:
39
+ - uses: actions/checkout@v7
40
+ - name: Wait for PyPI to serve the new version
41
+ run: |
42
+ for i in $(seq 1 30); do
43
+ curl -sf "https://pypi.org/pypi/basalam-mcp/${GITHUB_REF_NAME#v}/json" >/dev/null && exit 0
44
+ sleep 10
45
+ done
46
+ exit 1
47
+ - name: Install mcp-publisher
48
+ run: |
49
+ curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
50
+ - run: ./mcp-publisher login github-oidc
51
+ - run: ./mcp-publisher publish
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sepehr Radmard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: basalam-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for Basalam: search marketplace products, find the cheapest offers, read shops, categories and reviews.
5
+ Project-URL: Homepage, https://github.com/sepehr071/basalam-mcp
6
+ Project-URL: Issues, https://github.com/sepehr071/basalam-mcp/issues
7
+ Author-email: Sepehr Radmard <sepehrr80@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: basalam,iran,marketplace,mcp,model-context-protocol,price-comparison
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: curl-cffi>=0.16.3
22
+ Requires-Dist: httpx-curl-cffi>=0.1.5
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: mcp<3,>=2.2
25
+ Description-Content-Type: text/markdown
26
+
27
+ <!-- mcp-name: io.github.sepehr071/basalam-mcp -->
28
+
29
+ # basalam-mcp: Basalam marketplace MCP server for AI agents
30
+
31
+ **Let your AI agent shop on Basalam (باسلام).** An unofficial, read-only MCP server for Iran's marketplace of
32
+ independent shops: search products with price, category, shipping and discount filters, find the cheapest in-stock
33
+ offers, read product details, shops, categories and customer reviews, all from Claude, Cursor or Copilot.
34
+
35
+ ## For AI agents
36
+
37
+ - **What:** unofficial MCP server for Basalam (basalam.com). Queries in Persian match best; prices in Toman.
38
+ - **Install:** `uvx basalam-mcp` or `claude mcp add basalam -- uvx basalam-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python 3.10+).
39
+ - **Auth:** none, no API key or account. Basalam answers from non-Iranian IPs too.
40
+ - **Optional env:** `BASALAM_MCP_PROXY`, an HTTP proxy URL (or a comma-separated list). System proxy settings are ignored on purpose.
41
+ - **Read-only:** no login, basket, order, chat or review posting.
42
+
43
+ ## Quick start
44
+
45
+ ```bash
46
+ claude mcp add basalam -- uvx basalam-mcp
47
+ ```
48
+
49
+ Or in any MCP client config: `{"command": "uvx", "args": ["basalam-mcp"]}`.
50
+
51
+ ## Tools
52
+
53
+ | Tool | What it does |
54
+ |---|---|
55
+ | `bs_search` | Search products; filters: price range (Toman), category, free shipping, discount, top shops, installments; sort by relevance, sales, price, newest. |
56
+ | `bs_find_cheapest` | Cheapest in-stock offers for a query, cheapest first, with a price floor and optional minimum rating. |
57
+ | `bs_product` | One product: price, discount, stock, sales count, free-shipping threshold, options (sizes, colors), attributes, rating, shop. |
58
+ | `bs_vendor` | A shop's profile: city, activity, product count, shipping methods. |
59
+ | `bs_vendor_products` | One shop's products, searchable, filterable and sortable; an unknown shop is an error. |
60
+ | `bs_categories` | The category tree: top level, children of an id, or search by name (inside `parent_id` when both are given). Ids feed `category_id`. |
61
+ | `bs_reviews` | Customer reviews with the star histogram of the product and its shop. |
62
+
63
+ ## Notes
64
+
65
+ - Prices are Toman. The upstream API reports Rial; the server divides by 10.
66
+ - Result totals stop at 10000 (the search index cap). Pages hold at most 30 products (keeps each reply under 15 KB; a product's link is `https://basalam.com/p/<id>`), and a short page is refilled from the following rows, so neighbouring pages can repeat a few products.
67
+ - The cheapest sort is full of filler listings priced at a few hundred Toman; `bs_find_cheapest` hides prices under 5000 Toman by default.
68
+ - Dates and times are passed through as Basalam returns them (Iran local time, not converted; unconfirmed). The vendor API's own `score` field is always 0, so it is not exposed; use the shop stars from `bs_reviews`.
69
+ - Review and description text is user-generated and cut to 400 characters unless `full_text=true`.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ uv sync
75
+ uv run ruff check . && uv run ruff format --check .
76
+ uv run python -m pytest -q # offline unit tests
77
+ uv run python -m pytest -m live -q # one live test per tool
78
+ uv build
79
+ ```
80
+
81
+ Unofficial: not affiliated with Basalam. MIT License.
@@ -0,0 +1,55 @@
1
+ <!-- mcp-name: io.github.sepehr071/basalam-mcp -->
2
+
3
+ # basalam-mcp: Basalam marketplace MCP server for AI agents
4
+
5
+ **Let your AI agent shop on Basalam (باسلام).** An unofficial, read-only MCP server for Iran's marketplace of
6
+ independent shops: search products with price, category, shipping and discount filters, find the cheapest in-stock
7
+ offers, read product details, shops, categories and customer reviews, all from Claude, Cursor or Copilot.
8
+
9
+ ## For AI agents
10
+
11
+ - **What:** unofficial MCP server for Basalam (basalam.com). Queries in Persian match best; prices in Toman.
12
+ - **Install:** `uvx basalam-mcp` or `claude mcp add basalam -- uvx basalam-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python 3.10+).
13
+ - **Auth:** none, no API key or account. Basalam answers from non-Iranian IPs too.
14
+ - **Optional env:** `BASALAM_MCP_PROXY`, an HTTP proxy URL (or a comma-separated list). System proxy settings are ignored on purpose.
15
+ - **Read-only:** no login, basket, order, chat or review posting.
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ claude mcp add basalam -- uvx basalam-mcp
21
+ ```
22
+
23
+ Or in any MCP client config: `{"command": "uvx", "args": ["basalam-mcp"]}`.
24
+
25
+ ## Tools
26
+
27
+ | Tool | What it does |
28
+ |---|---|
29
+ | `bs_search` | Search products; filters: price range (Toman), category, free shipping, discount, top shops, installments; sort by relevance, sales, price, newest. |
30
+ | `bs_find_cheapest` | Cheapest in-stock offers for a query, cheapest first, with a price floor and optional minimum rating. |
31
+ | `bs_product` | One product: price, discount, stock, sales count, free-shipping threshold, options (sizes, colors), attributes, rating, shop. |
32
+ | `bs_vendor` | A shop's profile: city, activity, product count, shipping methods. |
33
+ | `bs_vendor_products` | One shop's products, searchable, filterable and sortable; an unknown shop is an error. |
34
+ | `bs_categories` | The category tree: top level, children of an id, or search by name (inside `parent_id` when both are given). Ids feed `category_id`. |
35
+ | `bs_reviews` | Customer reviews with the star histogram of the product and its shop. |
36
+
37
+ ## Notes
38
+
39
+ - Prices are Toman. The upstream API reports Rial; the server divides by 10.
40
+ - Result totals stop at 10000 (the search index cap). Pages hold at most 30 products (keeps each reply under 15 KB; a product's link is `https://basalam.com/p/<id>`), and a short page is refilled from the following rows, so neighbouring pages can repeat a few products.
41
+ - The cheapest sort is full of filler listings priced at a few hundred Toman; `bs_find_cheapest` hides prices under 5000 Toman by default.
42
+ - Dates and times are passed through as Basalam returns them (Iran local time, not converted; unconfirmed). The vendor API's own `score` field is always 0, so it is not exposed; use the shop stars from `bs_reviews`.
43
+ - Review and description text is user-generated and cut to 400 characters unless `full_text=true`.
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ uv sync
49
+ uv run ruff check . && uv run ruff format --check .
50
+ uv run python -m pytest -q # offline unit tests
51
+ uv run python -m pytest -m live -q # one live test per tool
52
+ uv build
53
+ ```
54
+
55
+ Unofficial: not affiliated with Basalam. MIT License.
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "basalam-mcp"
3
+ version = "0.1.0"
4
+ description = "Unofficial read-only MCP server for Basalam: search marketplace products, find the cheapest offers, read shops, categories and reviews."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Sepehr Radmard", email = "sepehrr80@gmail.com" }]
9
+ requires-python = ">=3.10"
10
+ keywords = ["mcp", "model-context-protocol", "basalam", "iran", "marketplace", "price-comparison"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = [
23
+ "mcp>=2.2,<3",
24
+ "httpx>=0.27",
25
+ "httpx-curl-cffi>=0.1.5",
26
+ "curl-cffi>=0.16.3",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/sepehr071/basalam-mcp"
31
+ Issues = "https://github.com/sepehr071/basalam-mcp/issues"
32
+
33
+ [project.scripts]
34
+ basalam-mcp = "basalam_mcp.server:main"
35
+
36
+ [dependency-groups]
37
+ dev = ["pytest>=8", "ruff>=0.6"]
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ addopts = "-m 'not live'"
46
+ markers = ["live: calls the real Basalam APIs (run with -m live)"]
47
+
48
+ [tool.ruff]
49
+ line-length = 120
50
+ target-version = "py310"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "B", "UP"]
54
+ ignore = ["E501"] # long Field(description=...) strings read better on one line
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.sepehr071/basalam-mcp",
4
+ "title": "Basalam",
5
+ "description": "Unofficial read-only Basalam marketplace: search products, cheapest offers, shops, reviews.",
6
+ "repository": {
7
+ "url": "https://github.com/sepehr071/basalam-mcp",
8
+ "source": "github"
9
+ },
10
+ "websiteUrl": "https://github.com/sepehr071/basalam-mcp",
11
+ "version": "0.1.0",
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "registryBaseUrl": "https://pypi.org",
16
+ "identifier": "basalam-mcp",
17
+ "version": "0.1.0",
18
+ "runtimeHint": "uvx",
19
+ "transport": {
20
+ "type": "stdio"
21
+ },
22
+ "environmentVariables": [
23
+ {
24
+ "name": "BASALAM_MCP_PROXY",
25
+ "description": "Optional HTTP proxy URL; system proxy settings are ignored. Also accepts a comma-separated list: one is used until it gets blocked or fails to connect, then the next.",
26
+ "isRequired": false,
27
+ "isSecret": false
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,5 @@
1
+ """Unofficial MCP server for the Basalam marketplace."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("basalam-mcp")
@@ -0,0 +1,260 @@
1
+ """Product search, cheapest offers and the category tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from pydantic import Field
8
+
9
+ from .http import CORE, SERVICES, fetch
10
+ from .registry import clip, tool
11
+
12
+ SORTS = {"relevance": "rank", "bestselling": "sales", "cheapest": "price", "expensive": "pricedesc", "newest": "newest"}
13
+
14
+ Query = Annotated[
15
+ str, Field(min_length=1, pattern=r"\S", description="Search words, Persian matches best, e.g. 'عسل طبیعی'.")
16
+ ]
17
+ MinPrice = Annotated[int | None, Field(ge=0, description="Lowest price in Toman, e.g. 100000.")]
18
+ MaxPrice = Annotated[int | None, Field(ge=0, description="Highest price in Toman, e.g. 500000.")]
19
+ CategoryId = Annotated[
20
+ int | None, Field(ge=1, description="Category id from bs_categories (any level), e.g. 172 for honey.")
21
+ ]
22
+ FreeShipping = Annotated[bool, Field(description="Only products with free shipping to the whole country.")]
23
+ HasDiscount = Annotated[bool, Field(description="Only products with a discount or coupon.")]
24
+ TopVendors = Annotated[bool, Field(description="Only products from top-rated shops (غرفه برتر).")]
25
+ Installments = Annotated[bool, Field(description="Only products that can be bought in installments (قسطی).")]
26
+
27
+
28
+ def toman(rial: float | int | None) -> int | None:
29
+ """Basalam prices are Rial; the tools speak Toman."""
30
+ return None if rial is None else round(rial / 10)
31
+
32
+
33
+ def card(p: dict[str, Any]) -> dict[str, Any]:
34
+ """One compact product row from a search hit."""
35
+ price, old = p.get("price"), p.get("primaryPrice")
36
+ v, r = p.get("vendor") or {}, p.get("rating") or {}
37
+ return {
38
+ "id": p["id"],
39
+ "name": clip(p.get("name"), n=100),
40
+ "price": toman(price),
41
+ "original_price": toman(old) if old and price and old > price else None,
42
+ "discount_pct": round((1 - price / old) * 100) if old and price and old > price else None,
43
+ "rating": r.get("average") or None,
44
+ "rating_count": r.get("count") or None,
45
+ "free_shipping": p.get("isFreeShipping") or None,
46
+ "in_stock": p.get("IsAvailable"),
47
+ "stock": p.get("stock"),
48
+ "unit": p.get("mainAttribute"),
49
+ "category": p.get("categoryTitle"),
50
+ "shop": v.get("name"),
51
+ "shop_id": v.get("identifier"),
52
+ }
53
+
54
+
55
+ def search_params(
56
+ query: str | None,
57
+ sort: str,
58
+ min_price: int | None,
59
+ max_price: int | None,
60
+ category_id: int | None,
61
+ free_shipping: bool,
62
+ has_discount: bool,
63
+ top_vendors: bool,
64
+ installments: bool,
65
+ vendor: str | None,
66
+ start: int,
67
+ size: int,
68
+ ) -> dict[str, Any]:
69
+ flags = {
70
+ "freeShipping": free_shipping,
71
+ "hasDiscount": has_discount,
72
+ "vendorScore": top_vendors,
73
+ "salampay": installments,
74
+ }
75
+ p: dict[str, Any] = {"q": query, "sort": SORTS[sort], "from": start, "size": size}
76
+ p["filters.minPrice"] = min_price * 10 if min_price is not None else None # API takes Rial
77
+ p["filters.maxPrice"] = max_price * 10 if max_price is not None else None
78
+ p["filters.categories"] = category_id
79
+ p["filters.vendorIdentifier"] = vendor
80
+ p.update({f"filters.{k}": "true" for k, on in flags.items() if on})
81
+ return p
82
+
83
+
84
+ async def run_search(**kw: Any) -> tuple[list[dict[str, Any]], int]:
85
+ """Fetch one search page; return deduplicated cards and the total match count (the API caps it at 10000).
86
+
87
+ The API drops duplicate rows, so a page can come back short; the missing ones are fetched from the rows after it
88
+ (at most 5 requests), which means neighbouring pages can repeat a few products.
89
+ """
90
+ start, size = kw.pop("start"), kw.pop("size")
91
+ seen: set[int] = set()
92
+ cards: list[dict[str, Any]] = []
93
+ total = 0
94
+ for _ in range(5):
95
+ want = size - len(cards)
96
+ body = await fetch(f"{SERVICES}/search/product/search", search_params(**kw, start=start, size=want))
97
+ rows = body.get("products", [])
98
+ for p in rows:
99
+ if p["id"] not in seen:
100
+ seen.add(p["id"])
101
+ cards.append(card(p))
102
+ total = (body.get("meta") or {}).get("count", len(cards))
103
+ start += want
104
+ if len(cards) >= size or not rows or start >= total:
105
+ break
106
+ return cards, total
107
+
108
+
109
+ @tool("Search products")
110
+ async def bs_search(
111
+ query: Query,
112
+ sort: Annotated[
113
+ Literal["relevance", "bestselling", "cheapest", "expensive", "newest"], Field(description="Result order.")
114
+ ] = "relevance",
115
+ min_price: MinPrice = None,
116
+ max_price: MaxPrice = None,
117
+ category_id: CategoryId = None,
118
+ free_shipping: FreeShipping = False,
119
+ has_discount: HasDiscount = False,
120
+ top_vendors: TopVendors = False,
121
+ installments: Installments = False,
122
+ page: Annotated[int, Field(ge=1, le=200, description="Page number, 1-based.")] = 1,
123
+ limit: Annotated[
124
+ int, Field(ge=1, le=30, description="Products per page (max 30, keeps the reply under 15 KB).")
125
+ ] = 12,
126
+ ) -> dict[str, Any]:
127
+ """Search Basalam products and return a page of cards with prices in Toman.
128
+
129
+ Use for browsing a product type; the filters and sort run on the server. To rank by real price use
130
+ bs_find_cheapest, for one product bs_product, for one shop's catalog bs_vendor_products. A product's link is
131
+ https://basalam.com/p/<id>; neighbouring pages can repeat a few products.
132
+ """
133
+ cards, total = await run_search(
134
+ query=query,
135
+ sort=sort,
136
+ min_price=min_price,
137
+ max_price=max_price,
138
+ category_id=category_id,
139
+ free_shipping=free_shipping,
140
+ has_discount=has_discount,
141
+ top_vendors=top_vendors,
142
+ installments=installments,
143
+ vendor=None,
144
+ start=(page - 1) * limit,
145
+ size=limit,
146
+ )
147
+ return {"total": total, "page": page, "products": cards[:limit]}
148
+
149
+
150
+ @tool("Find cheapest products")
151
+ async def bs_find_cheapest(
152
+ query: Query,
153
+ min_price: Annotated[
154
+ int,
155
+ Field(
156
+ ge=0,
157
+ description="Lowest price in Toman. Default 5000 hides sample or filler listings that cost a few hundred.",
158
+ ),
159
+ ] = 5000,
160
+ max_price: MaxPrice = None,
161
+ category_id: CategoryId = None,
162
+ free_shipping: FreeShipping = False,
163
+ top_vendors: TopVendors = False,
164
+ rating_min: Annotated[
165
+ float, Field(ge=0, le=5, description="Drop products rated below this; above 0 also drops unrated ones.")
166
+ ] = 0,
167
+ limit: Annotated[int, Field(ge=1, le=30, description="Offers to return.")] = 10,
168
+ ) -> dict[str, Any]:
169
+ """List the cheapest in-stock offers for a query, cheapest first, prices in Toman.
170
+
171
+ Reads the server's price-ascending sort (two pages of 48), drops out-of-stock items and, with rating_min,
172
+ low-rated ones. Raise min_price when results are still accessories; narrow with category_id from bs_categories.
173
+ """
174
+ offers: list[dict[str, Any]] = []
175
+ total = 0
176
+ for start in (0, 48):
177
+ cards, total = await run_search(
178
+ query=query,
179
+ sort="cheapest",
180
+ min_price=min_price,
181
+ max_price=max_price,
182
+ category_id=category_id,
183
+ free_shipping=free_shipping,
184
+ has_discount=False,
185
+ top_vendors=top_vendors,
186
+ installments=False,
187
+ vendor=None,
188
+ start=start,
189
+ size=48,
190
+ )
191
+ offers += [c for c in cards if c["in_stock"] is not False and (c.get("rating") or 0) >= rating_min]
192
+ if len(offers) >= limit or len(cards) < 48:
193
+ break
194
+ offers.sort(key=lambda c: c["price"] or 0)
195
+ return {"total_matches": total, "offers": offers[:limit]}
196
+
197
+
198
+ def _node(c: dict[str, Any]) -> dict[str, Any]:
199
+ return {"id": c["id"], "title": c["title"], "children": len(c.get("children") or [])}
200
+
201
+
202
+ def _find(nodes: list[dict[str, Any]], cid: int) -> dict[str, Any] | None:
203
+ for n in nodes:
204
+ if n["id"] == cid:
205
+ return n
206
+ if hit := _find(n.get("children") or [], cid):
207
+ return hit
208
+ return None
209
+
210
+
211
+ @tool("List categories")
212
+ async def bs_categories(
213
+ parent_id: Annotated[
214
+ int | None, Field(ge=1, description="Category id to list the children of, e.g. 1 (food). Omit for top level.")
215
+ ] = None,
216
+ query: Annotated[
217
+ str | None,
218
+ Field(
219
+ min_length=2,
220
+ description="Find categories whose title contains this, at any depth (inside parent_id when given), e.g. 'عسل'.",
221
+ ),
222
+ ] = None,
223
+ ) -> dict[str, Any]:
224
+ """Browse Basalam's category tree and return ids for the category_id filter of bs_search.
225
+
226
+ Without arguments lists the 12 top-level categories; pass parent_id to go one level down, or query to
227
+ find a category by name anywhere in the tree (returns its path). With both, query searches only inside
228
+ parent_id's subtree. Ids at any level work as filters.
229
+ """
230
+ tree = await fetch(f"{CORE}/category")
231
+ if query:
232
+ root = tree
233
+ base: list[str] = []
234
+ if parent_id is not None:
235
+ parent = _find(tree, parent_id)
236
+ if parent is None:
237
+ return {"categories": [], "note": f"No category {parent_id}."}
238
+ root, base = parent.get("children") or [], [parent["title"]]
239
+
240
+ def norm(s: str) -> str: # ZWNJ as space, Arabic yeh/kaf as Persian
241
+ return s.replace("\u200c", " ").replace("\u064a", "\u06cc").replace("\u0643", "\u06a9")
242
+
243
+ q = norm(query)
244
+ hits: list[dict[str, Any]] = []
245
+
246
+ def walk(nodes: list[dict[str, Any]], path: list[str]) -> None:
247
+ for n in nodes:
248
+ here = [*path, n["title"]]
249
+ if q in norm(n["title"]) and len(hits) < 30:
250
+ hits.append({**_node(n), "path": " > ".join(here)})
251
+ walk(n.get("children") or [], here)
252
+
253
+ walk(root, base)
254
+ return {"categories": hits}
255
+ if parent_id is None:
256
+ return {"categories": [_node(c) for c in tree]}
257
+ parent = _find(tree, parent_id)
258
+ if parent is None:
259
+ return {"categories": [], "note": f"No category {parent_id}."}
260
+ return {"parent": parent["title"], "categories": [_node(c) for c in parent.get("children") or []]}