first commit

This commit is contained in:
Bivekich
2025-08-13 21:23:15 +03:00
parent 10d4d41e95
commit c17c903bea
19 changed files with 2578 additions and 384 deletions

1
.gitignore vendored
View File

@ -41,3 +41,4 @@ yarn-error.log*
next-env.d.ts
/src/generated
.zzap-session.json

View File

@ -9,16 +9,17 @@
- ✅ GraphQL API
- ✅ Современный UI с Shadcn/ui
- ✅ Работа с базой данных через Prisma ORM
- ✅ Скрипт «AI Gateway» (AI SDK)
- 🔄 Интеграция с S3 для файлов (в разработке)
## Технологии
- **Frontend**: Next.js 15, React 19, TypeScript
- **UI**: Tailwind CSS, Shadcn/ui, Radix UI
- **Backend**: GraphQL (Apollo Server)
- **Database**: PostgreSQL + Prisma ORM
- **Forms**: React Hook Form + Zod validation
- **Storage**: AWS S3 (планируется)
- Frontend: Next.js 15, React 19, TypeScript
- UI: Tailwind CSS, Shadcn/ui, Radix UI
- Backend: GraphQL (Apollo Server)
- Database: PostgreSQL + Prisma ORM
- Forms: React Hook Form + Zod validation
- Storage: AWS S3 (планируется)
## Установка и настройка
@ -65,7 +66,23 @@ npx prisma generate
npm run dev
```
Откройте [http://localhost:3000](http://localhost:3000) в браузере.
Откройте http://localhost:3000 в браузере.
### 5. AI Gateway (скрипт)
Для запуска скрипта AI Gateway нужен ключ в `.env`:
```env
AI_GATEWAY_API_KEY="your_api_key_here"
```
Запуск примера (TypeScript выполняется через `tsx`):
```bash
npm run ai:gateway
```
Скрипт: `scripts/gateway.ts` — использует `ai` SDK и печатает стрим-ответ, usage и причину завершения.
## Первый запуск
@ -137,10 +154,10 @@ npx prisma migrate dev --name [migration-name]
## Тестирование
### Тест S3 хранилища
Откройте [http://localhost:3000/test-s3](http://localhost:3000/test-s3) для тестирования загрузки файлов в S3.
Откройте http://localhost:3000/test-s3 для тестирования загрузки файлов в S3.
### Тест GraphQL API
Откройте [http://localhost:3000/api/graphql](http://localhost:3000/api/graphql) для проверки GraphQL API.
Откройте http://localhost:3000/api/graphql для проверки GraphQL API.
## Следующие шаги
@ -149,4 +166,4 @@ npx prisma migrate dev --name [migration-name]
- [ ] Добавить управление контентом
- [ ] Создать систему ролей и разрешений
- [ ] Добавить логирование и мониторинг
# protekauto-cms
# protek

1222
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "protekauto-cms",
"version": "0.1.0",
"dependencies": {
"@ai-sdk/openai": "^1.3.24",
"@apollo/client": "^3.13.8",
"@apollo/experimental-nextjs-app-support": "^0.12.2",
"@apollo/server": "^4.12.2",
@ -23,6 +24,7 @@
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@ -37,6 +39,7 @@
"@types/pg": "^8.15.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"ai": "^3.4.33",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@ -81,10 +84,207 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"tsx": "^4.19.2",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}
},
"node_modules/@ai-sdk/openai": {
"version": "1.3.24",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz",
"integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"@ai-sdk/provider-utils": "2.2.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz",
"integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz",
"integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"nanoid": "^3.3.8",
"secure-json-parse": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.23.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "0.0.26",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.26.tgz",
"integrity": "sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.22.tgz",
"integrity": "sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "0.0.26",
"eventsource-parser": "^1.1.2",
"nanoid": "^3.3.7",
"secure-json-parse": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/react": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-0.0.70.tgz",
"integrity": "sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/solid": {
"version": "0.0.54",
"resolved": "https://registry.npmjs.org/@ai-sdk/solid/-/solid-0.0.54.tgz",
"integrity": "sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"solid-js": "^1.7.7"
},
"peerDependenciesMeta": {
"solid-js": {
"optional": true
}
}
},
"node_modules/@ai-sdk/svelte": {
"version": "0.0.57",
"resolved": "https://registry.npmjs.org/@ai-sdk/svelte/-/svelte-0.0.57.tgz",
"integrity": "sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50",
"sswr": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"svelte": {
"optional": true
}
}
},
"node_modules/@ai-sdk/ui-utils": {
"version": "0.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz",
"integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "0.0.26",
"@ai-sdk/provider-utils": "1.0.22",
"json-schema": "^0.4.0",
"secure-json-parse": "^2.7.0",
"zod-to-json-schema": "^3.23.3"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/vue": {
"version": "0.0.59",
"resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-0.0.59.tgz",
"integrity": "sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50",
"swrv": "^1.0.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"vue": "^3.3.4"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -1408,6 +1608,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
@ -1417,6 +1627,22 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
@ -1426,6 +1652,20 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@ -1457,6 +1697,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -2283,6 +2965,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@ -2546,6 +3239,15 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -3322,6 +4024,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
"integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
@ -4400,6 +5133,16 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@ -4717,11 +5460,16 @@
"@types/node": "*"
}
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/express": {
@ -5465,6 +6213,115 @@
"win32"
]
},
"node_modules/@vue/compiler-core": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz",
"integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@vue/shared": "3.5.18",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz",
"integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz",
"integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@vue/compiler-core": "3.5.18",
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.18",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.17",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz",
"integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz",
"integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz",
"integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz",
"integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.18",
"@vue/runtime-core": "3.5.18",
"@vue/shared": "3.5.18",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz",
"integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.18"
},
"peerDependencies": {
"vue": "3.5.18"
}
},
"node_modules/@vue/shared": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz",
"integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==",
"license": "MIT",
"peer": true
},
"node_modules/@whatwg-node/promise-helpers": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz",
@ -5569,7 +6426,6 @@
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -5606,6 +6462,54 @@
"node": ">= 14"
}
},
"node_modules/ai": {
"version": "3.4.33",
"resolved": "https://registry.npmjs.org/ai/-/ai-3.4.33.tgz",
"integrity": "sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "0.0.26",
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/react": "0.0.70",
"@ai-sdk/solid": "0.0.54",
"@ai-sdk/svelte": "0.0.57",
"@ai-sdk/ui-utils": "0.0.50",
"@ai-sdk/vue": "0.0.59",
"@opentelemetry/api": "1.9.0",
"eventsource-parser": "1.1.2",
"json-schema": "^0.4.0",
"jsondiffpatch": "0.6.0",
"secure-json-parse": "^2.7.0",
"zod-to-json-schema": "^3.23.3"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"openai": "^4.42.0",
"react": "^18 || ^19 || ^19.0.0-rc",
"sswr": "^2.1.0",
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0",
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"openai": {
"optional": true
},
"react": {
"optional": true
},
"sswr": {
"optional": true
},
"svelte": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -5669,7 +6573,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -5938,7 +6841,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -6875,6 +7777,15 @@
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@ -6912,6 +7823,12 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@ -7057,6 +7974,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -7260,6 +8190,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.9",
"@esbuild/android-arm": "0.25.9",
"@esbuild/android-arm64": "0.25.9",
"@esbuild/android-x64": "0.25.9",
"@esbuild/darwin-arm64": "0.25.9",
"@esbuild/darwin-x64": "0.25.9",
"@esbuild/freebsd-arm64": "0.25.9",
"@esbuild/freebsd-x64": "0.25.9",
"@esbuild/linux-arm": "0.25.9",
"@esbuild/linux-arm64": "0.25.9",
"@esbuild/linux-ia32": "0.25.9",
"@esbuild/linux-loong64": "0.25.9",
"@esbuild/linux-mips64el": "0.25.9",
"@esbuild/linux-ppc64": "0.25.9",
"@esbuild/linux-riscv64": "0.25.9",
"@esbuild/linux-s390x": "0.25.9",
"@esbuild/linux-x64": "0.25.9",
"@esbuild/netbsd-arm64": "0.25.9",
"@esbuild/netbsd-x64": "0.25.9",
"@esbuild/openbsd-arm64": "0.25.9",
"@esbuild/openbsd-x64": "0.25.9",
"@esbuild/openharmony-arm64": "0.25.9",
"@esbuild/sunos-x64": "0.25.9",
"@esbuild/win32-arm64": "0.25.9",
"@esbuild/win32-ia32": "0.25.9",
"@esbuild/win32-x64": "0.25.9"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -7671,6 +8643,13 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
@ -7715,6 +8694,16 @@
"node": ">=0.10"
}
},
"node_modules/esrap": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@ -7737,6 +8726,13 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -7763,6 +8759,15 @@
"optional": true,
"peer": true
},
"node_modules/eventsource-parser": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz",
"integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==",
"license": "MIT",
"engines": {
"node": ">=14.18"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
@ -8188,6 +9193,21 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -9034,6 +10054,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -9294,6 +10324,12 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -9321,6 +10357,35 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz",
"integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==",
"license": "MIT",
"dependencies": {
"@types/diff-match-patch": "^1.0.36",
"chalk": "^5.3.0",
"diff-match-patch": "^1.0.5"
},
"bin": {
"jsondiffpatch": "bin/jsondiffpatch.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/jsondiffpatch/node_modules/chalk": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@ -9695,6 +10760,13 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -11682,6 +12754,12 @@
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@ -12094,6 +13172,18 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/sswr": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/sswr/-/sswr-2.2.0.tgz",
"integrity": "sha512-clTszLPZkmycALTHD1mXGU+mOtA/MIoLgS1KGTTzFNVm9rytQVykgRaP+z1zl572cz0bTqj4rFVoC2N+IGK4Sg==",
"license": "MIT",
"dependencies": {
"swrev": "^4.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@ -12457,6 +13547,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": {
"version": "5.38.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz",
"integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^2.1.0",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
@ -12467,6 +13583,34 @@
"node": ">=12.0.0"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/swrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/swrev/-/swrev-4.0.0.tgz",
"integrity": "sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==",
"license": "MIT"
},
"node_modules/swrv": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/swrv/-/swrv-1.1.0.tgz",
"integrity": "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ==",
"license": "Apache-2.0",
"peerDependencies": {
"vue": ">=3.2.26 < 4"
}
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@ -12570,6 +13714,18 @@
"utrie": "^1.0.2"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
@ -12690,6 +13846,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz",
"integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/ttf2woff2": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/ttf2woff2/-/ttf2woff2-8.0.0.tgz",
@ -13073,6 +14249,28 @@
"node": ">= 0.8"
}
},
"node_modules/vue": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
"@vue/runtime-dom": "3.5.18",
"@vue/server-renderer": "3.5.18",
"@vue/shared": "3.5.18"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -13476,6 +14674,13 @@
"zen-observable": "0.8.15"
}
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.56",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz",
@ -13484,6 +14689,15 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
}
}
}

View File

@ -7,6 +7,7 @@
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"ai:gateway": "tsx scripts/gateway.ts",
"test-db": "node scripts/test-db.mjs",
"db:push": "prisma db push",
"db:generate": "prisma generate",
@ -18,6 +19,7 @@
"update:env": "bash scripts/update-env.sh"
},
"dependencies": {
"@ai-sdk/openai": "^1.3.24",
"@apollo/client": "^3.13.8",
"@apollo/experimental-nextjs-app-support": "^0.12.2",
"@apollo/server": "^4.12.2",
@ -33,6 +35,7 @@
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@ -47,6 +50,7 @@
"@types/pg": "^8.15.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"ai": "^3.4.33",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@ -91,6 +95,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"tsx": "^4.19.2",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}

View File

@ -418,6 +418,23 @@ model PartsSearchHistory {
@@map("parts_search_history")
}
// История запросов ZZAP со скриншотами
model ZzapRequest {
id String @id @default(cuid())
provider String @default("zzap")
article String
statsUrl String?
imageUrl String?
ok Boolean @default(false)
selector String?
logs Json?
requestedBy String?
createdAt DateTime @default(now())
@@index([article])
@@map("zzap_requests")
}
model ClientDeliveryAddress {
id String @id @default(cuid())
clientId String

20
scripts/gateway.ts Normal file
View File

@ -0,0 +1,20 @@
import { streamText } from 'ai'
import 'dotenv/config'
async function main() {
const result = streamText({
model: 'openai/gpt-4.1',
prompt: 'Invent a new holiday and describe its traditions.',
})
for await (const textPart of result.textStream) {
process.stdout.write(textPart)
}
console.log()
console.log('Token usage:', await result.usage)
console.log('Finish reason:', await result.finishReason)
}
main().catch(console.error)

6
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function AdminRedirect() {
redirect('/dashboard')
}

View File

@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function AdminZzapRedirect() {
redirect('/dashboard/zzap')
}

View File

@ -0,0 +1,79 @@
export async function POST(req: Request) {
try {
const { messages } = await req.json();
const response = await fetch('https://gateway.ai.cloudflare.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.AI_GATEWAY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const stream = new ReadableStream({
async start(controller) {
const reader = response.body?.getReader();
if (!reader) return;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
controller.enqueue(encoder.encode(content));
}
} catch (e) {
// Ignore parsing errors
}
}
}
}
} finally {
reader.releaseLock();
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
console.error('AI Chat Error:', error);
return new Response(
JSON.stringify({ error: 'Произошла ошибка при обработке запроса' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
}

View File

@ -0,0 +1,71 @@
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1)
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10) || 20))
const q = (searchParams.get('q') || '').trim()
const skip = (page - 1) * pageSize
let items: any[] = []
let total = 0
if ((prisma as any).zzapRequest?.findMany) {
const where = q ? { article: { contains: q, mode: 'insensitive' as const } } : undefined
const [i, t] = await Promise.all([
(prisma as any).zzapRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
select: {
id: true,
provider: true,
article: true,
statsUrl: true,
imageUrl: true,
ok: true,
selector: true,
logs: true,
createdAt: true,
}
}),
(prisma as any).zzapRequest.count({ where })
])
items = i
total = t
} else {
// Fallback via raw SQL if client not regenerated yet (Postgres quoted camelCase)
let where = 'WHERE 1=1'
if (q) {
const esc = q.replace(/['\\]/g, (m) => ({"'":"''","\\":"\\\\"}[m] as string)).replace(/[%_]/g, (m) => '\\' + m)
where += ` AND "article" ILIKE '%${esc}%' ESCAPE '\\'`
}
const rows = await prisma.$queryRawUnsafe<any[]>(
`SELECT id, provider, article, "statsUrl" as "statsUrl", "imageUrl" as "imageUrl", ok, selector, logs, "createdAt" as "createdAt"
FROM "zzap_requests"
${where}
ORDER BY "createdAt" DESC
LIMIT ${pageSize} OFFSET ${skip}`
)
const cnt = await prisma.$queryRawUnsafe<any[]>(
`SELECT COUNT(*)::int as count FROM "zzap_requests" ${where}`
)
items = rows
total = cnt?.[0]?.count || 0
}
return new Response(JSON.stringify({ items, total, page, pageSize }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
})
} catch (err: any) {
return new Response(JSON.stringify({ error: String(err?.message || err || 'Unknown error') }), {
status: 500,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
}

View File

@ -0,0 +1,666 @@
import type { NextRequest } from 'next/server'
import puppeteer from 'puppeteer'
import { uploadBuffer } from '@/lib/s3'
import { prisma } from '@/lib/prisma'
import fs from 'fs'
import path from 'path'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const ZZAP_BASE = process.env.ZZAP_BASE || 'https://www.zzap.ru'
const ZZAP_TIMEOUT_MS = Number(process.env.ZZAP_TIMEOUT_MS || 30000)
const COOKIE_FILE = process.env.ZZAP_COOKIE_FILE || path.join(process.cwd(), '.zzap-session.json')
const COOKIE_TTL_MIN = Number(process.env.ZZAP_SESSION_TTL_MINUTES || 180)
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
async function waitForAnySelector(page: any, selectors: string[], timeoutMs = 10000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const sel of selectors) {
try { if (await page.$(sel)) return sel } catch {}
}
await sleep(300)
}
return null as string | null
}
async function setInputValue(page: any, selector: string, value: string) {
const exists = await page.$(selector)
if (!exists) return false
try {
await page.evaluate((sel, val) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (!el) return
el.focus()
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
el.blur()
}, selector, value)
return true
} catch {
try {
await page.click(selector, { clickCount: 3 })
await page.type(selector, value, { delay: 20 })
return true
} catch {
return false
}
}
}
async function clickByText(page: any, text: string) {
const handle = await page.evaluateHandle((t: string) => {
const target = t.toLowerCase()
const candidates = Array.from(document.querySelectorAll('button, a, input[type="submit"], span, div')) as HTMLElement[]
for (const el of candidates) {
const txt = (el.innerText || el.textContent || '').trim().toLowerCase()
if (!txt) continue
if (txt.includes(target)) return el
}
return null
}, text)
try {
const el = (handle as any).asElement?.()
if (el) {
await el.click()
return true
}
} catch {}
try { await (handle as any).dispose?.() } catch {}
return false
}
async function findLargestElementHandle(page: any, selectors: string[]) {
for (const sel of selectors) {
const handles = await page.$$(sel)
if (handles.length) {
let best = handles[0]
let bestArea = 0
for (const h of handles) {
const box = await h.boundingBox()
const area = box ? box.width * box.height : 0
if (area > bestArea) {
best = h
bestArea = area
}
}
return best
}
}
return null
}
async function persistHistorySafely(
data: { article: string; statsUrl: string | null; imageUrl?: string; ok: boolean; selector?: string | null; logs?: any },
log: (m: string) => void
) {
try {
if ((prisma as any).zzapRequest?.create) {
await (prisma as any).zzapRequest.create({
data: {
provider: 'zzap',
article: data.article,
statsUrl: data.statsUrl || undefined,
imageUrl: data.imageUrl || undefined,
ok: data.ok,
selector: data.selector || undefined,
logs: data.logs ?? undefined
}
})
log('DB: request persisted')
} else {
const esc = (v: any) => (v == null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`)
const logsJson = data.logs ? `'${JSON.stringify(data.logs).replace(/'/g, "''")}'::jsonb` : 'NULL'
const sql = `INSERT INTO "zzap_requests" ("provider","article","statsUrl","imageUrl","ok","selector","logs") VALUES ('zzap', ${esc(data.article)}, ${esc(data.statsUrl)}, ${esc(data.imageUrl)}, ${data.ok ? 'true' : 'false'}, ${esc(data.selector)}, ${logsJson})`
await prisma.$executeRawUnsafe(sql)
log('DB: request persisted (raw)')
}
} catch (e: any) {
log(`DB error: ${e?.message || e}`)
}
}
async function restoreSession(page: any, log: (m: string) => void) {
try {
if (!fs.existsSync(COOKIE_FILE)) return false
const raw = fs.readFileSync(COOKIE_FILE, 'utf-8')
const data = JSON.parse(raw) as { cookies: any[]; savedAt: number }
if (!data?.cookies?.length || !data?.savedAt) return false
const ageMin = (Date.now() - data.savedAt) / 60000
if (ageMin > COOKIE_TTL_MIN) { log(`Session expired: ${ageMin.toFixed(1)}min > ${COOKIE_TTL_MIN}min`); return false }
await page.setCookie(...data.cookies)
log('Session cookies restored')
return true
} catch (e) {
log(`Restore session error: ${String((e as any)?.message || e)}`)
return false
}
}
async function saveSession(page: any, log: (m: string) => void) {
try {
const cookies = await page.cookies()
const payload = { cookies, savedAt: Date.now() }
fs.writeFileSync(COOKIE_FILE, JSON.stringify(payload))
log(`Session cookies saved (${cookies.length})`)
} catch (e) {
log(`Save session error: ${String((e as any)?.message || e)}`)
}
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const article = searchParams.get('article')?.trim()
const explicitSelector = searchParams.get('selector')?.trim()
const debug = searchParams.get('debug') === '1'
if (!article) {
return new Response(JSON.stringify({ error: 'Не передан артикул ?article=' }), { status: 400, headers: { 'content-type': 'application/json; charset=utf-8' } })
}
const email = process.env.ZZAP_EMAIL
const password = process.env.ZZAP_PASSWORD
if (!email || !password) {
return new Response(JSON.stringify({ error: 'Отсутствуют ZZAP_EMAIL/ZZAP_PASSWORD в .env' }), { status: 500, headers: { 'content-type': 'application/json; charset=utf-8' } })
}
const logs: string[] = []
const log = (m: string) => { logs.push(m) }
try {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
let workPage: any = page
await page.setViewport({ width: 1440, height: 900 })
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await page.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
try { page.setDefaultNavigationTimeout?.(ZZAP_TIMEOUT_MS); page.setDefaultTimeout?.(ZZAP_TIMEOUT_MS) } catch {}
// 0) Try restore session first, verify, otherwise login
let loggedInEarly = false
await restoreSession(page, log)
try {
await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: 60000 })
const byDom = await page.evaluate(() => {
const byId = !!document.querySelector('#ctl00_lnkLogout')
const byText = Array.from(document.querySelectorAll('a')).some(a => /выход|выйти|logout|logoff/i.test((a.textContent||'').trim()))
return byId || byText
}).catch(() => false)
loggedInEarly = Boolean(byDom)
log(`Restored session check: loggedIn=${loggedInEarly}`)
} catch {}
// 0b) Explicit login if not logged yet
try {
if (!loggedInEarly) {
await page.goto(`${ZZAP_BASE}/user/logon.aspx`, { waitUntil: 'domcontentloaded', timeout: Math.min(20000, ZZAP_TIMEOUT_MS) })
log(`Open login: ${page.url()}`)
} else {
log('Skip login: already authenticated')
}
// DevExpress (ZZap) stable selectors by id suffix / full name
const devxEmail = 'input[id$="AddrEmail1TextBox_I"]'
const devxPass = 'input[id$="PasswordTextBox_I"]'
const devxEmailName = 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$AddrEmail1TextBox"]'
const devxPassName = 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$PasswordTextBox"]'
const devxEmailFull = '#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_AddrEmail1TextBox_I'
const devxPassFull = '#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_PasswordTextBox_I'
// Prefer known DevExpress IDs first, then legacy ASP.NET, then generic
const userSelPriority = [devxEmailFull, devxEmail, devxEmailName, '#ctl00_ContentPlaceHolder1_Login1_UserName', '#ctl00_ContentPlaceHolder1_tbLogin', 'input[name*="login" i]', 'input[type="email"]', 'input[name*="email" i]']
const passSelPriority = [devxPassFull, devxPass, devxPassName, '#ctl00_ContentPlaceHolder1_Login1_Password', '#ctl00_ContentPlaceHolder1_tbPass', 'input[type="password"]', 'input[name*="pass" i]']
const submitSelPriority = ['#ctl00_ContentPlaceHolder1_Login1_LoginButton', '#ctl00_ContentPlaceHolder1_btnLogin', 'button[type="submit" i]', 'input[type="submit" i]']
// Wait a moment for anti-bot/DevExpress to initialize
if (!loggedInEarly) await sleep(2000)
// Wait for any input to appear explicitly
const appeared = await waitForAnySelector(page, [devxEmailFull, devxPassFull, devxEmail, devxPass, devxEmailName, devxPassName], 10000)
if (!appeared) log('Login inputs still not present after wait')
// Try waiting explicitly for DevExpress fields
// Resolve selectors to strings (avoid handle click issues)
const emailSelectors = [devxEmailFull, devxEmail, devxEmailName, '#ctl00_ContentPlaceHolder1_Login1_UserName', '#ctl00_ContentPlaceHolder1_tbLogin', 'input[name*="login" i]', 'input[type="email"]', 'input[name*="email" i]']
const passSelectors = [devxPassFull, devxPass, devxPassName, '#ctl00_ContentPlaceHolder1_Login1_Password', '#ctl00_ContentPlaceHolder1_tbPass', 'input[type="password"]', 'input[name*="pass" i]']
let emailSelUsed: string | null = null
for (const sel of emailSelectors) { if (await setInputValue(page, sel, email)) { emailSelUsed = sel; break } }
// No XPath fallback to avoid $x in older runtimes
let passSelUsed: string | null = null
for (const sel of passSelectors) { if (await setInputValue(page, sel, password)) { passSelUsed = sel; break } }
// No XPath fallback to avoid $x in older runtimes
if (!loggedInEarly && emailSelUsed && passSelUsed) {
log(`Login using emailSel=${emailSelUsed}, passSel=${passSelUsed}`)
// Helper to detect login without relying only on nav
const checkLoggedIn = async () => {
const url = page.url()
if (!/logon\.aspx/i.test(url)) return true
const byDom = await page.evaluate(() => {
const byId = !!document.querySelector('#ctl00_lnkLogout')
const byText = Array.from(document.querySelectorAll('a')).some(a => /выход|выйти|logout|logoff/i.test((a.textContent||'').trim()))
return byId || byText
}).catch(() => false)
return byDom
}
const waitStep = async (label: string) => {
await sleep(1500)
const ok = await checkLoggedIn()
log(`${label} -> loggedIn=${ok}, url=${page.url()}`)
return ok
}
let loggedIn = false
// 1) Press Enter on password
try { await page.focus(passSelUsed); await page.keyboard.press('Enter') ; } catch {}
loggedIn = await waitStep('After Enter')
// 2) Click submit via known selectors
if (!loggedIn) {
for (const sel of submitSelPriority) {
try {
const el = await page.$(sel)
if (el) {
await el.click().catch(() => {})
if (await waitStep(`After click ${sel}`)) { loggedIn = true; break }
}
} catch {}
}
}
// 3) Click any descendant with text "Войти" inside login panel
if (!loggedIn) {
try {
const did = await page.evaluate(() => {
const root = document.querySelector('#ctl00_BodyPlace_LogonFormCallbackPanel') || document.body
if (!root) return false
const nodes = Array.from(root.querySelectorAll('button, a, span, div, input[type="submit"]')) as HTMLElement[]
const lc = 'войти'
for (const el of nodes) {
const txt = (el.innerText || el.textContent || '').trim().toLowerCase()
if (!txt) continue
if (txt.includes(lc)) { (el as HTMLElement).click(); return true }
}
return false
})
if (did) loggedIn = await waitStep('After panel text click')
} catch {}
}
// 4) Try submitting the form directly
if (!loggedIn) {
try { await page.evaluate(() => { (document.querySelector('form') as HTMLFormElement | null)?.submit() }) } catch {}
loggedIn = await waitStep('After form.submit()')
}
log(`Login success=${loggedIn}`)
if (loggedIn) { await saveSession(page, log) }
} else {
if (!loggedInEarly) log('Login inputs not found; continuing')
}
const cookies = await page.cookies()
log(`Cookies: ${cookies.map(c=>c.name).join(',')}`)
} catch (e: any) {
log(`Login step error: ${e?.message || e}`)
}
// 1) Open homepage
await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
log(`Open: ${page.url()}`)
// Cookie banners common accept
await clickByText(page, 'Соглас').catch(() => {})
await clickByText(page, 'Принять').catch(() => {})
await clickByText(page, 'Хорошо').catch(() => {})
// 2) Try to open login
const loginCandidates = ['a[href*="login" i]', 'a[href*="signin" i]', 'button[name="login" i]']
let openedLogin = false
for (const sel of loginCandidates) {
const el = await page.$(sel)
if (el) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}),
el.click()
])
openedLogin = true
break
}
}
if (!openedLogin) {
await clickByText(page, 'войти').catch(() => {})
}
log(`Login page: ${page.url()}`)
// 3) Fill credentials
// Try DevExpress selectors first on whatever login UI is visible
const emailSel = ['#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_AddrEmail1TextBox_I', 'input[id$="AddrEmail1TextBox_I"]', 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$AddrEmail1TextBox"]', 'input[type="email"]', 'input[name="email" i]', 'input[name*="login" i]']
const passSel = ['#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_PasswordTextBox_I', 'input[id$="PasswordTextBox_I"]', 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$PasswordTextBox"]', 'input[type="password"]', 'input[name="password" i]']
let emailInput = null
for (const sel of emailSel) { emailInput = await page.$(sel); if (emailInput) break }
let passInput = null
for (const sel of passSel) { passInput = await page.$(sel); if (passInput) break }
if (emailInput && passInput) {
await emailInput.click({ clickCount: 3 }).catch(() => {})
await emailInput.type(email, { delay: 20 })
await passInput.type(password, { delay: 20 })
const submitSel = ['button[type="submit" i]', 'input[type="submit" i]']
let clicked = false
for (const sel of submitSel) {
const el = await page.$(sel)
if (el) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 60000 }).catch(() => {}),
el.click()
])
clicked = true
break
}
}
if (!clicked) {
await clickByText(page, 'войти').catch(() => {})
await sleep(1500)
}
}
log(`After login: ${page.url()}`)
// 4) Navigate to search by article (try a few patterns)
const searchUrls = [
`${ZZAP_BASE}/public/search.aspx#rawdata=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search/?article=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search?article=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search?txt=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search?query=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/catalog/?q=${encodeURIComponent(article)}`
]
let reached = false
for (const url of searchUrls) {
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
log(`Search try: ${url} -> ${page.url()}`)
reached = true
break
} catch {}
}
if (!reached) {
// fallback: try search input on homepage
await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
const inputCandidates = ['input[type="search"]', 'input[name*="search" i]', 'input[placeholder*="артик" i]']
let searchInput = null
for (const sel of inputCandidates) { searchInput = await page.$(sel); if (searchInput) break }
if (!searchInput) throw new Error('Не найдено поле поиска')
await searchInput.type(article, { delay: 30 })
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }).catch(() => {}),
page.keyboard.press('Enter')
])
}
log(`Search results: ${page.url()}`)
// 4b) Try to open stats via explicit anchor present in the grid
try {
const statLinkSel = 'a[id$="_StatHyperlink"], a[onclick*="statpartpricehistory.aspx" i]'
const statLink = await page.waitForSelector(statLinkSel, { timeout: Math.min(8000, ZZAP_TIMEOUT_MS) }).catch(() => null)
if (statLink) {
const rel = await statLink.evaluate((el: HTMLAnchorElement) => {
const href = el.getAttribute('href') || ''
const onclick = el.getAttribute('onclick') || ''
const rx = /['"]([^'\"]*statpartpricehistory\.aspx[^'\"]*)['"]/i
const m = onclick.match(rx)
const candidate = m ? m[1] : (href && href.includes('statpartpricehistory') ? href : null)
return candidate ? candidate.replace(/&amp;/g, '&') : null
})
if (rel) {
const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
workPage = statsPage
log(`Stats page opened via anchor onclick: ${targetUrl}`)
}
}
} catch {}
// If we already opened stats page directly, skip modal logic
const onStatsPage = () => /\/user\/statpartpricehistory\.aspx/i.test(workPage.url?.() || '')
let statsOpened = onStatsPage()
if (statsOpened) {
log(`Stats page already open: ${workPage.url?.()}`)
}
// 5) Open statistics (open modal, extract iframe src, load it as page) or new page
// Attempt 0: scrape any statpartpricehistory URL from page HTML (onclick/inline)
try {
const rel = await page.evaluate(() => {
const html = document.documentElement?.outerHTML || ''
const m = html.match(/(https?:\/\/[^"'<> ]+)?(\/user\/statpartpricehistory\.aspx[^"'<> ]*)/i)
if (!m) return null
const full = m[1] ? `${m[1]}${m[2]}` : m[2]
return full.replace(/&amp;/g, '&')
})
if (rel) {
const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'networkidle0', timeout: 60000 })
workPage = statsPage
statsOpened = true
log(`Stats page opened via HTML scrape: ${targetUrl}`)
}
} catch {}
if (!statsOpened) {
await clickByText(page, 'статистика').catch(() => {})
try {
const clickedByTitle = await page.evaluate(() => {
const cand = document.querySelector('[title*="статист" i], [alt*="статист" i]') as HTMLElement | null
if (cand) { cand.click(); return true }
return false
})
if (clickedByTitle) { log('Clicked stats by title/alt attribute') }
} catch {}
await sleep(500)
}
// Look for iframe anywhere, not only inside DevExpress container
let modalIframeHandle = statsOpened ? null : await page.waitForSelector('iframe[src*="statpartpricehistory.aspx"]', { timeout: Math.min(4000, ZZAP_TIMEOUT_MS) }).catch(() => null)
if (!modalIframeHandle) {
// one more short wait and retry
if (!statsOpened) {
await sleep(300)
modalIframeHandle = await page.waitForSelector('iframe[src*="statpartpricehistory.aspx"]', { timeout: Math.min(2500, ZZAP_TIMEOUT_MS) }).catch(() => null)
}
}
if (modalIframeHandle) {
statsOpened = true
try {
const src: string | null = await modalIframeHandle.evaluate((el: HTMLIFrameElement) => el.getAttribute('src'))
if (src) {
const targetUrl = src.startsWith('http') ? src : `${ZZAP_BASE}${src}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
// Reuse session cookies
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
workPage = statsPage
log(`Stats page opened via iframe src: ${targetUrl}`)
// We won't use the iframe anymore
modalIframeHandle = null
}
} catch {}
}
if (!statsOpened || workPage === page) {
// Fallback: direct link on page to statpartpricehistory
try {
const rel = await page.evaluate(() => {
const a = document.querySelector('a[href*="statpartpricehistory.aspx"]') as HTMLAnchorElement | null
return a?.getAttribute('href') || null
})
if (rel) {
const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
workPage = statsPage
statsOpened = true
log(`Stats page opened via anchor href: ${targetUrl}`)
}
} catch {}
}
if (!statsOpened || workPage === page) {
// Fallback: explicit selectors that might open new page
const statsSelectors = ['a[href*="statpartpricehistory" i]', 'a[href*="stat" i]', 'button[href*="stat" i]']
for (const sel of statsSelectors) {
const el = await page.$(sel)
if (el) {
const targetCreated = new Promise<any>((resolve) => {
const handler = async (target: any) => {
const newPage = await target.page().catch(() => null)
if (newPage) {
browser.off('targetcreated', handler)
resolve(newPage)
}
}
browser.on('targetcreated', handler)
})
await el.click().catch(() => {})
const maybeNewPage: any = await Promise.race([
targetCreated,
(async () => { await page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(8000, ZZAP_TIMEOUT_MS) }).catch(() => {}) ; return null })()
])
if (maybeNewPage) {
workPage = maybeNewPage
await sleep(1200)
}
statsOpened = true
break
}
}
}
log(`Stats page: ${workPage.url?.() || page.url()}`)
// Early bailout: if we couldn't navigate away from search page quickly, return its screenshot
try {
const urlNow = workPage.url?.() || page.url()
if (/\/public\/search\.aspx/i.test(urlNow)) {
log('Bailout: still on search page, returning current page screenshot')
const buf = (await page.screenshot({ fullPage: true, type: 'png' })) as Buffer
// Save to S3 + DB history
try {
const key = `zzap/${encodeURIComponent(article)}/${Date.now()}-search.png`
const up = await uploadBuffer(buf, key, 'image/png')
logs.push(`Uploaded to S3: ${up.url}`)
await persistHistorySafely({ article, statsUrl: urlNow, imageUrl: up.url, ok: false, selector: null, logs }, log)
} catch (e: any) {
logs.push(`Persist error (bailout): ${e?.message || e}`)
}
await browser.close().catch(() => {})
if (debug) {
return new Response(JSON.stringify({ ok: true, url: urlNow, foundSelector: null, logs }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
return new Response(buf, { status: 200, headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } })
}
} catch {}
// 6) Wait a bit for charts to render (short)
if (onStatsPage()) {
try { await (workPage.waitForSelector?.('.highcharts-container', { timeout: Math.min(3000, ZZAP_TIMEOUT_MS) })) } catch {}
await sleep(300)
}
// 7) Capture element screenshot
let handle = null as any
// No modal any more we open stats full page; try in current/new page context
if (explicitSelector) {
handle = await workPage.$(explicitSelector)
}
if (!handle) {
handle = await findLargestElementHandle(workPage, [
'.highcharts-container',
'canvas',
'svg',
'[id*="chart" i]',
'[class*="chart" i]'
])
}
let imageBuffer: Buffer
let foundSelector: string | null = null
if (!onStatsPage() && handle) {
try { await handle.evaluate((el: any) => el.scrollIntoView({ behavior: 'instant', block: 'center' })) } catch {}
await sleep(300)
foundSelector = explicitSelector || 'auto'
imageBuffer = (await handle.screenshot({ type: 'png' })) as Buffer
} else {
// Full-page screenshot (stats page often has 3 charts; capture all)
imageBuffer = (await workPage.screenshot({ fullPage: true, type: 'png' })) as Buffer
}
// Upload to S3 and persist history
let uploadedUrl: string | undefined
try {
const key = `zzap/${encodeURIComponent(article)}/${Date.now()}.png`
const up = await uploadBuffer(imageBuffer, key, 'image/png')
uploadedUrl = up.url
logs.push(`Uploaded to S3: ${up.url}`)
} catch (e: any) {
logs.push(`S3 upload error: ${e?.message || e}`)
}
await persistHistorySafely({ article, statsUrl: workPage.url?.() || page.url(), imageUrl: uploadedUrl, ok: true, selector: foundSelector, logs }, log)
await browser.close().catch(() => {})
if (debug) {
return new Response(JSON.stringify({ ok: true, url: workPage.url?.() || page.url(), foundSelector, imageUrl: uploadedUrl, logs }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
return new Response(imageBuffer, {
status: 200,
headers: {
'content-type': 'image/png',
'cache-control': 'no-store'
}
})
} catch (err: any) {
const errorBody = { error: String(err?.message || err || 'Unknown error'), logs }
return new Response(JSON.stringify(errorBody), {
status: 500,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
}

View File

@ -0,0 +1,125 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from 'ai/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Bot, Send, User } from 'lucide-react';
export default function AIChat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/ai/chat',
});
const scrollAreaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
}
}, [messages]);
return (
<div className="container max-w-4xl mx-auto py-6">
<Card className="h-[calc(100vh-8rem)] flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" />
Чат с ИИ
</CardTitle>
<p className="text-sm text-muted-foreground">
Задайте любой вопрос искусственному интеллекту
</p>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<ScrollArea className="h-full pr-4" ref={scrollAreaRef}>
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Bot className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Добро пожаловать!</h3>
<p className="text-muted-foreground">
Начните разговор с ИИ, задав свой первый вопрос
</p>
</div>
)}
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={`flex gap-3 ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
{message.role === 'assistant' && (
<Avatar className="h-8 w-8">
<AvatarFallback>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
)}
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === 'user'
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted text-foreground'
}`}
>
<div className="whitespace-pre-wrap text-sm">
{message.content}
</div>
</div>
{message.role === 'user' && (
<Avatar className="h-8 w-8">
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
)}
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<Avatar className="h-8 w-8">
<AvatarFallback>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="bg-muted rounded-lg px-4 py-2">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-pulse" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-pulse delay-100" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-pulse delay-200" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
<CardFooter>
<form onSubmit={handleSubmit} className="flex gap-2 w-full">
<Input
value={input}
onChange={handleInputChange}
placeholder="Введите ваш вопрос..."
disabled={isLoading}
className="flex-1"
/>
<Button type="submit" disabled={isLoading || !input.trim()} size="icon">
<Send className="h-4 w-4" />
</Button>
</form>
</CardFooter>
</Card>
</div>
);
}

View File

@ -0,0 +1,182 @@
"use client"
import { useCallback, useEffect, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export default function ZzapStatsPage() {
const [article, setArticle] = useState('')
const [selector, setSelector] = useState('')
const [loading, setLoading] = useState(false)
const [imgSrc, setImgSrc] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [debug, setDebug] = useState(false)
const [debugInfo, setDebugInfo] = useState<any>(null)
const [history, setHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const [historyPage, setHistoryPage] = useState(1)
const [historyPageSize] = useState(20)
const [historyTotal, setHistoryTotal] = useState(0)
const [query, setQuery] = useState('')
const loadHistory = useCallback(async () => {
try {
setHistoryLoading(true)
setHistoryError(null)
const params = new URLSearchParams()
params.set('page', String(historyPage))
params.set('pageSize', String(historyPageSize))
if (query.trim()) params.set('q', query.trim())
const res = await fetch(`/api/zzap/history?${params.toString()}`, { cache: 'no-store' })
if (!res.ok) throw new Error(`History error ${res.status}`)
const data = await res.json()
setHistory(data.items || [])
setHistoryTotal(data.total || 0)
} catch (e: any) {
setHistoryError(e?.message || 'Не удалось получить историю')
} finally {
setHistoryLoading(false)
}
}, [historyPage, historyPageSize, query])
// initial load
useEffect(() => { loadHistory() }, [loadHistory])
const onSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
setImgSrc(null)
try {
const params = new URLSearchParams({ article })
if (selector) params.set('selector', selector)
if (debug) params.set('debug', '1')
const res = await fetch(`/api/zzap/screenshot?${params.toString()}`)
const ct = res.headers.get('content-type') || ''
if (res.ok && ct.includes('image/png')) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
setImgSrc(url)
setDebugInfo(null)
loadHistory()
} else {
const data = await res.json().catch(() => ({}))
if (debug) {
setDebugInfo(data)
throw new Error(data?.error || `Ошибка ${res.status}`)
}
throw new Error(data?.error || `Ошибка ${res.status}`)
}
} catch (err: any) {
setError(err.message || 'Не удалось получить скриншот')
} finally {
setLoading(false)
}
}, [article, selector, debug])
return (
<div className="p-6">
<Card className="max-w-3xl">
<CardHeader>
<CardTitle>ZZAP: скриншот графика статистики</CardTitle>
<CardDescription>
Введите артикул, сервис авторизуется на zzap.ru и вернёт PNG скриншот графика.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={onSubmit} className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2 sm:col-span-1">
<Label htmlFor="article">Артикул</Label>
<Input id="article" value={article} onChange={(e) => setArticle(e.target.value)} placeholder="например, 06A145710P" required />
</div>
<div className="grid gap-2 sm:col-span-1">
<Label htmlFor="selector">CSS селектор (опционально)</Label>
<Input id="selector" value={selector} onChange={(e) => setSelector(e.target.value)} placeholder="например, .chart-container" />
</div>
<div className="flex items-center gap-2 sm:col-span-2">
<input id="debug" type="checkbox" checked={debug} onChange={(e) => setDebug(e.target.checked)} />
<Label htmlFor="debug">Режим отладки (вернуть детали вместо PNG)</Label>
</div>
<div className="sm:col-span-2">
<Button type="submit" disabled={loading || !article}>
{loading ? 'Получаю…' : 'Получить скрин графика'}
</Button>
</div>
</form>
{error && <p className="text-sm text-red-600">{error}</p>}
{imgSrc && (
<div className="mt-2">
<h3 className="mb-2 font-medium">Результат</h3>
<img src={imgSrc} alt="Скриншот графика ZZAP" className="max-w-full border rounded-md" />
</div>
)}
{debugInfo && (
<pre className="mt-2 whitespace-pre-wrap text-xs bg-muted p-3 rounded-md overflow-auto max-h-[50vh]">
{JSON.stringify(debugInfo, null, 2)}
</pre>
)}
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle>История запросов</CardTitle>
<CardDescription>Последние 20 запросов ZZAP. Кликните по ссылке, чтобы открыть изображение.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<Input placeholder="Поиск по артикулу" value={query} onChange={(e) => { setQuery(e.target.value); setHistoryPage(1); }} className="max-w-xs" />
<Button variant="outline" onClick={() => { setHistoryPage(1); loadHistory() }} disabled={historyLoading}>Найти</Button>
</div>
{historyError && <p className="text-sm text-red-600">{historyError}</p>}
<Table>
<TableHeader>
<TableRow>
<TableHead>Дата</TableHead>
<TableHead>Артикул</TableHead>
<TableHead>Статус</TableHead>
<TableHead>URL статистики</TableHead>
<TableHead>Изображение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historyLoading && (
<TableRow><TableCell colSpan={5}>Загрузка</TableCell></TableRow>
)}
{!historyLoading && history.length === 0 && (
<TableRow><TableCell colSpan={5}>Пусто</TableCell></TableRow>
)}
{history.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.createdAt ? format(new Date(item.createdAt), 'dd.MM.yyyy HH:mm') : '—'}</TableCell>
<TableCell>{item.article}</TableCell>
<TableCell>{item.ok ? 'OK' : 'ERR'}</TableCell>
<TableCell className="max-w-[280px] truncate">
{item.statsUrl ? <a className="text-blue-600 underline" href={item.statsUrl} target="_blank" rel="noreferrer">{item.statsUrl}</a> : '—'}
</TableCell>
<TableCell>
{item.imageUrl ? <a className="text-blue-600 underline" href={item.imageUrl} target="_blank" rel="noreferrer">Открыть</a> : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">Всего: {historyTotal}</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setHistoryPage((p) => Math.max(1, p - 1))} disabled={historyPage === 1 || historyLoading}>Назад</Button>
<div className="text-sm">Стр. {historyPage} / {Math.max(1, Math.ceil(historyTotal / historyPageSize))}</div>
<Button variant="outline" size="sm" onClick={() => setHistoryPage((p) => p + 1)} disabled={historyPage >= Math.ceil(historyTotal / historyPageSize) || historyLoading}>Вперёд</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -16,7 +16,9 @@ import {
Palette,
Star,
Image,
Shield
BarChart3,
Shield,
Bot
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/AuthProvider'
@ -31,6 +33,11 @@ const navigationItems = [
href: '/dashboard',
icon: Home,
},
{
title: 'ZZAP статистика',
href: '/dashboard/zzap',
icon: BarChart3,
},
{
title: 'Каталог',
href: '/dashboard/catalog',
@ -41,6 +48,11 @@ const navigationItems = [
href: '/dashboard/kraja',
icon: Shield,
},
{
title: 'Чат с ИИ',
href: '/dashboard/ai',
icon: Bot,
},
{
title: 'Навигация сайта',
href: '/dashboard/navigation',

View File

@ -17,11 +17,16 @@ export const createToken = (payload: JWTPayload): string => {
// Верификация JWT токена
export const verifyToken = (token: string): JWTPayload | null => {
// Быстрый фильтр: клиентские токены и не-JWT (без двух точек) не проверяем
if (!token || token.startsWith('client_') || token.split('.').length !== 3) {
return null
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
return decoded
} catch (error) {
console.error('Ошибка верификации токена:', error)
} catch (_error) {
// Токен выглядел как JWT, но не прошёл проверку — тихо возвращаем null без шума в логах
return null
}
}

View File

@ -2412,7 +2412,7 @@ export const resolvers = {
console.log(`🌐 Найдено ${externalOffers.length} предложений в AutoEuro`)
console.log('📦 Первые 3 внешних предложения:', externalOffers.slice(0, 3))
// 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений
// 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений (может быть отключён)
let partsIndexData: any = null
try {
@ -2421,11 +2421,14 @@ export const resolvers = {
brand: cleanBrand
})
// Используем прямой поиск по артикулу и бренду
// Используем прямой поиск по артикулу и бренду, только если сервис включён
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
if (partsIndexEnabled) {
partsIndexData = await partsIndexService.searchEntityByCode(
cleanArticleNumber,
cleanBrand
)
}
if (partsIndexData) {
console.log('✅ GraphQL Resolver - найден товар в PartsIndex:', {
@ -3969,10 +3972,13 @@ export const resolvers = {
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
const partsIndexEntity = partsIndexEnabled
? await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
: null
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
@ -4058,10 +4064,13 @@ export const resolvers = {
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
const partsIndexEntity = partsIndexEnabled
? await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
: null
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
@ -4153,10 +4162,13 @@ export const resolvers = {
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
const partsIndexEntity = partsIndexEnabled
? await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
: null
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)

View File

@ -1,363 +1,46 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { Pool } from 'pg'
class PartsDatabase {
private pool: Pool
// Temporary no-op implementation to avoid requiring 'pg' when parts index is disabled.
class NoopPartsDatabase {
constructor() {
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set')
console.warn('Parts DB disabled: using no-op implementation')
}
this.pool = new Pool({
connectionString,
max: 10, // Reduce concurrent connections to avoid overwhelming the DB
idleTimeoutMillis: 60000, // 1 minute
connectionTimeoutMillis: 10000, // 10 seconds
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
})
console.log('🔌 Parts Database connection initialized (using main DATABASE_URL)')
async createCategoryTable() {
return
}
// Create table for a specific category
async createCategoryTable(categoryId: string, categoryName: string, categoryType: 'partsindex' | 'partsapi'): Promise<void> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
try {
const query = `
CREATE TABLE IF NOT EXISTS "${tableName}" (
id SERIAL PRIMARY KEY,
external_id VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(500) NOT NULL,
brand VARCHAR(255),
article VARCHAR(255),
description TEXT,
image_url VARCHAR(500),
price DECIMAL(10,2),
category_id VARCHAR(255) NOT NULL,
category_name VARCHAR(500) NOT NULL,
category_type VARCHAR(20) NOT NULL,
group_id VARCHAR(255),
group_name VARCHAR(500),
raw_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS "idx_${tableName}_external_id" ON "${tableName}" (external_id);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_category_id" ON "${tableName}" (category_id);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_brand" ON "${tableName}" (brand);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_article" ON "${tableName}" (article);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_created_at" ON "${tableName}" (created_at);
-- Create trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_${tableName}_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_update_${tableName}_timestamp ON "${tableName}";
CREATE TRIGGER trigger_update_${tableName}_timestamp
BEFORE UPDATE ON "${tableName}"
FOR EACH ROW
EXECUTE FUNCTION update_${tableName}_timestamp();
`
await this.pool.query(query)
console.log(`✅ Created table ${tableName} for category: ${categoryName}`)
} catch (error) {
console.error(`❌ Error creating table ${tableName}:`, error)
throw error
}
async insertProducts(_categoryId, _categoryName, _categoryType, products) {
console.warn(`Parts DB noop: insertProducts called for ${products?.length || 0} items`)
return 0
}
// Insert or update products in category table
async insertProducts(
categoryId: string,
categoryName: string,
categoryType: 'partsindex' | 'partsapi',
products: any[],
groupId?: string,
groupName?: string
): Promise<number> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
console.log(`🔄 Starting to insert ${products.length} products into ${tableName}`)
// First ensure table exists
await this.createCategoryTable(categoryId, categoryName, categoryType)
let insertedCount = 0
let errorCount = 0
// Process in smaller batches to reduce connection pressure
const batchSize = 50
const batches: any[][] = []
for (let i = 0; i < products.length; i += batchSize) {
batches.push(products.slice(i, i + batchSize))
}
console.log(`📦 Processing ${products.length} products in ${batches.length} batches of ${batchSize}`)
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
const batch = batches[batchIndex]
console.log(`🔄 Processing batch ${batchIndex + 1}/${batches.length} (${batch.length} products)`)
for (let i = 0; i < batch.length; i++) {
const globalIndex = batchIndex * batchSize + i
const product = batch[i]
// Retry logic with exponential backoff
let retryAttempts = 0
const maxRetries = 3
let success = false
while (retryAttempts < maxRetries && !success) {
try {
const values = this.prepareProductData(product, categoryId, categoryName, categoryType, groupId, groupName)
const query = `
INSERT INTO "${tableName}" (
external_id, name, brand, article, description, image_url, price,
category_id, category_name, category_type, group_id, group_name, raw_data
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (external_id)
DO UPDATE SET
name = EXCLUDED.name,
brand = EXCLUDED.brand,
article = EXCLUDED.article,
description = EXCLUDED.description,
image_url = EXCLUDED.image_url,
price = EXCLUDED.price,
group_id = EXCLUDED.group_id,
group_name = EXCLUDED.group_name,
raw_data = EXCLUDED.raw_data,
updated_at = CURRENT_TIMESTAMP
`
await this.pool.query(query, values)
insertedCount++
success = true
// Log progress every 100 insertions
if (insertedCount % 100 === 0) {
console.log(`📊 Progress: ${insertedCount}/${products.length} products inserted into ${tableName}`)
}
} catch (error: any) {
retryAttempts++
// Check if it's a network/connection error that might be retryable
if (error.code === 'ENOTFOUND' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
if (retryAttempts < maxRetries) {
const delayMs = Math.pow(2, retryAttempts) * 1000 // Exponential backoff: 2s, 4s, 8s
console.log(`🔄 Retry ${retryAttempts}/${maxRetries} for product ${globalIndex + 1} after ${delayMs}ms delay`)
await new Promise(resolve => setTimeout(resolve, delayMs))
continue
}
}
// If max retries exceeded or non-retryable error
errorCount++
console.error(`❌ Error inserting product ${globalIndex + 1}/${products.length} into ${tableName} (after ${retryAttempts} retries):`, error)
console.error(`❌ Product data:`, product)
break
}
}
}
// Small delay between batches to allow DB to recover
if (batchIndex < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
console.log(`✅ Insertion complete for ${tableName}:`)
console.log(` - Successfully inserted/updated: ${insertedCount} products`)
console.log(` - Errors: ${errorCount} products`)
console.log(` - Total processed: ${products.length} products`)
return insertedCount
}
// Get products from category table
async getProducts(
categoryId: string,
categoryType: 'partsindex' | 'partsapi',
options: {
limit?: number
offset?: number
search?: string
} = {}
): Promise<{ products: any[], total: number }> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
const { limit = 50, offset = 0, search } = options
try {
let whereClause = ''
let searchParams: any[] = []
if (search) {
whereClause = 'WHERE (name ILIKE $1 OR brand ILIKE $1 OR article ILIKE $1 OR description ILIKE $1)'
searchParams = [`%${search}%`]
}
// Count total
const countQuery = `SELECT COUNT(*) FROM "${tableName}" ${whereClause}`
const countResult = await this.pool.query(countQuery, searchParams)
const total = parseInt(countResult.rows[0].count)
// Get products
const dataQuery = `
SELECT * FROM "${tableName}"
${whereClause}
ORDER BY created_at DESC
LIMIT $${searchParams.length + 1} OFFSET $${searchParams.length + 2}
`
const dataResult = await this.pool.query(dataQuery, [...searchParams, limit, offset])
return {
products: dataResult.rows,
total
}
} catch (error) {
console.error(`❌ Error getting products from ${tableName}:`, error)
async getProducts(_categoryId, _categoryType, _options = {}) {
return { products: [], total: 0 }
}
}
// Get all category tables
async getCategoryTables(): Promise<{ tableName: string, categoryId: string, categoryType: string, recordCount: number }[]> {
try {
const query = `
SELECT
tablename,
schemaname
FROM pg_tables
WHERE schemaname = 'public'
AND (tablename LIKE 'category_partsindex_%' OR tablename LIKE 'category_partsapi_%')
ORDER BY tablename
`
const result = await this.pool.query(query)
const tables: { tableName: string, categoryId: string, categoryType: string, recordCount: number }[] = []
for (const row of result.rows) {
const tableName = row.tablename
// Parse category info from table name
const [, categoryType, categoryId] = tableName.split('_')
// Get record count
const countQuery = `SELECT COUNT(*) FROM "${tableName}"`
const countResult = await this.pool.query(countQuery)
const recordCount = parseInt(countResult.rows[0].count)
tables.push({
tableName,
categoryId,
categoryType,
recordCount
})
}
return tables
} catch (error) {
console.error('❌ Error getting category tables:', error)
async getCategoryTables() {
return []
}
async deleteCategoryTable() {
return
}
// Delete category table
async deleteCategoryTable(categoryId: string, categoryType: 'partsindex' | 'partsapi'): Promise<void> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
try {
await this.pool.query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`)
console.log(`✅ Deleted table ${tableName}`)
} catch (error) {
console.error(`❌ Error deleting table ${tableName}:`, error)
throw error
}
async testConnection() {
return true
}
// Helper method to generate table name
private getCategoryTableName(categoryId: string, categoryType: 'partsindex' | 'partsapi'): string {
// Sanitize category ID for use in table name
const sanitizedId = categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()
async close() {
return
}
// Keep signature compatibility with previous helper
getCategoryTableName(categoryId, categoryType) {
const sanitizedId = String(categoryId || '').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()
return `category_${categoryType}_${sanitizedId}`
}
// Helper method to prepare product data
private prepareProductData(
product: any,
categoryId: string,
categoryName: string,
categoryType: 'partsindex' | 'partsapi',
groupId?: string,
groupName?: string
): any[] {
if (categoryType === 'partsindex') {
return [
product.id || product.external_id || `${Date.now()}_${Math.random()}`,
product.name || '',
product.brand || '',
product.article || '',
product.description || '',
product.image || '',
product.price ? parseFloat(product.price) : null,
categoryId,
categoryName,
categoryType,
groupId || null,
groupName || null,
JSON.stringify(product)
]
} else {
// PartsAPI
return [
product.artId || `${Date.now()}_${Math.random()}`,
product.artArticleNr || '',
product.artSupBrand || '',
product.artArticleNr || '',
product.productGroup || '',
'', // no image for PartsAPI
null, // no price for PartsAPI
categoryId,
categoryName,
categoryType,
groupId || null,
groupName || null,
JSON.stringify(product)
]
}
}
// Test database connection
async testConnection(): Promise<boolean> {
try {
await this.pool.query('SELECT 1')
console.log('✅ Parts database connection test successful')
return true
} catch (error) {
console.error('❌ Parts database connection test failed:', error)
return false
}
}
// Close connection pool
async close(): Promise<void> {
await this.pool.end()
console.log('🔌 Parts database connection closed')
}
}
// Export singleton instance
export const partsDb = new PartsDatabase()
export const partsDb = new NoopPartsDatabase()

View File

@ -155,8 +155,10 @@ interface CacheEntry<T> {
}
class PartsIndexService {
private baseURL = process.env.PARTSAPI_URL+"/v1" || 'https://api.parts-index.com/v1';
private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
private baseHost = process.env.PARTSAPI_URL || 'https://api.parts-index.com';
private baseURL = `${this.baseHost}/v1`;
private apiKey = process.env.PARTSAPI_KEY || 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
private enabled = Boolean(process.env.PARTSAPI_URL) || process.env.PARTSINDEX_ENABLED === 'true';
// Простой in-memory кэш
private cache = new Map<string, CacheEntry<any>>();
@ -232,6 +234,10 @@ class PartsIndexService {
// Получить список каталогов
async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexCatalog[]> {
if (!this.enabled) {
// Disabled: return empty to avoid external calls during local dev
return [];
}
const cacheKey = `catalogs_${lang}`;
// Проверяем кэш
@ -271,6 +277,9 @@ class PartsIndexService {
// Получить группы каталога
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> {
if (!this.enabled) {
return [];
}
const cacheKey = `groups_${catalogId}_${lang}`;
// Проверяем кэш
@ -352,6 +361,9 @@ class PartsIndexService {
} = options;
try {
if (!this.enabled) {
return [];
}
console.log('🔍 PartsIndex запрос ВСЕХ товаров каталога:', {
catalogId,
groupId,
@ -434,6 +446,10 @@ class PartsIndexService {
params
} = options;
if (!this.enabled) {
return null;
}
// Создаем ключ кэша на основе всех параметров
const cacheKey = `entities_${catalogId}_${groupId}_${lang}_${limit}_${page}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
@ -670,6 +686,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntityDetail | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex запрос детали товара:', { catalogId, entityId, lang });
const response = await axios.get(`${this.baseURL}/catalogs/${catalogId}/entities/${entityId}`, {
@ -716,6 +735,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntityDetail | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex прямой поиск по артикулу:', { code, brand, lang });
const params: any = {
@ -768,6 +790,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntity | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex поиск товара в конкретной категории:', {
catalogId,
groupId,