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.
- basalam_mcp-0.1.0/.github/workflows/ci.yml +22 -0
- basalam_mcp-0.1.0/.github/workflows/publish.yml +51 -0
- basalam_mcp-0.1.0/.gitignore +6 -0
- basalam_mcp-0.1.0/LICENSE +21 -0
- basalam_mcp-0.1.0/PKG-INFO +81 -0
- basalam_mcp-0.1.0/README.md +55 -0
- basalam_mcp-0.1.0/pyproject.toml +54 -0
- basalam_mcp-0.1.0/server.json +32 -0
- basalam_mcp-0.1.0/src/basalam_mcp/__init__.py +5 -0
- basalam_mcp-0.1.0/src/basalam_mcp/catalog.py +260 -0
- basalam_mcp-0.1.0/src/basalam_mcp/http.py +233 -0
- basalam_mcp-0.1.0/src/basalam_mcp/registry.py +21 -0
- basalam_mcp-0.1.0/src/basalam_mcp/server.py +87 -0
- basalam_mcp-0.1.0/src/basalam_mcp/shop.py +217 -0
- basalam_mcp-0.1.0/tests/conftest.py +72 -0
- basalam_mcp-0.1.0/tests/fixtures/categories.json +235 -0
- basalam_mcp-0.1.0/tests/fixtures/product.json +327 -0
- basalam_mcp-0.1.0/tests/fixtures/reviews.json +105 -0
- basalam_mcp-0.1.0/tests/fixtures/search.json +242 -0
- basalam_mcp-0.1.0/tests/fixtures/vendor.json +65 -0
- basalam_mcp-0.1.0/tests/test_basalam.py +265 -0
- basalam_mcp-0.1.0/tests/test_live_basalam.py +67 -0
- basalam_mcp-0.1.0/uv.lock +1247 -0
|
@@ -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,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,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 []]}
|