vkusvill 0.0.0-dev.0 → 0.0.0-stage
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.
- package/README.md +2 -90
- package/package.json +4 -37
- package/LICENSE +0 -21
- package/README.ru.md +0 -89
- package/bin/vv.js +0 -12
- package/src/api.js +0 -228
- package/src/app.js +0 -328
- package/src/cli.js +0 -159
- package/src/image.js +0 -144
- package/src/main.js +0 -113
- package/src/product.js +0 -135
- package/src/safe.js +0 -37
- package/src/store.js +0 -190
- package/src/sys.js +0 -36
- package/src/term/input.js +0 -131
- package/src/term/screen.js +0 -204
- package/src/term/width.js +0 -74
- package/src/ui/draw.js +0 -324
- package/src/ui/product-ui.js +0 -192
- package/src/ui/theme.js +0 -47
- package/src/ui/widgets.js +0 -236
- package/src/views/browser.js +0 -251
- package/src/views/cart.js +0 -306
- package/src/views/catalog.js +0 -150
- package/src/views/overlays.js +0 -324
- package/src/views/product.js +0 -308
- package/src/views/profile.js +0 -228
- package/src/views/recipes.js +0 -358
- package/src/views/shops.js +0 -396
package/README.md
CHANGED
|
@@ -1,91 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Temporary Holding Version
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
An unofficial VkusVill client for the terminal. A full-screen app drawn in text graphics on top of the [official VkusVill MCP server](https://github.com/vkustech/vkusvill-mcp-server-manifest): the catalog, deals, recipes, shops and a cart you check out on your phone by scanning a QR code straight from the terminal.
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npx vkusvill-cli # once published to npm
|
|
9
|
-
npx ./vv-cli # from this folder, today
|
|
10
|
-
npx vkusvill-cli сырники # open straight into a search
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Needs Node 18.17+ and a terminal of at least 64×18. It looks best with 24-bit colour: iTerm2, WezTerm, Ghostty, kitty, the VS Code terminal. Terminal.app is limited to 256 colours.
|
|
14
|
-
|
|
15
|
-
The interface speaks Russian, like the store.
|
|
16
|
-
|
|
17
|
-
## What's inside
|
|
18
|
-
|
|
19
|
-
- **Catalog.** Search as you type, categories from VkusVill's answer, suggestions and recent searches. An empty search shows today's deals. The side card shows the photo in half-blocks, the price in postal-envelope digits, the rating, nutrition facts with the energy split, ingredient flags and the ingredient list with sugar, additives, gluten and lactose highlighted.
|
|
20
|
-
- **Sorting and filters.** VkusVill's own sorts plus local ones over loaded results: price per kg, protein per rouble, calories, discount size. Filters: no sugar, gluten, lactose, additives or palm oil; on sale only; rating 4.8+; protein 10 g+; under 150 kcal. When filters leave too few results, more pages load by themselves.
|
|
21
|
-
- **Product page.** Photo gallery, a Braille doughnut of the protein/fat/carb energy split, description and every property (shelf life, storage, maker, allergens). `[` and `]` step through neighbouring products, `a` shows analogues.
|
|
22
|
-
- **Compare.** Up to four products side by side, with the best value in each row marked.
|
|
23
|
-
- **Deals.** Loyalty-card discounts and "buy N or more" offers, VkusVill brand only or every brand.
|
|
24
|
-
- **Recipes.** VkusVill's search and filters: exclude allergens, cooking time, difficulty, method. A recipe page has the steps and an ingredient checklist; `+` puts every ticked ingredient in the cart.
|
|
25
|
-
- **Shops.** Region, city, metro and amenities. City and metro lists narrow to the chosen region. Search by address with `/`, an "open now" count, opening hours turned into "open until 23:00" or "closed", courier-only dark stores marked as such. The map is drawn in Braille dots over the outline of the MKAD ring road and the Garden Ring; `z` zooms to the selected shop, `L` loads every shop of the filter onto the map.
|
|
26
|
-
- **Cart.** Quantities change with the arrows; the total is in big digits, with savings from deals, the nutrition of the whole cart and a breakdown by category. Prices older than six hours are re-checked quietly. Checkout creates a VkusVill link and shows a QR code: point your phone at it and the cart opens in the VkusVill app. Payment and delivery stay with VkusVill.
|
|
27
|
-
- **Profile.** Favourites and history are kept locally. Orders, "favourite product" and personal discounts are only available to signed-in users; pass a token via `VV_TOKEN` if you have one.
|
|
28
|
-
- **Everywhere.** Mouse (click, double click, wheel), a command palette on `Ctrl+K`, barcode lookup (`b`), help (`?`). Hotkeys also work with the Russian keyboard layout. The last tab, sorts and filters are remembered between runs.
|
|
29
|
-
|
|
30
|
-
## Keys
|
|
31
|
-
|
|
32
|
-
| Key | Action |
|
|
33
|
-
|---|---|
|
|
34
|
-
| `1`…`6`, `Tab` | tabs |
|
|
35
|
-
| `/` | search |
|
|
36
|
-
| `↑↓`, `j k` | move |
|
|
37
|
-
| `⏎` | open |
|
|
38
|
-
| `+ −` | add to / remove from cart |
|
|
39
|
-
| `*` | favourite |
|
|
40
|
-
| `s`, `f`, `g` | sort, filters, category |
|
|
41
|
-
| `x`, `X` | mark for comparison, compare |
|
|
42
|
-
| `o` | open on the website; in the cart — check out |
|
|
43
|
-
| `Ctrl+K` | command palette |
|
|
44
|
-
| `?` | all keys |
|
|
45
|
-
| `esc` | back |
|
|
46
|
-
| `q` | quit |
|
|
47
|
-
|
|
48
|
-
## Commands for scripts
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
vv search творог --sort price_asc # a table; --json prints VkusVill's answer
|
|
52
|
-
vv deals --quantity
|
|
53
|
-
vv product 27695
|
|
54
|
-
vv barcode 4607167640050
|
|
55
|
-
vv recipes тыква
|
|
56
|
-
vv shops Речной вокзал
|
|
57
|
-
vv cart add 27695 2
|
|
58
|
-
vv cart link # link and QR code right in the output
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## How it works
|
|
62
|
-
|
|
63
|
-
- `src/term` — a dependency-free terminal engine: a cell buffer that sends only changed cells, synchronised output without flicker, truecolor with a 256-colour fallback, SGR mouse, bracketed paste, wide characters.
|
|
64
|
-
- `src/api.js` — JSON-RPC to `https://mcp.vkusvill.ru/mcp`. A disk cache (catalog 6 h, deals 1 h, shops a week, photos a month), a priority queue where interactive requests go before background ones, at most 55 requests a minute, retries on 429.
|
|
65
|
-
- `src/image.js` — WebP photos are decoded inside Node via wasm (`@jsquash/webp`), no system libraries. Plain margins are trimmed and the picture is averaged down to the cell grid.
|
|
66
|
-
- Big prices are drawn with background-filled cells rather than glyphs, so they stay solid whatever the line spacing.
|
|
67
|
-
- The QR code comes from `qrcode-generator`; `test/qr-check.mjs` decodes the code rebuilt from screen cells.
|
|
68
|
-
|
|
69
|
-
## Privacy and safety
|
|
70
|
-
|
|
71
|
-
- **Nothing leaves your machine except requests to VkusVill.** No analytics, no third-party servers.
|
|
72
|
-
- **Text from the network is cleaned** before it reaches the screen or the command output: terminal control characters (ESC, C1 codes such as `\x9b`) and bidi overrides are removed, so a product name cannot drive your terminal or disguise itself.
|
|
73
|
-
- **Links open only to `https://` VkusVill and Yandex hosts**, without a shell (on Windows via `rundll32`, not `cmd`). Photos load only from VkusVill hosts, with size limits.
|
|
74
|
-
- **The token is sent to `mcp.vkusvill.ru` only**, even if `VV_ENDPOINT` points elsewhere. Orders, personal discounts and personalised results are never written to disk.
|
|
75
|
-
- **State** lives in `~/.config/vv-cli/state.json`, readable by you only (0600). Writes are atomic; a damaged file is kept as `state.json.broken-<time>` instead of being overwritten; a second running copy or `vv cart add` updates the open app instead of clobbering it.
|
|
76
|
-
- **The cache** lives in `~/Library/Caches/vv-cli` or `~/.cache/vv-cli`, expires by itself and is capped at 300 MB. Crashes are logged to `vv-cli.log` next to it.
|
|
77
|
-
|
|
78
|
-
Ingredient flags ("sugar", "additives ×3" and so on) are heuristics over the first manufacturer's ingredient list, not medical advice. Shop hours are compared with Moscow time.
|
|
79
|
-
|
|
80
|
-
## Development
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
npm install
|
|
84
|
-
node bin/vv.js --no-splash
|
|
85
|
-
npm test # offline checks: terminal safety, links, state file, command line
|
|
86
|
-
node test/snap.js 120 36 # scripted run without a terminal → test/out/*.html (xterm.js) and gallery.html
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
Environment: `VV_TOKEN`, `VV_COLOR=256|truecolor`, `NO_COLOR`, `VV_NO_SPLASH`, `VV_CACHE_DIR`, `VV_CONFIG_DIR`, `VV_ENDPOINT`.
|
|
90
|
-
|
|
91
|
-
Not affiliated with VkusVill. Data and trademarks belong to their owners.
|
|
3
|
+
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
package/package.json
CHANGED
|
@@ -1,39 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vkusvill",
|
|
3
|
-
"version": "0.0.0-
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"vkusvill-cli": "bin/vv.js",
|
|
8
|
-
"vv": "bin/vv.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"bin",
|
|
12
|
-
"src",
|
|
13
|
-
"README.md",
|
|
14
|
-
"README.ru.md",
|
|
15
|
-
"LICENSE"
|
|
16
|
-
],
|
|
17
|
-
"engines": {
|
|
18
|
-
"node": ">=18.17"
|
|
19
|
-
},
|
|
20
|
-
"scripts": {
|
|
21
|
-
"start": "node bin/vv.js",
|
|
22
|
-
"test": "node test/check.mjs",
|
|
23
|
-
"snap": "node test/snap.js"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"vkusvill",
|
|
27
|
-
"вкусвилл",
|
|
28
|
-
"tui",
|
|
29
|
-
"cli",
|
|
30
|
-
"mcp",
|
|
31
|
-
"terminal",
|
|
32
|
-
"grocery"
|
|
33
|
-
],
|
|
34
|
-
"license": "MIT",
|
|
35
|
-
"dependencies": {
|
|
36
|
-
"@jsquash/webp": "1.5.0",
|
|
37
|
-
"qrcode-generator": "1.5.2"
|
|
38
|
-
}
|
|
39
|
-
}
|
|
3
|
+
"version": "0.0.0-stage",
|
|
4
|
+
"stub": true,
|
|
5
|
+
"description": "Temporary package placeholder for staged publishing"
|
|
6
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Anton Golub
|
|
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.
|
package/README.ru.md
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# vkusvill-cli
|
|
2
|
-
|
|
3
|
-
[English](README.md) · **Русский**
|
|
4
|
-
|
|
5
|
-
Неофициальный ВкусВилл в терминале. Полноэкранное приложение на псевдографике поверх [официального MCP-сервера ВкусВилла](https://github.com/vkustech/vkusvill-mcp-server-manifest): каталог, акции, рецепты, магазины и корзина, которую можно оформить на телефоне по QR-коду прямо из терминала.
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npx vkusvill-cli # после публикации в npm
|
|
9
|
-
npx ./vv-cli # из этой папки, уже сейчас
|
|
10
|
-
npx vkusvill-cli сырники # открыть сразу с поиском
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Нужен Node 18.17+ и терминал от 64×18. Лучше всего выглядит с 24-битным цветом: iTerm2, WezTerm, Ghostty, kitty, терминал VS Code. В Terminal.app цветов 256.
|
|
14
|
-
|
|
15
|
-
## Что внутри
|
|
16
|
-
|
|
17
|
-
- **Каталог.** Поиск по мере набора, категории из ответа ВкусВилла, подсказки и недавние запросы. Пустой запрос показывает товары дня со скидкой. Справа карточка: фото полублоками, цена цифрами почтового индекса, рейтинг, КБЖУ и доли энергии, флаги состава и сам состав с подсветкой сахара, добавок, глютена и лактозы.
|
|
18
|
-
- **Сортировка и фильтры.** Сортировки ВкусВилла плюс свои по загруженному: цена за кг, белок на рубль, калории, скидка. Фильтры: без сахара, глютена, лактозы, добавок или пальмового масла, только со скидкой, рейтинг от 4,8, белок от 10 г, до 150 ккал. Если фильтры отсеяли почти всё, страницы догружаются сами.
|
|
19
|
-
- **Карточка товара.** Галерея фото, «бублик» энергии БЖУ точками Брайля, описание, все свойства (срок годности, хранение, производитель, аллергены). `[` и `]` листают соседние товары, `a` показывает аналоги.
|
|
20
|
-
- **Сравнение.** До четырёх товаров бок о бок, лучшее значение в каждой строке помечено.
|
|
21
|
-
- **Акции.** Скидки по карте и «от N штук», только ВкусВилл или все бренды.
|
|
22
|
-
- **Рецепты.** Поиск и фильтры ВкусВилла: исключить аллергены, время, сложность, способ готовки. На странице рецепта шаги и чек-лист ингредиентов; `+` кладёт все отмеченные ингредиенты в корзину.
|
|
23
|
-
- **Магазины.** Регион, город, метро, особенности. Списки городов и метро сужаются по выбранному региону. Поиск по адресу клавишей `/`, сводка «открыто сейчас», часы работы превращаются в «открыт до 23:00» или «закрыт», склады «только для курьеров» помечены отдельно. Карта нарисована точками Брайля поверх контура МКАД и Садового кольца; `z` приближает к выбранному магазину, `L` загружает на карту все магазины фильтра.
|
|
24
|
-
- **Корзина.** Количество меняется стрелками; итог крупными цифрами, экономия по акциям, КБЖУ всей корзины и доли по категориям. Цены старше шести часов сверяются сами. Оформление создаёт ссылку ВкусВилла и показывает QR-код: наведите камеру телефона, и корзина откроется в приложении ВкусВилла. Оплата и доставка остаются на стороне ВкусВилла.
|
|
25
|
-
- **Профиль.** Избранное и история хранятся локально. Заказы, любимый продукт и «6 скидок» ВкусВилл отдаёт только после входа; если у вас есть токен, передайте его через `VV_TOKEN`.
|
|
26
|
-
- **Везде.** Мышь (клик, двойной клик, колесо), палитра команд по `Ctrl+K`, поиск по штрихкоду (`b`), справка (`?`). Горячие клавиши работают и в русской раскладке. Последняя вкладка, сортировки и фильтры запоминаются между запусками.
|
|
27
|
-
|
|
28
|
-
## Клавиши
|
|
29
|
-
|
|
30
|
-
| Клавиша | Действие |
|
|
31
|
-
|---|---|
|
|
32
|
-
| `1`…`6`, `Tab` | вкладки |
|
|
33
|
-
| `/` | поиск |
|
|
34
|
-
| `↑↓`, `j k` | выбор |
|
|
35
|
-
| `⏎` | открыть |
|
|
36
|
-
| `+ −` | в корзину / убрать |
|
|
37
|
-
| `*` | избранное |
|
|
38
|
-
| `s`, `f`, `g` | сортировка, фильтры, категория |
|
|
39
|
-
| `x`, `X` | отметить для сравнения, сравнить |
|
|
40
|
-
| `o` | открыть на сайте; в корзине — оформить |
|
|
41
|
-
| `Ctrl+K` | палитра команд |
|
|
42
|
-
| `?` | все клавиши |
|
|
43
|
-
| `esc` | назад |
|
|
44
|
-
| `q` | выход |
|
|
45
|
-
|
|
46
|
-
## Команды для скриптов
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
vv search творог --sort price_asc # таблица; --json — ответ ВкусВилла
|
|
50
|
-
vv deals --quantity
|
|
51
|
-
vv product 27695
|
|
52
|
-
vv barcode 4607167640050
|
|
53
|
-
vv recipes тыква
|
|
54
|
-
vv shops Речной вокзал
|
|
55
|
-
vv cart add 27695 2
|
|
56
|
-
vv cart link # ссылка и QR-код прямо в выводе
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## Как это устроено
|
|
60
|
-
|
|
61
|
-
- `src/term` — свой движок терминала без зависимостей: буфер ячеек, в терминал уходят только изменения, синхронный вывод без мерцания, truecolor с откатом на 256 цветов, мышь SGR, вставка из буфера обмена, широкие символы.
|
|
62
|
-
- `src/api.js` — JSON-RPC к `https://mcp.vkusvill.ru/mcp`. Дисковый кэш (каталог 6 ч, акции 1 ч, магазины неделя, фото месяц), очередь с приоритетами: интерактивные запросы идут раньше фоновых, не больше 55 запросов в минуту, повтор при 429.
|
|
63
|
-
- `src/image.js` — фото в WebP декодируются внутри Node через wasm (`@jsquash/webp`), без системных библиотек. Однотонные поля обрезаются, картинка усредняется под сетку ячеек.
|
|
64
|
-
- Крупные цены рисуются ячейками, залитыми фоном, а не символами, поэтому не рвутся при любом межстрочном интервале.
|
|
65
|
-
- QR рисует `qrcode-generator`; `test/qr-check.mjs` распознаёт код, восстановленный из ячеек экрана.
|
|
66
|
-
|
|
67
|
-
## Приватность и безопасность
|
|
68
|
-
|
|
69
|
-
- **С компьютера уходят только запросы к ВкусВиллу.** Никакой аналитики и сторонних серверов.
|
|
70
|
-
- **Текст из сети очищается**, прежде чем попасть на экран или в вывод команд: управляющие символы терминала (ESC, C1-коды вроде `\x9b`) и символы смены направления текста удаляются, так что название товара не может управлять терминалом или выдавать себя за другое.
|
|
71
|
-
- **Ссылки открываются только на `https://`-адреса ВкусВилла и Яндекса**, без оболочки (на Windows — через `rundll32`, а не `cmd`). Фото грузятся только с адресов ВкусВилла и с ограничением размера.
|
|
72
|
-
- **Токен уходит только на `mcp.vkusvill.ru`**, даже если `VV_ENDPOINT` указывает в другое место. Заказы, персональные скидки и персонализированная выдача на диск не пишутся.
|
|
73
|
-
- **Состояние** хранится в `~/.config/vv-cli/state.json`, доступном только вам (0600). Запись атомарная; повреждённый файл сохраняется как `state.json.broken-<время>`, а не затирается; второй запущенный экземпляр или `vv cart add` обновляют открытое приложение, а не перезаписывают его корзину.
|
|
74
|
-
- **Кэш** лежит в `~/Library/Caches/vv-cli` или `~/.cache/vv-cli`, устаревает сам и не превышает 300 МБ. Падения записываются в `vv-cli.log` рядом с ним.
|
|
75
|
-
|
|
76
|
-
Флаги состава («сахар», «добавки ×3» и т. п.) — эвристика по составу первого производителя, а не медицинская оценка. Часы работы магазинов сравниваются с московским временем.
|
|
77
|
-
|
|
78
|
-
## Разработка
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
npm install
|
|
82
|
-
node bin/vv.js --no-splash
|
|
83
|
-
npm test # проверки без сети: безопасность вывода, ссылки, файл состояния, команды
|
|
84
|
-
node test/snap.js 120 36 # сценарий без терминала → test/out/*.html (xterm.js) и gallery.html
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Окружение: `VV_TOKEN`, `VV_COLOR=256|truecolor`, `NO_COLOR`, `VV_NO_SPLASH`, `VV_CACHE_DIR`, `VV_CONFIG_DIR`, `VV_ENDPOINT`.
|
|
88
|
-
|
|
89
|
-
Проект не связан с ВкусВиллом. Данные и товарные знаки принадлежат их владельцам.
|
package/bin/vv.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// VkusVill in the terminal. With no arguments, opens the full-screen interface; with a command, prints plain output for scripts.
|
|
3
|
-
|
|
4
|
-
import { main } from "../src/main.js";
|
|
5
|
-
|
|
6
|
-
// `vv search … | head` closes the pipe early; that is not an error
|
|
7
|
-
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
|
|
8
|
-
|
|
9
|
-
main(process.argv.slice(2)).catch((e) => {
|
|
10
|
-
console.error(e?.message ?? e);
|
|
11
|
-
process.exit(1);
|
|
12
|
-
});
|
package/src/api.js
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
// Client for the official VkusVill MCP server (https://mcp.vkusvill.ru/mcp): JSON-RPC tools/call,
|
|
2
|
-
// a disk cache, a priority queue under the ~60 requests/minute limit and an optional OAuth token.
|
|
3
|
-
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import { mkdir, readFile, writeFile, stat, readdir, unlink, utimes } from "node:fs/promises";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import { clean, cleanDeep } from "./safe.js";
|
|
9
|
-
|
|
10
|
-
export const ENDPOINT = process.env.VV_ENDPOINT ?? "https://mcp.vkusvill.ru/mcp";
|
|
11
|
-
export const TOKEN = process.env.VV_TOKEN || null;
|
|
12
|
-
// The token is only ever sent to the official server, even when VV_ENDPOINT points elsewhere
|
|
13
|
-
const OFFICIAL = (() => {
|
|
14
|
-
try { const u = new URL(ENDPOINT); return u.protocol === "https:" && u.hostname === "mcp.vkusvill.ru"; } catch { return false; }
|
|
15
|
-
})();
|
|
16
|
-
|
|
17
|
-
const MAX_RESPONSE = 20 * 1024 * 1024;
|
|
18
|
-
const MEM_LIMIT = 400;
|
|
19
|
-
const DAY = 24 * 3600e3;
|
|
20
|
-
|
|
21
|
-
function cacheBase() {
|
|
22
|
-
if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
|
|
23
|
-
if (process.platform === "darwin") return path.join(homedir(), "Library", "Caches");
|
|
24
|
-
if (process.platform === "win32" && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
|
|
25
|
-
return path.join(homedir(), ".cache");
|
|
26
|
-
}
|
|
27
|
-
export const CACHE_DIR = process.env.VV_CACHE_DIR ?? path.join(cacheBase(), "vv-cli");
|
|
28
|
-
|
|
29
|
-
const TTL = { default: 6 * 3600e3, vkusvill_shops: 7 * DAY, vkusvill_recipes: DAY, vkusvill_products_discount: 3600e3 };
|
|
30
|
-
// Personal data and one-off links never touch the disk
|
|
31
|
-
const NO_CACHE = new Set(["vkusvill_cart_link_create", "vkusvill_orders_history", "vkusvill_product_lp", "vkusvill_discounts_personal"]);
|
|
32
|
-
|
|
33
|
-
const mem = new Map();
|
|
34
|
-
const inflight = new Map();
|
|
35
|
-
export const stats = { requests: 0, cacheHits: 0, active: 0, queued: 0, lastError: null };
|
|
36
|
-
|
|
37
|
-
// ——— Disk cache ———
|
|
38
|
-
|
|
39
|
-
function keyFile(kind, key) {
|
|
40
|
-
return path.join(CACHE_DIR, kind, createHash("sha1").update(key).digest("hex").slice(0, 20) + ".json");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function diskGet(kind, key, ttl) {
|
|
44
|
-
try {
|
|
45
|
-
const f = keyFile(kind, key);
|
|
46
|
-
if (Date.now() - (await stat(f)).mtimeMs > ttl) return undefined;
|
|
47
|
-
return JSON.parse(await readFile(f, "utf8"));
|
|
48
|
-
} catch {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function diskPut(kind, key, value) {
|
|
54
|
-
try {
|
|
55
|
-
const f = keyFile(kind, key);
|
|
56
|
-
await mkdir(path.dirname(f), { recursive: true, mode: 0o700 });
|
|
57
|
-
await writeFile(f, JSON.stringify(value), { mode: 0o600 });
|
|
58
|
-
} catch {}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Removes expired entries and keeps the cache under a size cap; runs at most once a day
|
|
62
|
-
const PRUNE = { api: 8 * DAY, img: 31 * DAY };
|
|
63
|
-
const CACHE_CAP = 300 * 1024 * 1024;
|
|
64
|
-
export async function pruneCache() {
|
|
65
|
-
const marker = path.join(CACHE_DIR, ".pruned");
|
|
66
|
-
try { if (Date.now() - (await stat(marker)).mtimeMs < DAY) return; } catch {}
|
|
67
|
-
const files = [];
|
|
68
|
-
for (const [kind, maxAge] of Object.entries(PRUNE)) {
|
|
69
|
-
const dir = path.join(CACHE_DIR, kind);
|
|
70
|
-
let names = [];
|
|
71
|
-
try { names = await readdir(dir); } catch { continue; }
|
|
72
|
-
for (const name of names) {
|
|
73
|
-
const f = path.join(dir, name);
|
|
74
|
-
try {
|
|
75
|
-
const st = await stat(f);
|
|
76
|
-
if (Date.now() - st.mtimeMs > maxAge) await unlink(f);
|
|
77
|
-
else files.push({ f, size: st.size, t: st.mtimeMs });
|
|
78
|
-
} catch {}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
let total = files.reduce((s, x) => s + x.size, 0);
|
|
82
|
-
for (const x of files.sort((a, b) => a.t - b.t)) {
|
|
83
|
-
if (total <= CACHE_CAP) break;
|
|
84
|
-
try { await unlink(x.f); total -= x.size; } catch {}
|
|
85
|
-
}
|
|
86
|
-
try {
|
|
87
|
-
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
88
|
-
await writeFile(marker, "", { mode: 0o600 });
|
|
89
|
-
await utimes(marker, new Date(), new Date());
|
|
90
|
-
} catch {}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ——— Request queue ———
|
|
94
|
-
// Interactive requests (hi) go before background ones (lo); at most 4 in flight
|
|
95
|
-
// and at most 55 within any sliding minute.
|
|
96
|
-
|
|
97
|
-
const queue = { hi: [], lo: [] };
|
|
98
|
-
const sent = [];
|
|
99
|
-
let running = 0;
|
|
100
|
-
let onChange = () => {};
|
|
101
|
-
export function onActivity(fn) { onChange = fn; }
|
|
102
|
-
|
|
103
|
-
function pump() {
|
|
104
|
-
stats.queued = queue.hi.length + queue.lo.length;
|
|
105
|
-
while (running < 4 && (queue.hi.length || queue.lo.length)) {
|
|
106
|
-
const now = Date.now();
|
|
107
|
-
while (sent.length && now - sent[0] > 60e3) sent.shift();
|
|
108
|
-
if (sent.length >= 55) { setTimeout(pump, 60e3 - (now - sent[0]) + 50); return; }
|
|
109
|
-
const job = queue.hi.shift() ?? queue.lo.shift();
|
|
110
|
-
sent.push(now);
|
|
111
|
-
running++;
|
|
112
|
-
stats.active = running;
|
|
113
|
-
onChange();
|
|
114
|
-
job().finally(() => { running--; stats.active = running; onChange(); pump(); });
|
|
115
|
-
}
|
|
116
|
-
stats.queued = queue.hi.length + queue.lo.length;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function enqueue(fn, prio) {
|
|
120
|
-
return new Promise((resolve, reject) => {
|
|
121
|
-
queue[prio === "lo" ? "lo" : "hi"].push(() => fn().then(resolve, reject));
|
|
122
|
-
pump();
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ——— Calls ———
|
|
127
|
-
|
|
128
|
-
export class VVError extends Error {
|
|
129
|
-
constructor(message, code) { super(message); this.code = code; }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function parseJSON(text, what) {
|
|
133
|
-
try { return JSON.parse(text); } catch { throw new VVError(`ВкусВилл прислал некорректный ${what}`, "rpc"); }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function post(body) {
|
|
137
|
-
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
138
|
-
if (TOKEN && OFFICIAL) headers.Authorization = `Bearer ${TOKEN}`;
|
|
139
|
-
try {
|
|
140
|
-
return await fetch(ENDPOINT, { method: "POST", headers, body, signal: AbortSignal.timeout(20e3) });
|
|
141
|
-
} catch (e) {
|
|
142
|
-
if (e?.name === "TimeoutError") throw new VVError("ВкусВилл не ответил за 20 секунд", "net");
|
|
143
|
-
throw new VVError("Нет связи с ВкусВиллом", "net");
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function rawCall(tool, args) {
|
|
148
|
-
const body = JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name: tool, arguments: args } });
|
|
149
|
-
for (let attempt = 0; ; attempt++) {
|
|
150
|
-
stats.requests++;
|
|
151
|
-
const res = await post(body);
|
|
152
|
-
if (res.status === 429 && attempt < 3) { await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); continue; }
|
|
153
|
-
if (res.status === 401) throw new VVError(TOKEN && !OFFICIAL ? "Токен отправляется только на mcp.vkusvill.ru" : "Нужен вход во ВкусВилл (VV_TOKEN)", "auth");
|
|
154
|
-
if (!res.ok) throw new VVError(`ВкусВилл ответил ${res.status}`, "http");
|
|
155
|
-
if (+(res.headers.get("content-length") ?? 0) > MAX_RESPONSE) throw new VVError("Слишком большой ответ", "http");
|
|
156
|
-
let text = await res.text();
|
|
157
|
-
if (text.length > MAX_RESPONSE) throw new VVError("Слишком большой ответ", "http");
|
|
158
|
-
// The server may answer with an SSE stream: take the last data event
|
|
159
|
-
if (text.startsWith("event:") || text.startsWith("data:")) {
|
|
160
|
-
const datas = text.split("\n").filter((l) => l.startsWith("data:"));
|
|
161
|
-
text = datas.at(-1)?.slice(5) ?? "{}";
|
|
162
|
-
}
|
|
163
|
-
const env = parseJSON(text, "ответ");
|
|
164
|
-
if (env.error) throw new VVError(clean(env.error.message).slice(0, 200), "rpc");
|
|
165
|
-
const content = env.result?.content?.[0]?.text ?? "{}";
|
|
166
|
-
const payload = parseJSON(content, "ответ инструмента");
|
|
167
|
-
if (env.result?.isError || payload.ok === false) {
|
|
168
|
-
const msg = clean(payload.error?.message ?? content).slice(0, 200);
|
|
169
|
-
throw new VVError(msg, /auth|token|jwt|unauthor/i.test(msg) ? "auth" : payload.error?.code ?? "rpc");
|
|
170
|
-
}
|
|
171
|
-
// Everything downstream — the UI, command output, the cache — gets strings with control characters removed
|
|
172
|
-
return cleanDeep(payload.data ?? payload);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// LRU in memory: old responses are evicted
|
|
177
|
-
function remember(key, value) {
|
|
178
|
-
mem.delete(key);
|
|
179
|
-
mem.set(key, value);
|
|
180
|
-
while (mem.size > MEM_LIMIT) mem.delete(mem.keys().next().value);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export async function call(tool, args = {}, { prio = "hi", fresh = false } = {}) {
|
|
184
|
-
const key = JSON.stringify([tool, args]);
|
|
185
|
-
const cacheable = !NO_CACHE.has(tool);
|
|
186
|
-
if (cacheable && !fresh && mem.has(key)) { stats.cacheHits++; const v = mem.get(key); remember(key, v); return v; }
|
|
187
|
-
if (inflight.has(key)) return inflight.get(key);
|
|
188
|
-
const p = (async () => {
|
|
189
|
-
const ttl = TTL[tool] ?? TTL.default;
|
|
190
|
-
if (cacheable && !fresh) {
|
|
191
|
-
const hit = await diskGet("api", key, ttl);
|
|
192
|
-
if (hit !== undefined) { stats.cacheHits++; remember(key, hit); return hit; }
|
|
193
|
-
}
|
|
194
|
-
const data = await enqueue(() => rawCall(tool, args), prio);
|
|
195
|
-
if (cacheable) {
|
|
196
|
-
remember(key, data);
|
|
197
|
-
// Results personalised for a signed-in user stay in memory only
|
|
198
|
-
if (!data?.meta?.personalized) diskPut("api", key, data);
|
|
199
|
-
}
|
|
200
|
-
return data;
|
|
201
|
-
})();
|
|
202
|
-
inflight.set(key, p);
|
|
203
|
-
p.then(() => { stats.lastError = null; }, (e) => { stats.lastError = e.message; }).finally(() => inflight.delete(key));
|
|
204
|
-
return p;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ——— Tool wrappers ———
|
|
208
|
-
|
|
209
|
-
export const search = (q, { page = 1, sort = "popularity", category = 0, vvonly = 1 } = {}, o) =>
|
|
210
|
-
call("vkusvill_products_search", { q, page, limit: 10, sort, category_id: category, vvonly, mode: "full" }, o);
|
|
211
|
-
|
|
212
|
-
export const details = (id, o) => call("vkusvill_product_details", { id: +id }, o);
|
|
213
|
-
export const barcode = (code) => call("vkusvill_product_barcode", { barcode: code });
|
|
214
|
-
export const analogs = (id) => call("vkusvill_product_analogs", { id: +id });
|
|
215
|
-
export const discounts = ({ type = "card", page = 1, sort = "popularity", vvonly = 1 } = {}, o) =>
|
|
216
|
-
call("vkusvill_products_discount", { type, page, sort, vvonly }, o);
|
|
217
|
-
export const cartLink = (products) => call("vkusvill_cart_link_create", { products });
|
|
218
|
-
export const shops = ({ page = 1, region = 0, city = 0, subway = 0, feature = 0 } = {}, o) =>
|
|
219
|
-
call("vkusvill_shops", { page, id_region_filter: region, id_city_filter: city, id_subway_filter: subway, id_feature_filter: feature }, o);
|
|
220
|
-
export const recipes = ({ q = "", page = 1, sort = "popularity", feature = 0, time = 0, method = 0, complexity = 0, category = 0, allergens = [] } = {}, o) =>
|
|
221
|
-
call("vkusvill_recipes", {
|
|
222
|
-
q, page, sort,
|
|
223
|
-
id_feature_filter: feature, id_cooking_time_filter: time, id_cooking_method_filter: method,
|
|
224
|
-
id_complexity_filter: complexity, id_category_filter: category, id_exclude_allergens_filter: allergens,
|
|
225
|
-
}, o);
|
|
226
|
-
export const orders = (page = 1) => call("vkusvill_orders_history", { page });
|
|
227
|
-
export const favoriteProduct = () => call("vkusvill_product_lp", {});
|
|
228
|
-
export const personalDiscounts = () => call("vkusvill_discounts_personal", {});
|